diff --git a/.github/workflows/basic-ci.yaml b/.github/workflows/basic-ci.yaml index db169448..57cd2047 100644 --- a/.github/workflows/basic-ci.yaml +++ b/.github/workflows/basic-ci.yaml @@ -4,25 +4,52 @@ on: [push, pull_request] jobs: basic_ci: name: Basic CI - runs-on: ubuntu-18.04 + runs-on: ubuntu-22.04 strategy: matrix: - python-version: [3.8] + python-version: ['3.8', 3.x] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install Dependencies And Self + - name: Install Test Dependencies And Self run: | python -m pip install --upgrade pip setuptools wheel - # Workaround for https://github.com/docker/docker-py/issues/2807 - python -m pip install six python -m pip install -e .[test] codecov pytest-cov - name: Run headless tests - uses: GabrielBB/xvfb-action@v1 + uses: coactions/setup-xvfb@v1 with: run: python -m pytest -s -v --cov=rocker -m "not nvidia" - name: Upload coverage to Codecov - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 + cli_smoke_tests: + name: CLI smoke tests + runs-on: ubuntu-22.04 + strategy: + matrix: + python-version: [3.8, '3.x'] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Dependencies And Self + run: | + python -m pip install --upgrade pip setuptools wheel + python -m pip install -e . + - name: Check os_detector script + run: | + detect_docker_image_os ubuntu + - name: Check detector help + run: | + detect_docker_image_os ubuntu -h + - name: Check main runs + run: | + rocker ubuntu 'true' + - name: Check rocker help + run: | + rocker -h + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..9fdcaf35 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,324 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + + +## [v0.2.18](https://github.com/osrf/rocker/releases/tag/v0.2.18) - 2025-01-10 + +[Compare with v0.2.17](https://github.com/osrf/rocker/compare/v0.2.17...v0.2.18) + +### Added + +- Add UI feedback that image is being cleaned up, and how to avoid. ([3b21f78](https://github.com/osrf/rocker/commit/3b21f78fd4372a5451611a2a3e607f6941bcc398) by Tully Foote). +- 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). +- Add support for ulimit flag (#291) ([cff5cb2](https://github.com/osrf/rocker/commit/cff5cb27c04f4db8d115493c2e2704c6a10726df) by Felipe Padula Sanches). +- Add support for --shm-size flag (#306) + +### Fixed + +- fix test for new cuda installation package ([3449afa](https://github.com/osrf/rocker/commit/3449afabd700c4723d21c22163e73f9cbf9b358d) by Tully Foote). +- Removed deprecation warning from volume extensino (#292) +- Updated and simplified CUDA installation (#299 by jonazpiazu) + +### Removed + +- Remove default value for defaults (#289) ([3f02bdc](https://github.com/osrf/rocker/commit/3f02bdcc542c786eb08bdb3b324887bfa21698a2) by Tully Foote). + +## [v0.2.17](https://github.com/osrf/rocker/releases/tag/v0.2.17) - 2024-08-28 + +[Compare with v0.2.16](https://github.com/osrf/rocker/compare/v0.2.16...v0.2.17) + +### Added +- [nvidia_extension] add 24.04 to supported_versions (#279) + +## [v0.2.16](https://github.com/osrf/rocker/releases/tag/v0.2.16) - 2024-04-01 + +[Compare with v0.2.15](https://github.com/osrf/rocker/compare/v0.2.15...v0.2.16) + +### Fixed + +- Fix --user arg when using empy 4 (#276) ([4078d22](https://github.com/osrf/rocker/commit/4078d223a2158e9a6fea31d8871dc7df7ee094f0) by Gary Servin). + +## [v0.2.15](https://github.com/osrf/rocker/releases/tag/v0.2.15) - 2024-03-01 + +[Compare with v0.2.14](https://github.com/osrf/rocker/compare/v0.2.14...v0.2.15) + +### Added + +- Add changelog ([39a2842](https://github.com/osrf/rocker/commit/39a2842d03fec73b4b0d70c3922c4ab1c93c2cc0) by Tully Foote). +- Add a shorthand option for switching to alternate rmws (#268) ([35e68c1](https://github.com/osrf/rocker/commit/35e68c17232a50cfb3a781b066e2c0d6691cb07e) by Tully Foote). + +## [v0.2.14](https://github.com/osrf/rocker/releases/tag/v0.2.14) - 2024-01-26 + +[Compare with v0.2.13](https://github.com/osrf/rocker/compare/v0.2.13...v0.2.14) + +### Removed + +- Remove deprecated use of pkg_resources (#265) ([998761f](https://github.com/osrf/rocker/commit/998761f919302f29db2377f53efb5cb472c335fa) by Tully Foote). + +## [v0.2.13](https://github.com/osrf/rocker/releases/tag/v0.2.13) - 2023-12-10 + +[Compare with v0.2.12](https://github.com/osrf/rocker/compare/v0.2.12...v0.2.13) + +### Added + +- Add indirection for changes in empy 3 vs 4 (#261) ([4de56c1](https://github.com/osrf/rocker/commit/4de56c18cdac09f3a9ec62a6b81cca7a4cdd46aa) by Tully Foote). +- add simple test of CLI (#256) ([6d276a4](https://github.com/osrf/rocker/commit/6d276a4fe965f50246e2ff5eb0a03c9f16ed06c8) by Tully Foote). +- Add extension ordering (#242) ([3afa6ac](https://github.com/osrf/rocker/commit/3afa6acbf6b0323f540ac16c2a0091b29099086a) by agyoungs). + +### Fixed + +- fix: nvidia arg (#234) ([2b8d5ab](https://github.com/osrf/rocker/commit/2b8d5abb18c829d22f290679b739dc53765198ad) by Amadeusz Szymko). +- Fix nvidia runtime fail on arm64 & add --group-add plugin (#211) ([e01d9cc](https://github.com/osrf/rocker/commit/e01d9cca8672e0a85b7ff42118ee456c4c414d9e) by Amadeusz Szymko). + +### Removed + +- Remove old tutorial links (#254) ([41502b3](https://github.com/osrf/rocker/commit/41502b3f41e7d36bc108d9685bce61b6d27286bc) by Tully Foote). + +## [v0.2.12](https://github.com/osrf/rocker/releases/tag/v0.2.12) - 2023-05-04 + +[Compare with v0.2.11](https://github.com/osrf/rocker/compare/v0.2.11...v0.2.12) + +### Fixed + +- Fix default logic for user-preserve-groups (#224) ([04cfc29](https://github.com/osrf/rocker/commit/04cfc290351a60b7df28c7eff67dc2c27e9ab918) by Tully Foote). + +## [v0.2.11](https://github.com/osrf/rocker/releases/tag/v0.2.11) - 2023-05-04 + +[Compare with v0.2.10](https://github.com/osrf/rocker/compare/v0.2.10...v0.2.11) + +### Added + +- adding debian bookworm support ([b00c1c6](https://github.com/osrf/rocker/commit/b00c1c6e2832c6d375f8b12bcee8fe74642ef043) by Tully Foote). +- add error messages for group issues ([4ce4e29](https://github.com/osrf/rocker/commit/4ce4e29041ac81830f78e1b00afbff9168194929) by Tully Foote). +- 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). +- add link to mp_rocker (#213) ([49a23e7](https://github.com/osrf/rocker/commit/49a23e70a3055898daba80fc35f1742dc01d9a09) by Tully Foote). +- Add ability to preserve host user groups inside container ([b16136e](https://github.com/osrf/rocker/commit/b16136e589ace3519e4b5c85697e770b29a151fa) by Miguel Prada). +- Add port and expose extensions with tests (#201) ([2770045](https://github.com/osrf/rocker/commit/27700451d0b1a0701d8fe5c80c64a52942b61ce0) by Will Baker). +- Added note for linking Intel Xe cards with rocker (#190) ([3a2bf74](https://github.com/osrf/rocker/commit/3a2bf74209f577f666857054095a57bbeb3c6c1d) by Zahi Kakish). + +### Removed + +- Removed '-v' for rocker's version. Only --version should be available. (#205) ([67a4142](https://github.com/osrf/rocker/commit/67a414278c5940d587b9e3060eb7ab9e2e293510) by George Stavrinos). + +## [v0.2.10](https://github.com/osrf/rocker/releases/tag/v0.2.10) - 2022-07-30 + +[Compare with v0.2.9](https://github.com/osrf/rocker/compare/v0.2.9...v0.2.10) + +## [v0.2.9](https://github.com/osrf/rocker/releases/tag/v0.2.9) - 2022-03-23 + +[Compare with v0.2.8](https://github.com/osrf/rocker/compare/v0.2.8...v0.2.9) + +## [v0.2.8](https://github.com/osrf/rocker/releases/tag/v0.2.8) - 2022-02-14 + +[Compare with v0.2.7](https://github.com/osrf/rocker/compare/v0.2.7...v0.2.8) + +## [v0.2.7](https://github.com/osrf/rocker/releases/tag/v0.2.7) - 2021-12-01 + +[Compare with v0.2.6](https://github.com/osrf/rocker/compare/v0.2.6...v0.2.7) + +### Added + +- 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). + +## [v0.2.6](https://github.com/osrf/rocker/releases/tag/v0.2.6) - 2021-10-05 + +[Compare with v0.2.5](https://github.com/osrf/rocker/compare/v0.2.5...v0.2.6) + +## [v0.2.5](https://github.com/osrf/rocker/releases/tag/v0.2.5) - 2021-10-05 + +[Compare with v0.2.4](https://github.com/osrf/rocker/compare/v0.2.4...v0.2.5) + +### Added + +- Add an option to tag the image (#159) ([ef09014](https://github.com/osrf/rocker/commit/ef0901419dd99132bef5c1b797892927e63f5925) by Tully Foote). + +## [v0.2.4](https://github.com/osrf/rocker/releases/tag/v0.2.4) - 2021-08-03 + +[Compare with 0.2.3](https://github.com/osrf/rocker/compare/0.2.3...v0.2.4) + +### Added + +- 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). +- Add the privileged extension (#132) ([4214d73](https://github.com/osrf/rocker/commit/4214d73aeb3de235153905344d14ee18edc3c229) by Gaël Écorchard). +- Add --nocleanup option (#111) ([5507dc5](https://github.com/osrf/rocker/commit/5507dc510c3e339beb3573e52dcc77d8585bb285) by Tim Redick). + +### Fixed + +- Fix detector for the deprecated distro syntax (#156) ([2760838](https://github.com/osrf/rocker/commit/2760838dc4bc8c3bd436344ec8ab9225ad914696) by Tully Foote). +- Fix os detection for non-root images (cont.) (#150) ([8ecaf53](https://github.com/osrf/rocker/commit/8ecaf530cc374b30be121eef7a324b8200788eea) by Miguel Prada). + +### Removed + +- Remove intermediate containers and name os_detect images (#133) ([80602eb](https://github.com/osrf/rocker/commit/80602eb6868a9044279d88c13650828f654a32f9) by Daniel Stonier). +- Remove redundant device additions to docker_args ([a759fe6](https://github.com/osrf/rocker/commit/a759fe6b241edf37b95d104f238eb6ef9fb3d70b) by Peter Polidoro). + +## [0.2.3](https://github.com/osrf/rocker/releases/tag/0.2.3) - 2020-11-25 + +[Compare with v0.2.2](https://github.com/osrf/rocker/compare/v0.2.2...0.2.3) + +### Added + +- add x11 flags to examples (#104) ([ae7d3f6](https://github.com/osrf/rocker/commit/ae7d3f6edcd74cb74b345f005f401061b33bda3a) by Tully Foote). +- 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). +- Added --name argument ([019a543](https://github.com/osrf/rocker/commit/019a543afb759664751afe847162ab9ba09db889) by ahcorde). + +### Fixed + +- Fix using rocker for non-root containers (#50) ([3585298](https://github.com/osrf/rocker/commit/35852984cc4554bbfcf55f3612fa17cc89d5d715) by Johannes Meyer). + +## [v0.2.2](https://github.com/osrf/rocker/releases/tag/v0.2.2) - 2020-06-24 + +[Compare with v0.2.1](https://github.com/osrf/rocker/compare/v0.2.1...v0.2.2) + +## [v0.2.1](https://github.com/osrf/rocker/releases/tag/v0.2.1) - 2020-06-24 + +[Compare with v0.2.0](https://github.com/osrf/rocker/compare/v0.2.0...v0.2.1) + +### Added + +- add bullseye target too ([b9f407b](https://github.com/osrf/rocker/commit/b9f407ba5423c61bd9dd0a46f36d21cd7e795adf) by Tully Foote). +- add focal as a target ([55b704f](https://github.com/osrf/rocker/commit/55b704f575dc5f6af1150e29fb18faf51cfa3336) by Tully Foote). + +## [v0.2.0](https://github.com/osrf/rocker/releases/tag/v0.2.0) - 2020-05-08 + +[Compare with v0.1.10](https://github.com/osrf/rocker/compare/v0.1.10...v0.2.0) + +### Added + +- Add support for focal and buster (#83) ([a76acb2](https://github.com/osrf/rocker/commit/a76acb2aafb1c5d5609e923b42d8d2a351e28906) by Tully Foote). +- Add support for --env-file option pass through (#82) ([135e73f](https://github.com/osrf/rocker/commit/135e73fc11cb1a6751cda6253efa26d543345e47) by Tully Foote). +- Add an Extension Manager class (#77) ([413d25a](https://github.com/osrf/rocker/commit/413d25a7e578d4eab410eda99042cabb8dc8026b) by Tully Foote). +- add codeowners so I get auto review requested (#74) ([15b604b](https://github.com/osrf/rocker/commit/15b604be5d1a940e4e242dbebff45d9298fcb787) by Tully Foote). + +### Fixed + +- Fix documentation for User extension (#81) ([6e9ee9c](https://github.com/osrf/rocker/commit/6e9ee9c96aa2664b51974724377cd771c465dbbf) by Emerson Knapp). +- Fix import paths (#76) ([d26004b](https://github.com/osrf/rocker/commit/d26004b2dcc919bf08ae7aeeef0e669f0a66a7f4) by Tully Foote). + +## [v0.1.10](https://github.com/osrf/rocker/releases/tag/v0.1.10) - 2019-12-05 + +[Compare with v0.1.9](https://github.com/osrf/rocker/compare/v0.1.9...v0.1.10) + +## [v0.1.9](https://github.com/osrf/rocker/releases/tag/v0.1.9) - 2019-10-14 + +[Compare with v0.1.8](https://github.com/osrf/rocker/compare/v0.1.8...v0.1.9) + +### Added + +- Add verbosity outputs ([6246570](https://github.com/osrf/rocker/commit/6246570e1630592c9426e64c7477d53339edc568) by Tully Foote). + +### Removed + +- Remove xvfb-run which started failing CI. (#69) ([d38927a](https://github.com/osrf/rocker/commit/d38927ab13ffe6ccf84bb9408261feb6a3df3231) by Tully Foote). + +## [v0.1.8](https://github.com/osrf/rocker/releases/tag/v0.1.8) - 2019-09-18 + +[Compare with v0.1.7](https://github.com/osrf/rocker/compare/v0.1.7...v0.1.8) + +### Added + +- Add a version cli option (#65) ([28fc9ff](https://github.com/osrf/rocker/commit/28fc9ff1fab435ab71c1077ac6882380d38d66f1) by Tully Foote). + +### Fixed + +- FIx the window resize (#66) ([7757df0](https://github.com/osrf/rocker/commit/7757df091bee54714368a2d08236f6ba9caf4bc5) by Tully Foote). + +## [v0.1.7](https://github.com/osrf/rocker/releases/tag/v0.1.7) - 2019-09-17 + +[Compare with v0.1.6](https://github.com/osrf/rocker/compare/v0.1.6...v0.1.7) + +### Added + +- add --home (#64) ([733e29b](https://github.com/osrf/rocker/commit/733e29b7521a4037ba381d3d219cabaf3855a608) by Tully Foote). +- Add sigwinch passthrough for xterm resizing (#57) ([efec0f9](https://github.com/osrf/rocker/commit/efec0f964a12b239f6c201498b2aa478fe56d83c) by Ruffin). +- Add new extensions git and ssh (#58) ([5405f3f](https://github.com/osrf/rocker/commit/5405f3fc824b3948772c6b60efff4370954497d3) by Johannes Meyer). + +### Fixed + +- Fix sudo with user extension for some usernames (#55) ([0bc20b8](https://github.com/osrf/rocker/commit/0bc20b84c72d16b5f81dda59283c6e242e203938) by Johannes Meyer). + +## [v0.1.6](https://github.com/osrf/rocker/releases/tag/v0.1.6) - 2019-08-29 + +[Compare with 0.1.5](https://github.com/osrf/rocker/compare/0.1.5...v0.1.6) + +## [0.1.5](https://github.com/osrf/rocker/releases/tag/0.1.5) - 2019-08-28 + +[Compare with 0.1.4](https://github.com/osrf/rocker/compare/0.1.4...0.1.5) + +### Added + +- Add extension to pass custom environment variables (#51) ([e27cc0b](https://github.com/osrf/rocker/commit/e27cc0bb8d6677807a9c791470b023253569fedf) by Johannes Meyer). +- Add Cosmic, Disco, and Eoan suites. ([daf23a7](https://github.com/osrf/rocker/commit/daf23a7ca4f10e557e1dcdf9edb854b83d2186bf) by Steven! Ragnarök). + +### Fixed + +- Fix OS detection (fix #43) ([7a419f9](https://github.com/osrf/rocker/commit/7a419f91b0e8aa0fb0beae040844b4ed77e3f7a1) by Johannes Meyer). + +### Removed + +- remove unused imports ([39118bb](https://github.com/osrf/rocker/commit/39118bb877553364f70e082be24aaf07559f7ef4) by Tully Foote). + +## [0.1.4](https://github.com/osrf/rocker/releases/tag/0.1.4) - 2019-03-13 + +[Compare with 0.1.3](https://github.com/osrf/rocker/compare/0.1.3...0.1.4) + +### Added + +- add documentation about how to use an intel integrated graphics card ([f03299f](https://github.com/osrf/rocker/commit/f03299ffddeb3d050f1f16cb048e2a9983895d90) by Tully Foote). +- Add No-Python2 flag ([101813f](https://github.com/osrf/rocker/commit/101813fa2fdf75d80e628ee0871d8cb238e11337) by Tully Foote). +- adding coverage of nvidia ([6f8121f](https://github.com/osrf/rocker/commit/6f8121f7b5c5aad379708ea468c01b97671af159) by Tully Foote). +- add coverage for extensions (#32) ([ac3afc5](https://github.com/osrf/rocker/commit/ac3afc5592771e45f866f22ba4357346f4353db4) by Tully Foote). +- adding a few basic unit tests (#30) ([ea951b7](https://github.com/osrf/rocker/commit/ea951b77cfaae6fd6921555779bfb195c748402e) by Tully Foote). +- Add codecov reports (#28) ([9ef5f36](https://github.com/osrf/rocker/commit/9ef5f361cdd94f1361a48c80deae48b5a58dea15) by Tully Foote). +- adding basic travis (#27) ([b538350](https://github.com/osrf/rocker/commit/b538350fcef5cc08eba4ad39a0911127270f9a20) by Tully Foote). +- Add unit tests for os_detection ([4b42a2d](https://github.com/osrf/rocker/commit/4b42a2de5d5dd9df7f8af6fed17c0080fdc4cbe8) by Tully Foote). +- add dependencies to backport script ([0df081e](https://github.com/osrf/rocker/commit/0df081efe22e8c4853c4bc1b942d7a2fe89aba9a) by Tully Foote). + +### Fixed + +- 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). + +## [0.1.3](https://github.com/osrf/rocker/releases/tag/0.1.3) - 2019-01-10 + +[Compare with 0.1.2](https://github.com/osrf/rocker/compare/0.1.2...0.1.3) + +### Added + +- add comment about python3-distro ([e28b546](https://github.com/osrf/rocker/commit/e28b54661dcd2c3fbc08f677d228c2ce7e649e3e) by Tully Foote). + +### Fixed + +- fix dependencies ([fafcab5](https://github.com/osrf/rocker/commit/fafcab54b24ec6cf0aa92b3adad3a16057717829) by Tully Foote). + +## [0.1.2](https://github.com/osrf/rocker/releases/tag/0.1.2) - 2019-01-09 + +[Compare with first commit](https://github.com/osrf/rocker/compare/abc236cbf234c8ac7bff30b865836aefb751dbef...0.1.2) + +### Added + +- add command explicitly to argument parsing ([5e308a6](https://github.com/osrf/rocker/commit/5e308a61443c721f4a0de9129040f9898f538a04) by Tully Foote). +- Add tests for extensions ([50ac127](https://github.com/osrf/rocker/commit/50ac1278a488049ae6ce4325356151e24f4df4b7) by Tully Foote). +- add a few ignores core cleaner workspace diffs ([b498f03](https://github.com/osrf/rocker/commit/b498f031e50acff8c5e9d62c2e41365b289dd808) by Tully Foote). +- Add stdeb.cfg ([034abf1](https://github.com/osrf/rocker/commit/034abf1e41ed0ea0c6c5abf2703377257a660e24) by Tully Foote). +- add support for mounting devices, with a soft fail on not existing ([d3244ab](https://github.com/osrf/rocker/commit/d3244ab8f788bbc1e4355000e99176502d7f7d47) by Tully Foote). +- adding copyright headers ([601a156](https://github.com/osrf/rocker/commit/601a1561ccb8bbb2107e85a8afce14f623c12a04) by Tully Foote). +- 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). +- add to the wishlist ([6e224e2](https://github.com/osrf/rocker/commit/6e224e2435cedb74b3c42e727bd388190610f46b) by Tully Foote). +- add readme ([fa0d251](https://github.com/osrf/rocker/commit/fa0d251779bbf2add0842dcaefb40464451f3fd6) by Tully Foote). +- add a readme ([9d6bd66](https://github.com/osrf/rocker/commit/9d6bd6636d858e2b2bac3cb8ae74f683cad311b1) by Tully Foote). +- add parameter for disabling caching ([79a6beb](https://github.com/osrf/rocker/commit/79a6beb9301e0c446d9fe79d25d54067094c5c4d) by Tully Foote). + +### Fixed + +- fix assert spacing ([02601da](https://github.com/osrf/rocker/commit/02601da2fa9413192fa96df32cb65bf8a07cb9c6) by Tully Foote). +- fix network argument parsing ([d8b12af](https://github.com/osrf/rocker/commit/d8b12af21fe6ebf7bafdadf8e5f36e151ceb0ec6) by Tully Foote). +- fix docker API usage for pull ([a749985](https://github.com/osrf/rocker/commit/a749985104855bc44a5615eb8e25aa63833bd27a) by Tully Foote). + +### Removed + +- remove legacy test function ([4c1466a](https://github.com/osrf/rocker/commit/4c1466a217e4e150aa722836b3768abcd2efe425) by Tully Foote). +- remove legacy comments ([38b5903](https://github.com/osrf/rocker/commit/38b59034eb373ff12619529383dc8812446890ad) by Tully Foote). diff --git a/README.md b/README.md index f851f0f6..38f14f24 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Whereas with `rocker` you can invoke your specific plugins and it will use multi -## Know extensions +## Known extensions Rocker supports extensions via entry points there are some built in but you can add your own. @@ -38,7 +38,7 @@ You can get full details on the extensions from the main `rocker --help` command - pulse -- Mount pulse audio into the container - ssh -- Pass through ssh access to the container. -As well as access to many of the docker arguments as well such as `device`, `env`, `volume`, `name`, `network`, and `privileged`. +As well as access to many of the docker arguments as well such as `device`, `env`, `volume`, `name`, `network`, `ipc`, and `privileged`. ### Externally maintained extensions @@ -47,8 +47,21 @@ Here's a list of public repositories with extensions. - Off-your-rocker: https://github.com/sloretz/off-your-rocker - mp_rocker: https://github.com/miguelprada/mp_rocker - ghrocker: https://github.com/tfoote/ghrocker +- novnc_rocker: https://github.com/tfoote/novnc-rocker +- template_rocker: https://github.com/blooop/template_rocker +- deps_rocker: https://github.com/blooop/deps_rocker +- pixi_rocker: https://github.com/blooop/pixi_rocker +- conda_rocker: https://github.com/blooop/conda_rocker +- palanteer_rocker: https://github.com/blooop/palanteer_rocker +- lazygit_rocker: https://github.com/blooop/lazygit_rocker +### Externally maintained rocker wrappers + +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. + +- rockerc: https://github.com/blooop/rockerc wraps rocker to enable putting rocker commands into a yaml config file. +- rockervsc: https://github.com/blooop/rockervsc wraps rocker so that a vscode instance attaches to the launched container. # Prerequisites @@ -153,7 +166,7 @@ Then you can run pytest. Notes: - Make sure to use the python3 instance of pytest from inside the environment. -- The tests include an nvidia test which assumes you're using a machine with an nvidia gpu. +- The tests include an nvidia test which assumes you're using a machine with an nvidia gpu. To skip them use `-m "not nvidia"` # Example usage @@ -169,15 +182,6 @@ After the ekf converges, You can send a takeoff command and then click to command the vehicle to fly to a point on the map. - -## Fly a plane - -Example usage with a plane - - rocker --nvidia --x11 --user --home --pull --pulse tfoote/drone_demo roslaunch sitl_launcher plane_demo.launch world_name:=worlds/plane.world gui:=false - -In QGroundControl go ahead and make a mission, upload it, and then start the mission. - ## ROS 2 rviz rocker --nvidia --x11 osrf/ros:crystal-desktop rviz2 diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..252003bf --- /dev/null +++ b/codecov.yml @@ -0,0 +1,3 @@ +comment: + layout: "reach, diff, flags, files" + behavior: default diff --git a/setup.py b/setup.py index a217acf4..5a80203a 100644 --- a/setup.py +++ b/setup.py @@ -3,10 +3,14 @@ import os from setuptools import setup +# importlib-metadata dependency can be removed when RHEL8 and other 3.6 based systems are not in support cycles + install_requires = [ 'empy', + 'importlib-metadata; python_version < "3.8"', 'pexpect', 'packaging', + 'urllib3', ] # docker API used to be in a package called `docker-py` before the 2.0 release @@ -31,7 +35,7 @@ kwargs = { 'name': 'rocker', - 'version': '0.2.10', + 'version': '0.2.18', 'packages': ['rocker'], 'package_dir': {'': 'src'}, 'package_data': {'rocker': ['templates/*.em']}, @@ -39,7 +43,7 @@ 'console_scripts': [ 'rocker = rocker.cli:main', 'detect_docker_image_os = rocker.cli:detect_image_os', - ], + ], 'rocker.extensions': [ 'cuda = rocker.nvidia_extension:Cuda', 'devices = rocker.extensions:Devices', @@ -47,19 +51,25 @@ 'env = rocker.extensions:Environment', 'expose = rocker.extensions:Expose', 'git = rocker.git_extension:Git', + 'group_add = rocker.extensions:GroupAdd', 'home = rocker.extensions:HomeDir', + 'hostname = rocker.extensions:Hostname', + 'ipc = rocker.extensions:Ipc', 'name = rocker.extensions:Name', 'network = rocker.extensions:Network', 'nvidia = rocker.nvidia_extension:Nvidia', 'port = rocker.extensions:Port', 'privileged = rocker.extensions:Privileged', 'pulse = rocker.extensions:PulseAudio', + 'rmw = rocker.rmw_extension:RMW', + 'shm_size = rocker.extensions:ShmSize', 'ssh = rocker.ssh_extension:Ssh', + 'ulimit = rocker.ulimit_extension:Ulimit', 'user = rocker.extensions:User', 'volume = rocker.volume_extension:Volume', 'x11 = rocker.nvidia_extension:X11', ] - }, + }, 'author': 'Tully Foote', 'author_email': 'tfoote@osrfoundation.org', 'keywords': ['Docker'], @@ -83,4 +93,3 @@ } setup(**kwargs) - diff --git a/src/rocker/cli.py b/src/rocker/cli.py index 7d78641c..e74791b7 100644 --- a/src/rocker/cli.py +++ b/src/rocker/cli.py @@ -20,6 +20,7 @@ from .core import get_rocker_version from .core import RockerExtensionManager from .core import DependencyMissing +from .core import ExtensionError from .os_detector import detect_os @@ -34,6 +35,7 @@ def main(): parser.add_argument('--noexecute', action='store_true', help='Deprecated') parser.add_argument('--nocache', action='store_true') parser.add_argument('--nocleanup', action='store_true', help='do not remove the docker container when stopped') + 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 parser.add_argument('--pull', action='store_true') parser.add_argument('--version', action='version', version='%(prog)s ' + get_rocker_version()) @@ -54,9 +56,11 @@ def main(): args_dict['mode'] = OPERATIONS_DRY_RUN print('DEPRECATION Warning: --noexecute is deprecated for --mode dry-run please switch your usage by December 2020') - active_extensions = extension_manager.get_active_extensions(args_dict) - # Force user to end if present otherwise it will break other extensions - active_extensions.sort(key=lambda e:e.get_name().startswith('user')) + try: + active_extensions = extension_manager.get_active_extensions(args_dict) + except ExtensionError as e: + print(f"ERROR! {str(e)}") + return 1 print("Active extensions %s" % [e.get_name() for e in active_extensions]) base_image = args.image @@ -65,10 +69,16 @@ def main(): exit_code = dig.build(**vars(args)) if exit_code != 0: print("Build failed exiting") + if not args_dict['persist_image']: + dig.clear_image() return exit_code # Convert command into string args.command = ' '.join(args.command) - return dig.run(**args_dict) + result = dig.run(**args_dict) + if not args_dict['persist_image']: + print(f'Clearing Image: {dig.image_id}s\nTo not clean up use --persist-images') + dig.clear_image() + return result def detect_image_os(): diff --git a/src/rocker/core.py b/src/rocker/core.py index 2f1a5353..03af51c3 100644 --- a/src/rocker/core.py +++ b/src/rocker/core.py @@ -15,10 +15,17 @@ from collections import OrderedDict import io import os +import pwd import re import sys -import pkg_resources +# importlib-metadata dependency can be removed when RHEL8 and other 3.6 based systems are not in support cycles +if sys.version_info >= (3, 8): + import importlib.metadata as importlib_metadata +else: + import importlib_metadata + + import pkgutil from requests.exceptions import ConnectionError import shlex @@ -29,9 +36,11 @@ import pexpect import fcntl +from pathlib import Path import signal import struct import termios +import typing SYS_STDOUT = sys.stdout @@ -45,6 +54,10 @@ class DependencyMissing(RuntimeError): pass +class ExtensionError(RuntimeError): + pass + + class RockerExtension(object): """The base class for Rocker extension points""" @@ -58,10 +71,31 @@ def validate_environment(self, cliargs): necessary resources are available, like hardware.""" pass + def invoke_after(self, cliargs) -> typing.Set[str]: + """ + This extension should be loaded after the extensions in the returned + set. These extensions are not required to be present, but if they are, + they will be loaded before this extension. + """ + return set() + + def required(self, cliargs) -> typing.Set[str]: + """ + Ensures the specified extensions are present and combined with + this extension. If the required extension should be loaded before + this extension, it should also be added to the `invoke_after` set. + """ + return set() + def get_preamble(self, cliargs): return '' def get_snippet(self, cliargs): + """ Get a dockerfile snippet to be executed as ROOT.""" + return '' + + def get_user_snippet(self, cliargs): + """ Get a dockerfile snippet to be executed after switchingto the expected USER.""" return '' def get_files(self, cliargs): @@ -83,7 +117,7 @@ def check_args_for_activation(cls, cli_args): return True if cli_args.get(cls.get_name()) else False @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): raise NotImplementedError @@ -106,13 +140,70 @@ def extend_cli_parser(self, parser, default_args={}): parser.add_argument('--extension-blacklist', nargs='*', default=[], help='Prevent any of these extensions from being loaded.') + parser.add_argument('--strict-extension-selection', action='store_true', + help='When enabled, causes an error if required extensions are not explicitly ' + 'called out on the command line. Otherwise, the required extensions will ' + 'automatically be loaded if available.') def get_active_extensions(self, cli_args): - active_extensions = [e() for e in self.available_plugins.values() if e.check_args_for_activation(cli_args) and e.get_name() not in cli_args['extension_blacklist']] - active_extensions.sort(key=lambda e:e.get_name().startswith('user')) - return active_extensions + """ + Checks for missing dependencies (specified by each extension's + required() method) and additionally sorts them. + """ + def sort_extensions(extensions, cli_args): + + def topological_sort(source: typing.Dict[str, typing.Set[str]]) -> typing.List[str]: + """Perform a topological sort on names and dependencies and returns the sorted list of names.""" + names = set(source.keys()) + # prune optional dependencies if they are not present (at this point the required check has already occurred) + pending = [(name, dependencies.intersection(names)) for name, dependencies in source.items()] + emitted = [] + while pending: + next_pending = [] + next_emitted = [] + for entry in pending: + name, deps = entry + deps.difference_update(emitted) # remove dependencies already emitted + if deps: # still has dependencies? recheck during next pass + next_pending.append(entry) + else: # no more dependencies? time to emit + yield name + next_emitted.append(name) # remember what was emitted for difference_update() + if not next_emitted: + raise ExtensionError("Cyclic dependancy detected: %r" % (next_pending,)) + pending = next_pending + emitted = next_emitted + + extension_graph = {name: cls.invoke_after(cli_args) for name, cls in sorted(extensions.items())} + active_extension_list = [extensions[name] for name in topological_sort(extension_graph)] + return active_extension_list + + active_extensions = {} + find_reqs = set([name for name, cls in self.available_plugins.items() if cls.check_args_for_activation(cli_args)]) + while find_reqs: + name = find_reqs.pop() + + if name in self.available_plugins.keys(): + if name not in cli_args['extension_blacklist']: + ext = self.available_plugins[name]() + active_extensions[name] = ext + else: + raise ExtensionError(f"Extension '{name}' is blacklisted.") + else: + raise ExtensionError(f"Extension '{name}' not found. Is it installed?") + + # add additional reqs for processing not already known about + known_reqs = set(active_extensions.keys()).union(find_reqs) + missing_reqs = ext.required(cli_args).difference(known_reqs) + if missing_reqs: + if cli_args['strict_extension_selection']: + raise ExtensionError(f"Extension '{name}' is missing required extension(s) {list(missing_reqs)}") + else: + print(f"Adding implicilty required extension(s) {list(missing_reqs)} required by extension '{name}'") + find_reqs = find_reqs.union(missing_reqs) + return sort_extensions(active_extensions, cli_args) def get_docker_client(): """Simple helper function for pre 2.0 imports""" @@ -132,6 +223,9 @@ def get_docker_client(): ' This is usually by being a member of the docker group.' ' The underlying error was:\n"""\n%s\n"""\n' % ex) +def get_user_name(): + userinfo = pwd.getpwuid(os.getuid()) + return getattr(userinfo, 'pw_' + 'name') def docker_build(docker_client = None, output_callback = None, **kwargs): image_id = None @@ -157,6 +251,12 @@ def docker_build(docker_client = None, output_callback = None, **kwargs): print("no more output and success not detected") return None +def docker_remove_image(image_id, docker_client = None, output_callback = None, **kwargs): + + if not docker_client: + docker_client = get_docker_client() + + docker_client.remove_image(image_id) class SIGWINCHPassthrough(object): def __init__ (self, process): @@ -254,7 +354,6 @@ def get_operating_mode(self, args): print("No tty detected for stdin forcing non-interactive") return operating_mode - def generate_docker_cmd(self, command='', **kwargs): docker_args = '' @@ -320,17 +419,28 @@ def run(self, command='', **kwargs): print("Docker run failed\n", ex) return ex.returncode + def clear_image(self): + if self.image_id: + docker_remove_image(self.image_id) + self.image_id = None + self.built = False def write_files(extensions, args_dict, target_directory): all_files = {} for active_extension in extensions: - for file_name, contents in active_extension.get_files(args_dict).items(): - if os.path.isabs(file_name): + for file_path, contents in active_extension.get_files(args_dict).items(): + if os.path.isabs(file_path): print('WARNING!! Path %s from extension %s is absolute' - 'and cannot be written out, skipping' % (file_name, active_extension.get_name())) + 'and cannot be written out, skipping' % (file_path, active_extension.get_name())) continue - full_path = os.path.join(target_directory, file_name) - with open(full_path, 'w') as fh: + full_path = os.path.join(target_directory, file_path) + if Path(target_directory).resolve() not in Path(full_path).resolve().parents: + print('WARNING!! Path %s from extension %s is outside target directory' + 'and cannot be written out, skipping' % (file_path, active_extension.get_name())) + continue + Path(os.path.dirname(full_path)).mkdir(exist_ok=True, parents=True) + mode = 'wb' if isinstance(contents, bytes) else 'w' # check to see if contents should be written as binary + with open(full_path, mode) as fh: print('Writing to file %s' % full_path) fh.write(contents) return all_files @@ -338,22 +448,50 @@ def write_files(extensions, args_dict, target_directory): def generate_dockerfile(extensions, args_dict, base_image): dockerfile_str = '' + # Preamble snippets for el in extensions: - dockerfile_str += '# Preamble from extension [%s]\n' % el.name + dockerfile_str += '# Preamble from extension [%s]\n' % el.get_name() dockerfile_str += el.get_preamble(args_dict) + '\n' dockerfile_str += '\nFROM %s\n' % base_image + # ROOT snippets dockerfile_str += 'USER root\n' for el in extensions: - dockerfile_str += '# Snippet from extension [%s]\n' % el.name + dockerfile_str += '# Snippet from extension [%s]\n' % el.get_name() dockerfile_str += el.get_snippet(args_dict) + '\n' + # Set USER if user extension activated + if 'user' in args_dict and args_dict['user']: + if 'user_override_name' in args_dict and args_dict['user_override_name']: + username = args_dict['user_override_name'] + else: + username = get_user_name() + dockerfile_str += f'USER {username}\n' + # USER snippets + for el in extensions: + dockerfile_str += '# User Snippet from extension [%s]\n' % el.get_name() + dockerfile_str += el.get_user_snippet(args_dict) + '\n' return dockerfile_str +def list_entry_points(): + entry_points = importlib_metadata.entry_points() + if hasattr(entry_points, 'select'): + styles_groups = entry_points.select(group='flake8_import_order.styles') + else: + styles_groups = entry_points.get('flake8_import_order.styles', []) + + return styles_groups + def list_plugins(extension_point='rocker.extensions'): + + all_entry_points = importlib_metadata.entry_points() + if hasattr(all_entry_points, 'select'): + rocker_extensions = all_entry_points.select(group=extension_point) + else: + rocker_extensions = all_entry_points.get(extension_point, []) + unordered_plugins = { entry_point.name: entry_point.load() - for entry_point - in pkg_resources.iter_entry_points(extension_point) + for entry_point in rocker_extensions } # Order plugins by extension point name for consistent ordering below plugin_names = list(unordered_plugins.keys()) @@ -362,4 +500,4 @@ def list_plugins(extension_point='rocker.extensions'): def get_rocker_version(): - return pkg_resources.require('rocker')[0].version + return importlib_metadata.version('rocker') diff --git a/src/rocker/em.py b/src/rocker/em.py new file mode 100644 index 00000000..c2c3df53 --- /dev/null +++ b/src/rocker/em.py @@ -0,0 +1,23 @@ +# Copyright 2019 Open Source Robotics Foundation + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import em + +def empy_expand(template, substitution_variables): + """Indirection for empy version compatibility.""" + if em.__version__.startswith('3'): + return em.expand(template, substitution_variables) + else: + return em.expand(template, globals=substitution_variables) \ No newline at end of file diff --git a/src/rocker/extensions.py b/src/rocker/extensions.py index bdfcd4a2..bee93639 100644 --- a/src/rocker/extensions.py +++ b/src/rocker/extensions.py @@ -15,7 +15,6 @@ import grp import os import docker -import em import getpass import pwd import pkgutil @@ -25,6 +24,7 @@ import sys from .core import get_docker_client +from .em import empy_expand def name_to_argument(name): @@ -54,7 +54,7 @@ def get_docker_args(self, cliargs): return args @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument('--devices', default=defaults.get('devices', None), nargs='*', @@ -81,16 +81,88 @@ def get_preamble(self, cliargs): def get_snippet(self, cliargs): snippet = pkgutil.get_data('rocker', 'templates/%s_snippet.Dockerfile.em' % self.name).decode('utf-8') - return em.expand(snippet, self.get_environment_subs()) + return empy_expand(snippet, self.get_environment_subs()) @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument(name_to_argument(DevHelpers.get_name()), action='store_true', default=defaults.get('dev_helpers', None), help="add development tools emacs and byobu to your environment") +class Expose(RockerExtension): + @staticmethod + def get_name(): + return 'expose' + + def __init__(self): + self.name = Expose.get_name() + + def get_preamble(self, cliargs): + return '' + + def get_docker_args(self, cliargs): + args = [''] + ports = cliargs.get('expose', []) + for port in ports: + args.append(' --expose {0}'.format(port)) + return ' '.join(args) + + @staticmethod + def register_arguments(parser, defaults={}): + parser.add_argument('--expose', + default=defaults.get('expose', None), + action='append', + help="Exposes a port from the container to host machine.") + + +class Hostname(RockerExtension): + @staticmethod + def get_name(): + return 'hostname' + + def __init__(self): + self.name = Hostname.get_name() + + def get_preamble(self, cliargs): + return '' + + def get_docker_args(self, cliargs): + args = '' + hostname = cliargs.get('hostname', None) + if hostname: + args += ' --hostname %s ' % hostname + return args + + @staticmethod + def register_arguments(parser, defaults): + parser.add_argument('--hostname', default=defaults.get('hostname', ''), + help='Hostname of the container.') + + +class Ipc(RockerExtension): + @staticmethod + def get_name(): + return 'ipc' + def __init__(self): + self.name = Ipc.get_name() + + def get_preamble(self, cliargs): + return '' + + def get_docker_args(self, cliargs): + args = '' + ipc = cliargs.get('ipc', None) + args += ' --ipc %s ' % ipc + return args + + @staticmethod + def register_arguments(parser, defaults={}): + parser.add_argument('--ipc', default=defaults.get('ipc', None), + 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') + + class Name(RockerExtension): @staticmethod def get_name(): @@ -110,7 +182,7 @@ def get_docker_args(self, cliargs): return args @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument('--name', default=defaults.get('name', ''), help='Name of the container.') @@ -133,39 +205,13 @@ def get_docker_args(self, cliargs): return args @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): client = get_docker_client() parser.add_argument('--network', choices=[n['Name'] for n in client.networks()], default=defaults.get('network', None), help="What network configuration to use.") -class Expose(RockerExtension): - @staticmethod - def get_name(): - return 'expose' - - def __init__(self): - self.name = Expose.get_name() - - def get_preamble(self, cliargs): - return '' - - def get_docker_args(self, cliargs): - args = [''] - ports = cliargs.get('expose', []) - for port in ports: - args.append(' --expose {0}'.format(port)) - return ' '.join(args) - - @staticmethod - def register_arguments(parser, defaults={}): - parser.add_argument('--expose', - default=defaults.get('expose', None), - action='append', - help="Exposes a port from the container to host machine.") - - class Port(RockerExtension): @staticmethod def get_name(): @@ -185,7 +231,7 @@ def get_docker_args(self, cliargs): return ' '.join(args) @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument('--port', default=defaults.get('port', None), action='append', @@ -215,7 +261,7 @@ def get_preamble(self, cliargs): def get_snippet(self, cliargs): snippet = pkgutil.get_data('rocker', 'templates/%s_snippet.Dockerfile.em' % self.name).decode('utf-8') - return em.expand(snippet, self.get_environment_subs()) + return empy_expand(snippet, self.get_environment_subs()) def get_docker_args(self, cliargs): args = ' -v /run/user/%(user_id)s/pulse:/run/user/%(user_id)s/pulse --device /dev/snd '\ @@ -223,7 +269,7 @@ def get_docker_args(self, cliargs): return args % self.get_environment_subs() @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument(name_to_argument(PulseAudio.get_name()), action='store_true', default=defaults.get(PulseAudio.get_name(), None), @@ -242,7 +288,7 @@ def get_docker_args(self, cliargs): return ' -v %s:%s ' % (Path.home(), Path.home()) @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument(name_to_argument(HomeDir.get_name()), action='store_true', default=defaults.get(HomeDir.get_name(), None), @@ -274,16 +320,31 @@ def get_snippet(self, cliargs): substitutions['name'] = cliargs['user_override_name'] substitutions['dir'] = os.path.join('/home/', cliargs['user_override_name']) substitutions['user_preserve_home'] = True if 'user_preserve_home' in cliargs and cliargs['user_preserve_home'] else False + if 'user_preserve_groups' in cliargs and isinstance(cliargs['user_preserve_groups'], list): + query_groups = cliargs['user_preserve_groups'] + all_groups = grp.getgrall() + if query_groups: + matched_groups = [g for g in all_groups if g.gr_name in query_groups] + matched_group_names = [g.gr_name for g in matched_groups] + unmatched_groups = [n for n in cliargs['user_preserve_groups'] if n not in matched_group_names] + if unmatched_groups: + print('Warning skipping groups %s because they do not exist on the host.' % unmatched_groups) + substitutions['user_groups'] = ' '.join(['{};{}'.format(g.gr_name, g.gr_gid) for g in matched_groups]) + else: + substitutions['user_groups'] = ' '.join(['{};{}'.format(g.gr_name, g.gr_gid) for g in all_groups if substitutions['name'] in g.gr_mem]) + else: + substitutions['user_groups'] = '' + substitutions['user_preserve_groups_permissive'] = True if 'user_preserve_groups_permissive' in cliargs and cliargs['user_preserve_groups_permissive'] else False substitutions['home_extension_active'] = True if 'home' in cliargs and cliargs['home'] else False if 'user_override_shell' in cliargs and cliargs['user_override_shell'] is not None: if cliargs['user_override_shell'] == '': substitutions['shell'] = None else: substitutions['shell'] = cliargs['user_override_shell'] - return em.expand(snippet, substitutions) + return empy_expand(snippet, substitutions) @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument(name_to_argument(User.get_name()), action='store_true', default=defaults.get('user', None), @@ -296,6 +357,16 @@ def register_arguments(parser, defaults={}): action='store_true', default=defaults.get('user-preserve-home', False), help="Do not delete home directory if it exists when making a new user.") + parser.add_argument('--user-preserve-groups', + action='store', + nargs='*', + default=defaults.get('user-preserve-groups', False), + help="Assign user to same groups as he belongs in host. If arguments provided they are the explicit list of groups.") + parser.add_argument('--user-preserve-groups-permissive', + action='store_true', + default=defaults.get('user-preserve-groups-permissive', False), + help="If using user-preserve-groups allow failures in assignment." + "This is important if the host and target have different rules. https://unix.stackexchange.com/a/11481/83370" ) parser.add_argument('--user-override-shell', action='store', default=defaults.get('user-override-shell', None), @@ -329,7 +400,7 @@ def get_docker_args(self, cli_args): return ' '.join(args) @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument('--env', '-e', metavar='NAME[=VALUE]', type=str, @@ -365,8 +436,59 @@ def get_docker_args(self, cli_args): return ' --privileged' @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument(name_to_argument(Privileged.get_name()), action='store_true', default=defaults.get(Privileged.get_name(), None), help="give extended privileges to the container") + + +class GroupAdd(RockerExtension): + """Add additional groups to running container.""" + @staticmethod + def get_name(): + return 'group_add' + + def __init__(self): + self.name = GroupAdd.get_name() + + def get_preamble(self, cliargs): + return '' + + def get_docker_args(self, cliargs): + args = [''] + groups = cliargs.get('group_add', []) + for group in groups: + args.append(' --group-add {0}'.format(group)) + return ' '.join(args) + + @staticmethod + def register_arguments(parser, defaults): + parser.add_argument(name_to_argument(GroupAdd.get_name()), + default=defaults.get(GroupAdd.get_name(), None), + action='append', + help="Add additional groups to join.") + +class ShmSize(RockerExtension): + @staticmethod + def get_name(): + return 'shm_size' + + def __init__(self): + self.name = ShmSize.get_name() + + def get_preamble(self, cliargs): + return '' + + def get_docker_args(self, cliargs): + args = '' + shm_size = cliargs.get('shm_size', None) + if shm_size: + args += f' --shm-size {shm_size} ' + return args + + @staticmethod + def register_arguments(parser, defaults={}): + parser.add_argument('--shm-size', + default=defaults.get('shm_size', None), + help="Set the size of the shared memory for the container (e.g., 512m, 1g).") \ No newline at end of file diff --git a/src/rocker/git_extension.py b/src/rocker/git_extension.py index b35c3979..4fc0905f 100644 --- a/src/rocker/git_extension.py +++ b/src/rocker/git_extension.py @@ -35,7 +35,9 @@ def get_docker_args(self, cli_args): user_gitconfig = cli_args.get('git_config_path', os.path.expanduser('~/.gitconfig')) user_gitconfig_target = '/root/.gitconfig' if 'user' in cli_args and cli_args['user']: - username = cli_args.get('user_override_name', getpass.getuser()) + username = getpass.getuser() + if 'user_override_name' in cli_args and cli_args['user_override_name']: + username = cli_args['user_override_name'] user_gitconfig_target = '/home/%(username)s/.gitconfig' % locals() if os.path.exists(system_gitconfig): args += ' -v {system_gitconfig}:{system_gitconfig_target}:ro'.format(**locals()) @@ -44,7 +46,7 @@ def get_docker_args(self, cli_args): return args @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument('--git', action='store_true', default=defaults.get(Git.get_name(), None), diff --git a/src/rocker/nvidia_extension.py b/src/rocker/nvidia_extension.py index 1bae9fa9..3644c220 100644 --- a/src/rocker/nvidia_extension.py +++ b/src/rocker/nvidia_extension.py @@ -13,7 +13,6 @@ # limitations under the License. import os -import em import getpass import tempfile from packaging.version import Version @@ -27,12 +26,37 @@ from .extensions import name_to_argument from .core import get_docker_client from .core import RockerExtension +from .em import empy_expand + +GLVND_VERSION_POLICY_LATEST_LTS='latest_lts' + +NVIDIA_GLVND_VALID_VERSIONS=['16.04', '18.04','20.04', '22.04', '24.04'] def get_docker_version(): docker_version_raw = get_docker_client().version()['Version'] # Fix for version 17.09.0-ce return Version(docker_version_raw.split('-')[0]) +def glvnd_version_from_policy(image_version, policy): + # Default policy GLVND_VERSION_POLICY_LATEST_LTS + if not policy: + policy = GLVND_VERSION_POLICY_LATEST_LTS + + if policy == GLVND_VERSION_POLICY_LATEST_LTS: + if image_version in ['16.04', '16.10', '17.04', '17.10']: + return '16.04' + if image_version in ['18.04', '18.10', '19.04', '19.10']: + return '18.04' + if image_version in ['20.04', '20.10', '21.04', '21.10']: + return '20.04' + if image_version in ['22.04', '22.10', '23.04', '23.10']: + return '22.04' + # 24.04 is not available yet + # if image_version in ['24.04', '24.10', '25.04', '25.10']: + # return '24.04' + return '22.04' + return None + class X11(RockerExtension): @staticmethod def get_name(): @@ -70,7 +94,7 @@ def precondition_environment(self, cliargs): raise ex @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument(name_to_argument(X11.get_name()), action='store_true', default=defaults.get(X11.get_name(), None), @@ -86,7 +110,7 @@ def __init__(self): self._env_subs = None self.name = Nvidia.get_name() self.supported_distros = ['Ubuntu', 'Debian GNU/Linux'] - self.supported_versions = ['16.04', '18.04', '20.04', '10', '22.04'] + self.supported_versions = ['16.04', '18.04', '20.04', '10', '22.04', '24.04'] def get_environment_subs(self, cliargs={}): @@ -94,7 +118,7 @@ def get_environment_subs(self, cliargs={}): self._env_subs = {} self._env_subs['user_id'] = os.getuid() self._env_subs['username'] = getpass.getuser() - + # non static elements test every time detected_os = detect_os(cliargs['base_image'], print, nocache=cliargs.get('nocache', False)) if detected_os is None: @@ -111,28 +135,47 @@ def get_environment_subs(self, cliargs={}): print("WARNING distro %s version %s not in supported list by Nvidia supported versions" % (dist, ver), self.supported_versions) sys.exit(1) # TODO(tfoote) add a standard mechanism for checking preconditions and disabling plugins + nvidia_glvnd_version = cliargs.get('nvidia_glvnd_version', None) + if not nvidia_glvnd_version: + nvidia_glvnd_version = glvnd_version_from_policy(ver, cliargs.get('nvidia_glvnd_policy', None) ) + self._env_subs['nvidia_glvnd_version'] = nvidia_glvnd_version return self._env_subs def get_preamble(self, cliargs): preamble = pkgutil.get_data('rocker', 'templates/%s_preamble.Dockerfile.em' % self.name).decode('utf-8') - return em.expand(preamble, self.get_environment_subs(cliargs)) + return empy_expand(preamble, self.get_environment_subs(cliargs)) def get_snippet(self, cliargs): snippet = pkgutil.get_data('rocker', 'templates/%s_snippet.Dockerfile.em' % self.name).decode('utf-8') - return em.expand(snippet, self.get_environment_subs(cliargs)) + return empy_expand(snippet, self.get_environment_subs(cliargs)) def get_docker_args(self, cliargs): + force_flag = cliargs.get('nvidia', None) + if force_flag == 'runtime': + return " --runtime=nvidia" + if force_flag == 'gpus': + return " --gpus all" if get_docker_version() >= Version("19.03"): return " --gpus all" return " --runtime=nvidia" @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument(name_to_argument(Nvidia.get_name()), - action='store_true', + choices=['auto', 'runtime', 'gpus'], + nargs='?', + const='auto', default=defaults.get(Nvidia.get_name(), None), - help="Enable nvidia") + help="Enable nvidia. Default behavior is to pick flag based on docker version.") + parser.add_argument('--nvidia-glvnd-version', + choices=NVIDIA_GLVND_VALID_VERSIONS, + default=defaults.get('nvidia-glvnd-version', None), + help="Explicitly select an nvidia glvnd version") + parser.add_argument('--nvidia-glvnd-policy', + choices=[GLVND_VERSION_POLICY_LATEST_LTS], + default=defaults.get('nvidia-glvnd-policy', GLVND_VERSION_POLICY_LATEST_LTS), + help="Set an nvidia glvnd version policy if version is unset") class Cuda(RockerExtension): @staticmethod @@ -143,7 +186,7 @@ def __init__(self): self._env_subs = None self.name = Cuda.get_name() self.supported_distros = ['Ubuntu', 'Debian GNU/Linux'] - self.supported_versions = ['20.04', '22.04', '18.04', '11'] # Debian 11 + self.supported_versions = ['20.04', '22.04', '24.04', '11', '12'] # Debian 11 and 12 def get_environment_subs(self, cliargs={}): if not self._env_subs: @@ -177,21 +220,19 @@ def get_environment_subs(self, cliargs={}): def get_preamble(self, cliargs): return '' # preamble = pkgutil.get_data('rocker', 'templates/%s_preamble.Dockerfile.em' % self.name).decode('utf-8') - # return em.expand(preamble, self.get_environment_subs(cliargs)) + # return empy_expand(preamble, self.get_environment_subs(cliargs)) def get_snippet(self, cliargs): snippet = pkgutil.get_data('rocker', 'templates/%s_snippet.Dockerfile.em' % self.name).decode('utf-8') - return em.expand(snippet, self.get_environment_subs(cliargs)) + return empy_expand(snippet, self.get_environment_subs(cliargs)) def get_docker_args(self, cliargs): return "" # Runtime requires --nvidia option too @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument(name_to_argument(Cuda.get_name()), action='store_true', default=defaults.get('cuda', None), help="Install cuda and nvidia-cuda-dev into the container") - - diff --git a/src/rocker/rmw_extension.py b/src/rocker/rmw_extension.py new file mode 100644 index 00000000..5c52ee32 --- /dev/null +++ b/src/rocker/rmw_extension.py @@ -0,0 +1,84 @@ +# Copyright 2024 Open Source Robotics Foundation + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from argparse import ArgumentTypeError +import os +import pkgutil + +from .em import empy_expand +from rocker.extensions import RockerExtension +from rocker.extensions import name_to_argument + + +class RMW(RockerExtension): + rmw_map = { + 'cyclonedds': ['ros-${ROS_DISTRO}-rmw-cyclonedds-cpp'], + 'fastrtps' : ['ros-${ROS_DISTRO}-rmw-fastrtps-cpp'], + # TODO(tfoote) Enable connext with license acceptance method + # 'connextdds': ['ros-${ROS_DISTRO}-rmw-connextdds'], + } + + @staticmethod + def get_package_names(rmw_name): + return RMW.rmw_map[rmw_name] + + @staticmethod + def get_name(): + return 'rmw' + + def __init__(self): + self._env_subs = None + self.name = RMW.get_name() + + def get_docker_args(self, cli_args): + rmw_config = cli_args.get('rmw') + if not rmw_config: + return '' # not active + implementation = rmw_config[0] + args = f' -e RMW_IMPLEMENTATION=rmw_{implementation}_cpp' + return args #% self.get_environment_subs() + + def get_environment_subs(self): + if not self._env_subs: + self._env_subs = {} + return self._env_subs + + def get_preamble(self, cliargs): + return '' + + def get_snippet(self, cliargs): + snippet = pkgutil.get_data('rocker', 'templates/%s_snippet.Dockerfile.em' % RMW.get_name()).decode('utf-8') + data = self.get_environment_subs() + # data['rosdistro'] = cliargs.get('rosdistro', 'rolling') + rmw = cliargs.get('rmw', None) + if rmw: + rmw = rmw[0] + else: + return '' # rmw not active + data['rmw'] = rmw + data['packages'] = RMW.get_package_names(rmw) + # data['rosdistro'] = 'rolling' + return empy_expand(snippet, data) + + @staticmethod + def register_arguments(parser, defaults): + parser.add_argument(name_to_argument(RMW.get_name()), + default=defaults.get('rmw', None), + nargs=1, + choices=RMW.rmw_map.keys(), + help="Set the default RMW implementation") + + # parser.add_argument('rosdistro', + # default=defaults.get('rosdistro', None), + # help="Set the default rosdistro, else autodetect") diff --git a/src/rocker/ssh_extension.py b/src/rocker/ssh_extension.py index 37de6c68..4417b605 100644 --- a/src/rocker/ssh_extension.py +++ b/src/rocker/ssh_extension.py @@ -46,7 +46,7 @@ def get_docker_args(self, cli_args): return args @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument('--ssh', action='store_true', default=defaults.get(Ssh.get_name(), None), diff --git a/src/rocker/templates/cuda_snippet.Dockerfile.em b/src/rocker/templates/cuda_snippet.Dockerfile.em index 92a12d4f..c4575b6f 100644 --- a/src/rocker/templates/cuda_snippet.Dockerfile.em +++ b/src/rocker/templates/cuda_snippet.Dockerfile.em @@ -13,23 +13,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Enable contrib on debian to get required # https://packages.debian.org/bullseye/glx-alternative-nvidia -# Enable non-free for nvidia-cuda-dev -# https://packages.debian.org/bullseye/nvidia-cuda-dev RUN \ - @[if download_osstring == 'ubuntu']@ - wget https://developer.download.nvidia.com/compute/cuda/repos/@(download_osstring)@(download_verstring)/x86_64/cuda-@(download_osstring)@(download_verstring).pin \ - && mv cuda-@(download_osstring)@(download_verstring).pin /etc/apt/preferences.d/cuda-repository-pin-600 && \ - add-apt-repository restricted && \ - @[else]@ + wget -q https://developer.download.nvidia.com/compute/cuda/repos/@(download_osstring)@(download_verstring)/x86_64/cuda-keyring_1.1-1_all.deb && \ + dpkg -i cuda-keyring_1.1-1_all.deb && \ + rm cuda-keyring_1.1-1_all.deb && \ + \@[if download_osstring == 'debian']@ add-apt-repository contrib && \ - add-apt-repository non-free && \ - @[end if]@ - apt-key adv --fetch-keys https://developer.download.nvidia.com/compute/cuda/repos/@(download_osstring)@(download_verstring)/x86_64/@(download_keyid).pub \ - && add-apt-repository "deb https://developer.download.nvidia.com/compute/cuda/repos/@(download_osstring)@(download_verstring)/x86_64/ /" \ - && apt-get update \ - && apt-get -y install cuda \ - && rm -rf /var/lib/apt/lists/* + \@[end if]@ + apt-get update && \ + apt-get -y install cuda-toolkit && \ + rm -rf /var/lib/apt/lists/* # File conflict problem with libnvidia-ml.so.1 and libcuda.so.1 # https://github.com/NVIDIA/nvidia-docker/issues/1551 diff --git a/src/rocker/templates/nvidia_preamble.Dockerfile.em b/src/rocker/templates/nvidia_preamble.Dockerfile.em index 1bab5337..5b9681c6 100644 --- a/src/rocker/templates/nvidia_preamble.Dockerfile.em +++ b/src/rocker/templates/nvidia_preamble.Dockerfile.em @@ -1,3 +1 @@ -# Ubuntu 16.04 with nvidia-docker2 beta opengl support -@{suffix = '16.04' if image_distro_version == '16.04' else '18.04'}@ -FROM nvidia/opengl:1.0-glvnd-devel-ubuntu@(suffix) as glvnd +FROM nvidia/opengl:1.0-glvnd-devel-ubuntu@(nvidia_glvnd_version) as glvnd diff --git a/src/rocker/templates/rmw_snippet.Dockerfile.em b/src/rocker/templates/rmw_snippet.Dockerfile.em new file mode 100644 index 00000000..87f50275 --- /dev/null +++ b/src/rocker/templates/rmw_snippet.Dockerfile.em @@ -0,0 +1,16 @@ +# workspace development helpers + + +@[ if rmw ]@ +RUN \ + if [ -z "${ROS_DISTRO}" ]; then echo "ROS_DISTRO is unset cannot override RMW" ; exit 1 ; fi ;\ + if dpkg -l @(' '.join(packages)) > /dev/null 2>&1; then \ + apt-get update \ + && DEBIAN_FRONTENT=non-interactive apt-get install -qy --no-install-recommends\ + @(' '.join(packages)) \ + && apt-get clean ;\ + else \ + echo "Found rmw packages @(' '.join(packages)) no need to install" ; \ + fi +@[ end if ]@ + diff --git a/src/rocker/templates/user_snippet.Dockerfile.em b/src/rocker/templates/user_snippet.Dockerfile.em index 63911409..2d94d619 100644 --- a/src/rocker/templates/user_snippet.Dockerfile.em +++ b/src/rocker/templates/user_snippet.Dockerfile.em @@ -17,14 +17,24 @@ RUN existing_user_by_uid=`getent passwd "@(uid)" | cut -f1 -d: || true` && \ groupadd -g "@(gid)" "@name"; \ fi && \ useradd --no-log-init --no-create-home --uid "@(uid)" @(str('-s ' + shell) if shell else '') -c "@(gecos)" -g "@(gid)" -d "@(dir)" "@(name)" && \ +@[if user_groups != '']@ + user_groups="@(user_groups)" && \ + for groupinfo in ${user_groups}; do \ + existing_group_by_name=`getent group ${groupinfo%;*} || true`; \ + existing_group_by_gid=`getent group ${groupinfo#*;} || true`; \ + if [ -z "${existing_group_by_name}" ] && [ -z "${existing_group_by_gid}" ]; then \ + 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); \ + elif [ "${existing_group_by_name}" = "${existing_group_by_gid}" ]; then \ + 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); \ + fi; \ + done && \ +@[end if]@ echo "@(name) ALL=NOPASSWD: ALL" >> /etc/sudoers.d/rocker @[if not home_extension_active ]@ # Making sure a home directory exists if we haven't mounted the user's home directory explicitly RUN mkdir -p "$(dirname "@(dir)")" && mkhomedir_helper @(name) @[end if]@ -# Commands below run as the developer user -USER @(name) WORKDIR @(dir) @[else]@ # Detected user is root, which already exists so not creating new user. diff --git a/src/rocker/ulimit_extension.py b/src/rocker/ulimit_extension.py new file mode 100644 index 00000000..105650a1 --- /dev/null +++ b/src/rocker/ulimit_extension.py @@ -0,0 +1,67 @@ +# Copyright 2019 Open Source Robotics Foundation + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from argparse import ArgumentTypeError +import re +from rocker.extensions import RockerExtension, name_to_argument + + +class Ulimit(RockerExtension): + """ + A RockerExtension to handle ulimit settings for Docker containers. + + This extension allows specifying ulimit options in the format TYPE=SOFT_LIMIT[:HARD_LIMIT] + and validates the format before passing them as Docker arguments. + """ + EXPECTED_FORMAT = "TYPE=SOFT_LIMIT[:HARD_LIMIT]" + + @staticmethod + def get_name(): + return 'ulimit' + + def get_docker_args(self, cliargs): + args = [''] + ulimits = [x for sublist in cliargs[Ulimit.get_name()] for x in sublist] + for ulimit in ulimits: + if self.arg_format_is_valid(ulimit): + args.append(f"--ulimit {ulimit}") + else: + raise ArgumentTypeError( + f"Error processing {Ulimit.get_name()} flag '{ulimit}': expected format" + f" {Ulimit.EXPECTED_FORMAT}") + return ' '.join(args) + + def arg_format_is_valid(self, arg: str): + """ + Validate the format of the ulimit argument. + + Args: + arg (str): The ulimit argument to validate. + + Returns: + bool: True if the format is valid, False otherwise. + """ + ulimit_format = r'(\w+)=(\w+)(:\w+)?$' + match = re.match(ulimit_format, arg) + return match is not None + + @staticmethod + def register_arguments(parser, defaults): + parser.add_argument(name_to_argument(Ulimit.get_name()), + type=str, + nargs='+', + action='append', + metavar=Ulimit.EXPECTED_FORMAT, + default=defaults.get(Ulimit.get_name(), None), + help='ulimit options to add into the container.') diff --git a/src/rocker/volume_extension.py b/src/rocker/volume_extension.py index 6f2626fb..7958afc1 100644 --- a/src/rocker/volume_extension.py +++ b/src/rocker/volume_extension.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from argparse import ArgumentParser from argparse import ArgumentTypeError import os from rocker.extensions import RockerExtension @@ -61,7 +62,7 @@ def get_docker_args(self, cli_args): return ' '.join(args) @staticmethod - def register_arguments(parser): + def register_arguments(parser: ArgumentParser, defaults: dict): parser.add_argument(Volume.ARG_ROCKER_VOLUME, metavar='HOST-DIR[:CONTAINER-DIR[:OPTIONS]]', type=str, diff --git a/stdeb.cfg b/stdeb.cfg index 4cd9cbd4..2e413391 100644 --- a/stdeb.cfg +++ b/stdeb.cfg @@ -3,5 +3,5 @@ Debian-Version: 100 No-Python2: Depends3: python3-docker, python3-empy, python3-pexpect, python3-packaging Conflicts3: python-rocker -Suite: bionic focal jammy stretch buster bullseye +Suite: focal jammy noble bookworm trixie X-Python3-Version: >= 3.2 diff --git a/test/test_core.py b/test/test_core.py index f57e659b..855d585e 100644 --- a/test/test_core.py +++ b/test/test_core.py @@ -17,15 +17,20 @@ import argparse import em +import os +import pwd import pytest import unittest from itertools import chain from rocker.core import DockerImageGenerator +from rocker.core import ExtensionError from rocker.core import list_plugins from rocker.core import get_docker_client from rocker.core import get_rocker_version +from rocker.core import get_user_name +from rocker.core import RockerExtension from rocker.core import RockerExtensionManager class RockerCoreTest(unittest.TestCase): @@ -63,6 +68,7 @@ def test_run_before_build(self): self.assertEqual(dig.run('true'), 1) self.assertEqual(dig.build(), 0) self.assertEqual(dig.run('true'), 0) + dig.clear_image() @pytest.mark.docker def test_return_code_no_extensions(self): @@ -70,6 +76,7 @@ def test_return_code_no_extensions(self): self.assertEqual(dig.build(), 0) self.assertEqual(dig.run('true'), 0) self.assertEqual(dig.run('false'), 1) + dig.clear_image() @pytest.mark.docker def test_return_code_multiple_extensions(self): @@ -80,12 +87,14 @@ def test_return_code_multiple_extensions(self): self.assertEqual(dig.build(), 0) self.assertEqual(dig.run('true'), 0) self.assertEqual(dig.run('false'), 1) + dig.clear_image() @pytest.mark.docker def test_noexecute(self): dig = DockerImageGenerator([], {}, 'ubuntu:bionic') self.assertEqual(dig.build(), 0) self.assertEqual(dig.run('true', noexecute=True), 0) + dig.clear_image() @pytest.mark.docker def test_dry_run(self): @@ -93,6 +102,7 @@ def test_dry_run(self): self.assertEqual(dig.build(), 0) self.assertEqual(dig.run('true', mode='dry-run'), 0) self.assertEqual(dig.run('false', mode='dry-run'), 0) + dig.clear_image() @pytest.mark.docker def test_non_interactive(self): @@ -100,6 +110,7 @@ def test_non_interactive(self): self.assertEqual(dig.build(), 0) self.assertEqual(dig.run('true', mode='non-interactive'), 0) self.assertEqual(dig.run('false', mode='non-interactive'), 1) + dig.clear_image() @pytest.mark.docker def test_device(self): @@ -107,6 +118,7 @@ def test_device(self): self.assertEqual(dig.build(), 0) self.assertEqual(dig.run('true', devices=['/dev/random']), 0) self.assertEqual(dig.run('true', devices=['/dev/does_not_exist']), 0) + dig.clear_image() @pytest.mark.docker def test_network(self): @@ -115,6 +127,7 @@ def test_network(self): networks = ['bridge', 'host', 'none'] for n in networks: self.assertEqual(dig.run('true', network=n), 0) + dig.clear_image() @pytest.mark.docker def test_extension_manager(self): @@ -128,9 +141,82 @@ def test_extension_manager(self): self.assertIn('non-interactive', help_str) self.assertIn('--extension-blacklist', help_str) - active_extensions = active_extensions = extension_manager.get_active_extensions({'user': True, 'ssh': True, 'extension_blacklist': ['ssh']}) - self.assertEqual(len(active_extensions), 1) - self.assertEqual(active_extensions[0].get_name(), 'user') + self.assertRaises(ExtensionError, + extension_manager.get_active_extensions, + {'user': True, 'ssh': True, 'extension_blacklist': ['ssh']}) + + def test_strict_required_extensions(self): + class Foo(RockerExtension): + @classmethod + def get_name(cls): + return 'foo' + + class Bar(RockerExtension): + @classmethod + def get_name(cls): + return 'bar' + + def required(self, cli_args): + return {'foo'} + + extension_manager = RockerExtensionManager() + extension_manager.available_plugins = {'foo': Foo, 'bar': Bar} + + correct_extensions_args = {'strict_extension_selection': True, 'bar': True, 'foo': True, 'extension_blacklist': []} + extension_manager.get_active_extensions(correct_extensions_args) + + incorrect_extensions_args = {'strict_extension_selection': True, 'bar': True, 'extension_blacklist': []} + self.assertRaises(ExtensionError, + extension_manager.get_active_extensions, incorrect_extensions_args) + + def test_implicit_required_extensions(self): + class Foo(RockerExtension): + @classmethod + def get_name(cls): + return 'foo' + + class Bar(RockerExtension): + @classmethod + def get_name(cls): + return 'bar' + + def required(self, cli_args): + return {'foo'} + + extension_manager = RockerExtensionManager() + extension_manager.available_plugins = {'foo': Foo, 'bar': Bar} + + implicit_extensions_args = {'strict_extension_selection': False, 'bar': True, 'extension_blacklist': []} + active_extensions = extension_manager.get_active_extensions(implicit_extensions_args) + self.assertEqual(len(active_extensions), 2) + # required extensions are not ordered, just check to make sure they are both present + if active_extensions[0].get_name() == 'foo': + self.assertEqual(active_extensions[1].get_name(), 'bar') + else: + self.assertEqual(active_extensions[0].get_name(), 'bar') + self.assertEqual(active_extensions[1].get_name(), 'foo') + + def test_extension_sorting(self): + class Foo(RockerExtension): + @classmethod + def get_name(cls): + return 'foo' + + class Bar(RockerExtension): + @classmethod + def get_name(cls): + return 'bar' + + def invoke_after(self, cli_args): + return {'foo', 'absent_extension'} + + extension_manager = RockerExtensionManager() + extension_manager.available_plugins = {'foo': Foo, 'bar': Bar} + + args = {'bar': True, 'foo': True, 'extension_blacklist': []} + active_extensions = extension_manager.get_active_extensions(args) + self.assertEqual(active_extensions[0].get_name(), 'foo') + self.assertEqual(active_extensions[1].get_name(), 'bar') def test_docker_cmd_interactive(self): dig = DockerImageGenerator([], {}, 'ubuntu:bionic') @@ -148,6 +234,63 @@ def test_docker_cmd_interactive(self): self.assertNotIn('-it', dig.generate_docker_cmd(mode='non-interactive')) + def test_docker_user_detection(self): + userinfo = pwd.getpwuid(os.getuid()) + username_detected = getattr(userinfo, 'pw_' + 'name') + self.assertEqual(username_detected, get_user_name()) + + @pytest.mark.docker + def test_docker_user_setting(self): + parser = argparse.ArgumentParser() + extension_manager = RockerExtensionManager() + default_args = {} + extension_manager.extend_cli_parser(parser, default_args) + active_extensions = extension_manager.get_active_extensions({'user': True, 'extension_blacklist': ['ssh']}) + dig = DockerImageGenerator(active_extensions, {'user_override_name': 'foo'}, 'ubuntu:bionic') + + self.assertIn('USER root', dig.dockerfile) + self.assertNotIn('USER foo', dig.dockerfile) + dig = DockerImageGenerator(active_extensions, {'user': True, 'user_override_name': 'foo'}, 'ubuntu:bionic') + self.assertIn('USER root', dig.dockerfile) + self.assertIn('USER foo', dig.dockerfile) + + def test_docker_user_snippet(self): + + root_snippet_content = "RUN echo run as root" + user_snippet_content = "RUN echo run as user" + + class UserSnippet(RockerExtension): + def __init__(self): + self.name = 'usersnippet' + + @classmethod + def get_name(cls): + return 'usersnippet' + + def get_snippet(self, cli_args): + return root_snippet_content + + + def get_user_snippet(self, cli_args): + return user_snippet_content + + extension_manager = RockerExtensionManager() + extension_manager.available_plugins = {'usersnippet': UserSnippet} + active_extensions = extension_manager.get_active_extensions({'user': True, 'usersnippet': UserSnippet, 'extension_blacklist': ['ssh']}) + self.assertTrue(active_extensions) + + # No user snippet + mock_cli_args = {'user': False, 'usersnippet': True, 'user_override_name': 'foo'} + dig = DockerImageGenerator(active_extensions, mock_cli_args, 'ubuntu:bionic') + self.assertIn('USER root', dig.dockerfile) + self.assertNotIn('USER foo', dig.dockerfile) + + # User snippet added + mock_cli_args = {'user': True, 'usersnippet': True, 'user_override_name': 'foo'} + dig = DockerImageGenerator(active_extensions, mock_cli_args, 'ubuntu:bionic') + self.assertIn(root_snippet_content, dig.dockerfile) + self.assertIn('USER foo', dig.dockerfile) + self.assertIn(user_snippet_content, dig.dockerfile) def test_docker_cmd_nocleanup(self): dig = DockerImageGenerator([], {}, 'ubuntu:bionic') diff --git a/test/test_extension.py b/test/test_extension.py index 0c6aa7b4..1ad88d85 100644 --- a/test/test_extension.py +++ b/test/test_extension.py @@ -36,7 +36,7 @@ def plugin_load_parser_correctly(plugin): """A helper function to test that the plugins at least register an option for their own name.""" parser = argparse.ArgumentParser(description='test_parser') - plugin.register_arguments(parser) + plugin.register_arguments(parser, {}) argument_name = name_to_argument(plugin.get_name()) for action in parser._actions: option_strings = getattr(action, 'option_strings', []) @@ -114,6 +114,40 @@ def test_home_extension(self): args = p.get_docker_args(mock_cliargs) self.assertTrue('-v %s:%s' % (Path.home(), Path.home()) in args) + +class IpcExtensionTest(unittest.TestCase): + + def setUp(self): + # Work around interference between empy Interpreter + # stdout proxy and test runner. empy installs a proxy on stdout + # to be able to capture the information. + # And the test runner creates a new stdout object for each test. + # This breaks empy as it assumes that the proxy has persistent + # between instances of the Interpreter class + # empy will error with the exception + # "em.Error: interpreter stdout proxy lost" + em.Interpreter._wasProxyInstalled = False + + @pytest.mark.docker + def test_ipc_extension(self): + plugins = list_plugins() + ipc_plugin = plugins['ipc'] + self.assertEqual(ipc_plugin.get_name(), 'ipc') + + p = ipc_plugin() + self.assertTrue(plugin_load_parser_correctly(ipc_plugin)) + + mock_cliargs = {'ipc': 'none'} + self.assertEqual(p.get_snippet(mock_cliargs), '') + self.assertEqual(p.get_preamble(mock_cliargs), '') + args = p.get_docker_args(mock_cliargs) + self.assertTrue('--ipc none' in args) + + mock_cliargs = {'ipc': 'host'} + args = p.get_docker_args(mock_cliargs) + self.assertTrue('--ipc host' in args) + + class NetworkExtensionTest(unittest.TestCase): def setUp(self): @@ -243,6 +277,37 @@ def test_name_extension(self): args = p.get_docker_args(mock_cliargs) self.assertTrue('--name docker_name' in args) +class HostnameExtensionTest(unittest.TestCase): + + def setUp(self): + # Work around interference between empy Interpreter + # stdout proxy and test runner. empy installs a proxy on stdout + # to be able to capture the information. + # And the test runner creates a new stdout object for each test. + # This breaks empy as it assumes that the proxy has persistent + # between instances of the Interpreter class + # empy will error with the exception + # "em.Error: interpreter stdout proxy lost" + em.Interpreter._wasProxyInstalled = False + + def test_name_extension(self): + plugins = list_plugins() + name_plugin = plugins['hostname'] + self.assertEqual(name_plugin.get_name(), 'hostname') + + p = name_plugin() + self.assertTrue(plugin_load_parser_correctly(name_plugin)) + + mock_cliargs = {'hostname': 'none'} + self.assertEqual(p.get_snippet(mock_cliargs), '') + self.assertEqual(p.get_preamble(mock_cliargs), '') + args = p.get_docker_args(mock_cliargs) + self.assertTrue('--hostname none' in args) + + mock_cliargs = {'hostname': 'docker-hostname'} + args = p.get_docker_args(mock_cliargs) + self.assertTrue('--hostname docker-hostname' in args) + class PrivilegedExtensionTest(unittest.TestCase): @@ -317,9 +382,25 @@ def test_user_extension(self): self.assertFalse('mkhomedir_helper' in p.get_snippet(home_active_cliargs)) user_override_active_cliargs = mock_cliargs + user_override_active_cliargs['user_preserve_groups'] = [] + snippet_result = p.get_snippet(user_override_active_cliargs) + self.assertTrue('usermod -aG' in snippet_result) + + user_override_active_cliargs = mock_cliargs + user_override_active_cliargs['user_preserve_groups'] = ['cdrom', 'audio'] + snippet_result = p.get_snippet(user_override_active_cliargs) + self.assertTrue('cdrom' in snippet_result) + self.assertTrue('audio' in snippet_result) + + user_override_active_cliargs = mock_cliargs + user_override_active_cliargs['user_preserve_groups'] = [] + user_override_active_cliargs['user_preserve_groups_permissive'] = True + snippet_result = p.get_snippet(user_override_active_cliargs) + self.assertTrue('usermod -aG' in snippet_result) + self.assertTrue('user-preserve-group-permissive Enabled' in snippet_result) + user_override_active_cliargs['user_override_name'] = 'testusername' snippet_result = p.get_snippet(user_override_active_cliargs) - self.assertTrue('USER testusername' in snippet_result) self.assertTrue('WORKDIR /home/testusername' in snippet_result) self.assertTrue('userdel -r' in snippet_result) @@ -373,6 +454,7 @@ def test_user_collisions(self): self.assertTrue(exit_code == 0, f"Build failed with exit code {exit_code}") run_exit_code = dig.run(**build_args) self.assertTrue(run_exit_code == 0, f"Run failed with exit code {run_exit_code}") + dig.clear_image() # Test colliding UID and name @@ -383,6 +465,7 @@ def test_user_collisions(self): self.assertTrue(exit_code == 0, f"Build failed with exit code {exit_code}") run_exit_code = dig.run(**build_args) self.assertTrue(run_exit_code == 0, f"Run failed with exit code {run_exit_code}") + dig.clear_image() class PulseExtensionTest(unittest.TestCase): @@ -500,3 +583,69 @@ def test_env_file_extension(self): self.assertEqual(p.get_snippet(mock_cliargs), '') self.assertEqual(p.get_preamble(mock_cliargs), '') self.assertEqual(p.get_docker_args(mock_cliargs), ' --env-file foo --env-file bar') + + +class GroupAddExtensionTest(unittest.TestCase): + + def setUp(self): + # Work around interference between empy Interpreter + # stdout proxy and test runner. empy installs a proxy on stdout + # to be able to capture the information. + # And the test runner creates a new stdout object for each test. + # This breaks empy as it assumes that the proxy has persistent + # between instances of the Interpreter class + # empy will error with the exception + # "em.Error: interpreter stdout proxy lost" + em.Interpreter._wasProxyInstalled = False + + @pytest.mark.docker + def test_group_add_extension(self): + plugins = list_plugins() + group_add_plugin = plugins['group_add'] + self.assertEqual(group_add_plugin.get_name(), 'group_add') + + p = group_add_plugin() + self.assertTrue(plugin_load_parser_correctly(group_add_plugin)) + + mock_cliargs = {} + self.assertEqual(p.get_snippet(mock_cliargs), '') + self.assertEqual(p.get_preamble(mock_cliargs), '') + args = p.get_docker_args(mock_cliargs) + self.assertNotIn('--group_add', args) + + mock_cliargs = {'group_add': ['sudo', 'docker']} + args = p.get_docker_args(mock_cliargs) + self.assertIn('--group-add sudo', args) + self.assertIn('--group-add docker', args) + +class ShmSizeExtensionTest(unittest.TestCase): + + def setUp(self): + # Work around interference between empy Interpreter + # stdout proxy and test runner. empy installs a proxy on stdout + # to be able to capture the information. + # And the test runner creates a new stdout object for each test. + # This breaks empy as it assumes that the proxy has persistent + # between instances of the Interpreter class + # empy will error with the exception + # "em.Error: interpreter stdout proxy lost" + em.Interpreter._wasProxyInstalled = False + + @pytest.mark.docker + def test_shm_size_extension(self): + plugins = list_plugins() + shm_size_plugin = plugins['shm_size'] + self.assertEqual(shm_size_plugin.get_name(), 'shm_size') + + p = shm_size_plugin() + self.assertTrue(plugin_load_parser_correctly(shm_size_plugin)) + + mock_cliargs = {} + self.assertEqual(p.get_snippet(mock_cliargs), '') + self.assertEqual(p.get_preamble(mock_cliargs), '') + args = p.get_docker_args(mock_cliargs) + self.assertNotIn('--shm-size', args) + + mock_cliargs = {'shm_size': '12g'} + args = p.get_docker_args(mock_cliargs) + self.assertIn('--shm-size 12g', args) \ No newline at end of file diff --git a/test/test_file_writing.py b/test/test_file_writing.py index 03194812..e3e84ae0 100644 --- a/test/test_file_writing.py +++ b/test/test_file_writing.py @@ -48,15 +48,17 @@ def get_name(cls): def get_files(self, cliargs): all_files = {} - all_files['test_file.txt'] = """The quick brown fox jumped over the lazy dog. -%s""" % cliargs + all_files['test_file.txt'] = """The quick brown fox jumped over the lazy dog. %s""" % cliargs + all_files['path/to/test_file.txt'] = """The quick brown fox jumped over the lazy dog. %s""" % cliargs + all_files['test_file.bin'] = bytes("""The quick brown fox jumped over the lazy dog. %s""" % cliargs, 'utf-8') + all_files['../outside/path/to/test_file.txt'] = """Path outside directory should be skipped""" all_files['/absolute.txt'] = """Absolute file path should be skipped""" return all_files @staticmethod - def register_arguments(parser, defaults={}): + def register_arguments(parser, defaults): parser.add_argument('--test-file-injection', action='store_true', default=defaults.get('test_file_injection', False), @@ -89,4 +91,17 @@ def test_file_injection(self): self.assertIn('test_key', content) self.assertIn('test_value', content) + with open(os.path.join(td, 'path/to/test_file.txt'), 'r') as fh: + content = fh.read() + self.assertIn('quick brown', content) + self.assertIn('test_key', content) + self.assertIn('test_value', content) + + with open(os.path.join(td, 'test_file.bin'), 'r') as fh: # this particular binary file can be read in text mode + content = fh.read() + self.assertIn('quick brown', content) + self.assertIn('test_key', content) + self.assertIn('test_value', content) + + self.assertFalse(os.path.exists('../outside/path/to/test_file.txt')) self.assertFalse(os.path.exists('/absolute.txt')) diff --git a/test/test_git_extension.py b/test/test_git_extension.py index 7bdf0e3c..ef6941f5 100644 --- a/test/test_git_extension.py +++ b/test/test_git_extension.py @@ -57,7 +57,7 @@ def test_git_extension(self): p = git_plugin() self.assertTrue(plugin_load_parser_correctly(git_plugin)) - + mock_cliargs = {} mock_config_file = tempfile.NamedTemporaryFile() @@ -79,6 +79,12 @@ def test_git_extension(self): user_gitconfig_target = os.path.expanduser('~/.gitconfig') self.assertIn('-v %s:%s' % (user_gitconfig, user_gitconfig_target), user_args) + # Test with an existing overridden user key, but with None value + mock_cliargs['user_override_name'] = None + user_args = p.get_docker_args(mock_cliargs) + user_gitconfig_target = os.path.expanduser('~/.gitconfig') + self.assertIn('-v %s:%s' % (user_gitconfig, user_gitconfig_target), user_args) + # Test with overridden user mock_cliargs['user_override_name'] = 'testusername' user_args = p.get_docker_args(mock_cliargs) diff --git a/test/test_nvidia.py b/test/test_nvidia.py index 29ae0c2c..b5ae2ded 100644 --- a/test/test_nvidia.py +++ b/test/test_nvidia.py @@ -172,7 +172,15 @@ def test_nvidia_extension_basic(self): preamble = p.get_preamble(mock_cliargs) - self.assertIn('FROM nvidia/opengl:1.0-glvnd-devel-', preamble) + self.assertIn('FROM nvidia/opengl:1.0-glvnd-devel-ubuntu18.04', preamble) + + mock_cliargs = {'base_image': 'ubuntu:jammy'} + preamble = p.get_preamble(mock_cliargs) + self.assertIn('FROM nvidia/opengl:1.0-glvnd-devel-ubuntu22.04', preamble) + + mock_cliargs = {'base_image': 'ubuntu:jammy', 'nvidia_glvnd_version': '20.04'} + preamble = p.get_preamble(mock_cliargs) + self.assertIn('FROM nvidia/opengl:1.0-glvnd-devel-ubuntu20.04', preamble) docker_args = p.get_docker_args(mock_cliargs) #TODO(tfoote) restore with #37 self.assertIn(' -e DISPLAY -e TERM', docker_args) @@ -185,6 +193,21 @@ def test_nvidia_extension_basic(self): else: self.assertIn(' --runtime=nvidia', docker_args) + mock_cliargs = {'nvidia': 'auto'} + docker_args = p.get_docker_args(mock_cliargs) + if get_docker_version() >= Version("19.03"): + self.assertIn(' --gpus all', docker_args) + else: + self.assertIn(' --runtime=nvidia', docker_args) + + mock_cliargs = {'nvidia': 'gpus'} + docker_args = p.get_docker_args(mock_cliargs) + self.assertIn(' --gpus all', docker_args) + + mock_cliargs = {'nvidia': 'runtime'} + docker_args = p.get_docker_args(mock_cliargs) + self.assertIn(' --runtime=nvidia', docker_args) + def test_no_nvidia_glmark2(self): for tag in self.dockerfile_tags: @@ -227,15 +250,22 @@ def test_nvidia_env_subs(self): p.get_environment_subs(mock_cliargs) self.assertEqual(cm.exception.code, 1) +@pytest.mark.docker class CudaTest(unittest.TestCase): @classmethod def setUpClass(self): client = get_docker_client() self.dockerfile_tags = [] - for distro_version in ['focal', 'jammy']: + for (distro_name, distro_version) in [ + ('ubuntu','focal'), + ('ubuntu','jammy'), + ('ubuntu','noble'), + ('debian','bookworm'), + ('debian','bullseye'), + ]: dockerfile = """ -FROM ubuntu:%(distro_version)s -CMD dpkg -s cuda +FROM %(distro_name)s:%(distro_version)s +CMD dpkg -s cuda-toolkit """ dockerfile_tag = 'testfixture_%s_cuda' % distro_version iof = StringIO((dockerfile % locals()).encode()) @@ -257,24 +287,22 @@ def setUp(self): em.Interpreter._wasProxyInstalled = False - @pytest.mark.docker def test_no_cuda(self): for tag in self.dockerfile_tags: dig = DockerImageGenerator([], {}, tag) self.assertEqual(dig.build(), 0) self.assertNotEqual(dig.run(), 0) + dig.clear_image() - @pytest.mark.nvidia - @pytest.mark.x11 - @pytest.mark.docker - def test_cuda(self): + def test_cuda_install(self): plugins = list_plugins() - desired_plugins = ['x11', 'nvidia', 'cuda'] #TODO(Tfoote) encode the x11 dependency into the plugin and remove from test here + desired_plugins = ['cuda'] active_extensions = [e() for e in plugins.values() if e.get_name() in desired_plugins] for tag in self.dockerfile_tags: dig = DockerImageGenerator(active_extensions, {}, tag) self.assertEqual(dig.build(), 0) self.assertEqual(dig.run(), 0) + dig.clear_image() def test_cuda_env_subs(self): plugins = list_plugins() diff --git a/test/test_rmw_extension.py b/test/test_rmw_extension.py new file mode 100644 index 00000000..5c40a458 --- /dev/null +++ b/test/test_rmw_extension.py @@ -0,0 +1,95 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import em +import os +import unittest +import pytest + + +from rocker.core import DockerImageGenerator +from rocker.core import list_plugins + +from test_extension import plugin_load_parser_correctly + + + +class rmwExtensionTest(unittest.TestCase): + + def setUp(self): + # Work around interference between empy Interpreter + # stdout proxy and test runner. empy installs a proxy on stdout + # to be able to capture the information. + # And the test runner creates a new stdout object for each test. + # This breaks empy as it assumes that the proxy has persistent + # between instances of the Interpreter class + # empy will error with the exception + # "em.Error: interpreter stdout proxy lost" + em.Interpreter._wasProxyInstalled = False + + def test_rmw_extension(self): + plugins = list_plugins() + rmw_plugin = plugins['rmw'] + self.assertEqual(rmw_plugin.get_name(), 'rmw') + + p = rmw_plugin() + self.assertTrue(plugin_load_parser_correctly(rmw_plugin)) + + + mock_cliargs = {'rmw': ['cyclonedds']} + self.assertEqual(p.get_preamble(mock_cliargs), '') + args = p.get_docker_args(mock_cliargs) + self.assertIn('-e RMW_IMPLEMENTATION=rmw_cyclonedds_cpp', args) + snippet = p.get_snippet(mock_cliargs) + self.assertIn('rmw-cyclonedds-cpp', snippet) + + + #without it set + mock_cliargs = {'rmw': None} + args = p.get_docker_args(mock_cliargs) + snippet = p.get_snippet(mock_cliargs) + self.assertNotIn('RMW_IMPLEMENTATION', args) + self.assertNotIn('rmw-cyclonedds-cpp', snippet) + + +@pytest.mark.docker +class rmwRuntimeExtensionTest(unittest.TestCase): + + def setUp(self): + # Work around interference between empy Interpreter + # stdout proxy and test runner. empy installs a proxy on stdout + # to be able to capture the information. + # And the test runner creates a new stdout object for each test. + # This breaks empy as it assumes that the proxy has persistent + # between instances of the Interpreter class + # empy will error with the exception + # "em.Error: interpreter stdout proxy lost" + em.Interpreter._wasProxyInstalled = False + + def test_rmw_extension(self): + plugins = list_plugins() + rmw_plugin = plugins['rmw'] + + p = rmw_plugin() + self.assertTrue(plugin_load_parser_correctly(rmw_plugin)) + + mock_cliargs = {'rmw': ['cyclonedds']} + dig = DockerImageGenerator([rmw_plugin()], mock_cliargs, 'ros:rolling') + self.assertEqual(dig.build(), 0) + self.assertEqual(dig.run(command='dpkg -l ros-rolling-rmw-cyclonedds-cpp'), 0) + self.assertIn('-e RMW_IMPLEMENTATION=rmw_cyclonedds_cpp', dig.generate_docker_cmd('', mode='dry-run')) + dig.clear_image() diff --git a/test/test_ulimit.py b/test/test_ulimit.py new file mode 100644 index 00000000..4db8a6d4 --- /dev/null +++ b/test/test_ulimit.py @@ -0,0 +1,100 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +import unittest +from argparse import ArgumentTypeError + +from rocker.ulimit_extension import Ulimit + + +class UlimitTest(unittest.TestCase): + """Unit tests for the Ulimit class.""" + + def setUp(self): + self._instance = Ulimit() + + def _is_arg_translation_ok(self, mock_cliargs, expected): + is_ok = False + message_string = "" + try: + docker_args = self._instance.get_docker_args( + {self._instance.get_name(): [mock_cliargs]}) + is_ok = docker_args == expected + message_string = f"Expected: '{expected}', got: '{docker_args}'" + except ArgumentTypeError: + message_string = "Incorrect argument format" + return (is_ok, message_string) + + def test_args_single_soft(self): + """Test single soft limit argument.""" + mock_cliargs = ["rtprio=99"] + expected = " --ulimit rtprio=99" + self.assertTrue(*self._is_arg_translation_ok(mock_cliargs, expected)) + + def test_args_multiple_soft(self): + """Test multiple soft limit arguments.""" + mock_cliargs = ["rtprio=99", "memlock=102400"] + expected = " --ulimit rtprio=99 --ulimit memlock=102400" + self.assertTrue(*self._is_arg_translation_ok(mock_cliargs, expected)) + + def test_args_single_hard(self): + """Test single hard limit argument.""" + mock_cliargs = ["nofile=1024:524288"] + expected = " --ulimit nofile=1024:524288" + self.assertTrue(*self._is_arg_translation_ok(mock_cliargs, expected)) + + def test_args_multiple_hard(self): + """Test multiple hard limit arguments.""" + mock_cliargs = ["nofile=1024:524288", "rtprio=90:99"] + expected = " --ulimit nofile=1024:524288 --ulimit rtprio=90:99" + self.assertTrue(*self._is_arg_translation_ok(mock_cliargs, expected)) + + def test_args_multiple_mix(self): + """Test multiple mixed limit arguments.""" + mock_cliargs = ["rtprio=99", "memlock=102400", "nofile=1024:524288"] + expected = " --ulimit rtprio=99 --ulimit memlock=102400 --ulimit nofile=1024:524288" + self.assertTrue(*self._is_arg_translation_ok(mock_cliargs, expected)) + + def test_args_wrong_single_soft(self): + """Test if single soft limit argument is wrong.""" + mock_cliargs = ["rtprio99"] + expected = " --ulimit rtprio99" + self.assertFalse(*self._is_arg_translation_ok(mock_cliargs, expected)) + + def test_args_wrong_multiple_soft(self): + """Test if multiple soft limit arguments are wrong.""" + mock_cliargs = ["rtprio=99", "memlock102400"] + expected = " --ulimit rtprio=99 --ulimit memlock=102400" + self.assertFalse(*self._is_arg_translation_ok(mock_cliargs, expected)) + + def test_args_wrong_single_hard(self): + """Test if single hard limit arguments are wrong.""" + mock_cliargs = ["nofile=1024:524288:"] + expected = " --ulimit nofile=1024:524288" + self.assertFalse(*self._is_arg_translation_ok(mock_cliargs, expected)) + + def test_args_wrong_multiple_hard(self): + """Test if multiple hard limit arguments are wrong.""" + mock_cliargs = ["nofile1024524288", "rtprio=90:99"] + expected = " --ulimit nofile=1024:524288 --ulimit rtprio=90:99" + self.assertFalse(*self._is_arg_translation_ok(mock_cliargs, expected)) + + def test_args_wrong_multiple_mix(self): + """Test if multiple mixed limit arguments are wrong.""" + mock_cliargs = ["rtprio=:", "memlock102400", "nofile1024:524288:"] + expected = " --ulimit rtprio=99 --ulimit memlock=102400 --ulimit nofile=1024:524288" + self.assertFalse(*self._is_arg_translation_ok(mock_cliargs, expected)) diff --git a/test/test_volume.py b/test/test_volume.py index d9f4fda5..571599bc 100644 --- a/test/test_volume.py +++ b/test/test_volume.py @@ -45,19 +45,19 @@ def test_args_single(self): """Passing source path""" arg = [[self._curr_path]] expected = [['{}:{}'.format(self._curr_path, self._curr_path)]] - mock_cliargs = {Volume.name: arg} + mock_cliargs = {Volume.get_name(): arg} self._test_equals_args(mock_cliargs, expected) def test_args_twopaths(self): """Passing source path, dest path""" arg = ["{}:{}".format(self._curr_path, self._virtual_path)] - mock_cliargs = {Volume.name: [arg]} + mock_cliargs = {Volume.get_name(): [arg]} self._test_equals_args(mock_cliargs, arg) def test_args_twopaths_opt(self): """Passing source path, dest path, and Docker's volume option""" arg = ["{}:{}:ro".format(self._curr_path, self._virtual_path)] - mock_cliargs = {Volume.name: [arg]} + mock_cliargs = {Volume.get_name(): [arg]} self._test_equals_args(mock_cliargs, arg) def test_args_two_volumes(self): @@ -65,5 +65,5 @@ def test_args_two_volumes(self): arg_first = ["{}:{}:ro".format(self._curr_path, self._virtual_path)] arg_second = ["/tmp:{}".format(os.path.join(self._virtual_path, "tmp"))] args = [arg_first, arg_second] - mock_cliargs = {Volume.name: args} + mock_cliargs = {Volume.get_name(): args} self._test_equals_args(mock_cliargs, args)