├── .github ├── CODEOWNERS └── workflows │ ├── basic-ci.yaml │ └── mirror-main-to-master.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── codecov.yml ├── setup.cfg ├── setup.py ├── src └── rocker │ ├── __init__.py │ ├── cli.py │ ├── core.py │ ├── em.py │ ├── extensions.py │ ├── git_extension.py │ ├── nvidia_extension.py │ ├── os_detector.py │ ├── rmw_extension.py │ ├── ssh_extension.py │ ├── templates │ ├── Dockerfile.em │ ├── bash_cmd_snippet.Dockerfile.em │ ├── cuda_snippet.Dockerfile.em │ ├── dev_helpers_snippet.Dockerfile.em │ ├── nvidia_preamble.Dockerfile.em │ ├── nvidia_snippet.Dockerfile.em │ ├── pulse_snippet.Dockerfile.em │ ├── rmw_snippet.Dockerfile.em │ └── user_snippet.Dockerfile.em │ ├── ulimit_extension.py │ └── volume_extension.py ├── stdeb.cfg ├── test ├── test_core.py ├── test_extension.py ├── test_file_writing.py ├── test_git_extension.py ├── test_nvidia.py ├── test_os_detect.py ├── test_rmw_extension.py ├── test_ssh_extension.py ├── test_ulimit.py └── test_volume.py └── wishlist.txt /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Global codeowners 2 | 3 | * @tfoote 4 | -------------------------------------------------------------------------------- /.github/workflows/basic-ci.yaml: -------------------------------------------------------------------------------- 1 | name: Basic CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | basic_ci: 6 | name: Basic CI 7 | runs-on: ubuntu-22.04 8 | strategy: 9 | matrix: 10 | python-version: ['3.8', 3.x] 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install Test Dependencies And Self 18 | run: | 19 | python -m pip install --upgrade pip setuptools wheel 20 | python -m pip install -e .[test] codecov pytest-cov 21 | - name: Run headless tests 22 | uses: coactions/setup-xvfb@v1 23 | with: 24 | run: python -m pytest -s -v --cov=rocker -m "not nvidia" 25 | - name: Upload coverage to Codecov 26 | uses: codecov/codecov-action@v3 27 | cli_smoke_tests: 28 | name: CLI smoke tests 29 | runs-on: ubuntu-22.04 30 | strategy: 31 | matrix: 32 | python-version: [3.8, '3.x'] 33 | steps: 34 | - uses: actions/checkout@v4 35 | - name: Set up Python ${{ matrix.python-version }} 36 | uses: actions/setup-python@v5 37 | with: 38 | python-version: ${{ matrix.python-version }} 39 | - name: Install Dependencies And Self 40 | run: | 41 | python -m pip install --upgrade pip setuptools wheel 42 | python -m pip install -e . 43 | - name: Check os_detector script 44 | run: | 45 | detect_docker_image_os ubuntu 46 | - name: Check detector help 47 | run: | 48 | detect_docker_image_os ubuntu -h 49 | - name: Check main runs 50 | run: | 51 | rocker ubuntu 'true' 52 | - name: Check rocker help 53 | run: | 54 | rocker -h 55 | 56 | -------------------------------------------------------------------------------- /.github/workflows/mirror-main-to-master.yaml: -------------------------------------------------------------------------------- 1 | name: Mirror main to master 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | 7 | jobs: 8 | mirror-to-master: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: zofrex/mirror-branch@v1 12 | with: 13 | target-branch: master 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info 2 | __pycache__ 3 | dist 4 | deb_dist 5 | *.pyc 6 | .coverage 7 | build 8 | rocker_venv -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 6 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 7 | 8 | 9 | ## [v0.2.19](https://github.com/osrf/rocker/releases/tag/v0.2.19) - 2025-02-06 10 | 11 | [Compare with v0.2.18](https://github.com/osrf/rocker/compare/v0.2.18...v0.2.19) 12 | 13 | ### Added 14 | 15 | - add rmw zenoh support to rmw plugin (#315) ([5c6e8e7](https://github.com/osrf/rocker/commit/5c6e8e7fe45b34868bb74a40610c69b293341505) by Tully Foote). 16 | 17 | - Improve robustness of image cleanup (#313) ([149d4ef 18 | ](https://github.com/osrf/rocker/commit/149d4ef86ab5b42e7373066ba84ca1e985555011)) 19 | 20 | - Enable redirecting non-interactive run console output to file (#317) ([66fe159](https://github.com/osrf/rocker/commit/66fe159e3be5a80d3ba189320bf064da84371e73)) 21 | 22 | ## [v0.2.18](https://github.com/osrf/rocker/releases/tag/v0.2.18) - 2025-01-10 23 | 24 | [Compare with v0.2.17](https://github.com/osrf/rocker/compare/v0.2.17...v0.2.18) 25 | 26 | ### Added 27 | 28 | - Add UI feedback that image is being cleaned up, and how to avoid. ([3b21f78](https://github.com/osrf/rocker/commit/3b21f78fd4372a5451611a2a3e607f6941bcc398) by Tully Foote). 29 | - Add clear_image to ImageGenerator to enable non-persistence and not taking up all disk space. Especially for the tests. ([24dd5e1](https://github.com/osrf/rocker/commit/24dd5e1e7a6387e6c9b6c1df3644bf493f04b5c5) by Tully Foote). 30 | - Add support for ulimit flag (#291) ([cff5cb2](https://github.com/osrf/rocker/commit/cff5cb27c04f4db8d115493c2e2704c6a10726df) by Felipe Padula Sanches). 31 | - Add support for --shm-size flag (#306) 32 | 33 | ### Fixed 34 | 35 | - fix test for new cuda installation package ([3449afa](https://github.com/osrf/rocker/commit/3449afabd700c4723d21c22163e73f9cbf9b358d) by Tully Foote). 36 | - Removed deprecation warning from volume extensino (#292) 37 | - Updated and simplified CUDA installation (#299 by jonazpiazu) 38 | 39 | ### Removed 40 | 41 | - Remove default value for defaults (#289) ([3f02bdc](https://github.com/osrf/rocker/commit/3f02bdcc542c786eb08bdb3b324887bfa21698a2) by Tully Foote). 42 | 43 | ## [v0.2.17](https://github.com/osrf/rocker/releases/tag/v0.2.17) - 2024-08-28 44 | 45 | [Compare with v0.2.16](https://github.com/osrf/rocker/compare/v0.2.16...v0.2.17) 46 | 47 | ### Added 48 | - [nvidia_extension] add 24.04 to supported_versions (#279) 49 | 50 | ## [v0.2.16](https://github.com/osrf/rocker/releases/tag/v0.2.16) - 2024-04-01 51 | 52 | [Compare with v0.2.15](https://github.com/osrf/rocker/compare/v0.2.15...v0.2.16) 53 | 54 | ### Fixed 55 | 56 | - Fix --user arg when using empy 4 (#276) ([4078d22](https://github.com/osrf/rocker/commit/4078d223a2158e9a6fea31d8871dc7df7ee094f0) by Gary Servin). 57 | 58 | ## [v0.2.15](https://github.com/osrf/rocker/releases/tag/v0.2.15) - 2024-03-01 59 | 60 | [Compare with v0.2.14](https://github.com/osrf/rocker/compare/v0.2.14...v0.2.15) 61 | 62 | ### Added 63 | 64 | - Add changelog ([39a2842](https://github.com/osrf/rocker/commit/39a2842d03fec73b4b0d70c3922c4ab1c93c2cc0) by Tully Foote). 65 | - Add a shorthand option for switching to alternate rmws (#268) ([35e68c1](https://github.com/osrf/rocker/commit/35e68c17232a50cfb3a781b066e2c0d6691cb07e) by Tully Foote). 66 | 67 | ## [v0.2.14](https://github.com/osrf/rocker/releases/tag/v0.2.14) - 2024-01-26 68 | 69 | [Compare with v0.2.13](https://github.com/osrf/rocker/compare/v0.2.13...v0.2.14) 70 | 71 | ### Removed 72 | 73 | - Remove deprecated use of pkg_resources (#265) ([998761f](https://github.com/osrf/rocker/commit/998761f919302f29db2377f53efb5cb472c335fa) by Tully Foote). 74 | 75 | ## [v0.2.13](https://github.com/osrf/rocker/releases/tag/v0.2.13) - 2023-12-10 76 | 77 | [Compare with v0.2.12](https://github.com/osrf/rocker/compare/v0.2.12...v0.2.13) 78 | 79 | ### Added 80 | 81 | - Add indirection for changes in empy 3 vs 4 (#261) ([4de56c1](https://github.com/osrf/rocker/commit/4de56c18cdac09f3a9ec62a6b81cca7a4cdd46aa) by Tully Foote). 82 | - add simple test of CLI (#256) ([6d276a4](https://github.com/osrf/rocker/commit/6d276a4fe965f50246e2ff5eb0a03c9f16ed06c8) by Tully Foote). 83 | - Add extension ordering (#242) ([3afa6ac](https://github.com/osrf/rocker/commit/3afa6acbf6b0323f540ac16c2a0091b29099086a) by agyoungs). 84 | 85 | ### Fixed 86 | 87 | - fix: nvidia arg (#234) ([2b8d5ab](https://github.com/osrf/rocker/commit/2b8d5abb18c829d22f290679b739dc53765198ad) by Amadeusz Szymko). 88 | - Fix nvidia runtime fail on arm64 & add --group-add plugin (#211) ([e01d9cc](https://github.com/osrf/rocker/commit/e01d9cca8672e0a85b7ff42118ee456c4c414d9e) by Amadeusz Szymko). 89 | 90 | ### Removed 91 | 92 | - Remove old tutorial links (#254) ([41502b3](https://github.com/osrf/rocker/commit/41502b3f41e7d36bc108d9685bce61b6d27286bc) by Tully Foote). 93 | 94 | ## [v0.2.12](https://github.com/osrf/rocker/releases/tag/v0.2.12) - 2023-05-04 95 | 96 | [Compare with v0.2.11](https://github.com/osrf/rocker/compare/v0.2.11...v0.2.12) 97 | 98 | ### Fixed 99 | 100 | - Fix default logic for user-preserve-groups (#224) ([04cfc29](https://github.com/osrf/rocker/commit/04cfc290351a60b7df28c7eff67dc2c27e9ab918) by Tully Foote). 101 | 102 | ## [v0.2.11](https://github.com/osrf/rocker/releases/tag/v0.2.11) - 2023-05-04 103 | 104 | [Compare with v0.2.10](https://github.com/osrf/rocker/compare/v0.2.10...v0.2.11) 105 | 106 | ### Added 107 | 108 | - adding debian bookworm support ([b00c1c6](https://github.com/osrf/rocker/commit/b00c1c6e2832c6d375f8b12bcee8fe74642ef043) by Tully Foote). 109 | - add error messages for group issues ([4ce4e29](https://github.com/osrf/rocker/commit/4ce4e29041ac81830f78e1b00afbff9168194929) by Tully Foote). 110 | - Adding a permissive option to user-preserve-groups incase there are groups on the host that aren't permissible on the target but you'd like best-effort. ([44f7946](https://github.com/osrf/rocker/commit/44f7946653f1d58cbf52a088f8c482090f5f033c) by Tully Foote). 111 | - add link to mp_rocker (#213) ([49a23e7](https://github.com/osrf/rocker/commit/49a23e70a3055898daba80fc35f1742dc01d9a09) by Tully Foote). 112 | - Add ability to preserve host user groups inside container ([b16136e](https://github.com/osrf/rocker/commit/b16136e589ace3519e4b5c85697e770b29a151fa) by Miguel Prada). 113 | - Add port and expose extensions with tests (#201) ([2770045](https://github.com/osrf/rocker/commit/27700451d0b1a0701d8fe5c80c64a52942b61ce0) by Will Baker). 114 | - Added note for linking Intel Xe cards with rocker (#190) ([3a2bf74](https://github.com/osrf/rocker/commit/3a2bf74209f577f666857054095a57bbeb3c6c1d) by Zahi Kakish). 115 | 116 | ### Removed 117 | 118 | - Removed '-v' for rocker's version. Only --version should be available. (#205) ([67a4142](https://github.com/osrf/rocker/commit/67a414278c5940d587b9e3060eb7ab9e2e293510) by George Stavrinos). 119 | 120 | ## [v0.2.10](https://github.com/osrf/rocker/releases/tag/v0.2.10) - 2022-07-30 121 | 122 | [Compare with v0.2.9](https://github.com/osrf/rocker/compare/v0.2.9...v0.2.10) 123 | 124 | ## [v0.2.9](https://github.com/osrf/rocker/releases/tag/v0.2.9) - 2022-03-23 125 | 126 | [Compare with v0.2.8](https://github.com/osrf/rocker/compare/v0.2.8...v0.2.9) 127 | 128 | ## [v0.2.8](https://github.com/osrf/rocker/releases/tag/v0.2.8) - 2022-02-14 129 | 130 | [Compare with v0.2.7](https://github.com/osrf/rocker/compare/v0.2.7...v0.2.8) 131 | 132 | ## [v0.2.7](https://github.com/osrf/rocker/releases/tag/v0.2.7) - 2021-12-01 133 | 134 | [Compare with v0.2.6](https://github.com/osrf/rocker/compare/v0.2.6...v0.2.7) 135 | 136 | ### Added 137 | 138 | - add an option to preserve the home directory instead of deleting it if the uid or username collide (#164) ([0f1c7d1](https://github.com/osrf/rocker/commit/0f1c7d19da389be5d37977d2e4c8b87a4c5fe1e2) by Tully Foote). 139 | 140 | ## [v0.2.6](https://github.com/osrf/rocker/releases/tag/v0.2.6) - 2021-10-05 141 | 142 | [Compare with v0.2.5](https://github.com/osrf/rocker/compare/v0.2.5...v0.2.6) 143 | 144 | ## [v0.2.5](https://github.com/osrf/rocker/releases/tag/v0.2.5) - 2021-10-05 145 | 146 | [Compare with v0.2.4](https://github.com/osrf/rocker/compare/v0.2.4...v0.2.5) 147 | 148 | ### Added 149 | 150 | - Add an option to tag the image (#159) ([ef09014](https://github.com/osrf/rocker/commit/ef0901419dd99132bef5c1b797892927e63f5925) by Tully Foote). 151 | 152 | ## [v0.2.4](https://github.com/osrf/rocker/releases/tag/v0.2.4) - 2021-08-03 153 | 154 | [Compare with 0.2.3](https://github.com/osrf/rocker/compare/0.2.3...v0.2.4) 155 | 156 | ### Added 157 | 158 | - add an action to mirror main to master Providing backwards compatibility for renaming master to main ([9d7a392](https://github.com/osrf/rocker/commit/9d7a3924cd0d9a4c45ea163a7ab5296e4cdfdd3c) by Tully Foote). 159 | - Add the privileged extension (#132) ([4214d73](https://github.com/osrf/rocker/commit/4214d73aeb3de235153905344d14ee18edc3c229) by Gaël Écorchard). 160 | - Add --nocleanup option (#111) ([5507dc5](https://github.com/osrf/rocker/commit/5507dc510c3e339beb3573e52dcc77d8585bb285) by Tim Redick). 161 | 162 | ### Fixed 163 | 164 | - Fix detector for the deprecated distro syntax (#156) ([2760838](https://github.com/osrf/rocker/commit/2760838dc4bc8c3bd436344ec8ab9225ad914696) by Tully Foote). 165 | - Fix os detection for non-root images (cont.) (#150) ([8ecaf53](https://github.com/osrf/rocker/commit/8ecaf530cc374b30be121eef7a324b8200788eea) by Miguel Prada). 166 | 167 | ### Removed 168 | 169 | - Remove intermediate containers and name os_detect images (#133) ([80602eb](https://github.com/osrf/rocker/commit/80602eb6868a9044279d88c13650828f654a32f9) by Daniel Stonier). 170 | - Remove redundant device additions to docker_args ([a759fe6](https://github.com/osrf/rocker/commit/a759fe6b241edf37b95d104f238eb6ef9fb3d70b) by Peter Polidoro). 171 | 172 | ## [0.2.3](https://github.com/osrf/rocker/releases/tag/0.2.3) - 2020-11-25 173 | 174 | [Compare with v0.2.2](https://github.com/osrf/rocker/compare/v0.2.2...0.2.3) 175 | 176 | ### Added 177 | 178 | - add x11 flags to examples (#104) ([ae7d3f6](https://github.com/osrf/rocker/commit/ae7d3f6edcd74cb74b345f005f401061b33bda3a) by Tully Foote). 179 | - add the ability to inject an arbitrary file which can be used via a COPY command in the snippet (#101) ([eef5516](https://github.com/osrf/rocker/commit/eef5516e65a992832be6d30a9c12459e582cba7c) by Tully Foote). 180 | - Added --name argument ([019a543](https://github.com/osrf/rocker/commit/019a543afb759664751afe847162ab9ba09db889) by ahcorde). 181 | 182 | ### Fixed 183 | 184 | - Fix using rocker for non-root containers (#50) ([3585298](https://github.com/osrf/rocker/commit/35852984cc4554bbfcf55f3612fa17cc89d5d715) by Johannes Meyer). 185 | 186 | ## [v0.2.2](https://github.com/osrf/rocker/releases/tag/v0.2.2) - 2020-06-24 187 | 188 | [Compare with v0.2.1](https://github.com/osrf/rocker/compare/v0.2.1...v0.2.2) 189 | 190 | ## [v0.2.1](https://github.com/osrf/rocker/releases/tag/v0.2.1) - 2020-06-24 191 | 192 | [Compare with v0.2.0](https://github.com/osrf/rocker/compare/v0.2.0...v0.2.1) 193 | 194 | ### Added 195 | 196 | - add bullseye target too ([b9f407b](https://github.com/osrf/rocker/commit/b9f407ba5423c61bd9dd0a46f36d21cd7e795adf) by Tully Foote). 197 | - add focal as a target ([55b704f](https://github.com/osrf/rocker/commit/55b704f575dc5f6af1150e29fb18faf51cfa3336) by Tully Foote). 198 | 199 | ## [v0.2.0](https://github.com/osrf/rocker/releases/tag/v0.2.0) - 2020-05-08 200 | 201 | [Compare with v0.1.10](https://github.com/osrf/rocker/compare/v0.1.10...v0.2.0) 202 | 203 | ### Added 204 | 205 | - Add support for focal and buster (#83) ([a76acb2](https://github.com/osrf/rocker/commit/a76acb2aafb1c5d5609e923b42d8d2a351e28906) by Tully Foote). 206 | - Add support for --env-file option pass through (#82) ([135e73f](https://github.com/osrf/rocker/commit/135e73fc11cb1a6751cda6253efa26d543345e47) by Tully Foote). 207 | - Add an Extension Manager class (#77) ([413d25a](https://github.com/osrf/rocker/commit/413d25a7e578d4eab410eda99042cabb8dc8026b) by Tully Foote). 208 | - add codeowners so I get auto review requested (#74) ([15b604b](https://github.com/osrf/rocker/commit/15b604be5d1a940e4e242dbebff45d9298fcb787) by Tully Foote). 209 | 210 | ### Fixed 211 | 212 | - Fix documentation for User extension (#81) ([6e9ee9c](https://github.com/osrf/rocker/commit/6e9ee9c96aa2664b51974724377cd771c465dbbf) by Emerson Knapp). 213 | - Fix import paths (#76) ([d26004b](https://github.com/osrf/rocker/commit/d26004b2dcc919bf08ae7aeeef0e669f0a66a7f4) by Tully Foote). 214 | 215 | ## [v0.1.10](https://github.com/osrf/rocker/releases/tag/v0.1.10) - 2019-12-05 216 | 217 | [Compare with v0.1.9](https://github.com/osrf/rocker/compare/v0.1.9...v0.1.10) 218 | 219 | ## [v0.1.9](https://github.com/osrf/rocker/releases/tag/v0.1.9) - 2019-10-14 220 | 221 | [Compare with v0.1.8](https://github.com/osrf/rocker/compare/v0.1.8...v0.1.9) 222 | 223 | ### Added 224 | 225 | - Add verbosity outputs ([6246570](https://github.com/osrf/rocker/commit/6246570e1630592c9426e64c7477d53339edc568) by Tully Foote). 226 | 227 | ### Removed 228 | 229 | - Remove xvfb-run which started failing CI. (#69) ([d38927a](https://github.com/osrf/rocker/commit/d38927ab13ffe6ccf84bb9408261feb6a3df3231) by Tully Foote). 230 | 231 | ## [v0.1.8](https://github.com/osrf/rocker/releases/tag/v0.1.8) - 2019-09-18 232 | 233 | [Compare with v0.1.7](https://github.com/osrf/rocker/compare/v0.1.7...v0.1.8) 234 | 235 | ### Added 236 | 237 | - Add a version cli option (#65) ([28fc9ff](https://github.com/osrf/rocker/commit/28fc9ff1fab435ab71c1077ac6882380d38d66f1) by Tully Foote). 238 | 239 | ### Fixed 240 | 241 | - FIx the window resize (#66) ([7757df0](https://github.com/osrf/rocker/commit/7757df091bee54714368a2d08236f6ba9caf4bc5) by Tully Foote). 242 | 243 | ## [v0.1.7](https://github.com/osrf/rocker/releases/tag/v0.1.7) - 2019-09-17 244 | 245 | [Compare with v0.1.6](https://github.com/osrf/rocker/compare/v0.1.6...v0.1.7) 246 | 247 | ### Added 248 | 249 | - add --home (#64) ([733e29b](https://github.com/osrf/rocker/commit/733e29b7521a4037ba381d3d219cabaf3855a608) by Tully Foote). 250 | - Add sigwinch passthrough for xterm resizing (#57) ([efec0f9](https://github.com/osrf/rocker/commit/efec0f964a12b239f6c201498b2aa478fe56d83c) by Ruffin). 251 | - Add new extensions git and ssh (#58) ([5405f3f](https://github.com/osrf/rocker/commit/5405f3fc824b3948772c6b60efff4370954497d3) by Johannes Meyer). 252 | 253 | ### Fixed 254 | 255 | - Fix sudo with user extension for some usernames (#55) ([0bc20b8](https://github.com/osrf/rocker/commit/0bc20b84c72d16b5f81dda59283c6e242e203938) by Johannes Meyer). 256 | 257 | ## [v0.1.6](https://github.com/osrf/rocker/releases/tag/v0.1.6) - 2019-08-29 258 | 259 | [Compare with 0.1.5](https://github.com/osrf/rocker/compare/0.1.5...v0.1.6) 260 | 261 | ## [0.1.5](https://github.com/osrf/rocker/releases/tag/0.1.5) - 2019-08-28 262 | 263 | [Compare with 0.1.4](https://github.com/osrf/rocker/compare/0.1.4...0.1.5) 264 | 265 | ### Added 266 | 267 | - Add extension to pass custom environment variables (#51) ([e27cc0b](https://github.com/osrf/rocker/commit/e27cc0bb8d6677807a9c791470b023253569fedf) by Johannes Meyer). 268 | - Add Cosmic, Disco, and Eoan suites. ([daf23a7](https://github.com/osrf/rocker/commit/daf23a7ca4f10e557e1dcdf9edb854b83d2186bf) by Steven! Ragnarök). 269 | 270 | ### Fixed 271 | 272 | - Fix OS detection (fix #43) ([7a419f9](https://github.com/osrf/rocker/commit/7a419f91b0e8aa0fb0beae040844b4ed77e3f7a1) by Johannes Meyer). 273 | 274 | ### Removed 275 | 276 | - remove unused imports ([39118bb](https://github.com/osrf/rocker/commit/39118bb877553364f70e082be24aaf07559f7ef4) by Tully Foote). 277 | 278 | ## [0.1.4](https://github.com/osrf/rocker/releases/tag/0.1.4) - 2019-03-13 279 | 280 | [Compare with 0.1.3](https://github.com/osrf/rocker/compare/0.1.3...0.1.4) 281 | 282 | ### Added 283 | 284 | - add documentation about how to use an intel integrated graphics card ([f03299f](https://github.com/osrf/rocker/commit/f03299ffddeb3d050f1f16cb048e2a9983895d90) by Tully Foote). 285 | - Add No-Python2 flag ([101813f](https://github.com/osrf/rocker/commit/101813fa2fdf75d80e628ee0871d8cb238e11337) by Tully Foote). 286 | - adding coverage of nvidia ([6f8121f](https://github.com/osrf/rocker/commit/6f8121f7b5c5aad379708ea468c01b97671af159) by Tully Foote). 287 | - add coverage for extensions (#32) ([ac3afc5](https://github.com/osrf/rocker/commit/ac3afc5592771e45f866f22ba4357346f4353db4) by Tully Foote). 288 | - adding a few basic unit tests (#30) ([ea951b7](https://github.com/osrf/rocker/commit/ea951b77cfaae6fd6921555779bfb195c748402e) by Tully Foote). 289 | - Add codecov reports (#28) ([9ef5f36](https://github.com/osrf/rocker/commit/9ef5f361cdd94f1361a48c80deae48b5a58dea15) by Tully Foote). 290 | - adding basic travis (#27) ([b538350](https://github.com/osrf/rocker/commit/b538350fcef5cc08eba4ad39a0911127270f9a20) by Tully Foote). 291 | - Add unit tests for os_detection ([4b42a2d](https://github.com/osrf/rocker/commit/4b42a2de5d5dd9df7f8af6fed17c0080fdc4cbe8) by Tully Foote). 292 | - add dependencies to backport script ([0df081e](https://github.com/osrf/rocker/commit/0df081efe22e8c4853c4bc1b942d7a2fe89aba9a) by Tully Foote). 293 | 294 | ### Fixed 295 | 296 | - Fix empy stdout proxy logic for unit tests When the test runner changes std out for logging it breaks empy's stdout proxy logic. Fixes #9 ([0671e14](https://github.com/osrf/rocker/commit/0671e1429c0d54dba477bf475cae35252226c5f6) by Tully Foote). 297 | 298 | ## [0.1.3](https://github.com/osrf/rocker/releases/tag/0.1.3) - 2019-01-10 299 | 300 | [Compare with 0.1.2](https://github.com/osrf/rocker/compare/0.1.2...0.1.3) 301 | 302 | ### Added 303 | 304 | - add comment about python3-distro ([e28b546](https://github.com/osrf/rocker/commit/e28b54661dcd2c3fbc08f677d228c2ce7e649e3e) by Tully Foote). 305 | 306 | ### Fixed 307 | 308 | - fix dependencies ([fafcab5](https://github.com/osrf/rocker/commit/fafcab54b24ec6cf0aa92b3adad3a16057717829) by Tully Foote). 309 | 310 | ## [0.1.2](https://github.com/osrf/rocker/releases/tag/0.1.2) - 2019-01-09 311 | 312 | [Compare with first commit](https://github.com/osrf/rocker/compare/abc236cbf234c8ac7bff30b865836aefb751dbef...0.1.2) 313 | 314 | ### Added 315 | 316 | - add command explicitly to argument parsing ([5e308a6](https://github.com/osrf/rocker/commit/5e308a61443c721f4a0de9129040f9898f538a04) by Tully Foote). 317 | - Add tests for extensions ([50ac127](https://github.com/osrf/rocker/commit/50ac1278a488049ae6ce4325356151e24f4df4b7) by Tully Foote). 318 | - add a few ignores core cleaner workspace diffs ([b498f03](https://github.com/osrf/rocker/commit/b498f031e50acff8c5e9d62c2e41365b289dd808) by Tully Foote). 319 | - Add stdeb.cfg ([034abf1](https://github.com/osrf/rocker/commit/034abf1e41ed0ea0c6c5abf2703377257a660e24) by Tully Foote). 320 | - add support for mounting devices, with a soft fail on not existing ([d3244ab](https://github.com/osrf/rocker/commit/d3244ab8f788bbc1e4355000e99176502d7f7d47) by Tully Foote). 321 | - adding copyright headers ([601a156](https://github.com/osrf/rocker/commit/601a1561ccb8bbb2107e85a8afce14f623c12a04) by Tully Foote). 322 | - Add extra arguments necessary for pulse Found here: https://github.com/jacknlliu/ros-docker-images/issues/7 Also fixed typo in the config. ([a9b6eba](https://github.com/osrf/rocker/commit/a9b6ebab3d09664ec2a014e67bd0ab83732326be) by Tully Foote). 323 | - add to the wishlist ([6e224e2](https://github.com/osrf/rocker/commit/6e224e2435cedb74b3c42e727bd388190610f46b) by Tully Foote). 324 | - add readme ([fa0d251](https://github.com/osrf/rocker/commit/fa0d251779bbf2add0842dcaefb40464451f3fd6) by Tully Foote). 325 | - add a readme ([9d6bd66](https://github.com/osrf/rocker/commit/9d6bd6636d858e2b2bac3cb8ae74f683cad311b1) by Tully Foote). 326 | - add parameter for disabling caching ([79a6beb](https://github.com/osrf/rocker/commit/79a6beb9301e0c446d9fe79d25d54067094c5c4d) by Tully Foote). 327 | 328 | ### Fixed 329 | 330 | - fix assert spacing ([02601da](https://github.com/osrf/rocker/commit/02601da2fa9413192fa96df32cb65bf8a07cb9c6) by Tully Foote). 331 | - fix network argument parsing ([d8b12af](https://github.com/osrf/rocker/commit/d8b12af21fe6ebf7bafdadf8e5f36e151ceb0ec6) by Tully Foote). 332 | - fix docker API usage for pull ([a749985](https://github.com/osrf/rocker/commit/a749985104855bc44a5615eb8e25aa63833bd27a) by Tully Foote). 333 | 334 | ### Removed 335 | 336 | - remove legacy test function ([4c1466a](https://github.com/osrf/rocker/commit/4c1466a217e4e150aa722836b3768abcd2efe425) by Tully Foote). 337 | - remove legacy comments ([38b5903](https://github.com/osrf/rocker/commit/38b59034eb373ff12619529383dc8812446890ad) by Tully Foote). 338 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rocker 2 | 3 | A tool to run docker images with customized local support injected for things like nvidia support. And user id specific files for cleaner mounting file permissions. 4 | 5 | ## Difference from docker-compose 6 | 7 | A common question about rocker is how is it different than `docker-compose`. 8 | `rocker` is designed to solve a similar but different problem than `docker-compose`. 9 | The primary goal of `rocker` is to support the use of Docker in use cases where the containers will be effected by the local environment. 10 | A primary example of this is setting up file permissions inside the container to match the users outside of the container so that mounted files inside the container have the same UID as the host. 11 | Doing this enables quickly going in and out of different containrs while leverating the same workspace on your host for testing on different platforms etc. 12 | This is done by dynamically generating overlays on the same core image after detecting the local conditions required. 13 | 14 | The secondary feature that `rocker` provides that docker-compose does not address is the ability to inject extra use case specific capabilities into a container before running. 15 | A common example is the ability to use NVIDIA drivers on a standard published image. 16 | `rocker` will take that standard published image and both inject the necessary drivers into the container which will match your host driver and simultaneously set the correct runtime flags. 17 | This is possible to do with docker-compose or straight docker. 18 | But the drawbacks are that you have to build and publish the combinatoric images of all possible drivers, and in addition you need to manually make sure to pass all the correct runtime arguments. 19 | This is especially true if you want to combine multiple possible additional features, such that the number of images starts scaling in a polynomic manner and maintenance of the number of images becomes unmanagable quickly. 20 | Whereas with `rocker` you can invoke your specific plugins and it will use multi-stage builds of docker images to customize the container for your specific use case, which lets you use official upstream docker images without requiring you to maintain a plethora of parallel built images. 21 | 22 | 23 | 24 | ## Known extensions 25 | 26 | Rocker supports extensions via entry points there are some built in but you can add your own. 27 | 28 | ### Integrated Extensions 29 | 30 | There are a number of integrated extensions here's some of the highlights. 31 | You can get full details on the extensions from the main `rocker --help` command. 32 | 33 | - x11 -- Enable the use of X11 inside the container via the host X instance. 34 | - nvidia -- Enable NVIDIA graphics cards for rendering 35 | - cuda -- Enable NVIDIA CUDA in the container 36 | - user -- Create a user inside the container with the same settings as the host and run commands inside the container as that user. 37 | - home -- Mount the user's home directory into the container 38 | - pulse -- Mount pulse audio into the container 39 | - ssh -- Pass through ssh access to the container. 40 | 41 | As well as access to many of the docker arguments as well such as `device`, `env`, `volume`, `name`, `network`, `ipc`, and `privileged`. 42 | 43 | ### Externally maintained extensions 44 | 45 | Here's a list of public repositories with extensions. 46 | 47 | - Off-your-rocker: https://github.com/sloretz/off-your-rocker 48 | - mp_rocker: https://github.com/miguelprada/mp_rocker 49 | - ghrocker: https://github.com/tfoote/ghrocker 50 | - novnc_rocker: https://github.com/tfoote/novnc-rocker 51 | - template_rocker: https://github.com/blooop/template_rocker 52 | - deps_rocker: https://github.com/blooop/deps_rocker 53 | - pixi_rocker: https://github.com/blooop/pixi_rocker 54 | - conda_rocker: https://github.com/blooop/conda_rocker 55 | - palanteer_rocker: https://github.com/blooop/palanteer_rocker 56 | - lazygit_rocker: https://github.com/blooop/lazygit_rocker 57 | 58 | 59 | ### Externally maintained rocker wrappers 60 | 61 | Here is a list of public repositories that wrap rocker and extend its functionality. These tools are meant to be a drop in replacement of rocker so that all the existing behavior stays the same. 62 | 63 | - rockerc: https://github.com/blooop/rockerc wraps rocker to enable putting rocker commands into a yaml config file. 64 | - rockervsc: https://github.com/blooop/rockervsc wraps rocker so that a vscode instance attaches to the launched container. 65 | 66 | # Prerequisites 67 | 68 | This should work on most systems using with a recent docker version available. 69 | 70 | Docker installation instructions: https://docs.docker.com/install/ 71 | 72 | ## NVIDIA settings 73 | 74 | For the NVIDIA option this has been tested on the following systems using nvidia docker2: 75 | 76 | | Ubuntu distribution | Linux Kernel | Nvidia drivers | 77 | | ------------------- | ------------ | ------------------------------------------------- | 78 | | 16.04 | 4.15 | nvidia-384 (works)
nvidia-340 (doesn't work) | 79 | | 18.04 | | nvidia-390 (works) | 80 | | 20.04 | 5.4.0 | nvidia-driver-460 (works) | 81 | | 22.04 | 5.13.0 | nvidia-driver-470 (works) | 82 | 83 | Install nvidia-docker 2: https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html#docker 84 | 85 | ### Additional Configuration for rootless mode 86 | For executing Docker as a non-root user, separate installation instructions are provided here: https://docs.docker.com/engine/security/rootless/ 87 | 88 | After installing Rootless Docker, the nvidia-docker2 package can be installed as usual from the website above. 89 | Currently, [cgroups are not supported in rootless mode](https://github.com/moby/moby/issues/38729) so we need to change `no-cgroups` in */etc/nvidia-container-runtime/config.toml* 90 | 91 | ```shell 92 | [nvidia-container-cli] 93 | no-cgroups = true 94 | ``` 95 | 96 | Note, that changing this setting will lead to a `Failed to initialize NVML: Unknown Error` if Docker is executed as root (noted [here](https://github.com/NVIDIA/nvidia-container-runtime/issues/85)). 97 | 98 | ## Intel integrated graphics support 99 | 100 | For [Intel integrated graphics support](https://www.intel.com/content/www/us/en/develop/documentation/get-started-with-ai-linux/top/using-containers/using-containers-with-the-command-line.html) you will need to mount the `/dev/dri` directory as follows: 101 | 102 | ``` 103 | --devices /dev/dri 104 | ``` 105 | 106 | # Installation 107 | 108 | ## Debians (Recommended) 109 | Debian packages are available from the ROS repositories. You can set them up in step one [here](http://wiki.ros.org/kinetic/Installation/Ubuntu) then come back. 110 | 111 | Then you can `sudo apt-get install python3-rocker` 112 | 113 | ## PIP 114 | 115 | If you're not on a Ubuntu or Debian platform. 116 | 117 | Rocker is available via pip you can install it via pip using 118 | 119 | `pip install rocker` 120 | 121 | ## Archlinux ([AUR](https://aur.archlinux.org/)) 122 | 123 | Using any AUR helper, for example, with `paru` 124 | 125 | ```bash 126 | paru -S python-rocker 127 | ``` 128 | 129 | or 130 | 131 | ```bash 132 | paru -S python-rocker-git 133 | ``` 134 | 135 | ## Development 136 | To set things up in a virtual environment for isolation is a good way. If you don't already have it install python3's venv module. 137 | 138 | sudo apt-get install python3-venv 139 | 140 | Create a venv 141 | 142 | mkdir -p ~/rocker_venv 143 | python3 -m venv ~/rocker_venv 144 | 145 | Install rocker 146 | 147 | cd ~/rocker_venv 148 | . ~/rocker_venv/bin/activate 149 | pip install git+https://github.com/osrf/rocker.git 150 | 151 | For any new terminal re activate the venv before trying to use it. 152 | 153 | . ~/rocker_venv/bin/activate 154 | 155 | ### Testing 156 | 157 | To run tests install the 'test' extra and pytest-cov in the venv 158 | 159 | . ~/rocker_venv/bin/activate 160 | pip install -e .[test] pytest-cov 161 | 162 | Then you can run pytest. 163 | 164 | python3 -m pytest --cov=rocker 165 | 166 | Notes: 167 | 168 | - Make sure to use the python3 instance of pytest from inside the environment. 169 | - The tests include an nvidia test which assumes you're using a machine with an nvidia gpu. To skip them use `-m "not nvidia"` 170 | 171 | 172 | # Example usage 173 | 174 | 175 | ## Fly a drone 176 | 177 | Example usage with an iris 178 | 179 | rocker --nvidia --x11 --user --home --pull --pulse tfoote/drone_demo 180 | 181 | After the ekf converges, 182 | 183 | You can send a takeoff command and then click to command the vehicle to fly to a point on the map. 184 | 185 | ## ROS 2 rviz 186 | 187 | rocker --nvidia --x11 osrf/ros:crystal-desktop rviz2 188 | 189 | 190 | ## Generic gazebo 191 | 192 | On Xenial 193 | 194 | rocker --nvidia --x11 osrf/ros:kinetic-desktop-full gazebo 195 | 196 | On Bionic 197 | 198 | rocker --nvidia --x11 osrf/ros:melodic-desktop-full gazebo 199 | 200 | ## Volume mount 201 | 202 | ### For arguments with one element not colon separated 203 | 204 | `--volume` adds paths as docker volumes. 205 | 206 | **The last path must be terminated with two dashes `--`**. 207 | 208 | rocker --volume ~/.vimrc ~/.bashrc -- ubuntu:18.04 209 | 210 | The above example of the volume option will be expanded via absolute paths for `docker run` as follows: 211 | 212 | --volume /home//.vimrc:/home//.vimrc --volume /home//.bashrc:/home//.bashrc 213 | 214 | ### For arguments with colon separation 215 | 216 | It will process the same as `docker`'s `--volume` option, `rocker --volume` takes 3 fields. 217 | - 1st field: the path to the file or directory on the host machine. 218 | - 2nd field: (optional) the path where the file or directory is mounted in the container. 219 | - If only the 1st field is supplied, same value as the 1st field will be populated as the 2nd field. 220 | - 3rd field: (optional) bind propagation as `ro`, `z`, and `Z`. See [docs.docker.com](https://docs.docker.com/storage/bind-mounts/) for further detail. 221 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: 2 | layout: "reach, diff, flags, files" 3 | behavior: default 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | markers = 3 | # Tests which require a docker engine 4 | docker 5 | # Tests which require an NVIDIA GPU and drivers 6 | nvidia 7 | # Tests which require an X11 display of some kind 8 | x11 9 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | from setuptools import setup 5 | 6 | # importlib-metadata dependency can be removed when RHEL8 and other 3.6 based systems are not in support cycles 7 | 8 | install_requires = [ 9 | 'empy', 10 | 'importlib-metadata; python_version < "3.8"', 11 | 'pexpect', 12 | 'packaging', 13 | 'urllib3', 14 | ] 15 | 16 | # docker API used to be in a package called `docker-py` before the 2.0 release 17 | docker_package = 'docker' 18 | try: 19 | import docker 20 | except ImportError: 21 | # Docker is not yet installed, pick library based on platform 22 | # Use old name if platform has pre-2.0 version 23 | if os.path.isfile('/etc/os-release'): 24 | with open('/etc/os-release') as fin: 25 | content = fin.read() 26 | if 'xenial' in content: 27 | docker_package = 'docker-py' 28 | else: 29 | # Docker is installed, pick library based on what we found 30 | ver = docker.__version__.split('.') 31 | if int(ver[0]) < 2: 32 | docker_package = 'docker-py' 33 | 34 | install_requires.append(docker_package) 35 | 36 | kwargs = { 37 | 'name': 'rocker', 38 | 'version': '0.2.19', 39 | 'packages': ['rocker'], 40 | 'package_dir': {'': 'src'}, 41 | 'package_data': {'rocker': ['templates/*.em']}, 42 | 'entry_points': { 43 | 'console_scripts': [ 44 | 'rocker = rocker.cli:main', 45 | 'detect_docker_image_os = rocker.cli:detect_image_os', 46 | ], 47 | 'rocker.extensions': [ 48 | 'cuda = rocker.nvidia_extension:Cuda', 49 | 'detach = rocker.extensions:Detach', 50 | 'devices = rocker.extensions:Devices', 51 | 'dev_helpers = rocker.extensions:DevHelpers', 52 | 'env = rocker.extensions:Environment', 53 | 'expose = rocker.extensions:Expose', 54 | 'git = rocker.git_extension:Git', 55 | 'group_add = rocker.extensions:GroupAdd', 56 | 'home = rocker.extensions:HomeDir', 57 | 'hostname = rocker.extensions:Hostname', 58 | 'ipc = rocker.extensions:Ipc', 59 | 'name = rocker.extensions:Name', 60 | 'network = rocker.extensions:Network', 61 | 'nvidia = rocker.nvidia_extension:Nvidia', 62 | 'port = rocker.extensions:Port', 63 | 'privileged = rocker.extensions:Privileged', 64 | 'pulse = rocker.extensions:PulseAudio', 65 | 'rmw = rocker.rmw_extension:RMW', 66 | 'shm_size = rocker.extensions:ShmSize', 67 | 'ssh = rocker.ssh_extension:Ssh', 68 | 'ulimit = rocker.ulimit_extension:Ulimit', 69 | 'user = rocker.extensions:User', 70 | 'volume = rocker.volume_extension:Volume', 71 | 'x11 = rocker.nvidia_extension:X11', 72 | ] 73 | }, 74 | 'author': 'Tully Foote', 75 | 'author_email': 'tfoote@osrfoundation.org', 76 | 'keywords': ['Docker'], 77 | 'classifiers': [ 78 | 'Programming Language :: Python', 79 | 'Programming Language :: Python :: 3', 80 | 'License :: OSI Approved :: Apache Software License' 81 | ], 82 | 'description': 'A tool to run docker containers with customized extras', 83 | 'long_description': 'A tool to run docker containers with customized extra added like nvidia gui support overlayed.', 84 | 'license': 'Apache License 2.0', 85 | 'python_requires': '>=3.0', 86 | 87 | 'install_requires': install_requires, 88 | 'extras_require': { 89 | 'test': [ 90 | 'pytest' 91 | ] 92 | }, 93 | 'url': 'https://github.com/osrf/rocker' 94 | } 95 | 96 | setup(**kwargs) 97 | -------------------------------------------------------------------------------- /src/rocker/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osrf/rocker/ade980316020c2e3e82412081bb656c73bd85556/src/rocker/__init__.py -------------------------------------------------------------------------------- /src/rocker/cli.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Open Source Robotics Foundation 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import argparse 16 | import os 17 | import sys 18 | 19 | from .core import DockerImageGenerator 20 | from .core import get_rocker_version 21 | from .core import RockerExtensionManager 22 | from .core import DependencyMissing 23 | from .core import ExtensionError 24 | from .core import OPERATIONS_DRY_RUN 25 | from .core import OPERATIONS_INTERACTIVE 26 | from .core import OPERATIONS_NON_INTERACTIVE 27 | from .core import OPERATION_MODES 28 | 29 | from .os_detector import detect_os 30 | 31 | 32 | def main(): 33 | 34 | parser = argparse.ArgumentParser( 35 | description='A tool for running docker with extra options', 36 | formatter_class=argparse.ArgumentDefaultsHelpFormatter) 37 | parser.add_argument('image') 38 | parser.add_argument('command', nargs='*', default='') 39 | parser.add_argument('--noexecute', action='store_true', help='Deprecated') # TODO(tfoote) add when 3.13 is minimum supported, deprecated=True 40 | parser.add_argument('--nocache', action='store_true') 41 | parser.add_argument('--nocleanup', action='store_true', help='do not remove the docker container when stopped') 42 | parser.add_argument('--persist-image', action='store_true', help='do not remove the docker image when stopped', default=False) #TODO(tfoote) Add a name to it if persisting 43 | parser.add_argument('--pull', action='store_true') 44 | parser.add_argument('--version', action='version', 45 | version='%(prog)s ' + get_rocker_version()) 46 | 47 | try: 48 | extension_manager = RockerExtensionManager() 49 | default_args = {} 50 | extension_manager.extend_cli_parser(parser, default_args) 51 | except DependencyMissing as ex: 52 | # Catch errors if docker is missing or inaccessible. 53 | parser.error("DependencyMissing encountered: %s" % ex) 54 | 55 | args = parser.parse_args() 56 | args_dict = vars(args) 57 | 58 | if args.noexecute: 59 | from .core import OPERATIONS_DRY_RUN 60 | args_dict['mode'] = OPERATIONS_DRY_RUN 61 | print('DEPRECATION Warning: --noexecute is deprecated for --mode dry-run please switch your usage by December 2020') 62 | 63 | # validate_operating_mode 64 | operating_mode = args_dict.get('mode') 65 | # Don't try to be interactive if there's no tty 66 | if not os.isatty(sys.__stdin__.fileno()): 67 | if operating_mode == OPERATIONS_INTERACTIVE: 68 | parser.error("No tty detected cannot operate in interactive mode") 69 | elif not operating_mode: 70 | print("No tty detected for stdin defaulting mode to non-interactive") 71 | args_dict['mode'] = OPERATIONS_NON_INTERACTIVE 72 | 73 | # Check if detach extension is active and deconflict with interactive 74 | detach_active = args_dict.get('detach') 75 | operating_mode = args_dict.get('mode') 76 | if detach_active: 77 | if operating_mode == OPERATIONS_INTERACTIVE: 78 | parser.error("Command line option --mode=interactive and --detach are mutually exclusive") 79 | elif not operating_mode: 80 | print(f"Detach extension active, defaulting mode to {OPERATIONS_NON_INTERACTIVE}") 81 | args_dict['mode'] = OPERATIONS_NON_INTERACTIVE 82 | # TODO(tfoote) Deal with the case of dry-run + detach 83 | # Right now the printed results will include '-it' 84 | # But based on testing the --detach overrides -it in docker so it's ok. 85 | 86 | # Default to non-interactive if unset 87 | if args_dict.get('mode') not in OPERATION_MODES: 88 | print("Mode unset, defaulting to interactive") 89 | args_dict['mode'] = OPERATIONS_NON_INTERACTIVE 90 | 91 | try: 92 | active_extensions = extension_manager.get_active_extensions(args_dict) 93 | except ExtensionError as e: 94 | print(f"ERROR! {str(e)}") 95 | return 1 96 | print("Active extensions %s" % [e.get_name() for e in active_extensions]) 97 | 98 | base_image = args.image 99 | 100 | dig = DockerImageGenerator(active_extensions, args_dict, base_image) 101 | exit_code = dig.build(**vars(args)) 102 | if exit_code != 0: 103 | print("Build failed exiting") 104 | if not (args_dict['persist_image'] or args_dict.get('image_name')): 105 | dig.clear_image() 106 | return exit_code 107 | # Convert command into string 108 | args.command = ' '.join(args.command) 109 | result = dig.run(**args_dict) 110 | if not (args_dict['persist_image'] or args_dict.get('image_name')): 111 | print(f'Clearing Image: {dig.image_id}s\nTo not clean up use --persist-images') 112 | dig.clear_image() 113 | return result 114 | 115 | 116 | def detect_image_os(): 117 | parser = argparse.ArgumentParser(description='Detect the os in an image') 118 | parser.add_argument('image') 119 | parser.add_argument('--verbose', action='store_true', 120 | help='Display verbose output of the process') 121 | 122 | args = parser.parse_args() 123 | 124 | results = detect_os(args.image, print if args.verbose else None) 125 | print(results) 126 | if results: 127 | return 0 128 | else: 129 | return 1 130 | -------------------------------------------------------------------------------- /src/rocker/core.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Open Source Robotics Foundation 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from collections import OrderedDict 16 | from contextlib import nullcontext 17 | import io 18 | import os 19 | import pwd 20 | import re 21 | import sys 22 | 23 | # importlib-metadata dependency can be removed when RHEL8 and other 3.6 based systems are not in support cycles 24 | if sys.version_info >= (3, 8): 25 | import importlib.metadata as importlib_metadata 26 | else: 27 | import importlib_metadata 28 | 29 | 30 | import pkgutil 31 | from requests.exceptions import ConnectionError 32 | import shlex 33 | import subprocess 34 | import tempfile 35 | 36 | import docker 37 | import pexpect 38 | 39 | import fcntl 40 | from pathlib import Path 41 | import signal 42 | import struct 43 | import termios 44 | import typing 45 | 46 | SYS_STDOUT = sys.stdout 47 | 48 | OPERATIONS_DRY_RUN = 'dry-run' 49 | OPERATIONS_NON_INTERACTIVE = 'non-interactive' 50 | OPERATIONS_INTERACTIVE = 'interactive' 51 | OPERATION_MODES = [OPERATIONS_INTERACTIVE, OPERATIONS_NON_INTERACTIVE , OPERATIONS_DRY_RUN] 52 | 53 | 54 | class DependencyMissing(RuntimeError): 55 | pass 56 | 57 | 58 | class ExtensionError(RuntimeError): 59 | pass 60 | 61 | 62 | class RockerExtension(object): 63 | """The base class for Rocker extension points""" 64 | 65 | def precondition_environment(self, cliargs): 66 | """Modify the local environment such as setup tempfiles""" 67 | pass 68 | 69 | def validate_environment(self, cliargs): 70 | """ Check that the environment is something that can be used. 71 | This will check that we're on the right base OS and that the 72 | necessary resources are available, like hardware.""" 73 | pass 74 | 75 | def invoke_after(self, cliargs) -> typing.Set[str]: 76 | """ 77 | This extension should be loaded after the extensions in the returned 78 | set. These extensions are not required to be present, but if they are, 79 | they will be loaded before this extension. 80 | """ 81 | return set() 82 | 83 | def required(self, cliargs) -> typing.Set[str]: 84 | """ 85 | Ensures the specified extensions are present and combined with 86 | this extension. If the required extension should be loaded before 87 | this extension, it should also be added to the `invoke_after` set. 88 | """ 89 | return set() 90 | 91 | def get_preamble(self, cliargs): 92 | return '' 93 | 94 | def get_snippet(self, cliargs): 95 | """ Get a dockerfile snippet to be executed as ROOT.""" 96 | return '' 97 | 98 | def get_user_snippet(self, cliargs): 99 | """ Get a dockerfile snippet to be executed after switchingto the expected USER.""" 100 | return '' 101 | 102 | def get_files(self, cliargs): 103 | """Get a dict of local filenames and content to write into them""" 104 | return {} 105 | 106 | @staticmethod 107 | def get_name(): 108 | raise NotImplementedError 109 | 110 | def get_docker_args(self, cliargs): 111 | return '' 112 | 113 | @classmethod 114 | def check_args_for_activation(cls, cli_args): 115 | """ Returns true if the arguments indicate that this extension should be activated otherwise false. 116 | The default implementation looks for the extension name has any value. 117 | It is recommended to override this unless it's just a flag to enable the plugin.""" 118 | return True if cli_args.get(cls.get_name()) else False 119 | 120 | @staticmethod 121 | def register_arguments(parser, defaults): 122 | raise NotImplementedError 123 | 124 | 125 | class RockerExtensionManager: 126 | def __init__(self): 127 | self.available_plugins = list_plugins() 128 | 129 | def extend_cli_parser(self, parser, default_args={}): 130 | for p in self.available_plugins.values(): 131 | try: 132 | p.register_arguments(parser, default_args) 133 | except TypeError as ex: 134 | print("Extension %s doesn't support default arguments. Please extend it." % p.get_name()) 135 | p.register_arguments(parser) 136 | parser.add_argument('--mode', choices=OPERATION_MODES, 137 | help="Choose mode of operation for rocker, default interactive unless detached.") 138 | parser.add_argument('--image-name', default=None, 139 | help='Tag the final image, useful with dry-run') 140 | parser.add_argument('--extension-blacklist', nargs='*', 141 | default=[], 142 | help='Prevent any of these extensions from being loaded.') 143 | parser.add_argument('--strict-extension-selection', action='store_true', 144 | help='When enabled, causes an error if required extensions are not explicitly ' 145 | 'called out on the command line. Otherwise, the required extensions will ' 146 | 'automatically be loaded if available.') 147 | 148 | 149 | def get_active_extensions(self, cli_args): 150 | """ 151 | Checks for missing dependencies (specified by each extension's 152 | required() method) and additionally sorts them. 153 | """ 154 | def sort_extensions(extensions, cli_args): 155 | 156 | def topological_sort(source: typing.Dict[str, typing.Set[str]]) -> typing.List[str]: 157 | """Perform a topological sort on names and dependencies and returns the sorted list of names.""" 158 | names = set(source.keys()) 159 | # prune optional dependencies if they are not present (at this point the required check has already occurred) 160 | pending = [(name, dependencies.intersection(names)) for name, dependencies in source.items()] 161 | emitted = [] 162 | while pending: 163 | next_pending = [] 164 | next_emitted = [] 165 | for entry in pending: 166 | name, deps = entry 167 | deps.difference_update(emitted) # remove dependencies already emitted 168 | if deps: # still has dependencies? recheck during next pass 169 | next_pending.append(entry) 170 | else: # no more dependencies? time to emit 171 | yield name 172 | next_emitted.append(name) # remember what was emitted for difference_update() 173 | if not next_emitted: 174 | raise ExtensionError("Cyclic dependancy detected: %r" % (next_pending,)) 175 | pending = next_pending 176 | emitted = next_emitted 177 | 178 | extension_graph = {name: cls.invoke_after(cli_args) for name, cls in sorted(extensions.items())} 179 | active_extension_list = [extensions[name] for name in topological_sort(extension_graph)] 180 | return active_extension_list 181 | 182 | active_extensions = {} 183 | find_reqs = set([name for name, cls in self.available_plugins.items() if cls.check_args_for_activation(cli_args)]) 184 | while find_reqs: 185 | name = find_reqs.pop() 186 | 187 | if name in self.available_plugins.keys(): 188 | if name not in cli_args['extension_blacklist']: 189 | ext = self.available_plugins[name]() 190 | active_extensions[name] = ext 191 | else: 192 | raise ExtensionError(f"Extension '{name}' is blacklisted.") 193 | else: 194 | raise ExtensionError(f"Extension '{name}' not found. Is it installed?") 195 | 196 | # add additional reqs for processing not already known about 197 | known_reqs = set(active_extensions.keys()).union(find_reqs) 198 | missing_reqs = ext.required(cli_args).difference(known_reqs) 199 | if missing_reqs: 200 | if cli_args['strict_extension_selection']: 201 | raise ExtensionError(f"Extension '{name}' is missing required extension(s) {list(missing_reqs)}") 202 | else: 203 | print(f"Adding implicilty required extension(s) {list(missing_reqs)} required by extension '{name}'") 204 | find_reqs = find_reqs.union(missing_reqs) 205 | 206 | return sort_extensions(active_extensions, cli_args) 207 | 208 | def get_docker_client(): 209 | """Simple helper function for pre 2.0 imports""" 210 | try: 211 | try: 212 | docker_client = docker.from_env().api 213 | except AttributeError: 214 | # docker-py pre 2.0 215 | docker_client = docker.Client() 216 | # Validate that the server is available 217 | docker_client.ping() 218 | return docker_client 219 | except (docker.errors.DockerException, docker.errors.APIError, ConnectionError) as ex: 220 | raise DependencyMissing('Docker Client failed to connect to docker daemon.' 221 | ' Please verify that docker is installed and running.' 222 | ' As well as that you have permission to access the docker daemon.' 223 | ' This is usually by being a member of the docker group.' 224 | ' The underlying error was:\n"""\n%s\n"""\n' % ex) 225 | 226 | def get_user_name(): 227 | userinfo = pwd.getpwuid(os.getuid()) 228 | return getattr(userinfo, 'pw_' + 'name') 229 | 230 | def docker_build(docker_client = None, output_callback = None, **kwargs): 231 | image_id = None 232 | 233 | if not docker_client: 234 | docker_client = get_docker_client() 235 | kwargs['decode'] = True 236 | for line in docker_client.build(**kwargs): 237 | output = line.get('stream', '').rstrip() 238 | if not output: 239 | # print("non stream data", line) 240 | continue 241 | if output_callback is not None: 242 | output_callback(output) 243 | 244 | match = re.match(r'Successfully built ([a-z0-9]{12})', output) 245 | if match: 246 | image_id = match.group(1) 247 | 248 | if image_id: 249 | return image_id 250 | else: 251 | print("no more output and success not detected") 252 | return None 253 | 254 | def docker_remove_image( 255 | image_id, 256 | docker_client = None, 257 | fail_on_error = False, 258 | force = False, 259 | **kwargs): 260 | 261 | if not docker_client: 262 | docker_client = get_docker_client() 263 | 264 | try: 265 | docker_client.remove_image(image_id, force=force) 266 | except docker.errors.APIError as ex: 267 | ## removing the image can fail if there's child images 268 | if fail_on_error: 269 | return False 270 | return True 271 | 272 | class SIGWINCHPassthrough(object): 273 | def __init__ (self, process): 274 | self.process = process 275 | self.active = os.isatty(sys.__stdout__.fileno()) 276 | 277 | def set_window_size(self): 278 | s = struct.pack("HHHH", 0, 0, 0, 0) 279 | try: 280 | a = struct.unpack('hhhh', fcntl.ioctl(SYS_STDOUT.fileno(), 281 | termios.TIOCGWINSZ , s)) 282 | except (io.UnsupportedOperation, AttributeError) as ex: 283 | # We're not interacting with a real stdout, don't do the resize 284 | # This happens when we're in something like unit tests. 285 | return 286 | if not self.process.closed: 287 | self.process.setwinsize(a[0],a[1]) 288 | 289 | 290 | def __enter__(self): 291 | # Short circuit if not a tty 292 | if not self.active: 293 | return self 294 | # Expected function prototype for signal handling 295 | # ignoring unused arguments 296 | def sigwinch_passthrough (sig, data): 297 | self.set_window_size() 298 | 299 | signal.signal(signal.SIGWINCH, sigwinch_passthrough) 300 | 301 | # Initially set the window size since it may not be default sized 302 | self.set_window_size() 303 | return self 304 | 305 | # Clean up signal handler before returning. 306 | def __exit__(self, exc_type, exc_value, traceback): 307 | if not self.active: 308 | return 309 | # This was causing hangs and resolved as referenced 310 | # here: https://github.com/pexpect/pexpect/issues/465 311 | signal.signal(signal.SIGWINCH, signal.SIG_DFL) 312 | 313 | class DockerImageGenerator(object): 314 | def __init__(self, active_extensions, cliargs, base_image): 315 | self.built = False 316 | self.cliargs = cliargs 317 | self.cliargs['base_image'] = base_image # inject base image into arguments for use 318 | self.active_extensions = active_extensions 319 | 320 | self.dockerfile = generate_dockerfile(active_extensions, self.cliargs, base_image) 321 | self.image_id = None 322 | 323 | def build(self, **kwargs): 324 | with tempfile.TemporaryDirectory() as td: 325 | df = os.path.join(td, 'Dockerfile') 326 | print("Writing dockerfile to %s" % df) 327 | with open(df, 'w') as fh: 328 | fh.write(self.dockerfile) 329 | print('vvvvvv') 330 | print(self.dockerfile) 331 | print('^^^^^^') 332 | write_files(self.active_extensions, self.cliargs, td) 333 | arguments = {} 334 | arguments['path'] = td 335 | arguments['rm'] = True 336 | arguments['nocache'] = kwargs.get('nocache', False) 337 | arguments['pull'] = kwargs.get('pull', False) 338 | image_name = kwargs.get('image_name', None) 339 | if image_name: 340 | print(f"Running docker tag {self.image_id} {image_name}") 341 | arguments['tag'] = image_name 342 | print("Building docker file with arguments: ", arguments) 343 | try: 344 | self.image_id = docker_build( 345 | **arguments, 346 | output_callback=lambda output: print("building > %s" % output) 347 | ) 348 | if self.image_id: 349 | self.built = True 350 | return 0 351 | else: 352 | return 2 353 | 354 | except docker.errors.APIError as ex: 355 | print("Docker build failed\n", ex) 356 | return 1 357 | 358 | def get_operating_mode(self, args): 359 | operating_mode = args.get('mode') 360 | # Default to non-interactive if unset 361 | if operating_mode not in OPERATION_MODES: 362 | operating_mode = OPERATIONS_NON_INTERACTIVE 363 | return operating_mode 364 | 365 | def generate_docker_cmd(self, command='', **kwargs): 366 | docker_args = '' 367 | 368 | for e in self.active_extensions: 369 | docker_args += e.get_docker_args(self.cliargs) 370 | 371 | image_name = kwargs.get('image_name', None) 372 | if image_name: 373 | image = image_name 374 | else: 375 | image = self.image_id 376 | cmd = "docker run" 377 | if(not kwargs.get('nocleanup')): 378 | # remove container only if --nocleanup is not present 379 | cmd += " --rm" 380 | 381 | operating_mode = self.get_operating_mode(kwargs) 382 | if operating_mode != OPERATIONS_NON_INTERACTIVE: 383 | # only disable for OPERATIONS_NON_INTERACTIVE 384 | cmd += " -it" 385 | cmd += "%(docker_args)s %(image)s %(command)s" % locals() 386 | return cmd 387 | 388 | def run(self, command='', **kwargs): 389 | if not self.built: 390 | print("Cannot run if build has not passed.") 391 | return 1 392 | 393 | for e in self.active_extensions: 394 | try: 395 | e.precondition_environment(self.cliargs) 396 | except subprocess.CalledProcessError as ex: 397 | print("ERROR! Failed to precondition for extension [%s] with error: %s\ndeactivating" % (e.get_name(), ex)) 398 | return 1 399 | 400 | cmd = self.generate_docker_cmd(command, **kwargs) 401 | operating_mode = self.get_operating_mode(kwargs) 402 | console_output_file = kwargs.get('console_output_file') 403 | 404 | # $DOCKER_OPTS \ 405 | if operating_mode == OPERATIONS_DRY_RUN: 406 | print("Run this command: \n\n\n") 407 | print(cmd) 408 | return 0 409 | elif operating_mode == OPERATIONS_NON_INTERACTIVE: 410 | try: 411 | with open(console_output_file, 'a') if console_output_file else nullcontext() as consoleout_fh: 412 | if console_output_file: 413 | print(f"Logging output to file {console_output_file}") 414 | print("Executing command: ") 415 | print(cmd) 416 | p = subprocess.run(shlex.split(cmd), check=True, stdout=consoleout_fh if console_output_file else None, stderr=subprocess.STDOUT) 417 | return p.returncode 418 | except subprocess.CalledProcessError as ex: 419 | print("Non-interactive Docker run failed\n", ex) 420 | return ex.returncode 421 | else: 422 | try: 423 | print("Executing command: ") 424 | print(cmd) 425 | p = pexpect.spawn(cmd) 426 | with SIGWINCHPassthrough(p): 427 | p.interact() 428 | p.close(force=True) 429 | return p.exitstatus 430 | except pexpect.ExceptionPexpect as ex: 431 | print("Docker run failed\n", ex) 432 | return ex.returncode 433 | 434 | def clear_image(self): 435 | if self.image_id: 436 | if not docker_remove_image(self.image_id): 437 | print(f'Failed to clear image {self.image_id} it likely has child images.') 438 | self.image_id = None 439 | self.built = False 440 | 441 | def write_files(extensions, args_dict, target_directory): 442 | all_files = {} 443 | for active_extension in extensions: 444 | for file_path, contents in active_extension.get_files(args_dict).items(): 445 | if os.path.isabs(file_path): 446 | print('WARNING!! Path %s from extension %s is absolute' 447 | 'and cannot be written out, skipping' % (file_path, active_extension.get_name())) 448 | continue 449 | full_path = os.path.join(target_directory, file_path) 450 | if Path(target_directory).resolve() not in Path(full_path).resolve().parents: 451 | print('WARNING!! Path %s from extension %s is outside target directory' 452 | 'and cannot be written out, skipping' % (file_path, active_extension.get_name())) 453 | continue 454 | Path(os.path.dirname(full_path)).mkdir(exist_ok=True, parents=True) 455 | mode = 'wb' if isinstance(contents, bytes) else 'w' # check to see if contents should be written as binary 456 | with open(full_path, mode) as fh: 457 | print('Writing to file %s' % full_path) 458 | fh.write(contents) 459 | return all_files 460 | 461 | 462 | def generate_dockerfile(extensions, args_dict, base_image): 463 | dockerfile_str = '' 464 | # Preamble snippets 465 | for el in extensions: 466 | dockerfile_str += '# Preamble from extension [%s]\n' % el.get_name() 467 | dockerfile_str += el.get_preamble(args_dict) + '\n' 468 | dockerfile_str += '\nFROM %s\n' % base_image 469 | # ROOT snippets 470 | dockerfile_str += 'USER root\n' 471 | for el in extensions: 472 | dockerfile_str += '# Snippet from extension [%s]\n' % el.get_name() 473 | dockerfile_str += el.get_snippet(args_dict) + '\n' 474 | # Set USER if user extension activated 475 | if 'user' in args_dict and args_dict['user']: 476 | if 'user_override_name' in args_dict and args_dict['user_override_name']: 477 | username = args_dict['user_override_name'] 478 | else: 479 | username = get_user_name() 480 | dockerfile_str += f'USER {username}\n' 481 | # USER snippets 482 | for el in extensions: 483 | dockerfile_str += '# User Snippet from extension [%s]\n' % el.get_name() 484 | dockerfile_str += el.get_user_snippet(args_dict) + '\n' 485 | return dockerfile_str 486 | 487 | 488 | def list_entry_points(): 489 | entry_points = importlib_metadata.entry_points() 490 | if hasattr(entry_points, 'select'): 491 | styles_groups = entry_points.select(group='flake8_import_order.styles') 492 | else: 493 | styles_groups = entry_points.get('flake8_import_order.styles', []) 494 | 495 | return styles_groups 496 | 497 | def list_plugins(extension_point='rocker.extensions'): 498 | 499 | all_entry_points = importlib_metadata.entry_points() 500 | if hasattr(all_entry_points, 'select'): 501 | rocker_extensions = all_entry_points.select(group=extension_point) 502 | else: 503 | rocker_extensions = all_entry_points.get(extension_point, []) 504 | 505 | unordered_plugins = { 506 | entry_point.name: entry_point.load() 507 | for entry_point in rocker_extensions 508 | } 509 | # Order plugins by extension point name for consistent ordering below 510 | plugin_names = list(unordered_plugins.keys()) 511 | plugin_names.sort() 512 | return OrderedDict([(k, unordered_plugins[k]) for k in plugin_names]) 513 | 514 | 515 | def get_rocker_version(): 516 | return importlib_metadata.version('rocker') 517 | -------------------------------------------------------------------------------- /src/rocker/em.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Open Source Robotics Foundation 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | 16 | import em 17 | 18 | def empy_expand(template, substitution_variables): 19 | """Indirection for empy version compatibility.""" 20 | if em.__version__.startswith('3'): 21 | return em.expand(template, substitution_variables) 22 | else: 23 | return em.expand(template, globals=substitution_variables) -------------------------------------------------------------------------------- /src/rocker/extensions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Open Source Robotics Foundation 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import grp 16 | import os 17 | import docker 18 | import getpass 19 | import pwd 20 | import pkgutil 21 | from pathlib import Path 22 | from shlex import quote 23 | import subprocess 24 | import sys 25 | 26 | from .core import get_docker_client 27 | from .em import empy_expand 28 | 29 | 30 | def name_to_argument(name): 31 | return '--%s' % name.replace('_', '-') 32 | 33 | from .core import RockerExtension 34 | 35 | class Detach(RockerExtension): 36 | @staticmethod 37 | def get_name(): 38 | return 'detach' 39 | 40 | def __init__(self): 41 | self.name = Detach.get_name() 42 | 43 | def get_docker_args(self, cliargs): 44 | args = '' 45 | detach = cliargs.get('detach', False) 46 | if detach: 47 | args += ' --detach' 48 | return args 49 | 50 | @staticmethod 51 | def register_arguments(parser, defaults): 52 | parser.add_argument( 53 | '-d', 54 | '--detach', 55 | action='store_true', 56 | default=defaults.get('detach', False), 57 | help='Run the container in the background.' 58 | ) 59 | 60 | 61 | class Devices(RockerExtension): 62 | @staticmethod 63 | def get_name(): 64 | return 'devices' 65 | 66 | def __init__(self): 67 | self.name = Devices.get_name() 68 | 69 | def get_preamble(self, cliargs): 70 | return '' 71 | 72 | def get_docker_args(self, cliargs): 73 | args = '' 74 | devices = cliargs.get('devices', None) 75 | for device in devices: 76 | if not os.path.exists(device): 77 | print("ERROR device %s doesn't exist. Skipping" % device) 78 | continue 79 | args += ' --device %s ' % device 80 | return args 81 | 82 | @staticmethod 83 | def register_arguments(parser, defaults): 84 | parser.add_argument('--devices', 85 | default=defaults.get('devices', None), 86 | nargs='*', 87 | help="Mount devices into the container.") 88 | 89 | 90 | class DevHelpers(RockerExtension): 91 | @staticmethod 92 | def get_name(): 93 | return 'dev_helpers' 94 | 95 | def __init__(self): 96 | self._env_subs = None 97 | self.name = DevHelpers.get_name() 98 | 99 | 100 | def get_environment_subs(self): 101 | if not self._env_subs: 102 | self._env_subs = {} 103 | return self._env_subs 104 | 105 | def get_preamble(self, cliargs): 106 | return '' 107 | 108 | def get_snippet(self, cliargs): 109 | snippet = pkgutil.get_data('rocker', 'templates/%s_snippet.Dockerfile.em' % self.name).decode('utf-8') 110 | return empy_expand(snippet, self.get_environment_subs()) 111 | 112 | @staticmethod 113 | def register_arguments(parser, defaults): 114 | parser.add_argument(name_to_argument(DevHelpers.get_name()), 115 | action='store_true', 116 | default=defaults.get('dev_helpers', None), 117 | help="add development tools emacs and byobu to your environment") 118 | 119 | 120 | class Expose(RockerExtension): 121 | @staticmethod 122 | def get_name(): 123 | return 'expose' 124 | 125 | def __init__(self): 126 | self.name = Expose.get_name() 127 | 128 | def get_preamble(self, cliargs): 129 | return '' 130 | 131 | def get_docker_args(self, cliargs): 132 | args = [''] 133 | ports = cliargs.get('expose', []) 134 | for port in ports: 135 | args.append(' --expose {0}'.format(port)) 136 | return ' '.join(args) 137 | 138 | @staticmethod 139 | def register_arguments(parser, defaults={}): 140 | parser.add_argument('--expose', 141 | default=defaults.get('expose', None), 142 | action='append', 143 | help="Exposes a port from the container to host machine.") 144 | 145 | 146 | class Hostname(RockerExtension): 147 | @staticmethod 148 | def get_name(): 149 | return 'hostname' 150 | 151 | def __init__(self): 152 | self.name = Hostname.get_name() 153 | 154 | def get_preamble(self, cliargs): 155 | return '' 156 | 157 | def get_docker_args(self, cliargs): 158 | args = '' 159 | hostname = cliargs.get('hostname', None) 160 | if hostname: 161 | args += ' --hostname %s ' % hostname 162 | return args 163 | 164 | @staticmethod 165 | def register_arguments(parser, defaults): 166 | parser.add_argument('--hostname', default=defaults.get('hostname', ''), 167 | help='Hostname of the container.') 168 | 169 | 170 | class Ipc(RockerExtension): 171 | @staticmethod 172 | def get_name(): 173 | return 'ipc' 174 | def __init__(self): 175 | self.name = Ipc.get_name() 176 | 177 | def get_preamble(self, cliargs): 178 | return '' 179 | 180 | def get_docker_args(self, cliargs): 181 | args = '' 182 | ipc = cliargs.get('ipc', None) 183 | args += ' --ipc %s ' % ipc 184 | return args 185 | 186 | @staticmethod 187 | def register_arguments(parser, defaults={}): 188 | parser.add_argument('--ipc', default=defaults.get('ipc', None), 189 | help='IPC namespace to use. To share ipc with the host use host. More details can be found at https://docs.docker.com/reference/cli/docker/container/run/#ipc') 190 | 191 | 192 | class Name(RockerExtension): 193 | @staticmethod 194 | def get_name(): 195 | return 'name' 196 | 197 | def __init__(self): 198 | self.name = Name.get_name() 199 | 200 | def get_preamble(self, cliargs): 201 | return '' 202 | 203 | def get_docker_args(self, cliargs): 204 | args = '' 205 | name = cliargs.get('name', None) 206 | if name: 207 | args += ' --name %s ' % name 208 | return args 209 | 210 | @staticmethod 211 | def register_arguments(parser, defaults): 212 | parser.add_argument('--name', default=defaults.get('name', ''), 213 | help='Name of the container.') 214 | 215 | 216 | class Network(RockerExtension): 217 | @staticmethod 218 | def get_name(): 219 | return 'network' 220 | 221 | def __init__(self): 222 | self.name = Network.get_name() 223 | 224 | def get_preamble(self, cliargs): 225 | return '' 226 | 227 | def get_docker_args(self, cliargs): 228 | args = '' 229 | network = cliargs.get('network', None) 230 | args += ' --network %s ' % network 231 | return args 232 | 233 | @staticmethod 234 | def register_arguments(parser, defaults): 235 | client = get_docker_client() 236 | parser.add_argument('--network', choices=[n['Name'] for n in client.networks()], 237 | default=defaults.get('network', None), 238 | help="What network configuration to use.") 239 | 240 | 241 | class Port(RockerExtension): 242 | @staticmethod 243 | def get_name(): 244 | return 'port' 245 | 246 | def __init__(self): 247 | self.name = Port.get_name() 248 | 249 | def get_preamble(self, cliargs): 250 | return '' 251 | 252 | def get_docker_args(self, cliargs): 253 | args = [''] 254 | ports = cliargs.get('port', []) 255 | for port in ports: 256 | args.append(' -p {0}'.format(port)) 257 | return ' '.join(args) 258 | 259 | @staticmethod 260 | def register_arguments(parser, defaults): 261 | parser.add_argument('--port', 262 | default=defaults.get('port', None), 263 | action='append', 264 | help="Binds port from the container to host machine.") 265 | 266 | 267 | class PulseAudio(RockerExtension): 268 | @staticmethod 269 | def get_name(): 270 | return 'pulse' 271 | 272 | def __init__(self): 273 | self._env_subs = None 274 | self.name = PulseAudio.get_name() 275 | 276 | 277 | def get_environment_subs(self): 278 | if not self._env_subs: 279 | self._env_subs = {} 280 | self._env_subs['user_id'] = os.getuid() 281 | self._env_subs['XDG_RUNTIME_DIR'] = os.getenv('XDG_RUNTIME_DIR') 282 | self._env_subs['audio_group_id'] = grp.getgrnam('audio').gr_gid 283 | return self._env_subs 284 | 285 | def get_preamble(self, cliargs): 286 | return '' 287 | 288 | def get_snippet(self, cliargs): 289 | snippet = pkgutil.get_data('rocker', 'templates/%s_snippet.Dockerfile.em' % self.name).decode('utf-8') 290 | return empy_expand(snippet, self.get_environment_subs()) 291 | 292 | def get_docker_args(self, cliargs): 293 | args = ' -v /run/user/%(user_id)s/pulse:/run/user/%(user_id)s/pulse --device /dev/snd '\ 294 | ' -e PULSE_SERVER=unix:%(XDG_RUNTIME_DIR)s/pulse/native -v %(XDG_RUNTIME_DIR)s/pulse/native:%(XDG_RUNTIME_DIR)s/pulse/native --group-add %(audio_group_id)s ' 295 | return args % self.get_environment_subs() 296 | 297 | @staticmethod 298 | def register_arguments(parser, defaults): 299 | parser.add_argument(name_to_argument(PulseAudio.get_name()), 300 | action='store_true', 301 | default=defaults.get(PulseAudio.get_name(), None), 302 | help="mount pulse audio devices") 303 | 304 | 305 | class HomeDir(RockerExtension): 306 | @staticmethod 307 | def get_name(): 308 | return 'home' 309 | 310 | def __init__(self): 311 | self.name = HomeDir.get_name() 312 | 313 | def get_docker_args(self, cliargs): 314 | return ' -v %s:%s ' % (Path.home(), Path.home()) 315 | 316 | @staticmethod 317 | def register_arguments(parser, defaults): 318 | parser.add_argument(name_to_argument(HomeDir.get_name()), 319 | action='store_true', 320 | default=defaults.get(HomeDir.get_name(), None), 321 | help="mount the users home directory") 322 | 323 | 324 | class User(RockerExtension): 325 | @staticmethod 326 | def get_name(): 327 | return 'user' 328 | 329 | def get_environment_subs(self): 330 | if not self._env_subs: 331 | user_vars = ['name', 'uid', 'gid', 'gecos','dir', 'shell'] 332 | userinfo = pwd.getpwuid(os.getuid()) 333 | self._env_subs = { 334 | k: getattr(userinfo, 'pw_' + k) 335 | for k in user_vars } 336 | return self._env_subs 337 | 338 | def __init__(self): 339 | self._env_subs = None 340 | self.name = User.get_name() 341 | 342 | def get_snippet(self, cliargs): 343 | snippet = pkgutil.get_data('rocker', 'templates/%s_snippet.Dockerfile.em' % self.name).decode('utf-8') 344 | substitutions = self.get_environment_subs() 345 | if 'user_override_name' in cliargs and cliargs['user_override_name']: 346 | substitutions['name'] = cliargs['user_override_name'] 347 | substitutions['dir'] = os.path.join('/home/', cliargs['user_override_name']) 348 | substitutions['user_preserve_home'] = True if 'user_preserve_home' in cliargs and cliargs['user_preserve_home'] else False 349 | if 'user_preserve_groups' in cliargs and isinstance(cliargs['user_preserve_groups'], list): 350 | query_groups = cliargs['user_preserve_groups'] 351 | all_groups = grp.getgrall() 352 | if query_groups: 353 | matched_groups = [g for g in all_groups if g.gr_name in query_groups] 354 | matched_group_names = [g.gr_name for g in matched_groups] 355 | unmatched_groups = [n for n in cliargs['user_preserve_groups'] if n not in matched_group_names] 356 | if unmatched_groups: 357 | print('Warning skipping groups %s because they do not exist on the host.' % unmatched_groups) 358 | substitutions['user_groups'] = ' '.join(['{};{}'.format(g.gr_name, g.gr_gid) for g in matched_groups]) 359 | else: 360 | substitutions['user_groups'] = ' '.join(['{};{}'.format(g.gr_name, g.gr_gid) for g in all_groups if substitutions['name'] in g.gr_mem]) 361 | else: 362 | substitutions['user_groups'] = '' 363 | substitutions['user_preserve_groups_permissive'] = True if 'user_preserve_groups_permissive' in cliargs and cliargs['user_preserve_groups_permissive'] else False 364 | substitutions['home_extension_active'] = True if 'home' in cliargs and cliargs['home'] else False 365 | if 'user_override_shell' in cliargs and cliargs['user_override_shell'] is not None: 366 | if cliargs['user_override_shell'] == '': 367 | substitutions['shell'] = None 368 | else: 369 | substitutions['shell'] = cliargs['user_override_shell'] 370 | return empy_expand(snippet, substitutions) 371 | 372 | @staticmethod 373 | def register_arguments(parser, defaults): 374 | parser.add_argument(name_to_argument(User.get_name()), 375 | action='store_true', 376 | default=defaults.get('user', None), 377 | help="mount the current user's id and run as that user") 378 | parser.add_argument('--user-override-name', 379 | action='store', 380 | default=defaults.get('user-override-name', None), 381 | help="override the current user's name") 382 | parser.add_argument('--user-preserve-home', 383 | action='store_true', 384 | default=defaults.get('user-preserve-home', False), 385 | help="Do not delete home directory if it exists when making a new user.") 386 | parser.add_argument('--user-preserve-groups', 387 | action='store', 388 | nargs='*', 389 | default=defaults.get('user-preserve-groups', False), 390 | help="Assign user to same groups as he belongs in host. If arguments provided they are the explicit list of groups.") 391 | parser.add_argument('--user-preserve-groups-permissive', 392 | action='store_true', 393 | default=defaults.get('user-preserve-groups-permissive', False), 394 | help="If using user-preserve-groups allow failures in assignment." 395 | "This is important if the host and target have different rules. https://unix.stackexchange.com/a/11481/83370" ) 396 | parser.add_argument('--user-override-shell', 397 | action='store', 398 | default=defaults.get('user-override-shell', None), 399 | help="Override the current user's shell. Set to empty string to use container default shell") 400 | 401 | 402 | class Environment(RockerExtension): 403 | @staticmethod 404 | def get_name(): 405 | return 'env' 406 | 407 | def __init__(self): 408 | self.name = Environment.get_name() 409 | 410 | def get_snippet(self, cli_args): 411 | return '' 412 | 413 | def get_docker_args(self, cli_args): 414 | args = [''] 415 | 416 | if cli_args.get('env'): 417 | envs = [ x for sublist in cli_args['env'] for x in sublist] 418 | for env in envs: 419 | args.append('-e {0}'.format(quote(env))) 420 | 421 | if cli_args.get('env_file'): 422 | env_files = [ x for sublist in cli_args['env_file'] for x in sublist] 423 | for env_file in env_files: 424 | args.append('--env-file {0}'.format(quote(env_file))) 425 | 426 | return ' '.join(args) 427 | 428 | @staticmethod 429 | def register_arguments(parser, defaults): 430 | parser.add_argument('--env', '-e', 431 | metavar='NAME[=VALUE]', 432 | type=str, 433 | nargs='+', 434 | action='append', 435 | default=defaults.get(Environment.get_name(), []), 436 | help='set environment variables') 437 | parser.add_argument('--env-file', 438 | type=str, 439 | nargs=1, 440 | action='append', 441 | help='set environment variable via env-file') 442 | 443 | @classmethod 444 | def check_args_for_activation(cls, cli_args): 445 | """ Returns true if the arguments indicate that this extension should be activated otherwise false.""" 446 | return True if cli_args.get('env') or cli_args.get('env_file') else False 447 | 448 | 449 | class Privileged(RockerExtension): 450 | """Add --privileged to docker arguments.""" 451 | @staticmethod 452 | def get_name(): 453 | return 'privileged' 454 | 455 | def __init__(self): 456 | self.name = Privileged.get_name() 457 | 458 | def get_snippet(self, cli_args): 459 | return '' 460 | 461 | def get_docker_args(self, cli_args): 462 | return ' --privileged' 463 | 464 | @staticmethod 465 | def register_arguments(parser, defaults): 466 | parser.add_argument(name_to_argument(Privileged.get_name()), 467 | action='store_true', 468 | default=defaults.get(Privileged.get_name(), None), 469 | help="give extended privileges to the container") 470 | 471 | 472 | class GroupAdd(RockerExtension): 473 | """Add additional groups to running container.""" 474 | @staticmethod 475 | def get_name(): 476 | return 'group_add' 477 | 478 | def __init__(self): 479 | self.name = GroupAdd.get_name() 480 | 481 | def get_preamble(self, cliargs): 482 | return '' 483 | 484 | def get_docker_args(self, cliargs): 485 | args = [''] 486 | groups = cliargs.get('group_add', []) 487 | for group in groups: 488 | args.append(' --group-add {0}'.format(group)) 489 | return ' '.join(args) 490 | 491 | @staticmethod 492 | def register_arguments(parser, defaults): 493 | parser.add_argument(name_to_argument(GroupAdd.get_name()), 494 | default=defaults.get(GroupAdd.get_name(), None), 495 | action='append', 496 | help="Add additional groups to join.") 497 | 498 | class ShmSize(RockerExtension): 499 | @staticmethod 500 | def get_name(): 501 | return 'shm_size' 502 | 503 | def __init__(self): 504 | self.name = ShmSize.get_name() 505 | 506 | def get_preamble(self, cliargs): 507 | return '' 508 | 509 | def get_docker_args(self, cliargs): 510 | args = '' 511 | shm_size = cliargs.get('shm_size', None) 512 | if shm_size: 513 | args += f' --shm-size {shm_size} ' 514 | return args 515 | 516 | @staticmethod 517 | def register_arguments(parser, defaults={}): 518 | parser.add_argument('--shm-size', 519 | default=defaults.get('shm_size', None), 520 | help="Set the size of the shared memory for the container (e.g., 512m, 1g).") -------------------------------------------------------------------------------- /src/rocker/git_extension.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Open Source Robotics Foundation 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from argparse import ArgumentTypeError 16 | import getpass 17 | import os 18 | from rocker.extensions import RockerExtension 19 | 20 | 21 | class Git(RockerExtension): 22 | 23 | name = 'git' 24 | 25 | @classmethod 26 | def get_name(cls): 27 | return cls.name 28 | 29 | 30 | def get_docker_args(self, cli_args): 31 | args = '' 32 | # only parameterized for testing 33 | system_gitconfig = cli_args.get('git_config_path_system', '/etc/gitconfig') 34 | system_gitconfig_target = '/etc/gitconfig' 35 | user_gitconfig = cli_args.get('git_config_path', os.path.expanduser('~/.gitconfig')) 36 | user_gitconfig_target = '/root/.gitconfig' 37 | if 'user' in cli_args and cli_args['user']: 38 | username = getpass.getuser() 39 | if 'user_override_name' in cli_args and cli_args['user_override_name']: 40 | username = cli_args['user_override_name'] 41 | user_gitconfig_target = '/home/%(username)s/.gitconfig' % locals() 42 | if os.path.exists(system_gitconfig): 43 | args += ' -v {system_gitconfig}:{system_gitconfig_target}:ro'.format(**locals()) 44 | if os.path.exists(user_gitconfig): 45 | args += ' -v {user_gitconfig}:{user_gitconfig_target}:ro'.format(**locals()) 46 | return args 47 | 48 | @staticmethod 49 | def register_arguments(parser, defaults): 50 | parser.add_argument('--git', 51 | action='store_true', 52 | default=defaults.get(Git.get_name(), None), 53 | help="Use the global Git settings from the host (/etc/gitconfig and ~/.gitconfig)") 54 | parser.add_argument('--git-config-path', 55 | action='store', 56 | default=defaults.get('git_config_path', os.path.expanduser('~/.gitconfig')), 57 | help="Override the path to the git config default: %s" % defaults.get('git_config_path', os.path.expanduser('~/.gitconfig'))) 58 | -------------------------------------------------------------------------------- /src/rocker/nvidia_extension.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Open Source Robotics Foundation 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os 16 | import getpass 17 | import tempfile 18 | from packaging.version import Version 19 | import pkgutil 20 | from pathlib import Path 21 | import subprocess 22 | import sys 23 | 24 | from .os_detector import detect_os 25 | 26 | from .extensions import name_to_argument 27 | from .core import get_docker_client 28 | from .core import RockerExtension 29 | from .em import empy_expand 30 | 31 | GLVND_VERSION_POLICY_LATEST_LTS='latest_lts' 32 | 33 | NVIDIA_GLVND_VALID_VERSIONS=['16.04', '18.04','20.04', '22.04', '24.04'] 34 | 35 | def get_docker_version(): 36 | docker_version_raw = get_docker_client().version()['Version'] 37 | # Fix for version 17.09.0-ce 38 | return Version(docker_version_raw.split('-')[0]) 39 | 40 | def glvnd_version_from_policy(image_version, policy): 41 | # Default policy GLVND_VERSION_POLICY_LATEST_LTS 42 | if not policy: 43 | policy = GLVND_VERSION_POLICY_LATEST_LTS 44 | 45 | if policy == GLVND_VERSION_POLICY_LATEST_LTS: 46 | if image_version in ['16.04', '16.10', '17.04', '17.10']: 47 | return '16.04' 48 | if image_version in ['18.04', '18.10', '19.04', '19.10']: 49 | return '18.04' 50 | if image_version in ['20.04', '20.10', '21.04', '21.10']: 51 | return '20.04' 52 | if image_version in ['22.04', '22.10', '23.04', '23.10']: 53 | return '22.04' 54 | # 24.04 is not available yet 55 | # if image_version in ['24.04', '24.10', '25.04', '25.10']: 56 | # return '24.04' 57 | return '22.04' 58 | return None 59 | 60 | class X11(RockerExtension): 61 | @staticmethod 62 | def get_name(): 63 | return 'x11' 64 | 65 | def __init__(self): 66 | self.name = X11.get_name() 67 | self._env_subs = None 68 | self._xauth = None 69 | 70 | def get_docker_args(self, cliargs): 71 | assert self._xauth, 'xauth not initialized, get_docker_args must be called after precodition_environment' 72 | xauth = self._xauth.name 73 | return " -e DISPLAY -e TERM \ 74 | -e QT_X11_NO_MITSHM=1 \ 75 | -e XAUTHORITY=%(xauth)s -v %(xauth)s:%(xauth)s \ 76 | -v /tmp/.X11-unix:/tmp/.X11-unix \ 77 | -v /etc/localtime:/etc/localtime:ro " % locals() 78 | 79 | def precondition_environment(self, cliargs): 80 | self._xauth = tempfile.NamedTemporaryFile(prefix='.docker', suffix='.xauth', delete=not cliargs.get('nocleanup')) 81 | xauth = self._xauth.name 82 | display = os.getenv('DISPLAY') 83 | # Make sure processes in the container can connect to the x server 84 | # Necessary so gazebo can create a context for OpenGL rendering (even headless) 85 | if not os.path.exists(xauth): #if [ ! -f $XAUTH ] 86 | Path(xauth).touch() 87 | # print("touched %s" % xauth) 88 | cmd = 'xauth nlist %(display)s | sed -e \'s/^..../ffff/\' | xauth -f %(xauth)s nmerge -' % locals() 89 | # print("runnning %s" % cmd) 90 | try: 91 | subprocess.check_call(cmd, shell=True) 92 | except subprocess.CalledProcessError as ex: 93 | print("Failed setting up XAuthority with command %s" % cmd) 94 | raise ex 95 | 96 | @staticmethod 97 | def register_arguments(parser, defaults): 98 | parser.add_argument(name_to_argument(X11.get_name()), 99 | action='store_true', 100 | default=defaults.get(X11.get_name(), None), 101 | help="Enable x11") 102 | 103 | 104 | class Nvidia(RockerExtension): 105 | @staticmethod 106 | def get_name(): 107 | return 'nvidia' 108 | 109 | def __init__(self): 110 | self._env_subs = None 111 | self.name = Nvidia.get_name() 112 | self.supported_distros = ['Ubuntu', 'Debian GNU/Linux'] 113 | self.supported_versions = ['16.04', '18.04', '20.04', '10', '22.04', '24.04'] 114 | 115 | 116 | def get_environment_subs(self, cliargs={}): 117 | if not self._env_subs: 118 | self._env_subs = {} 119 | self._env_subs['user_id'] = os.getuid() 120 | self._env_subs['username'] = getpass.getuser() 121 | 122 | # non static elements test every time 123 | detected_os = detect_os(cliargs['base_image'], print, nocache=cliargs.get('nocache', False)) 124 | if detected_os is None: 125 | print("WARNING unable to detect os for base image '%s', maybe the base image does not exist" % cliargs['base_image']) 126 | sys.exit(1) 127 | dist, ver, codename = detected_os 128 | 129 | self._env_subs['image_distro_id'] = dist 130 | if self._env_subs['image_distro_id'] not in self.supported_distros: 131 | print("WARNING distro id %s not supported by Nvidia supported " % self._env_subs['image_distro_id'], self.supported_distros) 132 | sys.exit(1) 133 | self._env_subs['image_distro_version'] = ver 134 | if self._env_subs['image_distro_version'] not in self.supported_versions: 135 | print("WARNING distro %s version %s not in supported list by Nvidia supported versions" % (dist, ver), self.supported_versions) 136 | sys.exit(1) 137 | # TODO(tfoote) add a standard mechanism for checking preconditions and disabling plugins 138 | nvidia_glvnd_version = cliargs.get('nvidia_glvnd_version', None) 139 | if not nvidia_glvnd_version: 140 | nvidia_glvnd_version = glvnd_version_from_policy(ver, cliargs.get('nvidia_glvnd_policy', None) ) 141 | self._env_subs['nvidia_glvnd_version'] = nvidia_glvnd_version 142 | 143 | return self._env_subs 144 | 145 | def get_preamble(self, cliargs): 146 | preamble = pkgutil.get_data('rocker', 'templates/%s_preamble.Dockerfile.em' % self.name).decode('utf-8') 147 | return empy_expand(preamble, self.get_environment_subs(cliargs)) 148 | 149 | def get_snippet(self, cliargs): 150 | snippet = pkgutil.get_data('rocker', 'templates/%s_snippet.Dockerfile.em' % self.name).decode('utf-8') 151 | return empy_expand(snippet, self.get_environment_subs(cliargs)) 152 | 153 | def get_docker_args(self, cliargs): 154 | force_flag = cliargs.get('nvidia', None) 155 | if force_flag == 'runtime': 156 | return " --runtime=nvidia" 157 | if force_flag == 'gpus': 158 | return " --gpus all" 159 | if get_docker_version() >= Version("19.03"): 160 | return " --gpus all" 161 | return " --runtime=nvidia" 162 | 163 | @staticmethod 164 | def register_arguments(parser, defaults): 165 | parser.add_argument(name_to_argument(Nvidia.get_name()), 166 | choices=['auto', 'runtime', 'gpus'], 167 | nargs='?', 168 | const='auto', 169 | default=defaults.get(Nvidia.get_name(), None), 170 | help="Enable nvidia. Default behavior is to pick flag based on docker version.") 171 | parser.add_argument('--nvidia-glvnd-version', 172 | choices=NVIDIA_GLVND_VALID_VERSIONS, 173 | default=defaults.get('nvidia-glvnd-version', None), 174 | help="Explicitly select an nvidia glvnd version") 175 | parser.add_argument('--nvidia-glvnd-policy', 176 | choices=[GLVND_VERSION_POLICY_LATEST_LTS], 177 | default=defaults.get('nvidia-glvnd-policy', GLVND_VERSION_POLICY_LATEST_LTS), 178 | help="Set an nvidia glvnd version policy if version is unset") 179 | 180 | class Cuda(RockerExtension): 181 | @staticmethod 182 | def get_name(): 183 | return 'cuda' 184 | 185 | def __init__(self): 186 | self._env_subs = None 187 | self.name = Cuda.get_name() 188 | self.supported_distros = ['Ubuntu', 'Debian GNU/Linux'] 189 | self.supported_versions = ['20.04', '22.04', '24.04', '11', '12'] # Debian 11 and 12 190 | 191 | def get_environment_subs(self, cliargs={}): 192 | if not self._env_subs: 193 | self._env_subs = {} 194 | self._env_subs['user_id'] = os.getuid() 195 | self._env_subs['username'] = getpass.getuser() 196 | 197 | # non static elements test every time 198 | detected_os = detect_os(cliargs['base_image'], print, nocache=cliargs.get('nocache', False)) 199 | if detected_os is None: 200 | print("WARNING unable to detect os for base image '%s', maybe the base image does not exist" % cliargs['base_image']) 201 | sys.exit(1) 202 | dist, ver, codename = detected_os 203 | 204 | self._env_subs['download_osstring'] = dist.split()[0].lower() 205 | self._env_subs['download_verstring'] = ver.replace('.', '') 206 | self._env_subs['download_keyid'] = '3bf863cc' 207 | 208 | self._env_subs['image_distro_id'] = dist 209 | if self._env_subs['image_distro_id'] not in self.supported_distros: 210 | print("WARNING distro id %s not supported by Cuda supported " % self._env_subs['image_distro_id'], self.supported_distros) 211 | sys.exit(1) 212 | self._env_subs['image_distro_version'] = ver 213 | if self._env_subs['image_distro_version'] not in self.supported_versions: 214 | print("WARNING distro %s version %s not in supported list by Nvidia supported versions" % (dist, ver), self.supported_versions) 215 | sys.exit(1) 216 | # TODO(tfoote) add a standard mechanism for checking preconditions and disabling plugins 217 | 218 | return self._env_subs 219 | 220 | def get_preamble(self, cliargs): 221 | return '' 222 | # preamble = pkgutil.get_data('rocker', 'templates/%s_preamble.Dockerfile.em' % self.name).decode('utf-8') 223 | # return empy_expand(preamble, self.get_environment_subs(cliargs)) 224 | 225 | def get_snippet(self, cliargs): 226 | snippet = pkgutil.get_data('rocker', 'templates/%s_snippet.Dockerfile.em' % self.name).decode('utf-8') 227 | return empy_expand(snippet, self.get_environment_subs(cliargs)) 228 | 229 | def get_docker_args(self, cliargs): 230 | return "" 231 | # Runtime requires --nvidia option too 232 | 233 | @staticmethod 234 | def register_arguments(parser, defaults): 235 | parser.add_argument(name_to_argument(Cuda.get_name()), 236 | action='store_true', 237 | default=defaults.get('cuda', None), 238 | help="Install cuda and nvidia-cuda-dev into the container") 239 | -------------------------------------------------------------------------------- /src/rocker/os_detector.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019-2022 Arm Ltd., Open Source Robotics Foundation 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import json 16 | import pexpect 17 | 18 | from io import BytesIO as StringIO 19 | 20 | from .core import docker_build, get_docker_client 21 | 22 | 23 | DETECTION_TEMPLATE=""" 24 | FROM golang:1.19 as detector 25 | 26 | # For reliability, pin a distro-detect commit instead of targeting a branch. 27 | RUN git clone -q https://github.com/dekobon/distro-detect.git && \ 28 | cd distro-detect && \ 29 | git checkout -q 5f5b9c724b9d9a117732d2a4292e6288905734e1 && \ 30 | CGO_ENABLED=0 go build . 31 | 32 | FROM %(image_name)s 33 | 34 | COPY --from=detector /go/distro-detect/distro-detect /tmp/detect_os 35 | ENTRYPOINT [ "/tmp/detect_os", "-format", "json-one-line" ] 36 | CMD [ "" ] 37 | """ 38 | 39 | _detect_os_cache = dict() 40 | 41 | def detect_os(image_name, output_callback=None, nocache=False): 42 | # Do not rerun OS detection if there is already a cached result for the given image 43 | if image_name in _detect_os_cache: 44 | return _detect_os_cache[image_name] 45 | 46 | iof = StringIO((DETECTION_TEMPLATE % locals()).encode()) 47 | tag_name = "rocker:" + f"os_detect_{image_name}".replace(':', '_').replace('/', '_') 48 | image_id = docker_build( 49 | fileobj=iof, 50 | output_callback=output_callback, 51 | nocache=nocache, 52 | forcerm=True, # Remove intermediate containers from RUN commands in DETECTION_TEMPLATE 53 | tag=tag_name 54 | ) 55 | if not image_id: 56 | if output_callback: 57 | output_callback('Failed to build detector image') 58 | return None 59 | 60 | cmd="docker run -it --rm %s" % image_id 61 | if output_callback: 62 | output_callback("running, ", cmd) 63 | p = pexpect.spawn(cmd) 64 | output = p.read().decode() 65 | if output_callback: 66 | output_callback("output: ", output) 67 | p.terminate() 68 | 69 | # Clean up the image 70 | client = get_docker_client() 71 | client.remove_image(image=tag_name) 72 | 73 | if p.exitstatus == 0: 74 | try: 75 | detect_dict = json.loads(output.strip()) 76 | except ValueError: 77 | if output_callback: 78 | output_callback('Failed to parse JSON') 79 | return None 80 | 81 | dist = detect_dict.get('name', '') 82 | os_release = detect_dict.get('os_release', {}) 83 | ver = os_release.get('VERSION_ID', '') 84 | codename = os_release.get('VERSION_CODENAME', '') 85 | 86 | _detect_os_cache[image_name] = (dist, ver, codename) 87 | return _detect_os_cache[image_name] 88 | else: 89 | if output_callback: 90 | output_callback("/tmp/detect_os failed:") 91 | for l in output.splitlines(): 92 | output_callback("> %s" % l) 93 | return None 94 | -------------------------------------------------------------------------------- /src/rocker/rmw_extension.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Open Source Robotics Foundation 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from argparse import ArgumentTypeError 16 | import os 17 | import pkgutil 18 | 19 | from .em import empy_expand 20 | from rocker.extensions import RockerExtension 21 | from rocker.extensions import name_to_argument 22 | 23 | 24 | class RMW(RockerExtension): 25 | rmw_map = { 26 | 'cyclonedds': ['ros-${ROS_DISTRO}-rmw-cyclonedds-cpp'], 27 | 'fastrtps' : ['ros-${ROS_DISTRO}-rmw-fastrtps-cpp'], 28 | 'zenoh' : ['ros-${ROS_DISTRO}-rmw-zenoh-cpp', 'ros-${ROS_DISTRO}-zenoh-vendor'], 29 | # Second dependency not 100% necessary but validates dual package detection in tests. 30 | # TODO(tfoote) Enable connext with license acceptance method 31 | # 'connextdds': ['ros-${ROS_DISTRO}-rmw-connextdds'], 32 | } 33 | 34 | @staticmethod 35 | def get_package_names(rmw_name): 36 | return RMW.rmw_map[rmw_name] 37 | 38 | @staticmethod 39 | def get_name(): 40 | return 'rmw' 41 | 42 | def __init__(self): 43 | self._env_subs = None 44 | self.name = RMW.get_name() 45 | 46 | def get_docker_args(self, cli_args): 47 | rmw_config = cli_args.get('rmw') 48 | if not rmw_config: 49 | return '' # not active 50 | implementation = rmw_config[0] 51 | args = f' -e RMW_IMPLEMENTATION=rmw_{implementation}_cpp' 52 | return args #% self.get_environment_subs() 53 | 54 | def get_environment_subs(self): 55 | if not self._env_subs: 56 | self._env_subs = {} 57 | return self._env_subs 58 | 59 | def get_preamble(self, cliargs): 60 | return '' 61 | 62 | def get_snippet(self, cliargs): 63 | snippet = pkgutil.get_data('rocker', 'templates/%s_snippet.Dockerfile.em' % RMW.get_name()).decode('utf-8') 64 | data = self.get_environment_subs() 65 | # data['rosdistro'] = cliargs.get('rosdistro', 'rolling') 66 | rmw = cliargs.get('rmw', None) 67 | if rmw: 68 | rmw = rmw[0] 69 | else: 70 | return '' # rmw not active 71 | data['rmw'] = rmw 72 | data['packages'] = RMW.get_package_names(rmw) 73 | # data['rosdistro'] = 'rolling' 74 | return empy_expand(snippet, data) 75 | 76 | @staticmethod 77 | def register_arguments(parser, defaults): 78 | parser.add_argument(name_to_argument(RMW.get_name()), 79 | default=defaults.get('rmw', None), 80 | nargs=1, 81 | choices=RMW.rmw_map.keys(), 82 | help="Set the default RMW implementation") 83 | 84 | # parser.add_argument('rosdistro', 85 | # default=defaults.get('rosdistro', None), 86 | # help="Set the default rosdistro, else autodetect") 87 | -------------------------------------------------------------------------------- /src/rocker/ssh_extension.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Open Source Robotics Foundation 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from argparse import ArgumentTypeError 16 | import os 17 | import shlex 18 | 19 | from rocker.extensions import RockerExtension 20 | 21 | 22 | class Ssh(RockerExtension): 23 | 24 | name = 'ssh' 25 | 26 | @classmethod 27 | def get_name(cls): 28 | return cls.name 29 | 30 | def precondition_environment(self, cli_args): 31 | pass 32 | 33 | def validate_environment(self, cli_args): 34 | pass 35 | 36 | def get_preamble(self, cli_args): 37 | return '' 38 | 39 | def get_snippet(self, cli_args): 40 | return '' 41 | 42 | def get_docker_args(self, cli_args): 43 | args = '' 44 | if 'SSH_AUTH_SOCK' in os.environ: 45 | args += ' -e SSH_AUTH_SOCK -v ' + shlex.quote('{SSH_AUTH_SOCK}:{SSH_AUTH_SOCK}'.format(**os.environ)) 46 | return args 47 | 48 | @staticmethod 49 | def register_arguments(parser, defaults): 50 | parser.add_argument('--ssh', 51 | action='store_true', 52 | default=defaults.get(Ssh.get_name(), None), 53 | help="Forward SSH agent into the container") 54 | -------------------------------------------------------------------------------- /src/rocker/templates/Dockerfile.em: -------------------------------------------------------------------------------- 1 | TODO move logic here 2 | 3 | {{ for p in prefixes }} 4 | 5 | FROM {{base_image}} 6 | 7 | {{ for s in snippets }} -------------------------------------------------------------------------------- /src/rocker/templates/bash_cmd_snippet.Dockerfile.em: -------------------------------------------------------------------------------- 1 | CMD /bin/bash 2 | -------------------------------------------------------------------------------- /src/rocker/templates/cuda_snippet.Dockerfile.em: -------------------------------------------------------------------------------- 1 | # Installation instructions from NVIDIA: 2 | # https://developer.nvidia.com/cuda-downloads?target_os=Linux&target_arch=x86_64&Distribution=Debian&target_version=11&target_type=deb_network 3 | # https://developer.nvidia.com/cuda-downloads?target_os=Linux&target_arch=x86_64&Distribution=Ubuntu&target_version=22.04&target_type=deb_network 4 | 5 | # Keep the dockerfile non-interactive 6 | # TODO(tfoote) make this more generic/shared across instances 7 | ARG DEBIAN_FRONTEND=noninteractive 8 | 9 | # Prerequisites 10 | RUN apt-get update && apt-get install -y --no-install-recommends \ 11 | wget software-properties-common gnupg2 \ 12 | && rm -rf /var/lib/apt/lists/* 13 | 14 | # Enable contrib on debian to get required 15 | # https://packages.debian.org/bullseye/glx-alternative-nvidia 16 | 17 | RUN \ 18 | wget -q https://developer.download.nvidia.com/compute/cuda/repos/@(download_osstring)@(download_verstring)/x86_64/cuda-keyring_1.1-1_all.deb && \ 19 | dpkg -i cuda-keyring_1.1-1_all.deb && \ 20 | rm cuda-keyring_1.1-1_all.deb && \ 21 | \@[if download_osstring == 'debian']@ 22 | add-apt-repository contrib && \ 23 | \@[end if]@ 24 | apt-get update && \ 25 | apt-get -y install cuda-toolkit && \ 26 | rm -rf /var/lib/apt/lists/* 27 | 28 | # File conflict problem with libnvidia-ml.so.1 and libcuda.so.1 29 | # https://github.com/NVIDIA/nvidia-docker/issues/1551 30 | RUN rm -rf /usr/lib/x86_64-linux-gnu/libnv* 31 | RUN rm -rf /usr/lib/x86_64-linux-gnu/libcuda* 32 | 33 | # TODO(tfoote) Add documentation of why these are required 34 | ENV PATH /usr/local/cuda/bin${PATH:+:${PATH}} 35 | ENV LD_LIBRARY_PATH /usr/local/cuda/lib64/stubs:/usr/local/cuda/lib64${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}} -------------------------------------------------------------------------------- /src/rocker/templates/dev_helpers_snippet.Dockerfile.em: -------------------------------------------------------------------------------- 1 | # workspace development helpers 2 | RUN apt-get update \ 3 | && apt-get install -y \ 4 | byobu \ 5 | emacs \ 6 | && apt-get clean 7 | -------------------------------------------------------------------------------- /src/rocker/templates/nvidia_preamble.Dockerfile.em: -------------------------------------------------------------------------------- 1 | FROM nvidia/opengl:1.0-glvnd-devel-ubuntu@(nvidia_glvnd_version) as glvnd 2 | -------------------------------------------------------------------------------- /src/rocker/templates/nvidia_snippet.Dockerfile.em: -------------------------------------------------------------------------------- 1 | @[if image_distro_version == '16.04']@ 2 | # Open nvidia-docker2 GL support 3 | COPY --from=glvnd /usr/local/lib/x86_64-linux-gnu /usr/local/lib/x86_64-linux-gnu 4 | COPY --from=glvnd /usr/local/lib/i386-linux-gnu /usr/local/lib/i386-linux-gnu 5 | COPY --from=glvnd /usr/lib/x86_64-linux-gnu /usr/lib/x86_64-linux-gnu 6 | COPY --from=glvnd /usr/lib/i386-linux-gnu /usr/lib/i386-linux-gnu 7 | # if the path is alreaady present don't fail because of being unable to append 8 | RUN ( echo '/usr/local/lib/x86_64-linux-gnu' >> /etc/ld.so.conf.d/glvnd.conf && ldconfig || grep -q /usr/local/lib/x86_64-linux-gnu /etc/ld.so.conf.d/glvnd.conf ) && \ 9 | ( echo '/usr/local/lib/i386-linux-gnu' >> /etc/ld.so.conf.d/glvnd.conf && ldconfig || grep -q /usr/local/lib/i386-linux-gnu /etc/ld.so.conf.d/glvnd.conf ) 10 | ENV LD_LIBRARY_PATH /usr/local/lib/x86_64-linux-gnu:/usr/local/lib/i386-linux-gnu${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}} 11 | 12 | COPY --from=glvnd /usr/local/share/glvnd/egl_vendor.d/10_nvidia.json /usr/local/share/glvnd/egl_vendor.d/10_nvidia.json 13 | @[else]@ 14 | RUN apt-get update && apt-get install -y --no-install-recommends \ 15 | libglvnd0 \ 16 | libgl1 \ 17 | libglx0 \ 18 | libegl1 \ 19 | libgles2 \ 20 | && rm -rf /var/lib/apt/lists/* 21 | COPY --from=glvnd /usr/share/glvnd/egl_vendor.d/10_nvidia.json /usr/share/glvnd/egl_vendor.d/10_nvidia.json 22 | @[end if]@ 23 | 24 | 25 | 26 | 27 | ENV NVIDIA_VISIBLE_DEVICES ${NVIDIA_VISIBLE_DEVICES:-all} 28 | ENV NVIDIA_DRIVER_CAPABILITIES ${NVIDIA_DRIVER_CAPABILITIES:-all} 29 | -------------------------------------------------------------------------------- /src/rocker/templates/pulse_snippet.Dockerfile.em: -------------------------------------------------------------------------------- 1 | RUN mkdir -p /etc/pulse 2 | RUN echo '\n\ 3 | # Connect to the hosts server using the mounted UNIX socket\n\ 4 | default-server = unix:/run/user/@(user_id)/pulse/native\n\ 5 | \n\ 6 | # Prevent a server running in the container\n\ 7 | autospawn = no\n\ 8 | daemon-binary = /bin/true\n\ 9 | \n\ 10 | # Prevent the use of shared memory\n\ 11 | enable-shm = false\n\ 12 | \n'\ 13 | > /etc/pulse/client.conf 14 | -------------------------------------------------------------------------------- /src/rocker/templates/rmw_snippet.Dockerfile.em: -------------------------------------------------------------------------------- 1 | # workspace development helpers 2 | 3 | # TODO(tfoote) This could be optimized to skip repeated apt updates. 4 | @[ if rmw ]@ 5 | RUN \ 6 | if [ -z "${ROS_DISTRO}" ]; then echo "ROS_DISTRO is unset cannot override RMW" ; exit 1 ; fi ;\ 7 | @[for package in packages]@ 8 | if ! dpkg -l @(packages) | grep -q ^ii ; then \ 9 | apt-get update && \ 10 | DEBIAN_FRONTENT=non-interactive apt-get install -qy --no-install-recommends\ 11 | @(package) ;\ 12 | else \ 13 | echo "Found rmw package @(package) no need to install" ; \ 14 | fi ; \ 15 | @[end for]@ 16 | apt-get clean ;\ 17 | echo "Done detecting packages for rmw" 18 | @[ end if ]@ 19 | 20 | -------------------------------------------------------------------------------- /src/rocker/templates/user_snippet.Dockerfile.em: -------------------------------------------------------------------------------- 1 | # make sure sudo is installed to be able to give user sudo access in docker 2 | RUN if ! command -v sudo >/dev/null; then \ 3 | apt-get update \ 4 | && apt-get install -y sudo \ 5 | && apt-get clean; \ 6 | fi 7 | 8 | @[if name != 'root']@ 9 | RUN existing_user_by_uid=`getent passwd "@(uid)" | cut -f1 -d: || true` && \ 10 | if [ -n "${existing_user_by_uid}" ]; then userdel @('' if user_preserve_home else '-r') "${existing_user_by_uid}"; fi && \ 11 | existing_user_by_name=`getent passwd "@(name)" | cut -f1 -d: || true` && \ 12 | existing_user_uid=`getent passwd "@(name)" | cut -f3 -d: || true` && \ 13 | if [ -n "${existing_user_by_name}" ]; then find / -uid ${existing_user_uid} -exec chown -h @(uid) {} + || true ; find / -gid ${existing_user_uid} -exec chgrp -h @(uid) {} + || true ; fi && \ 14 | if [ -n "${existing_user_by_name}" ]; then userdel @('' if user_preserve_home else '-r') "${existing_user_by_name}"; fi && \ 15 | existing_group_by_gid=`getent group "@(gid)" | cut -f1 -d: || true` && \ 16 | if [ -z "${existing_group_by_gid}" ]; then \ 17 | groupadd -g "@(gid)" "@name"; \ 18 | fi && \ 19 | useradd --no-log-init --no-create-home --uid "@(uid)" @(str('-s ' + shell) if shell else '') -c "@(gecos)" -g "@(gid)" -d "@(dir)" "@(name)" && \ 20 | @[if user_groups != '']@ 21 | user_groups="@(user_groups)" && \ 22 | for groupinfo in ${user_groups}; do \ 23 | existing_group_by_name=`getent group ${groupinfo%;*} || true`; \ 24 | existing_group_by_gid=`getent group ${groupinfo#*;} || true`; \ 25 | if [ -z "${existing_group_by_name}" ] && [ -z "${existing_group_by_gid}" ]; then \ 26 | groupadd -g "${groupinfo#*;}" "${groupinfo%;*}" && usermod -aG "${groupinfo%;*}" "@(name)" @( ('|| (true && echo "user-preserve-group-permissive Enabled, continuing without processing group $groupinfo" )') if user_preserve_groups_permissive else '') || (echo "Failed to add group ${groupinfo%;*}, consider option --user-preserve-group-permissive" && exit 2); \ 27 | elif [ "${existing_group_by_name}" = "${existing_group_by_gid}" ]; then \ 28 | usermod -aG "${groupinfo%;*}" "@(name)" @( ('|| (true && echo "user-preserve-group-permissive Enabled, continuing without processing group $groupinfo" )') if user_preserve_groups_permissive else '') || (echo "Failed to adjust group ${groupinfo%;*}, consider option --user-preserve-group-permissive" && exit 2); \ 29 | fi; \ 30 | done && \ 31 | @[end if]@ 32 | echo "@(name) ALL=NOPASSWD: ALL" >> /etc/sudoers.d/rocker 33 | 34 | @[if not home_extension_active ]@ 35 | # Making sure a home directory exists if we haven't mounted the user's home directory explicitly 36 | RUN mkdir -p "$(dirname "@(dir)")" && mkhomedir_helper @(name) 37 | @[end if]@ 38 | WORKDIR @(dir) 39 | @[else]@ 40 | # Detected user is root, which already exists so not creating new user. 41 | @[end if]@ 42 | -------------------------------------------------------------------------------- /src/rocker/ulimit_extension.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Open Source Robotics Foundation 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from argparse import ArgumentTypeError 16 | import re 17 | from rocker.extensions import RockerExtension, name_to_argument 18 | 19 | 20 | class Ulimit(RockerExtension): 21 | """ 22 | A RockerExtension to handle ulimit settings for Docker containers. 23 | 24 | This extension allows specifying ulimit options in the format TYPE=SOFT_LIMIT[:HARD_LIMIT] 25 | and validates the format before passing them as Docker arguments. 26 | """ 27 | EXPECTED_FORMAT = "TYPE=SOFT_LIMIT[:HARD_LIMIT]" 28 | 29 | @staticmethod 30 | def get_name(): 31 | return 'ulimit' 32 | 33 | def get_docker_args(self, cliargs): 34 | args = [''] 35 | ulimits = [x for sublist in cliargs[Ulimit.get_name()] for x in sublist] 36 | for ulimit in ulimits: 37 | if self.arg_format_is_valid(ulimit): 38 | args.append(f"--ulimit {ulimit}") 39 | else: 40 | raise ArgumentTypeError( 41 | f"Error processing {Ulimit.get_name()} flag '{ulimit}': expected format" 42 | f" {Ulimit.EXPECTED_FORMAT}") 43 | return ' '.join(args) 44 | 45 | def arg_format_is_valid(self, arg: str): 46 | """ 47 | Validate the format of the ulimit argument. 48 | 49 | Args: 50 | arg (str): The ulimit argument to validate. 51 | 52 | Returns: 53 | bool: True if the format is valid, False otherwise. 54 | """ 55 | ulimit_format = r'(\w+)=(\w+)(:\w+)?$' 56 | match = re.match(ulimit_format, arg) 57 | return match is not None 58 | 59 | @staticmethod 60 | def register_arguments(parser, defaults): 61 | parser.add_argument(name_to_argument(Ulimit.get_name()), 62 | type=str, 63 | nargs='+', 64 | action='append', 65 | metavar=Ulimit.EXPECTED_FORMAT, 66 | default=defaults.get(Ulimit.get_name(), None), 67 | help='ulimit options to add into the container.') 68 | -------------------------------------------------------------------------------- /src/rocker/volume_extension.py: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Open Source Robotics Foundation 2 | 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | from argparse import ArgumentParser 16 | from argparse import ArgumentTypeError 17 | import os 18 | from rocker.extensions import RockerExtension 19 | 20 | 21 | class Volume(RockerExtension): 22 | 23 | ARG_DOCKER_VOLUME = "-v" 24 | ARG_ROCKER_VOLUME = "--volume" 25 | name = 'volume' 26 | 27 | @classmethod 28 | def get_name(cls): 29 | return cls.name 30 | 31 | def get_docker_args(self, cli_args): 32 | """ 33 | @param cli_args: {'volume': [[%arg%]]} 34 | - 'volume' is fixed. 35 | - %arg% can be: 36 | - %path_host%: a path on the host. Same path will be populated in 37 | the container. 38 | - %path_host%:%path_cont% 39 | - %path_host%:%path_cont%:%option% 40 | """ 41 | args = [''] 42 | 43 | # flatten cli_args['volume'] 44 | volumes = [ x for sublist in cli_args[self.name] for x in sublist] 45 | 46 | for volume in volumes: 47 | elems = volume.split(':') 48 | host_dir = os.path.abspath(elems[0]) 49 | if len(elems) == 1: 50 | args.append('{0} {1}:{1}'.format(self.ARG_DOCKER_VOLUME, host_dir)) 51 | elif len(elems) == 2: 52 | container_dir = elems[1] 53 | args.append('{0} {1}:{2}'.format(self.ARG_DOCKER_VOLUME, host_dir, container_dir)) 54 | elif len(elems) == 3: 55 | container_dir = elems[1] 56 | options = elems[2] 57 | args.append('{0} {1}:{2}:{3}'.format(self.ARG_DOCKER_VOLUME, host_dir, container_dir, options)) 58 | else: 59 | raise ArgumentTypeError( 60 | '{} expects arguments in format HOST-DIR[:CONTAINER-DIR[:OPTIONS]]'.format(self.ARG_ROCKER_VOLUME)) 61 | 62 | return ' '.join(args) 63 | 64 | @staticmethod 65 | def register_arguments(parser: ArgumentParser, defaults: dict): 66 | parser.add_argument(Volume.ARG_ROCKER_VOLUME, 67 | metavar='HOST-DIR[:CONTAINER-DIR[:OPTIONS]]', 68 | type=str, 69 | nargs='+', 70 | action='append', 71 | help='volume(s) to map into the container. The last path must be followed by two dashes "--"') 72 | -------------------------------------------------------------------------------- /stdeb.cfg: -------------------------------------------------------------------------------- 1 | [rocker] 2 | Debian-Version: 100 3 | No-Python2: 4 | Depends3: python3-docker, python3-empy, python3-pexpect, python3-packaging 5 | Conflicts3: python-rocker 6 | Suite: focal jammy noble bookworm trixie 7 | X-Python3-Version: >= 3.2 8 | -------------------------------------------------------------------------------- /test/test_core.py: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | 18 | import argparse 19 | import em 20 | import os 21 | import pwd 22 | import pytest 23 | import unittest 24 | 25 | from itertools import chain 26 | 27 | from rocker.core import DockerImageGenerator 28 | from rocker.core import ExtensionError 29 | from rocker.core import list_plugins 30 | from rocker.core import get_docker_client 31 | from rocker.core import get_rocker_version 32 | from rocker.core import get_user_name 33 | from rocker.core import RockerExtension 34 | from rocker.core import RockerExtensionManager 35 | 36 | class RockerCoreTest(unittest.TestCase): 37 | 38 | def setUp(self): 39 | # Work around interference between empy Interpreter 40 | # stdout proxy and test runner. empy installs a proxy on stdout 41 | # to be able to capture the information. 42 | # And the test runner creates a new stdout object for each test. 43 | # This breaks empy as it assumes that the proxy has persistent 44 | # between instances of the Interpreter class 45 | # empy will error with the exception 46 | # "em.Error: interpreter stdout proxy lost" 47 | em.Interpreter._wasProxyInstalled = False 48 | 49 | def test_list_plugins(self): 50 | plugins_found = list_plugins() 51 | plugin_names = plugins_found.keys() 52 | self.assertTrue('nvidia' in plugin_names ) 53 | self.assertTrue('pulse' in plugin_names ) 54 | self.assertTrue('user' in plugin_names ) 55 | self.assertTrue('home' in plugin_names ) 56 | 57 | def test_get_rocker_version(self): 58 | v = get_rocker_version() 59 | parts = v.split('.') 60 | self.assertEqual(len(parts), 3) 61 | for p in parts: 62 | # Check that it can be cast to an int 63 | i = int(p) 64 | 65 | @pytest.mark.docker 66 | def test_run_before_build(self): 67 | dig = DockerImageGenerator([], {}, 'ubuntu:bionic') 68 | self.assertEqual(dig.run('true'), 1) 69 | self.assertEqual(dig.build(), 0) 70 | self.assertEqual(dig.run('true'), 0) 71 | dig.clear_image() 72 | 73 | @pytest.mark.docker 74 | def test_return_code_no_extensions(self): 75 | dig = DockerImageGenerator([], {}, 'ubuntu:bionic') 76 | self.assertEqual(dig.build(), 0) 77 | self.assertEqual(dig.run('true'), 0) 78 | self.assertEqual(dig.run('false'), 1) 79 | dig.clear_image() 80 | 81 | @pytest.mark.docker 82 | def test_return_code_multiple_extensions(self): 83 | plugins = list_plugins() 84 | desired_plugins = ['home', 'user'] 85 | active_extensions = [e() for e in plugins.values() if e.get_name() in desired_plugins] 86 | dig = DockerImageGenerator(active_extensions, {}, 'ubuntu:bionic') 87 | self.assertEqual(dig.build(), 0) 88 | self.assertEqual(dig.run('true'), 0) 89 | self.assertEqual(dig.run('false'), 1) 90 | dig.clear_image() 91 | 92 | @pytest.mark.docker 93 | def test_noexecute(self): 94 | dig = DockerImageGenerator([], {}, 'ubuntu:bionic') 95 | self.assertEqual(dig.build(), 0) 96 | self.assertEqual(dig.run('true', noexecute=True), 0) 97 | dig.clear_image() 98 | 99 | @pytest.mark.docker 100 | def test_dry_run(self): 101 | dig = DockerImageGenerator([], {}, 'ubuntu:bionic') 102 | self.assertEqual(dig.build(), 0) 103 | self.assertEqual(dig.run('true', mode='dry-run'), 0) 104 | self.assertEqual(dig.run('false', mode='dry-run'), 0) 105 | dig.clear_image() 106 | 107 | @pytest.mark.docker 108 | def test_non_interactive(self): 109 | dig = DockerImageGenerator([], {}, 'ubuntu:bionic') 110 | self.assertEqual(dig.build(), 0) 111 | self.assertEqual(dig.run('true', mode='non-interactive'), 0) 112 | self.assertEqual(dig.run('false', mode='non-interactive'), 1) 113 | dig.clear_image() 114 | 115 | @pytest.mark.docker 116 | def test_device(self): 117 | dig = DockerImageGenerator([], {}, 'ubuntu:bionic') 118 | self.assertEqual(dig.build(), 0) 119 | self.assertEqual(dig.run('true', devices=['/dev/random']), 0) 120 | self.assertEqual(dig.run('true', devices=['/dev/does_not_exist']), 0) 121 | dig.clear_image() 122 | 123 | @pytest.mark.docker 124 | def test_network(self): 125 | dig = DockerImageGenerator([], {}, 'ubuntu:bionic') 126 | self.assertEqual(dig.build(), 0) 127 | networks = ['bridge', 'host', 'none'] 128 | for n in networks: 129 | self.assertEqual(dig.run('true', network=n), 0) 130 | dig.clear_image() 131 | 132 | @pytest.mark.docker 133 | def test_extension_manager(self): 134 | parser = argparse.ArgumentParser() 135 | extension_manager = RockerExtensionManager() 136 | default_args = {} 137 | extension_manager.extend_cli_parser(parser, default_args) 138 | help_str = parser.format_help() 139 | self.assertIn('--mode', help_str) 140 | self.assertIn('dry-run', help_str) 141 | self.assertIn('non-interactive', help_str) 142 | self.assertIn('--extension-blacklist', help_str) 143 | 144 | self.assertRaises(ExtensionError, 145 | extension_manager.get_active_extensions, 146 | {'user': True, 'ssh': True, 'extension_blacklist': ['ssh']}) 147 | 148 | def test_strict_required_extensions(self): 149 | class Foo(RockerExtension): 150 | @classmethod 151 | def get_name(cls): 152 | return 'foo' 153 | 154 | class Bar(RockerExtension): 155 | @classmethod 156 | def get_name(cls): 157 | return 'bar' 158 | 159 | def required(self, cli_args): 160 | return {'foo'} 161 | 162 | extension_manager = RockerExtensionManager() 163 | extension_manager.available_plugins = {'foo': Foo, 'bar': Bar} 164 | 165 | correct_extensions_args = {'strict_extension_selection': True, 'bar': True, 'foo': True, 'extension_blacklist': []} 166 | extension_manager.get_active_extensions(correct_extensions_args) 167 | 168 | incorrect_extensions_args = {'strict_extension_selection': True, 'bar': True, 'extension_blacklist': []} 169 | self.assertRaises(ExtensionError, 170 | extension_manager.get_active_extensions, incorrect_extensions_args) 171 | 172 | def test_implicit_required_extensions(self): 173 | class Foo(RockerExtension): 174 | @classmethod 175 | def get_name(cls): 176 | return 'foo' 177 | 178 | class Bar(RockerExtension): 179 | @classmethod 180 | def get_name(cls): 181 | return 'bar' 182 | 183 | def required(self, cli_args): 184 | return {'foo'} 185 | 186 | extension_manager = RockerExtensionManager() 187 | extension_manager.available_plugins = {'foo': Foo, 'bar': Bar} 188 | 189 | implicit_extensions_args = {'strict_extension_selection': False, 'bar': True, 'extension_blacklist': []} 190 | active_extensions = extension_manager.get_active_extensions(implicit_extensions_args) 191 | self.assertEqual(len(active_extensions), 2) 192 | # required extensions are not ordered, just check to make sure they are both present 193 | if active_extensions[0].get_name() == 'foo': 194 | self.assertEqual(active_extensions[1].get_name(), 'bar') 195 | else: 196 | self.assertEqual(active_extensions[0].get_name(), 'bar') 197 | self.assertEqual(active_extensions[1].get_name(), 'foo') 198 | 199 | def test_extension_sorting(self): 200 | class Foo(RockerExtension): 201 | @classmethod 202 | def get_name(cls): 203 | return 'foo' 204 | 205 | class Bar(RockerExtension): 206 | @classmethod 207 | def get_name(cls): 208 | return 'bar' 209 | 210 | def invoke_after(self, cli_args): 211 | return {'foo', 'absent_extension'} 212 | 213 | extension_manager = RockerExtensionManager() 214 | extension_manager.available_plugins = {'foo': Foo, 'bar': Bar} 215 | 216 | args = {'bar': True, 'foo': True, 'extension_blacklist': []} 217 | active_extensions = extension_manager.get_active_extensions(args) 218 | self.assertEqual(active_extensions[0].get_name(), 'foo') 219 | self.assertEqual(active_extensions[1].get_name(), 'bar') 220 | 221 | def test_docker_cmd_interactive(self): 222 | dig = DockerImageGenerator([], {}, 'ubuntu:bionic') 223 | 224 | self.assertNotIn('-it', dig.generate_docker_cmd(mode='')) 225 | self.assertIn('-it', dig.generate_docker_cmd(mode='dry-run')) 226 | 227 | # TODO(tfoote) mock this appropriately 228 | # google actions tests don't have a tty, local tests do 229 | # import os, sys 230 | # if os.isatty(sys.__stdin__.fileno()): 231 | # self.assertIn('-it', dig.generate_docker_cmd(mode='interactive')) 232 | # else: 233 | self.assertIn('-it', dig.generate_docker_cmd(mode='interactive')) 234 | 235 | self.assertNotIn('-it', dig.generate_docker_cmd(mode='non-interactive')) 236 | 237 | def test_docker_user_detection(self): 238 | userinfo = pwd.getpwuid(os.getuid()) 239 | username_detected = getattr(userinfo, 'pw_' + 'name') 240 | self.assertEqual(username_detected, get_user_name()) 241 | 242 | @pytest.mark.docker 243 | def test_docker_user_setting(self): 244 | parser = argparse.ArgumentParser() 245 | extension_manager = RockerExtensionManager() 246 | default_args = {} 247 | extension_manager.extend_cli_parser(parser, default_args) 248 | active_extensions = extension_manager.get_active_extensions({'user': True, 'extension_blacklist': ['ssh']}) 249 | dig = DockerImageGenerator(active_extensions, {'user_override_name': 'foo'}, 'ubuntu:bionic') 250 | 251 | self.assertIn('USER root', dig.dockerfile) 252 | self.assertNotIn('USER foo', dig.dockerfile) 253 | dig = DockerImageGenerator(active_extensions, {'user': True, 'user_override_name': 'foo'}, 'ubuntu:bionic') 254 | self.assertIn('USER root', dig.dockerfile) 255 | self.assertIn('USER foo', dig.dockerfile) 256 | 257 | def test_docker_user_snippet(self): 258 | 259 | root_snippet_content = "RUN echo run as root" 260 | user_snippet_content = "RUN echo run as user" 261 | 262 | class UserSnippet(RockerExtension): 263 | def __init__(self): 264 | self.name = 'usersnippet' 265 | 266 | @classmethod 267 | def get_name(cls): 268 | return 'usersnippet' 269 | 270 | def get_snippet(self, cli_args): 271 | return root_snippet_content 272 | 273 | 274 | def get_user_snippet(self, cli_args): 275 | return user_snippet_content 276 | 277 | extension_manager = RockerExtensionManager() 278 | extension_manager.available_plugins = {'usersnippet': UserSnippet} 279 | active_extensions = extension_manager.get_active_extensions({'user': True, 'usersnippet': UserSnippet, 'extension_blacklist': ['ssh']}) 280 | self.assertTrue(active_extensions) 281 | 282 | # No user snippet 283 | mock_cli_args = {'user': False, 'usersnippet': True, 'user_override_name': 'foo'} 284 | dig = DockerImageGenerator(active_extensions, mock_cli_args, 'ubuntu:bionic') 285 | self.assertIn('USER root', dig.dockerfile) 286 | self.assertNotIn('USER foo', dig.dockerfile) 287 | 288 | # User snippet added 289 | mock_cli_args = {'user': True, 'usersnippet': True, 'user_override_name': 'foo'} 290 | dig = DockerImageGenerator(active_extensions, mock_cli_args, 'ubuntu:bionic') 291 | self.assertIn(root_snippet_content, dig.dockerfile) 292 | self.assertIn('USER foo', dig.dockerfile) 293 | self.assertIn(user_snippet_content, dig.dockerfile) 294 | 295 | def test_docker_cmd_nocleanup(self): 296 | dig = DockerImageGenerator([], {}, 'ubuntu:bionic') 297 | 298 | self.assertIn('--rm', dig.generate_docker_cmd()) 299 | self.assertIn('--rm', dig.generate_docker_cmd(mode='dry-run')) 300 | self.assertIn('--rm', dig.generate_docker_cmd(nocleanup='')) 301 | 302 | self.assertNotIn('--rm', dig.generate_docker_cmd(nocleanup='true')) 303 | -------------------------------------------------------------------------------- /test/test_extension.py: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | 18 | import argparse 19 | import em 20 | import getpass 21 | import os 22 | import unittest 23 | from pathlib import Path 24 | import pwd 25 | import pytest 26 | from io import BytesIO as StringIO 27 | 28 | 29 | from rocker.core import DockerImageGenerator 30 | from rocker.core import docker_build 31 | from rocker.core import list_plugins 32 | from rocker.extensions import name_to_argument 33 | 34 | 35 | def plugin_load_parser_correctly(plugin): 36 | """A helper function to test that the plugins at least 37 | register an option for their own name.""" 38 | parser = argparse.ArgumentParser(description='test_parser') 39 | plugin.register_arguments(parser, {}) 40 | argument_name = name_to_argument(plugin.get_name()) 41 | for action in parser._actions: 42 | option_strings = getattr(action, 'option_strings', []) 43 | if argument_name in option_strings: 44 | return True 45 | return False 46 | 47 | 48 | class ExtensionsTest(unittest.TestCase): 49 | def test_name_to_argument(self): 50 | self.assertEqual(name_to_argument('asdf'), '--asdf') 51 | self.assertEqual(name_to_argument('as_df'), '--as-df') 52 | self.assertEqual(name_to_argument('as-df'), '--as-df') 53 | 54 | 55 | class DetachExtensionTest(unittest.TestCase): 56 | 57 | def setUp(self): 58 | # Work around interference between empy Interpreter 59 | # stdout proxy and test runner. empy installs a proxy on stdout 60 | # to be able to capture the information. 61 | # And the test runner creates a new stdout object for each test. 62 | # This breaks empy as it assumes that the proxy has persistent 63 | # between instances of the Interpreter class 64 | # empy will error with the exception 65 | # "em.Error: interpreter stdout proxy lost" 66 | em.Interpreter._wasProxyInstalled = False 67 | 68 | def test_detach_extension(self): 69 | plugins = list_plugins() 70 | detach_plugin = plugins['detach'] 71 | self.assertEqual(detach_plugin.get_name(), 'detach') 72 | 73 | p = detach_plugin() 74 | self.assertTrue(plugin_load_parser_correctly(detach_plugin)) 75 | 76 | mock_cliargs = {'detach': True} 77 | args = p.get_docker_args(mock_cliargs) 78 | self.assertTrue('--detach' in args) 79 | 80 | mock_cliargs = {'detach': False} 81 | args = p.get_docker_args(mock_cliargs) 82 | self.assertTrue('--detach' not in args) 83 | 84 | mock_cliargs = {} 85 | args = p.get_docker_args(mock_cliargs) 86 | self.assertTrue('--detach' not in args) 87 | 88 | 89 | class DevicesExtensionTest(unittest.TestCase): 90 | 91 | def setUp(self): 92 | # Work around interference between empy Interpreter 93 | # stdout proxy and test runner. empy installs a proxy on stdout 94 | # to be able to capture the information. 95 | # And the test runner creates a new stdout object for each test. 96 | # This breaks empy as it assumes that the proxy has persistent 97 | # between instances of the Interpreter class 98 | # empy will error with the exception 99 | # "em.Error: interpreter stdout proxy lost" 100 | em.Interpreter._wasProxyInstalled = False 101 | 102 | def test_devices_extension(self): 103 | plugins = list_plugins() 104 | devices_plugin = plugins['devices'] 105 | self.assertEqual(devices_plugin.get_name(), 'devices') 106 | 107 | p = devices_plugin() 108 | self.assertTrue(plugin_load_parser_correctly(devices_plugin)) 109 | 110 | mock_cliargs = {'devices': ['/dev/random']} 111 | self.assertEqual(p.get_snippet(mock_cliargs), '') 112 | self.assertEqual(p.get_preamble(mock_cliargs), '') 113 | args = p.get_docker_args(mock_cliargs) 114 | self.assertTrue('--device /dev/random' in args) 115 | 116 | # Check case for invalid device 117 | mock_cliargs = {'devices': ['/dev/does_not_exist']} 118 | self.assertEqual(p.get_snippet(mock_cliargs), '') 119 | self.assertEqual(p.get_preamble(mock_cliargs), '') 120 | args = p.get_docker_args(mock_cliargs) 121 | self.assertFalse('--device' in args) 122 | 123 | 124 | class HomeExtensionTest(unittest.TestCase): 125 | 126 | def setUp(self): 127 | # Work around interference between empy Interpreter 128 | # stdout proxy and test runner. empy installs a proxy on stdout 129 | # to be able to capture the information. 130 | # And the test runner creates a new stdout object for each test. 131 | # This breaks empy as it assumes that the proxy has persistent 132 | # between instances of the Interpreter class 133 | # empy will error with the exception 134 | # "em.Error: interpreter stdout proxy lost" 135 | em.Interpreter._wasProxyInstalled = False 136 | 137 | def test_home_extension(self): 138 | plugins = list_plugins() 139 | home_plugin = plugins['home'] 140 | self.assertEqual(home_plugin.get_name(), 'home') 141 | 142 | p = home_plugin() 143 | self.assertTrue(plugin_load_parser_correctly(home_plugin)) 144 | 145 | mock_cliargs = {} 146 | self.assertEqual(p.get_snippet(mock_cliargs), '') 147 | self.assertEqual(p.get_preamble(mock_cliargs), '') 148 | args = p.get_docker_args(mock_cliargs) 149 | self.assertTrue('-v %s:%s' % (Path.home(), Path.home()) in args) 150 | 151 | 152 | class IpcExtensionTest(unittest.TestCase): 153 | 154 | def setUp(self): 155 | # Work around interference between empy Interpreter 156 | # stdout proxy and test runner. empy installs a proxy on stdout 157 | # to be able to capture the information. 158 | # And the test runner creates a new stdout object for each test. 159 | # This breaks empy as it assumes that the proxy has persistent 160 | # between instances of the Interpreter class 161 | # empy will error with the exception 162 | # "em.Error: interpreter stdout proxy lost" 163 | em.Interpreter._wasProxyInstalled = False 164 | 165 | @pytest.mark.docker 166 | def test_ipc_extension(self): 167 | plugins = list_plugins() 168 | ipc_plugin = plugins['ipc'] 169 | self.assertEqual(ipc_plugin.get_name(), 'ipc') 170 | 171 | p = ipc_plugin() 172 | self.assertTrue(plugin_load_parser_correctly(ipc_plugin)) 173 | 174 | mock_cliargs = {'ipc': 'none'} 175 | self.assertEqual(p.get_snippet(mock_cliargs), '') 176 | self.assertEqual(p.get_preamble(mock_cliargs), '') 177 | args = p.get_docker_args(mock_cliargs) 178 | self.assertTrue('--ipc none' in args) 179 | 180 | mock_cliargs = {'ipc': 'host'} 181 | args = p.get_docker_args(mock_cliargs) 182 | self.assertTrue('--ipc host' in args) 183 | 184 | 185 | class NetworkExtensionTest(unittest.TestCase): 186 | 187 | def setUp(self): 188 | # Work around interference between empy Interpreter 189 | # stdout proxy and test runner. empy installs a proxy on stdout 190 | # to be able to capture the information. 191 | # And the test runner creates a new stdout object for each test. 192 | # This breaks empy as it assumes that the proxy has persistent 193 | # between instances of the Interpreter class 194 | # empy will error with the exception 195 | # "em.Error: interpreter stdout proxy lost" 196 | em.Interpreter._wasProxyInstalled = False 197 | 198 | @pytest.mark.docker 199 | def test_network_extension(self): 200 | plugins = list_plugins() 201 | network_plugin = plugins['network'] 202 | self.assertEqual(network_plugin.get_name(), 'network') 203 | 204 | p = network_plugin() 205 | self.assertTrue(plugin_load_parser_correctly(network_plugin)) 206 | 207 | mock_cliargs = {'network': 'none'} 208 | self.assertEqual(p.get_snippet(mock_cliargs), '') 209 | self.assertEqual(p.get_preamble(mock_cliargs), '') 210 | args = p.get_docker_args(mock_cliargs) 211 | self.assertTrue('--network none' in args) 212 | 213 | mock_cliargs = {'network': 'host'} 214 | args = p.get_docker_args(mock_cliargs) 215 | self.assertTrue('--network host' in args) 216 | 217 | class ExposeExtensionTest(unittest.TestCase): 218 | 219 | def setUp(self): 220 | # Work around interference between empy Interpreter 221 | # stdout proxy and test runner. empy installs a proxy on stdout 222 | # to be able to capture the information. 223 | # And the test runner creates a new stdout object for each test. 224 | # This breaks empy as it assumes that the proxy has persistent 225 | # between instances of the Interpreter class 226 | # empy will error with the exception 227 | # "em.Error: interpreter stdout proxy lost" 228 | em.Interpreter._wasProxyInstalled = False 229 | 230 | @pytest.mark.docker 231 | def test_expose_extension(self): 232 | plugins = list_plugins() 233 | expose_plugin = plugins['expose'] 234 | self.assertEqual(expose_plugin.get_name(), 'expose') 235 | 236 | p = expose_plugin() 237 | self.assertTrue(plugin_load_parser_correctly(expose_plugin)) 238 | 239 | mock_cliargs = {} 240 | self.assertEqual(p.get_snippet(mock_cliargs), '') 241 | self.assertEqual(p.get_preamble(mock_cliargs), '') 242 | args = p.get_docker_args(mock_cliargs) 243 | self.assertNotIn('--expose', args) 244 | 245 | mock_cliargs = {'expose': ['80', '8080']} 246 | args = p.get_docker_args(mock_cliargs) 247 | self.assertIn('--expose 80', args) 248 | self.assertIn('--expose 8080', args) 249 | 250 | class PortExtensionTest(unittest.TestCase): 251 | 252 | def setUp(self): 253 | # Work around interference between empy Interpreter 254 | # stdout proxy and test runner. empy installs a proxy on stdout 255 | # to be able to capture the information. 256 | # And the test runner creates a new stdout object for each test. 257 | # This breaks empy as it assumes that the proxy has persistent 258 | # between instances of the Interpreter class 259 | # empy will error with the exception 260 | # "em.Error: interpreter stdout proxy lost" 261 | em.Interpreter._wasProxyInstalled = False 262 | 263 | @pytest.mark.docker 264 | def test_port_extension(self): 265 | plugins = list_plugins() 266 | port_plugin = plugins['port'] 267 | self.assertEqual(port_plugin.get_name(), 'port') 268 | 269 | p = port_plugin() 270 | self.assertTrue(plugin_load_parser_correctly(port_plugin)) 271 | 272 | mock_cliargs = {} 273 | self.assertEqual(p.get_snippet(mock_cliargs), '') 274 | self.assertEqual(p.get_preamble(mock_cliargs), '') 275 | args = p.get_docker_args(mock_cliargs) 276 | self.assertNotIn('-p', args) 277 | 278 | mock_cliargs = {'port': ['80:8080', '81:8081']} 279 | args = p.get_docker_args(mock_cliargs) 280 | self.assertIn('-p 80:8080', args) 281 | self.assertIn('-p 81:8081', args) 282 | 283 | class NameExtensionTest(unittest.TestCase): 284 | 285 | def setUp(self): 286 | # Work around interference between empy Interpreter 287 | # stdout proxy and test runner. empy installs a proxy on stdout 288 | # to be able to capture the information. 289 | # And the test runner creates a new stdout object for each test. 290 | # This breaks empy as it assumes that the proxy has persistent 291 | # between instances of the Interpreter class 292 | # empy will error with the exception 293 | # "em.Error: interpreter stdout proxy lost" 294 | em.Interpreter._wasProxyInstalled = False 295 | 296 | def test_name_extension(self): 297 | plugins = list_plugins() 298 | name_plugin = plugins['name'] 299 | self.assertEqual(name_plugin.get_name(), 'name') 300 | 301 | p = name_plugin() 302 | self.assertTrue(plugin_load_parser_correctly(name_plugin)) 303 | 304 | mock_cliargs = {'name': 'none'} 305 | self.assertEqual(p.get_snippet(mock_cliargs), '') 306 | self.assertEqual(p.get_preamble(mock_cliargs), '') 307 | args = p.get_docker_args(mock_cliargs) 308 | self.assertTrue('--name none' in args) 309 | 310 | mock_cliargs = {'name': 'docker_name'} 311 | args = p.get_docker_args(mock_cliargs) 312 | self.assertTrue('--name docker_name' in args) 313 | 314 | class HostnameExtensionTest(unittest.TestCase): 315 | 316 | def setUp(self): 317 | # Work around interference between empy Interpreter 318 | # stdout proxy and test runner. empy installs a proxy on stdout 319 | # to be able to capture the information. 320 | # And the test runner creates a new stdout object for each test. 321 | # This breaks empy as it assumes that the proxy has persistent 322 | # between instances of the Interpreter class 323 | # empy will error with the exception 324 | # "em.Error: interpreter stdout proxy lost" 325 | em.Interpreter._wasProxyInstalled = False 326 | 327 | def test_name_extension(self): 328 | plugins = list_plugins() 329 | name_plugin = plugins['hostname'] 330 | self.assertEqual(name_plugin.get_name(), 'hostname') 331 | 332 | p = name_plugin() 333 | self.assertTrue(plugin_load_parser_correctly(name_plugin)) 334 | 335 | mock_cliargs = {'hostname': 'none'} 336 | self.assertEqual(p.get_snippet(mock_cliargs), '') 337 | self.assertEqual(p.get_preamble(mock_cliargs), '') 338 | args = p.get_docker_args(mock_cliargs) 339 | self.assertTrue('--hostname none' in args) 340 | 341 | mock_cliargs = {'hostname': 'docker-hostname'} 342 | args = p.get_docker_args(mock_cliargs) 343 | self.assertTrue('--hostname docker-hostname' in args) 344 | 345 | 346 | class PrivilegedExtensionTest(unittest.TestCase): 347 | 348 | def setUp(self): 349 | # Work around interference between empy Interpreter 350 | # stdout proxy and test runner. empy installs a proxy on stdout 351 | # to be able to capture the information. 352 | # And the test runner creates a new stdout object for each test. 353 | # This breaks empy as it assumes that the proxy has persistent 354 | # between instances of the Interpreter class 355 | # empy will error with the exception 356 | # "em.Error: interpreter stdout proxy lost" 357 | em.Interpreter._wasProxyInstalled = False 358 | 359 | def test_privileged_extension(self): 360 | plugins = list_plugins() 361 | print(plugins) 362 | privileged_plugin = plugins['privileged'] 363 | self.assertEqual(privileged_plugin.get_name(), 'privileged') 364 | 365 | p = privileged_plugin() 366 | self.assertTrue(plugin_load_parser_correctly(privileged_plugin)) 367 | 368 | mock_cliargs = {'privileged': True} 369 | self.assertEqual(p.get_snippet(mock_cliargs), '') 370 | self.assertEqual(p.get_preamble(mock_cliargs), '') 371 | args = p.get_docker_args(mock_cliargs) 372 | self.assertTrue('--privileged' in args) 373 | 374 | 375 | class UserExtensionTest(unittest.TestCase): 376 | 377 | def setUp(self): 378 | # Work around interference between empy Interpreter 379 | # stdout proxy and test runner. empy installs a proxy on stdout 380 | # to be able to capture the information. 381 | # And the test runner creates a new stdout object for each test. 382 | # This breaks empy as it assumes that the proxy has persistent 383 | # between instances of the Interpreter class 384 | # empy will error with the exception 385 | # "em.Error: interpreter stdout proxy lost" 386 | em.Interpreter._wasProxyInstalled = False 387 | 388 | def test_user_extension(self): 389 | plugins = list_plugins() 390 | user_plugin = plugins['user'] 391 | self.assertEqual(user_plugin.get_name(), 'user') 392 | 393 | p = user_plugin() 394 | self.assertTrue(plugin_load_parser_correctly(user_plugin)) 395 | 396 | env_subs = p.get_environment_subs() 397 | self.assertEqual(env_subs['gid'], os.getgid()) 398 | self.assertEqual(env_subs['uid'], os.getuid()) 399 | self.assertEqual(env_subs['name'], getpass.getuser()) 400 | self.assertEqual(env_subs['dir'], str(Path.home())) 401 | self.assertEqual(env_subs['gecos'], pwd.getpwuid(os.getuid()).pw_gecos) 402 | self.assertEqual(env_subs['shell'], pwd.getpwuid(os.getuid()).pw_shell) 403 | 404 | mock_cliargs = {} 405 | snippet = p.get_snippet(mock_cliargs).splitlines() 406 | 407 | uid_line = [l for l in snippet if '--uid' in l][0] 408 | self.assertTrue(str(os.getuid()) in uid_line) 409 | 410 | self.assertEqual(p.get_preamble(mock_cliargs), '') 411 | self.assertEqual(p.get_docker_args(mock_cliargs), '') 412 | 413 | self.assertTrue('mkhomedir_helper' in p.get_snippet(mock_cliargs)) 414 | home_active_cliargs = mock_cliargs 415 | home_active_cliargs['home'] = True 416 | self.assertFalse('mkhomedir_helper' in p.get_snippet(home_active_cliargs)) 417 | 418 | user_override_active_cliargs = mock_cliargs 419 | user_override_active_cliargs['user_preserve_groups'] = [] 420 | snippet_result = p.get_snippet(user_override_active_cliargs) 421 | self.assertTrue('usermod -aG' in snippet_result) 422 | 423 | user_override_active_cliargs = mock_cliargs 424 | user_override_active_cliargs['user_preserve_groups'] = ['cdrom', 'audio'] 425 | snippet_result = p.get_snippet(user_override_active_cliargs) 426 | self.assertTrue('cdrom' in snippet_result) 427 | self.assertTrue('audio' in snippet_result) 428 | 429 | user_override_active_cliargs = mock_cliargs 430 | user_override_active_cliargs['user_preserve_groups'] = [] 431 | user_override_active_cliargs['user_preserve_groups_permissive'] = True 432 | snippet_result = p.get_snippet(user_override_active_cliargs) 433 | self.assertTrue('usermod -aG' in snippet_result) 434 | self.assertTrue('user-preserve-group-permissive Enabled' in snippet_result) 435 | 436 | user_override_active_cliargs['user_override_name'] = 'testusername' 437 | snippet_result = p.get_snippet(user_override_active_cliargs) 438 | self.assertTrue('WORKDIR /home/testusername' in snippet_result) 439 | self.assertTrue('userdel -r' in snippet_result) 440 | 441 | user_override_active_cliargs['user_preserve_home'] = True 442 | snippet_result = p.get_snippet(user_override_active_cliargs) 443 | self.assertFalse('userdel -r' in snippet_result) 444 | 445 | snippet_result = p.get_snippet(user_override_active_cliargs) 446 | self.assertTrue(('-s ' + pwd.getpwuid(os.getuid()).pw_shell) in snippet_result) 447 | 448 | user_override_active_cliargs['user_override_shell'] = 'testshell' 449 | snippet_result = p.get_snippet(user_override_active_cliargs) 450 | self.assertTrue('-s testshell' in snippet_result) 451 | 452 | user_override_active_cliargs['user_override_shell'] = '' 453 | snippet_result = p.get_snippet(user_override_active_cliargs) 454 | self.assertFalse('-s' in snippet_result) 455 | 456 | @pytest.mark.docker 457 | def test_user_collisions(self): 458 | plugins = list_plugins() 459 | user_plugin = plugins['user'] 460 | self.assertEqual(user_plugin.get_name(), 'user') 461 | 462 | uid = os.getuid()+1 463 | COLLIDING_UID_DOCKERFILE = f"""FROM ubuntu:jammy 464 | RUN useradd test -u{uid} 465 | 466 | """ 467 | iof = StringIO(COLLIDING_UID_DOCKERFILE.encode()) 468 | image_id = docker_build( 469 | fileobj=iof, 470 | #output_callback=output_callback, 471 | nocache=True, 472 | forcerm=True, 473 | tag="rocker:" + f"user_extension_test_uid_collision" 474 | ) 475 | print(f'Image id is {image_id}') 476 | self.assertTrue(image_id, f"Image failed to build >>>{COLLIDING_UID_DOCKERFILE}<<<") 477 | 478 | # Test Colliding UID but not name 479 | build_args = { 480 | 'user': True, 481 | 'user_override_name': 'test2', 482 | 'user_preserve_home': True, 483 | # 'command': 'ls -l && touch /home/test2/home_directory_access_verification', 484 | 'command': 'touch /home/test2/testwrite', 485 | } 486 | dig = DockerImageGenerator([user_plugin()], build_args, image_id) 487 | exit_code = dig.build(**build_args) 488 | self.assertTrue(exit_code == 0, f"Build failed with exit code {exit_code}") 489 | run_exit_code = dig.run(**build_args) 490 | self.assertTrue(run_exit_code == 0, f"Run failed with exit code {run_exit_code}") 491 | dig.clear_image() 492 | 493 | 494 | # Test colliding UID and name 495 | build_args['user_override_name'] = 'test' 496 | build_args['command'] = 'touch /home/test/testwrite' 497 | dig = DockerImageGenerator([user_plugin()], build_args, image_id) 498 | exit_code = dig.build(**build_args) 499 | self.assertTrue(exit_code == 0, f"Build failed with exit code {exit_code}") 500 | run_exit_code = dig.run(**build_args) 501 | self.assertTrue(run_exit_code == 0, f"Run failed with exit code {run_exit_code}") 502 | dig.clear_image() 503 | 504 | 505 | class PulseExtensionTest(unittest.TestCase): 506 | 507 | def setUp(self): 508 | # Work around interference between empy Interpreter 509 | # stdout proxy and test runner. empy installs a proxy on stdout 510 | # to be able to capture the information. 511 | # And the test runner creates a new stdout object for each test. 512 | # This breaks empy as it assumes that the proxy has persistent 513 | # between instances of the Interpreter class 514 | # empy will error with the exception 515 | # "em.Error: interpreter stdout proxy lost" 516 | em.Interpreter._wasProxyInstalled = False 517 | 518 | def test_pulse_extension(self): 519 | plugins = list_plugins() 520 | pulse_plugin = plugins['pulse'] 521 | self.assertEqual(pulse_plugin.get_name(), 'pulse') 522 | 523 | p = pulse_plugin() 524 | self.assertTrue(plugin_load_parser_correctly(pulse_plugin)) 525 | 526 | mock_cliargs = {} 527 | snippet = p.get_snippet(mock_cliargs) 528 | #first line 529 | self.assertIn('RUN mkdir -p /etc/pulse', snippet) 530 | self.assertIn('default-server = unix:/run/user/', snippet) #skipping user id that's system dependent 531 | self.assertIn('autospawn = no', snippet) 532 | self.assertIn('daemon-binary = /bin/true', snippet) 533 | #last line 534 | self.assertIn('> /etc/pulse/client.conf', snippet) 535 | self.assertEqual(p.get_preamble(mock_cliargs), '') 536 | docker_args = p.get_docker_args(mock_cliargs) 537 | self.assertIn('-v /run/user/', docker_args) 538 | self.assertIn('/pulse:/run/user/', docker_args) 539 | self.assertIn('/pulse --device /dev/snd ', docker_args) 540 | self.assertIn(' -e PULSE_SERVER=unix', docker_args) 541 | self.assertIn('/pulse/native -v', docker_args) 542 | self.assertIn('/pulse/native:', docker_args) 543 | self.assertIn('/pulse/native --group-add', docker_args) 544 | 545 | EXPECTED_DEV_HELPERS_SNIPPET = """# workspace development helpers 546 | RUN apt-get update \\ 547 | && apt-get install -y \\ 548 | byobu \\ 549 | emacs \\ 550 | && apt-get clean 551 | """ 552 | 553 | class DevHelpersExtensionTest(unittest.TestCase): 554 | 555 | def setUp(self): 556 | # Work around interference between empy Interpreter 557 | # stdout proxy and test runner. empy installs a proxy on stdout 558 | # to be able to capture the information. 559 | # And the test runner creates a new stdout object for each test. 560 | # This breaks empy as it assumes that the proxy has persistent 561 | # between instances of the Interpreter class 562 | # empy will error with the exception 563 | # "em.Error: interpreter stdout proxy lost" 564 | em.Interpreter._wasProxyInstalled = False 565 | 566 | def test_pulse_extension(self): 567 | plugins = list_plugins() 568 | dev_helper_plugin = plugins['dev_helpers'] 569 | self.assertEqual(dev_helper_plugin.get_name(), 'dev_helpers') 570 | 571 | p = dev_helper_plugin() 572 | self.assertTrue(plugin_load_parser_correctly(dev_helper_plugin)) 573 | 574 | mock_cliargs = {} 575 | 576 | self.assertEqual(p.get_snippet(mock_cliargs), EXPECTED_DEV_HELPERS_SNIPPET) 577 | self.assertEqual(p.get_preamble(mock_cliargs), '') 578 | 579 | 580 | class EnvExtensionTest(unittest.TestCase): 581 | 582 | def setUp(self): 583 | # Work around interference between empy Interpreter 584 | # stdout proxy and test runner. empy installs a proxy on stdout 585 | # to be able to capture the information. 586 | # And the test runner creates a new stdout object for each test. 587 | # This breaks empy as it assumes that the proxy has persistent 588 | # between instances of the Interpreter class 589 | # empy will error with the exception 590 | # "em.Error: interpreter stdout proxy lost" 591 | em.Interpreter._wasProxyInstalled = False 592 | 593 | def test_env_extension(self): 594 | plugins = list_plugins() 595 | env_plugin = plugins['env'] 596 | self.assertEqual(env_plugin.get_name(), 'env') 597 | 598 | p = env_plugin() 599 | self.assertTrue(plugin_load_parser_correctly(env_plugin)) 600 | 601 | mock_cliargs = {'env': [['ENVVARNAME=envvar_value', 'ENV2=val2'], ['ENV3=val3']]} 602 | 603 | self.assertEqual(p.get_snippet(mock_cliargs), '') 604 | self.assertEqual(p.get_preamble(mock_cliargs), '') 605 | self.assertEqual(p.get_docker_args(mock_cliargs), ' -e ENVVARNAME=envvar_value -e ENV2=val2 -e ENV3=val3') 606 | 607 | def test_env_file_extension(self): 608 | plugins = list_plugins() 609 | env_plugin = plugins['env'] 610 | self.assertEqual(env_plugin.get_name(), 'env') 611 | 612 | p = env_plugin() 613 | self.assertTrue(plugin_load_parser_correctly(env_plugin)) 614 | 615 | mock_cliargs = {'env_file': [['foo'], ['bar']]} 616 | 617 | self.assertEqual(p.get_snippet(mock_cliargs), '') 618 | self.assertEqual(p.get_preamble(mock_cliargs), '') 619 | self.assertEqual(p.get_docker_args(mock_cliargs), ' --env-file foo --env-file bar') 620 | 621 | 622 | class GroupAddExtensionTest(unittest.TestCase): 623 | 624 | def setUp(self): 625 | # Work around interference between empy Interpreter 626 | # stdout proxy and test runner. empy installs a proxy on stdout 627 | # to be able to capture the information. 628 | # And the test runner creates a new stdout object for each test. 629 | # This breaks empy as it assumes that the proxy has persistent 630 | # between instances of the Interpreter class 631 | # empy will error with the exception 632 | # "em.Error: interpreter stdout proxy lost" 633 | em.Interpreter._wasProxyInstalled = False 634 | 635 | @pytest.mark.docker 636 | def test_group_add_extension(self): 637 | plugins = list_plugins() 638 | group_add_plugin = plugins['group_add'] 639 | self.assertEqual(group_add_plugin.get_name(), 'group_add') 640 | 641 | p = group_add_plugin() 642 | self.assertTrue(plugin_load_parser_correctly(group_add_plugin)) 643 | 644 | mock_cliargs = {} 645 | self.assertEqual(p.get_snippet(mock_cliargs), '') 646 | self.assertEqual(p.get_preamble(mock_cliargs), '') 647 | args = p.get_docker_args(mock_cliargs) 648 | self.assertNotIn('--group_add', args) 649 | 650 | mock_cliargs = {'group_add': ['sudo', 'docker']} 651 | args = p.get_docker_args(mock_cliargs) 652 | self.assertIn('--group-add sudo', args) 653 | self.assertIn('--group-add docker', args) 654 | 655 | class ShmSizeExtensionTest(unittest.TestCase): 656 | 657 | def setUp(self): 658 | # Work around interference between empy Interpreter 659 | # stdout proxy and test runner. empy installs a proxy on stdout 660 | # to be able to capture the information. 661 | # And the test runner creates a new stdout object for each test. 662 | # This breaks empy as it assumes that the proxy has persistent 663 | # between instances of the Interpreter class 664 | # empy will error with the exception 665 | # "em.Error: interpreter stdout proxy lost" 666 | em.Interpreter._wasProxyInstalled = False 667 | 668 | @pytest.mark.docker 669 | def test_shm_size_extension(self): 670 | plugins = list_plugins() 671 | shm_size_plugin = plugins['shm_size'] 672 | self.assertEqual(shm_size_plugin.get_name(), 'shm_size') 673 | 674 | p = shm_size_plugin() 675 | self.assertTrue(plugin_load_parser_correctly(shm_size_plugin)) 676 | 677 | mock_cliargs = {} 678 | self.assertEqual(p.get_snippet(mock_cliargs), '') 679 | self.assertEqual(p.get_preamble(mock_cliargs), '') 680 | args = p.get_docker_args(mock_cliargs) 681 | self.assertNotIn('--shm-size', args) 682 | 683 | mock_cliargs = {'shm_size': '12g'} 684 | args = p.get_docker_args(mock_cliargs) 685 | self.assertIn('--shm-size 12g', args) -------------------------------------------------------------------------------- /test/test_file_writing.py: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | 18 | import argparse 19 | import em 20 | import getpass 21 | import os 22 | import unittest 23 | from pathlib import Path 24 | import pwd 25 | import shlex 26 | from tempfile import TemporaryDirectory 27 | 28 | from rocker.core import list_plugins 29 | from rocker.core import write_files 30 | from rocker.extensions import name_to_argument 31 | from rocker.extensions import RockerExtension 32 | 33 | from test_extension import plugin_load_parser_correctly 34 | 35 | class ExtensionsTest(unittest.TestCase): 36 | def test_name_to_argument(self): 37 | self.assertEqual(name_to_argument('asdf'), '--asdf') 38 | self.assertEqual(name_to_argument('as_df'), '--as-df') 39 | self.assertEqual(name_to_argument('as-df'), '--as-df') 40 | 41 | class TestFileInjection(RockerExtension): 42 | 43 | name = 'test_file_injection' 44 | 45 | @classmethod 46 | def get_name(cls): 47 | return cls.name 48 | 49 | def get_files(self, cliargs): 50 | all_files = {} 51 | all_files['test_file.txt'] = """The quick brown fox jumped over the lazy dog. %s""" % cliargs 52 | all_files['path/to/test_file.txt'] = """The quick brown fox jumped over the lazy dog. %s""" % cliargs 53 | all_files['test_file.bin'] = bytes("""The quick brown fox jumped over the lazy dog. %s""" % cliargs, 'utf-8') 54 | all_files['../outside/path/to/test_file.txt'] = """Path outside directory should be skipped""" 55 | all_files['/absolute.txt'] = """Absolute file path should be skipped""" 56 | return all_files 57 | 58 | 59 | 60 | @staticmethod 61 | def register_arguments(parser, defaults): 62 | parser.add_argument('--test-file-injection', 63 | action='store_true', 64 | default=defaults.get('test_file_injection', False), 65 | help="Enable test_file_injection extension") 66 | 67 | 68 | class FileInjectionExtensionTest(unittest.TestCase): 69 | 70 | def setUp(self): 71 | # Work around interference between empy Interpreter 72 | # stdout proxy and test runner. empy installs a proxy on stdout 73 | # to be able to capture the information. 74 | # And the test runner creates a new stdout object for each test. 75 | # This breaks empy as it assumes that the proxy has persistent 76 | # between instances of the Interpreter class 77 | # empy will error with the exception 78 | # "em.Error: interpreter stdout proxy lost" 79 | em.Interpreter._wasProxyInstalled = False 80 | 81 | def test_file_injection(self): 82 | extensions = [TestFileInjection()] 83 | mock_cliargs = {'test_key': 'test_value'} 84 | 85 | with TemporaryDirectory() as td: 86 | write_files(extensions, mock_cliargs, td) 87 | 88 | with open(os.path.join(td, 'test_file.txt'), 'r') as fh: 89 | content = fh.read() 90 | self.assertIn('quick brown', content) 91 | self.assertIn('test_key', content) 92 | self.assertIn('test_value', content) 93 | 94 | with open(os.path.join(td, 'path/to/test_file.txt'), 'r') as fh: 95 | content = fh.read() 96 | self.assertIn('quick brown', content) 97 | self.assertIn('test_key', content) 98 | self.assertIn('test_value', content) 99 | 100 | with open(os.path.join(td, 'test_file.bin'), 'r') as fh: # this particular binary file can be read in text mode 101 | content = fh.read() 102 | self.assertIn('quick brown', content) 103 | self.assertIn('test_key', content) 104 | self.assertIn('test_value', content) 105 | 106 | self.assertFalse(os.path.exists('../outside/path/to/test_file.txt')) 107 | self.assertFalse(os.path.exists('/absolute.txt')) 108 | -------------------------------------------------------------------------------- /test/test_git_extension.py: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | 18 | import argparse 19 | import em 20 | import getpass 21 | import os 22 | import unittest 23 | from pathlib import Path 24 | import pwd 25 | import tempfile 26 | 27 | 28 | from rocker.core import list_plugins 29 | from rocker.extensions import name_to_argument 30 | 31 | from test_extension import plugin_load_parser_correctly 32 | 33 | class ExtensionsTest(unittest.TestCase): 34 | def test_name_to_argument(self): 35 | self.assertEqual(name_to_argument('asdf'), '--asdf') 36 | self.assertEqual(name_to_argument('as_df'), '--as-df') 37 | self.assertEqual(name_to_argument('as-df'), '--as-df') 38 | 39 | 40 | class GitExtensionTest(unittest.TestCase): 41 | 42 | def setUp(self): 43 | # Work around interference between empy Interpreter 44 | # stdout proxy and test runner. empy installs a proxy on stdout 45 | # to be able to capture the information. 46 | # And the test runner creates a new stdout object for each test. 47 | # This breaks empy as it assumes that the proxy has persistent 48 | # between instances of the Interpreter class 49 | # empy will error with the exception 50 | # "em.Error: interpreter stdout proxy lost" 51 | em.Interpreter._wasProxyInstalled = False 52 | 53 | def test_git_extension(self): 54 | plugins = list_plugins() 55 | git_plugin = plugins['git'] 56 | self.assertEqual(git_plugin.get_name(), 'git') 57 | 58 | p = git_plugin() 59 | self.assertTrue(plugin_load_parser_correctly(git_plugin)) 60 | 61 | 62 | mock_cliargs = {} 63 | mock_config_file = tempfile.NamedTemporaryFile() 64 | mock_system_config_file = tempfile.NamedTemporaryFile() 65 | mock_cliargs['git_config_path'] = mock_config_file.name 66 | mock_cliargs['git_config_path_system'] = mock_system_config_file.name 67 | args = p.get_docker_args(mock_cliargs) 68 | system_gitconfig = mock_system_config_file.name 69 | system_gitconfig_target = '/etc/gitconfig' 70 | user_gitconfig = mock_config_file.name 71 | user_gitconfig_target = '/root/.gitconfig' 72 | self.assertIn('-v %s:%s' % (system_gitconfig, system_gitconfig_target), args) 73 | self.assertIn('-v %s:%s' % (user_gitconfig, user_gitconfig_target), args) 74 | 75 | # Test with user "enabled" 76 | mock_cliargs = {'user': True} 77 | mock_cliargs['git_config_path'] = mock_config_file.name 78 | user_args = p.get_docker_args(mock_cliargs) 79 | user_gitconfig_target = os.path.expanduser('~/.gitconfig') 80 | self.assertIn('-v %s:%s' % (user_gitconfig, user_gitconfig_target), user_args) 81 | 82 | # Test with an existing overridden user key, but with None value 83 | mock_cliargs['user_override_name'] = None 84 | user_args = p.get_docker_args(mock_cliargs) 85 | user_gitconfig_target = os.path.expanduser('~/.gitconfig') 86 | self.assertIn('-v %s:%s' % (user_gitconfig, user_gitconfig_target), user_args) 87 | 88 | # Test with overridden user 89 | mock_cliargs['user_override_name'] = 'testusername' 90 | user_args = p.get_docker_args(mock_cliargs) 91 | user_gitconfig_target = '/home/testusername/.gitconfig' 92 | self.assertIn('-v %s:%s' % (user_gitconfig, user_gitconfig_target), user_args) 93 | 94 | # Test non-extant files no generation 95 | mock_cliargs['git_config_path'] = '/path-does-not-exist' 96 | mock_cliargs['git_config_path_system'] = '/path-does-not-exist-either' 97 | user_args = p.get_docker_args(mock_cliargs) 98 | self.assertNotIn('-v', user_args) 99 | -------------------------------------------------------------------------------- /test/test_nvidia.py: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | 18 | import docker 19 | import em 20 | import unittest 21 | import pexpect 22 | import pytest 23 | 24 | 25 | from io import BytesIO as StringIO 26 | from packaging.version import Version 27 | 28 | from rocker.core import DockerImageGenerator 29 | from rocker.core import list_plugins 30 | from rocker.core import get_docker_client 31 | from rocker.nvidia_extension import get_docker_version 32 | from test_extension import plugin_load_parser_correctly 33 | 34 | 35 | @pytest.mark.docker 36 | class X11Test(unittest.TestCase): 37 | @classmethod 38 | def setUpClass(self): 39 | client = get_docker_client() 40 | self.dockerfile_tags = [] 41 | for distro, distro_version in [('ubuntu', 'xenial'), ('ubuntu', 'bionic'), ('ubuntu', 'focal'), ('debian', 'buster')]: 42 | dockerfile = """ 43 | FROM %(distro)s:%(distro_version)s 44 | 45 | RUN apt-get update && apt-get install x11-utils -y && apt-get clean 46 | 47 | CMD xdpyinfo 48 | """ 49 | dockerfile_tag = 'testfixture_%s_x11_validate' % distro_version 50 | iof = StringIO((dockerfile % locals()).encode()) 51 | im = client.build(fileobj = iof, tag=dockerfile_tag) 52 | for e in im: 53 | pass 54 | #print(e) 55 | self.dockerfile_tags.append(dockerfile_tag) 56 | 57 | def setUp(self): 58 | # Work around interference between empy Interpreter 59 | # stdout proxy and test runner. empy installs a proxy on stdout 60 | # to be able to capture the information. 61 | # And the test runner creates a new stdout object for each test. 62 | # This breaks empy as it assumes that the proxy has persistent 63 | # between instances of the Interpreter class 64 | # empy will error with the exception 65 | # "em.Error: interpreter stdout proxy lost" 66 | em.Interpreter._wasProxyInstalled = False 67 | 68 | def test_x11_extension_basic(self): 69 | plugins = list_plugins() 70 | x11_plugin = plugins['x11'] 71 | self.assertEqual(x11_plugin.get_name(), 'x11') 72 | self.assertTrue(plugin_load_parser_correctly(x11_plugin)) 73 | 74 | p = x11_plugin() 75 | mock_cliargs = {'base_image': 'ubuntu:xenial'} 76 | 77 | # Must be called before get_docker_args 78 | docker_args = p.precondition_environment(mock_cliargs) 79 | 80 | docker_args = p.get_docker_args(mock_cliargs) 81 | self.assertIn(' -e DISPLAY -e TERM', docker_args) 82 | self.assertIn(' -e QT_X11_NO_MITSHM=1', docker_args) 83 | self.assertIn(' -e XAUTHORITY=', docker_args) 84 | self.assertIn(' -v /tmp/.X11-unix:/tmp/.X11-unix ', docker_args) 85 | self.assertIn(' -v /etc/localtime:/etc/localtime:ro ', docker_args) 86 | 87 | def test_x11_extension_nocleanup(self): 88 | plugins = list_plugins() 89 | x11_plugin = plugins['x11'] 90 | p = x11_plugin() 91 | mock_cliargs = {'base_image': 'ubuntu:xenial', 'nocleanup': True} 92 | docker_args = p.precondition_environment(mock_cliargs) 93 | # TODO(tfoote) do more to check that it doesn't actually clean up. 94 | # This is more of a smoke test 95 | 96 | 97 | def test_no_x11_xpdyinfo(self): 98 | for tag in self.dockerfile_tags: 99 | dig = DockerImageGenerator([], {}, tag) 100 | self.assertEqual(dig.build(), 0) 101 | self.assertNotEqual(dig.run(), 0) 102 | 103 | @pytest.mark.x11 104 | def test_x11_xpdyinfo(self): 105 | plugins = list_plugins() 106 | desired_plugins = ['x11'] 107 | active_extensions = [e() for e in plugins.values() if e.get_name() in desired_plugins] 108 | for tag in self.dockerfile_tags: 109 | dig = DockerImageGenerator(active_extensions, {}, tag) 110 | self.assertEqual(dig.build(), 0) 111 | self.assertEqual(dig.run(), 0) 112 | 113 | 114 | @pytest.mark.docker 115 | class NvidiaTest(unittest.TestCase): 116 | @classmethod 117 | def setUpClass(self): 118 | client = get_docker_client() 119 | self.dockerfile_tags = [] 120 | for distro_version in ['xenial', 'bionic']: 121 | dockerfile = """ 122 | FROM ubuntu:%(distro_version)s 123 | 124 | RUN apt-get update && apt-get install glmark2 -y && apt-get clean 125 | 126 | CMD glmark2 --validate 127 | """ 128 | dockerfile_tag = 'testfixture_%s_glmark2' % distro_version 129 | iof = StringIO((dockerfile % locals()).encode()) 130 | im = client.build(fileobj = iof, tag=dockerfile_tag) 131 | for e in im: 132 | pass 133 | #print(e) 134 | self.dockerfile_tags.append(dockerfile_tag) 135 | 136 | def setUp(self): 137 | # Work around interference between empy Interpreter 138 | # stdout proxy and test runner. empy installs a proxy on stdout 139 | # to be able to capture the information. 140 | # And the test runner creates a new stdout object for each test. 141 | # This breaks empy as it assumes that the proxy has persistent 142 | # between instances of the Interpreter class 143 | # empy will error with the exception 144 | # "em.Error: interpreter stdout proxy lost" 145 | em.Interpreter._wasProxyInstalled = False 146 | 147 | def test_nvidia_extension_basic(self): 148 | plugins = list_plugins() 149 | nvidia_plugin = plugins['nvidia'] 150 | self.assertEqual(nvidia_plugin.get_name(), 'nvidia') 151 | self.assertTrue(plugin_load_parser_correctly(nvidia_plugin)) 152 | 153 | p = nvidia_plugin() 154 | mock_cliargs = {'base_image': 'ubuntu:xenial'} 155 | snippet = p.get_snippet(mock_cliargs) 156 | 157 | self.assertIn('COPY --from=glvnd /usr/local/lib/x86_64-linux-gnu /usr/local/lib/x86_64-linux-gnu', snippet) 158 | self.assertIn('COPY --from=glvnd /usr/local/lib/i386-linux-gnu /usr/local/lib/i386-linux-gnu', snippet) 159 | self.assertIn('ENV LD_LIBRARY_PATH /usr/local/lib/x86_64-linux-gnu:/usr/local/lib/i386-linux-gnu', snippet) 160 | self.assertIn('NVIDIA_VISIBLE_DEVICES', snippet) 161 | self.assertIn('NVIDIA_DRIVER_CAPABILITIES', snippet) 162 | 163 | mock_cliargs = {'base_image': 'ubuntu:bionic'} 164 | snippet = p.get_snippet(mock_cliargs) 165 | self.assertIn('RUN apt-get update && apt-get install -y --no-install-recommends', snippet) 166 | self.assertIn(' libglvnd0 ', snippet) 167 | self.assertIn(' libgles2 ', snippet) 168 | self.assertIn('COPY --from=glvnd /usr/share/glvnd/egl_vendor.d/10_nvidia.json /usr/share/glvnd/egl_vendor.d/10_nvidia.json', snippet) 169 | 170 | self.assertIn('NVIDIA_VISIBLE_DEVICES', snippet) 171 | self.assertIn('NVIDIA_DRIVER_CAPABILITIES', snippet) 172 | 173 | 174 | preamble = p.get_preamble(mock_cliargs) 175 | self.assertIn('FROM nvidia/opengl:1.0-glvnd-devel-ubuntu18.04', preamble) 176 | 177 | mock_cliargs = {'base_image': 'ubuntu:jammy'} 178 | preamble = p.get_preamble(mock_cliargs) 179 | self.assertIn('FROM nvidia/opengl:1.0-glvnd-devel-ubuntu22.04', preamble) 180 | 181 | mock_cliargs = {'base_image': 'ubuntu:jammy', 'nvidia_glvnd_version': '20.04'} 182 | preamble = p.get_preamble(mock_cliargs) 183 | self.assertIn('FROM nvidia/opengl:1.0-glvnd-devel-ubuntu20.04', preamble) 184 | 185 | docker_args = p.get_docker_args(mock_cliargs) 186 | #TODO(tfoote) restore with #37 self.assertIn(' -e DISPLAY -e TERM', docker_args) 187 | #TODO(tfoote) restore with #37 self.assertIn(' -e QT_X11_NO_MITSHM=1', docker_args) 188 | #TODO(tfoote) restore with #37 self.assertIn(' -e XAUTHORITY=', docker_args) 189 | #TODO(tfoote) restore with #37 self.assertIn(' -v /tmp/.X11-unix:/tmp/.X11-unix ', docker_args) 190 | #TODO(tfoote) restore with #37 self.assertIn(' -v /etc/localtime:/etc/localtime:ro ', docker_args) 191 | if get_docker_version() >= Version("19.03"): 192 | self.assertIn(' --gpus all', docker_args) 193 | else: 194 | self.assertIn(' --runtime=nvidia', docker_args) 195 | 196 | mock_cliargs = {'nvidia': 'auto'} 197 | docker_args = p.get_docker_args(mock_cliargs) 198 | if get_docker_version() >= Version("19.03"): 199 | self.assertIn(' --gpus all', docker_args) 200 | else: 201 | self.assertIn(' --runtime=nvidia', docker_args) 202 | 203 | mock_cliargs = {'nvidia': 'gpus'} 204 | docker_args = p.get_docker_args(mock_cliargs) 205 | self.assertIn(' --gpus all', docker_args) 206 | 207 | mock_cliargs = {'nvidia': 'runtime'} 208 | docker_args = p.get_docker_args(mock_cliargs) 209 | self.assertIn(' --runtime=nvidia', docker_args) 210 | 211 | 212 | def test_no_nvidia_glmark2(self): 213 | for tag in self.dockerfile_tags: 214 | dig = DockerImageGenerator([], {}, tag) 215 | self.assertEqual(dig.build(), 0) 216 | self.assertNotEqual(dig.run(), 0) 217 | 218 | @pytest.mark.nvidia 219 | @pytest.mark.x11 220 | def test_nvidia_glmark2(self): 221 | plugins = list_plugins() 222 | desired_plugins = ['x11', 'nvidia', 'user'] #TODO(Tfoote) encode the x11 dependency into the plugin and remove from test here 223 | active_extensions = [e() for e in plugins.values() if e.get_name() in desired_plugins] 224 | for tag in self.dockerfile_tags: 225 | dig = DockerImageGenerator(active_extensions, {}, tag) 226 | self.assertEqual(dig.build(), 0) 227 | self.assertEqual(dig.run(), 0) 228 | 229 | def test_nvidia_env_subs(self): 230 | plugins = list_plugins() 231 | nvidia_plugin = plugins['nvidia'] 232 | 233 | p = nvidia_plugin() 234 | 235 | # base image doesn't exist 236 | mock_cliargs = {'base_image': 'ros:does-not-exist'} 237 | with self.assertRaises(SystemExit) as cm: 238 | p.get_environment_subs(mock_cliargs) 239 | self.assertEqual(cm.exception.code, 1) 240 | 241 | # unsupported version 242 | mock_cliargs = {'base_image': 'ubuntu:17.04'} 243 | with self.assertRaises(SystemExit) as cm: 244 | p.get_environment_subs(mock_cliargs) 245 | self.assertEqual(cm.exception.code, 1) 246 | 247 | # unsupported os 248 | mock_cliargs = {'base_image': 'fedora'} 249 | with self.assertRaises(SystemExit) as cm: 250 | p.get_environment_subs(mock_cliargs) 251 | self.assertEqual(cm.exception.code, 1) 252 | 253 | @pytest.mark.docker 254 | @pytest.mark.nvidia # Technically not needing nvidia but too resource intensive for main runs 255 | class CudaTest(unittest.TestCase): 256 | @classmethod 257 | def setUpClass(self): 258 | client = get_docker_client() 259 | self.dockerfile_tags = [] 260 | for (distro_name, distro_version) in [ 261 | ('ubuntu','focal'), 262 | ('ubuntu','jammy'), 263 | ('ubuntu','noble'), 264 | ('debian','bookworm'), 265 | ('debian','bullseye'), 266 | ]: 267 | dockerfile = """ 268 | FROM %(distro_name)s:%(distro_version)s 269 | CMD dpkg -s cuda-toolkit 270 | """ 271 | dockerfile_tag = 'testfixture_%s_cuda' % distro_version 272 | iof = StringIO((dockerfile % locals()).encode()) 273 | im = client.build(fileobj = iof, tag=dockerfile_tag) 274 | for e in im: 275 | pass 276 | #print(e) 277 | self.dockerfile_tags.append(dockerfile_tag) 278 | 279 | def setUp(self): 280 | # Work around interference between empy Interpreter 281 | # stdout proxy and test runner. empy installs a proxy on stdout 282 | # to be able to capture the information. 283 | # And the test runner creates a new stdout object for each test. 284 | # This breaks empy as it assumes that the proxy has persistent 285 | # between instances of the Interpreter class 286 | # empy will error with the exception 287 | # "em.Error: interpreter stdout proxy lost" 288 | em.Interpreter._wasProxyInstalled = False 289 | 290 | 291 | def test_no_cuda(self): 292 | for tag in self.dockerfile_tags: 293 | dig = DockerImageGenerator([], {}, tag) 294 | self.assertEqual(dig.build(), 0) 295 | self.assertNotEqual(dig.run(), 0) 296 | dig.clear_image() 297 | 298 | def test_cuda_install(self): 299 | plugins = list_plugins() 300 | desired_plugins = ['cuda'] 301 | active_extensions = [e() for e in plugins.values() if e.get_name() in desired_plugins] 302 | for tag in self.dockerfile_tags: 303 | dig = DockerImageGenerator(active_extensions, {}, tag) 304 | self.assertEqual(dig.build(), 0) 305 | self.assertEqual(dig.run(), 0) 306 | dig.clear_image() 307 | 308 | def test_cuda_env_subs(self): 309 | plugins = list_plugins() 310 | cuda_plugin = plugins['cuda'] 311 | 312 | p = cuda_plugin() 313 | 314 | # base image doesn't exist 315 | mock_cliargs = {'base_image': 'ros:does-not-exist'} 316 | with self.assertRaises(SystemExit) as cm: 317 | p.get_environment_subs(mock_cliargs) 318 | self.assertEqual(cm.exception.code, 1) 319 | 320 | # unsupported version 321 | mock_cliargs = {'base_image': 'ubuntu:17.04'} 322 | with self.assertRaises(SystemExit) as cm: 323 | p.get_environment_subs(mock_cliargs) 324 | self.assertEqual(cm.exception.code, 1) 325 | 326 | # unsupported os 327 | mock_cliargs = {'base_image': 'fedora'} 328 | with self.assertRaises(SystemExit) as cm: 329 | p.get_environment_subs(mock_cliargs) 330 | self.assertEqual(cm.exception.code, 1) 331 | -------------------------------------------------------------------------------- /test/test_os_detect.py: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | 18 | import docker 19 | import pytest 20 | import unittest 21 | 22 | 23 | from rocker.os_detector import detect_os 24 | 25 | class RockerOSDetectorTest(unittest.TestCase): 26 | 27 | @pytest.mark.docker 28 | def test_ubuntu(self): 29 | result = detect_os("ubuntu:xenial") 30 | self.assertEqual(result[0], 'Ubuntu') 31 | self.assertEqual(result[1], '16.04') 32 | 33 | result = detect_os("ubuntu:bionic") 34 | self.assertEqual(result[0], 'Ubuntu') 35 | self.assertEqual(result[1], '18.04') 36 | 37 | # Cover verbose codepath 38 | result = detect_os("ubuntu:bionic", output_callback=print) 39 | self.assertEqual(result[0], 'Ubuntu') 40 | self.assertEqual(result[1], '18.04') 41 | 42 | @pytest.mark.docker 43 | def test_fedora(self): 44 | result = detect_os("fedora:29") 45 | self.assertEqual(result[0], 'Fedora') 46 | self.assertEqual(result[1], '29') 47 | 48 | @pytest.mark.docker 49 | def test_does_not_exist(self): 50 | result = detect_os("osrf/ros:does_not_exist") 51 | self.assertEqual(result, None) 52 | 53 | @pytest.mark.docker 54 | def test_cannot_detect_os(self): 55 | # Test with output callback too get coverage of error reporting 56 | result = detect_os("scratch", output_callback=print) 57 | self.assertEqual(result, None) 58 | -------------------------------------------------------------------------------- /test/test_rmw_extension.py: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | 18 | import em 19 | import os 20 | import unittest 21 | import pytest 22 | 23 | 24 | from rocker.core import DockerImageGenerator 25 | from rocker.core import list_plugins 26 | 27 | from test_extension import plugin_load_parser_correctly 28 | 29 | from rocker.rmw_extension import RMW 30 | 31 | 32 | class rmwExtensionTest(unittest.TestCase): 33 | 34 | def setUp(self): 35 | # Work around interference between empy Interpreter 36 | # stdout proxy and test runner. empy installs a proxy on stdout 37 | # to be able to capture the information. 38 | # And the test runner creates a new stdout object for each test. 39 | # This breaks empy as it assumes that the proxy has persistent 40 | # between instances of the Interpreter class 41 | # empy will error with the exception 42 | # "em.Error: interpreter stdout proxy lost" 43 | em.Interpreter._wasProxyInstalled = False 44 | 45 | def test_rmw_extension(self): 46 | plugins = list_plugins() 47 | rmw_plugin = plugins['rmw'] 48 | self.assertEqual(rmw_plugin.get_name(), 'rmw') 49 | 50 | p = rmw_plugin() 51 | self.assertTrue(plugin_load_parser_correctly(rmw_plugin)) 52 | 53 | 54 | mock_cliargs = {'rmw': ['cyclonedds']} 55 | self.assertEqual(p.get_preamble(mock_cliargs), '') 56 | args = p.get_docker_args(mock_cliargs) 57 | self.assertIn('-e RMW_IMPLEMENTATION=rmw_cyclonedds_cpp', args) 58 | snippet = p.get_snippet(mock_cliargs) 59 | self.assertIn('rmw-cyclonedds-cpp', snippet) 60 | 61 | 62 | #without it set 63 | mock_cliargs = {'rmw': None} 64 | args = p.get_docker_args(mock_cliargs) 65 | snippet = p.get_snippet(mock_cliargs) 66 | self.assertNotIn('RMW_IMPLEMENTATION', args) 67 | self.assertNotIn('rmw-cyclonedds-cpp', snippet) 68 | 69 | 70 | @pytest.mark.docker 71 | class rmwRuntimeExtensionTest(unittest.TestCase): 72 | 73 | def setUp(self): 74 | # Work around interference between empy Interpreter 75 | # stdout proxy and test runner. empy installs a proxy on stdout 76 | # to be able to capture the information. 77 | # And the test runner creates a new stdout object for each test. 78 | # This breaks empy as it assumes that the proxy has persistent 79 | # between instances of the Interpreter class 80 | # empy will error with the exception 81 | # "em.Error: interpreter stdout proxy lost" 82 | em.Interpreter._wasProxyInstalled = False 83 | 84 | def test_rmw_extension(self): 85 | plugins = list_plugins() 86 | rmw_plugin = plugins['rmw'] 87 | 88 | rmws_to_test = RMW.rmw_map.keys() 89 | 90 | p = rmw_plugin() 91 | self.assertTrue(plugin_load_parser_correctly(rmw_plugin)) 92 | 93 | 94 | for rmw_name in rmws_to_test: 95 | mock_cliargs = {'rmw': [rmw_name]} 96 | dig = DockerImageGenerator([rmw_plugin()], mock_cliargs, 'ros:rolling') 97 | self.assertEqual(dig.build(), 0, msg=f'dig.build for rmw {rmw_name} failed') 98 | self.assertEqual(dig.run(command=f'dpkg -l ros-rolling-rmw-{rmw_name}-cpp'), 0) 99 | self.assertIn(f'-e RMW_IMPLEMENTATION=rmw_{rmw_name}_cpp', dig.generate_docker_cmd('', mode='dry-run')) 100 | dig.clear_image() 101 | -------------------------------------------------------------------------------- /test/test_ssh_extension.py: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | 18 | import argparse 19 | import em 20 | import getpass 21 | import os 22 | import unittest 23 | from pathlib import Path 24 | import pwd 25 | import shlex 26 | 27 | 28 | from rocker.core import list_plugins 29 | from rocker.extensions import name_to_argument 30 | 31 | from test_extension import plugin_load_parser_correctly 32 | 33 | class ExtensionsTest(unittest.TestCase): 34 | def test_name_to_argument(self): 35 | self.assertEqual(name_to_argument('asdf'), '--asdf') 36 | self.assertEqual(name_to_argument('as_df'), '--as-df') 37 | self.assertEqual(name_to_argument('as-df'), '--as-df') 38 | 39 | 40 | class sshExtensionTest(unittest.TestCase): 41 | 42 | def setUp(self): 43 | # Work around interference between empy Interpreter 44 | # stdout proxy and test runner. empy installs a proxy on stdout 45 | # to be able to capture the information. 46 | # And the test runner creates a new stdout object for each test. 47 | # This breaks empy as it assumes that the proxy has persistent 48 | # between instances of the Interpreter class 49 | # empy will error with the exception 50 | # "em.Error: interpreter stdout proxy lost" 51 | em.Interpreter._wasProxyInstalled = False 52 | 53 | def test_ssh_extension(self): 54 | plugins = list_plugins() 55 | ssh_plugin = plugins['ssh'] 56 | self.assertEqual(ssh_plugin.get_name(), 'ssh') 57 | 58 | p = ssh_plugin() 59 | self.assertTrue(plugin_load_parser_correctly(ssh_plugin)) 60 | 61 | 62 | mock_cliargs = {} 63 | self.assertEqual(p.get_snippet(mock_cliargs), '') 64 | self.assertEqual(p.get_preamble(mock_cliargs), '') 65 | # with SSH_AUTH_SOCK set 66 | os.environ['SSH_AUTH_SOCK'] = 'foo' 67 | args = p.get_docker_args(mock_cliargs) 68 | self.assertIn('-e SSH_AUTH_SOCK -v ' + shlex.quote('{SSH_AUTH_SOCK}:{SSH_AUTH_SOCK}'.format(**os.environ)), args) 69 | 70 | #without it set 71 | del os.environ['SSH_AUTH_SOCK'] 72 | args = p.get_docker_args(mock_cliargs) 73 | self.assertNotIn('SSH_AUTH_SOCK', args) -------------------------------------------------------------------------------- /test/test_ulimit.py: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | 18 | import unittest 19 | from argparse import ArgumentTypeError 20 | 21 | from rocker.ulimit_extension import Ulimit 22 | 23 | 24 | class UlimitTest(unittest.TestCase): 25 | """Unit tests for the Ulimit class.""" 26 | 27 | def setUp(self): 28 | self._instance = Ulimit() 29 | 30 | def _is_arg_translation_ok(self, mock_cliargs, expected): 31 | is_ok = False 32 | message_string = "" 33 | try: 34 | docker_args = self._instance.get_docker_args( 35 | {self._instance.get_name(): [mock_cliargs]}) 36 | is_ok = docker_args == expected 37 | message_string = f"Expected: '{expected}', got: '{docker_args}'" 38 | except ArgumentTypeError: 39 | message_string = "Incorrect argument format" 40 | return (is_ok, message_string) 41 | 42 | def test_args_single_soft(self): 43 | """Test single soft limit argument.""" 44 | mock_cliargs = ["rtprio=99"] 45 | expected = " --ulimit rtprio=99" 46 | self.assertTrue(*self._is_arg_translation_ok(mock_cliargs, expected)) 47 | 48 | def test_args_multiple_soft(self): 49 | """Test multiple soft limit arguments.""" 50 | mock_cliargs = ["rtprio=99", "memlock=102400"] 51 | expected = " --ulimit rtprio=99 --ulimit memlock=102400" 52 | self.assertTrue(*self._is_arg_translation_ok(mock_cliargs, expected)) 53 | 54 | def test_args_single_hard(self): 55 | """Test single hard limit argument.""" 56 | mock_cliargs = ["nofile=1024:524288"] 57 | expected = " --ulimit nofile=1024:524288" 58 | self.assertTrue(*self._is_arg_translation_ok(mock_cliargs, expected)) 59 | 60 | def test_args_multiple_hard(self): 61 | """Test multiple hard limit arguments.""" 62 | mock_cliargs = ["nofile=1024:524288", "rtprio=90:99"] 63 | expected = " --ulimit nofile=1024:524288 --ulimit rtprio=90:99" 64 | self.assertTrue(*self._is_arg_translation_ok(mock_cliargs, expected)) 65 | 66 | def test_args_multiple_mix(self): 67 | """Test multiple mixed limit arguments.""" 68 | mock_cliargs = ["rtprio=99", "memlock=102400", "nofile=1024:524288"] 69 | expected = " --ulimit rtprio=99 --ulimit memlock=102400 --ulimit nofile=1024:524288" 70 | self.assertTrue(*self._is_arg_translation_ok(mock_cliargs, expected)) 71 | 72 | def test_args_wrong_single_soft(self): 73 | """Test if single soft limit argument is wrong.""" 74 | mock_cliargs = ["rtprio99"] 75 | expected = " --ulimit rtprio99" 76 | self.assertFalse(*self._is_arg_translation_ok(mock_cliargs, expected)) 77 | 78 | def test_args_wrong_multiple_soft(self): 79 | """Test if multiple soft limit arguments are wrong.""" 80 | mock_cliargs = ["rtprio=99", "memlock102400"] 81 | expected = " --ulimit rtprio=99 --ulimit memlock=102400" 82 | self.assertFalse(*self._is_arg_translation_ok(mock_cliargs, expected)) 83 | 84 | def test_args_wrong_single_hard(self): 85 | """Test if single hard limit arguments are wrong.""" 86 | mock_cliargs = ["nofile=1024:524288:"] 87 | expected = " --ulimit nofile=1024:524288" 88 | self.assertFalse(*self._is_arg_translation_ok(mock_cliargs, expected)) 89 | 90 | def test_args_wrong_multiple_hard(self): 91 | """Test if multiple hard limit arguments are wrong.""" 92 | mock_cliargs = ["nofile1024524288", "rtprio=90:99"] 93 | expected = " --ulimit nofile=1024:524288 --ulimit rtprio=90:99" 94 | self.assertFalse(*self._is_arg_translation_ok(mock_cliargs, expected)) 95 | 96 | def test_args_wrong_multiple_mix(self): 97 | """Test if multiple mixed limit arguments are wrong.""" 98 | mock_cliargs = ["rtprio=:", "memlock102400", "nofile1024:524288:"] 99 | expected = " --ulimit rtprio=99 --ulimit memlock=102400 --ulimit nofile=1024:524288" 100 | self.assertFalse(*self._is_arg_translation_ok(mock_cliargs, expected)) 101 | -------------------------------------------------------------------------------- /test/test_volume.py: -------------------------------------------------------------------------------- 1 | # Licensed to the Apache Software Foundation (ASF) under one 2 | # or more contributor license agreements. See the NOTICE file 3 | # distributed with this work for additional information 4 | # regarding copyright ownership. The ASF licenses this file 5 | # to you under the Apache License, Version 2.0 (the 6 | # "License"); you may not use this file except in compliance 7 | # with the License. You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, 12 | # software distributed under the License is distributed on an 13 | # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 14 | # KIND, either express or implied. See the License for the 15 | # specific language governing permissions and limitations 16 | # under the License. 17 | 18 | import os 19 | import unittest 20 | 21 | from rocker.core import list_plugins 22 | from rocker.volume_extension import Volume 23 | 24 | 25 | class VolumeTest(unittest.TestCase): 26 | def setUp(self): 27 | self._instance = Volume() 28 | self._curr_path = os.path.abspath(os.path.curdir) 29 | self._virtual_path = "/path/in/container" 30 | 31 | def _test_equals_args(self, mock_cliargs, expected): 32 | """ 33 | @type mock_cliargs: { str: [[str]] } 34 | @type expected: [[str]] 35 | """ 36 | print("DEBUG: 'mock_cliargs' {}\n\t'expected': {}".format(mock_cliargs, expected)) 37 | docker_args = self._instance.get_docker_args(mock_cliargs) 38 | print("DEBUG: Resulted docker_args: {}".format(docker_args)) 39 | for arg_expected in expected: 40 | # Whitespace at the beginning is needed. 41 | complete_expected = " {} {}".format(Volume.ARG_DOCKER_VOLUME, arg_expected[0]) 42 | self.assertTrue(complete_expected in docker_args) 43 | 44 | def test_args_single(self): 45 | """Passing source path""" 46 | arg = [[self._curr_path]] 47 | expected = [['{}:{}'.format(self._curr_path, self._curr_path)]] 48 | mock_cliargs = {Volume.get_name(): arg} 49 | self._test_equals_args(mock_cliargs, expected) 50 | 51 | def test_args_twopaths(self): 52 | """Passing source path, dest path""" 53 | arg = ["{}:{}".format(self._curr_path, self._virtual_path)] 54 | mock_cliargs = {Volume.get_name(): [arg]} 55 | self._test_equals_args(mock_cliargs, arg) 56 | 57 | def test_args_twopaths_opt(self): 58 | """Passing source path, dest path, and Docker's volume option""" 59 | arg = ["{}:{}:ro".format(self._curr_path, self._virtual_path)] 60 | mock_cliargs = {Volume.get_name(): [arg]} 61 | self._test_equals_args(mock_cliargs, arg) 62 | 63 | def test_args_two_volumes(self): 64 | """Multiple volume points""" 65 | arg_first = ["{}:{}:ro".format(self._curr_path, self._virtual_path)] 66 | arg_second = ["/tmp:{}".format(os.path.join(self._virtual_path, "tmp"))] 67 | args = [arg_first, arg_second] 68 | mock_cliargs = {Volume.get_name(): args} 69 | self._test_equals_args(mock_cliargs, args) 70 | -------------------------------------------------------------------------------- /wishlist.txt: -------------------------------------------------------------------------------- 1 | # Things that would be good to add 2 | 3 | Roll runtime options into expandability 4 | add support for prerequisite checks. (aka able to make sure the dockerfile is compatible) 5 | Add additional host detection options to extension points 6 | - Detect alternative base OS xenial vs bionic 7 | - Detect different kernels/nvidia drivers 8 | 9 | Add support for profiles 10 | 11 | Provide a non-command line API for use in tooling such as unit tests. 12 | 13 | Add a mechanism to declare dependencies 14 | - other plugins like home really wants user, and nvidia could require X11 and separate that out 15 | - required before, required after, and user required at the end(maybe keep as special case), snippets need ordering preambles don't 16 | --------------------------------------------------------------------------------