pax_global_header00006660000000000000000000000064152101166230014507gustar00rootroot0000000000000052 comment=0f6537e9cfa38f6035ac57c1716b6d55dbaf3ca4 podman-compose-1.6.0/000077500000000000000000000000001521011662300144345ustar00rootroot00000000000000podman-compose-1.6.0/.codespellignore000066400000000000000000000000111521011662300176030ustar00rootroot00000000000000assertIn podman-compose-1.6.0/.codespellrc000066400000000000000000000002031521011662300167270ustar00rootroot00000000000000[codespell] skip = .git,*.pdf,*.svg,requirements.txt,test-requirements.txt # poped - loved variable name ignore-words-list = poped podman-compose-1.6.0/.coveragerc000066400000000000000000000000251521011662300165520ustar00rootroot00000000000000[run] parallel=True podman-compose-1.6.0/.editorconfig000066400000000000000000000004141521011662300171100ustar00rootroot00000000000000root = true [*] indent_style = space indent_size = tab tab_width = 4 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true max_line_length = 100 [*.{yml,yaml}] indent_style = space indent_size = 2 [*.py] indent_style = space podman-compose-1.6.0/.github/000077500000000000000000000000001521011662300157745ustar00rootroot00000000000000podman-compose-1.6.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001521011662300201575ustar00rootroot00000000000000podman-compose-1.6.0/.github/ISSUE_TEMPLATE/bug_report.md000066400000000000000000000024541521011662300226560ustar00rootroot00000000000000--- name: Bug report about: Create a report to help us improve title: '' labels: bug assignees: '' --- **Describe the bug** A clear and concise description of what the bug is. Please make sure it's not a bug in podman (in that case report it to podman) or your understanding of docker-compose or how rootless containers work (for example, it's normal for rootless container not to be able to listen for port less than 1024 like 80) **To Reproduce** Steps to reproduce the behavior: 1. what is the content of the current working directory (ex. `docker-compose.yml`, `.env`, `Dockerfile`, ...etc.) 2. what is the sequence of commands you typed please use [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example) for example give me a small busybox-based compose yaml **Expected behavior** A clear and concise description of what you expected to happen. **Actual behavior** What is the behavior you actually got and that should not happen. **Output** ``` $ podman-compose version using podman version: 3.4.0 podman-compose version 0.1.7dev podman --version podman version 3.4.0 $ podman-compose up ... ``` **Environment:** - OS: Linux / WSL / Mac - podman version: - podman compose version: (git hex) **Additional context** Add any other context about the problem here. podman-compose-1.6.0/.github/ISSUE_TEMPLATE/feature_request.md000066400000000000000000000011341521011662300237030ustar00rootroot00000000000000--- name: Feature request about: Suggest an idea for this project title: '' labels: enhancement assignees: '' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. podman-compose-1.6.0/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000011311521011662300215710ustar00rootroot00000000000000 ## Contributor Checklist: Please make sure to read development guidelines in CONTRIBUTING.md. Pull requests that do not follow the guidelines WILL TAKE LONGER TO REVIEW as the first review comment will be to follow these guidelines. If this PR adds a new feature that improves compatibility with docker-compose, please add a link to the exact part of compose spec that the PR touches. For any user-visible change please add a release note to newsfragments directory, e.g. newsfragments/my_feature.feature. See newsfragments/README.txt for more details. All changes require additional unit tests. podman-compose-1.6.0/.github/dependabot.yml000066400000000000000000000001661521011662300206270ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" podman-compose-1.6.0/.github/workflows/000077500000000000000000000000001521011662300200315ustar00rootroot00000000000000podman-compose-1.6.0/.github/workflows/codespell.yml000066400000000000000000000005631521011662300225320ustar00rootroot00000000000000--- name: Codespell on: push: pull_request: permissions: contents: read jobs: codespell: name: Check for spelling errors runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 - name: Codespell uses: codespell-project/actions-codespell@v2 with: ignore_words_file: .codespellignore podman-compose-1.6.0/.github/workflows/release.yml000066400000000000000000000011541521011662300221750ustar00rootroot00000000000000name: Build and Release on: push: tags: - 'v*.*.*' jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - name: Build binary run: | mkdir -p release/ docker build -t podman-compose-bin -v "$PWD/release:/result" . mv "$PWD/release/podman-compose" "$PWD/release/podman-compose-linux-x86" - name: Upload release asset uses: softprops/action-gh-release@v3 with: files: ./release/podman-compose-linux-x86 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} podman-compose-1.6.0/.github/workflows/static-checks.yml000066400000000000000000000014161521011662300233030ustar00rootroot00000000000000name: Static checks on: - push - pull_request jobs: static-checks: runs-on: ubuntu-latest container: image: docker.io/library/python:3.11-bookworm # cgroupns needed to address the following error: # write /sys/fs/cgroup/cgroup.subtree_control: operation not supported options: --privileged --cgroupns=host steps: - uses: actions/checkout@v6 - name: Install dependencies run: | set -e pip install -r test-requirements.txt - name: Analysing the code using ruff run: | set -e ruff format --check ruff check - name: Analysing the code using mypy run: | set -e mypy . - name: Analysing the code with pylint run: | pylint podman_compose.py podman-compose-1.6.0/.github/workflows/test.yml000066400000000000000000000166731521011662300215500ustar00rootroot00000000000000name: Tests on: workflow_dispatch: push: pull_request: jobs: test: strategy: fail-fast: false matrix: conf: [ {dist: bookworm, python: '3.9', podman: '4.3.1', mode: 'root'}, {dist: bookworm, python: '3.9', podman: '4.9.5', mode: 'root'}, {dist: bookworm, python: '3.9', podman: '5.0.3', mode: 'root'}, {dist: bookworm, python: '3.9', podman: '5.4.2', mode: 'root'}, {dist: bookworm, python: '3.10', podman: '4.3.1', mode: 'root'}, {dist: bookworm, python: '3.10', podman: '4.9.5', mode: 'root'}, {dist: bookworm, python: '3.10', podman: '5.0.3', mode: 'root'}, {dist: bookworm, python: '3.10', podman: '5.4.2', mode: 'root'}, {dist: bookworm, python: '3.11', podman: '4.3.1', mode: 'root'}, {dist: bookworm, python: '3.11', podman: '4.9.5', mode: 'root'}, {dist: bookworm, python: '3.11', podman: '5.0.3', mode: 'root'}, {dist: bookworm, python: '3.11', podman: '5.4.2', mode: 'root'}, {dist: bookworm, python: '3.12', podman: '4.3.1', mode: 'root'}, {dist: bookworm, python: '3.12', podman: '4.9.5', mode: 'root'}, {dist: bookworm, python: '3.12', podman: '5.0.3', mode: 'root'}, {dist: bookworm, python: '3.12', podman: '5.4.2', mode: 'root'}, {dist: bookworm, python: '3.13', podman: '4.3.1', mode: 'root'}, {dist: bookworm, python: '3.13', podman: '4.9.5', mode: 'root'}, {dist: bookworm, python: '3.13', podman: '5.0.3', mode: 'root'}, {dist: bookworm, python: '3.13', podman: '5.4.2', mode: 'root'}, {dist: trixie, python: '3.9', podman: '5.7.1', mode: 'root'}, {dist: trixie, python: '3.9', podman: '5.7.1', mode: 'rootless'}, {dist: trixie, python: '3.10', podman: '5.7.1', mode: 'root'}, {dist: trixie, python: '3.10', podman: '5.7.1', mode: 'rootless'}, {dist: trixie, python: '3.11', podman: '5.7.1', mode: 'root'}, {dist: trixie, python: '3.11', podman: '5.7.1', mode: 'rootless'}, {dist: trixie, python: '3.12', podman: '5.7.1', mode: 'root'}, {dist: trixie, python: '3.12', podman: '5.7.1', mode: 'rootless'}, {dist: trixie, python: '3.13', podman: '5.7.1', mode: 'root'}, {dist: trixie, python: '3.13', podman: '5.7.1', mode: 'rootless'}, ] runs-on: ubuntu-latest container: image: "docker.io/library/python:${{ matrix.conf.python }}-${{ matrix.conf.dist }}" # cgroupns needed to address the following error: # write /sys/fs/cgroup/cgroup.subtree_control: operation not supported options: --privileged --cgroupns=host steps: - uses: actions/checkout@v6 - name: Install podman-4.3.1 if: matrix.conf.podman == '4.3.1' run: | set -e apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y podman - name: Install dependencies for podman-4.9.5 if: matrix.conf.podman == '4.9.5' shell: bash run: | DEBIAN_FRONTEND=noninteractive apt update -y && apt install -y buildah BASE_URL="https://github.com/p12tic/podman-compose-test-data/raw/refs/heads/master/deb_files" DEB_FILES=( "${BASE_URL}/deb-files-podman-4.9.5/podman_4.9.5+compose1-1_amd64.deb" "${BASE_URL}/deb-files-crun-1-21/crun_1.21-1_amd64.deb" ) TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT for url in "${DEB_FILES[@]}"; do # strip everything up to the last slash filename=${url##*/} echo "Downloading $url ..." curl -fsSL -o "$TMPDIR/$filename" "$url" done echo "Installing packages ..." apt-get update -qq apt-get install -y -qq "$TMPDIR"/*.deb - name: Install dependencies for podman-5.0.3 if: matrix.conf.podman == '5.0.3' shell: bash run: | DEBIAN_FRONTEND=noninteractive apt update -y && apt install -y buildah BASE_URL="https://github.com/p12tic/podman-compose-test-data/raw/refs/heads/master/deb_files" DEB_FILES=( "${BASE_URL}/deb-files-podman-5.0.3/podman_5.0.3+compose1-1_amd64.deb" "${BASE_URL}/deb-files-crun-1-21/crun_1.21-1_amd64.deb" ) TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT for url in "${DEB_FILES[@]}"; do # strip everything up to the last slash filename=${url##*/} echo "Downloading $url ..." curl -fsSL -o "$TMPDIR/$filename" "$url" done echo "Installing packages ..." apt-get update -qq apt-get install -y -qq "$TMPDIR"/*.deb - name: Install dependencies for podman-5.4.2 if: matrix.conf.podman == '5.4.2' shell: bash run: | DEBIAN_FRONTEND=noninteractive apt update -y && apt install -y buildah BASE_URL="https://github.com/p12tic/podman-compose-test-data/raw/refs/heads/master/deb_files" DEB_FILES=( "${BASE_URL}/deb-files-podman-5.4.2/podman_5.4.2+composetest-1_amd64.deb" "${BASE_URL}/deb-files-crun-1-21/crun_1.21-1_amd64.deb" "${BASE_URL}/deb-files-netavark-1-14/netavark_1.14.0-1_amd64.deb" "${BASE_URL}/deb-files-aardvark-dns-1-14/aardvark-dns_1.14.0-1_amd64.deb" ) TMPDIR=$(mktemp -d) trap 'rm -rf "$TMPDIR"' EXIT for url in "${DEB_FILES[@]}"; do # strip everything up to the last slash filename=${url##*/} echo "Downloading $url ..." curl -fsSL -o "$TMPDIR/$filename" "$url" done echo "Installing packages ..." apt-get update -qq apt-get install -y -qq "$TMPDIR"/*.deb - name: Install dependencies for podman-5.7.1 if: matrix.conf.podman == '5.7.1' shell: bash run: | DEBIAN_FRONTEND=noninteractive apt update -y && apt install -y crun netavark aardvark-dns BASE_URL="https://github.com/p12tic/podman-compose-test-data/raw/refs/heads/master/deb_files" DEB_FILE="${BASE_URL}/deb-files-podman-5.7.1/podman_5.7.1+compose1-1_amd64.deb" echo "Downloading ${DEB_FILE##*/} ..." curl -fsSLO "$DEB_FILE" echo "Installing package ..." apt-get install -y ./podman_5.7.1+compose1-1_amd64.deb - name: Setup root/rootless run: | set -e if [ "${{ matrix.conf.mode }}" = "rootless" ]; then useradd -m myuser echo "USER_EXEC_CMD=su myuser" >> $GITHUB_ENV chown -R myuser:myuser . else echo "USER_EXEC_CMD=/bin/bash" >> $GITHUB_ENV fi - name: Setup virtualenv, install other test dependencies run: | $USER_EXEC_CMD -c " set -e python3 -m venv .venv . .venv/bin/activate pip install --upgrade pip pip install -r requirements.txt -r test-requirements.txt " - name: Run integration tests run: | $USER_EXEC_CMD -c " set -e . .venv/bin/activate python -m unittest discover -v tests/integration " env: TESTS_DEBUG: 1 - name: Run unit tests run: | $USER_EXEC_CMD -c " set -e . .venv/bin/activate coverage run --source podman_compose -m unittest discover tests/unit " - name: Report coverage run: | chmod o+w "$GITHUB_STEP_SUMMARY" $USER_EXEC_CMD -c " set -e . .venv/bin/activate coverage combine coverage report --format=markdown | tee -a $GITHUB_STEP_SUMMARY " podman-compose-1.6.0/.gitignore000066400000000000000000000023531521011662300164270ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python /build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ .idea/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ test-compose.yaml test-compose-?.yaml # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .vscode podman-compose-1.6.0/.pre-commit-config.yaml000066400000000000000000000006161521011662300207200ustar00rootroot00000000000000default_install_hook_types: [pre-commit, commit-msg] repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.14.8 hooks: - id: ruff-check - id: ruff-format - repo: https://github.com/codespell-project/codespell rev: v2.2.5 hooks: - id: codespell - repo: https://github.com/gklein/check_signoff rev: v1.0.5 hooks: - id: check-signoff podman-compose-1.6.0/.pylintrc000066400000000000000000000020001521011662300162710ustar00rootroot00000000000000[MESSAGES CONTROL] # C0111 missing-docstring: missing-class-docstring, missing-function-docstring, missing-method-docstring, missing-module-docstring # consider-using-with: we need it for color formatter pipe disable=too-many-lines,too-many-branches,too-many-locals,too-many-statements,too-many-arguments,too-many-instance-attributes,fixme,multiple-statements,missing-docstring,line-too-long,consider-using-f-string,consider-using-with,unnecessary-lambda-assignment,broad-exception-caught # allow _ for ignored variables # allow generic names like a,b,c and i,j,k,l,m,n and x,y,z # allow k,v for key/value # allow e for exceptions, it for iterator, ix for index # allow ip for ip address # allow w,h for width, height # allow op for operation/operator/opcode # allow t, t0, t1, t2, and t3 for time # allow dt for delta time # allow db for database # allow ls for list # allow p for pipe # allow ex for examples, exists ..etc good-names=_,a,b,c,dt,db,e,f,fn,fd,i,j,k,v,kv,kw,l,m,n,ls,t,t0,t1,t2,t3,w,h,x,y,z,it,ix,ip,op,p,ex podman-compose-1.6.0/CODE-OF-CONDUCT.md000066400000000000000000000003151521011662300170660ustar00rootroot00000000000000## The Podman Compose Project Community Code of Conduct The Podman Compose project follows the [Containers Community Code of Conduct](https://github.com/containers/common/blob/master/CODE-OF-CONDUCT.md). podman-compose-1.6.0/CONTRIBUTING.md000066400000000000000000000106771521011662300167000ustar00rootroot00000000000000# Contributing to podman-compose ## Development guidelines ### Always run tests All tests must pass on each commit to help bisecting. If you're doing TDD, commit the test afterwards. The following is the set of tests to run: ``` ruff format ruff check --fix mypy . pylint podman_compose.py python -m unittest discover tests/unit python -m unittest discover -v tests/integration # takes a while to run ``` ### Committing guidelines Add one change per commit. If you're refactoring and adding functionality, this should be at least 2 commits. If you want to add `and` to commit message - this is good indication that commits needed splitting. Commits must be complete. If there are commits that only fix previous commits, they should be squashed together. Otherwise they just confuse the reviewer. Do not add `feat:` or `fix:` prefixes to commit messages. Commits require a `Signed-off-by` message ([guide](https://github.com/containers/common/blob/main/CONTRIBUTING.md#sign-your-prs)). If you forget to add this message, run `git rebase main --signoff`. ### PR description If PR adds a new feature that improves compatibility with docker-compose, please add a link to the exact part of compose spec that the PR touches. ### Release notes If your change is user-facing - fixing a bug or adding a new feature, then a release note must be added to newsfragment/ directory. Check out docs/Changelog-1.4.0.md for examples of how a release note should look like. ### Tests All changes require tests. Use `nopush/podman-compose-test` image in integration test dockerfiles. This is to reduce the number of pulls Github CI does during a test run. ## Development environment setup 1. Fork the project repository and clone it: ```shell $ git clone https://github.com/USERNAME/podman-compose.git $ cd podman-compose ``` 2. Create a Python virtual environment and install project requirements. Example using python builtin `venv` module: ```shell $ python3 -m venv .venv $ . .venv/bin/activate $ pip install '.[devel]' ``` 3. (OPTIONAL) Install `pre-commit` git hook scripts (https://pre-commit.com/#3-install-the-git-hook-scripts): ```shell $ pre-commit install ``` ## Adding new commands To add a command, you need to add a function that is decorated with `@cmd_run`. The decorated function must be declared `async` and should accept two arguments: The compose instance and the command-specific arguments (resulting from Python's `argparse` package). In this function, you can run Podman (e.g. `await compose.podman.run(['inspect', 'something'])`), access `compose.pods`, `compose.containers` etc. Here is an example: ```python @cmd_run(podman_compose, 'build', 'build images defined in the stack') async def compose_build(compose, args): await compose.podman.run(['build', 'something']) ``` ## Command arguments parsing To add arguments to be parsed by a command, you need to add a function that is decorated with `@cmd_parse` which accepts the compose instance and the command's name (as a string list or as a single string). The decorated function should accept a single argument: An instance of `argparse`. In this function, you can call `parser.add_argument()` to add a new argument to the command. Note you can add such a function multiple times. Here is an example: ```python @cmd_parse(podman_compose, 'build') def compose_build_parse(parser): parser.add_argument("--pull", help="attempt to pull a newer version of the image", action='store_true') parser.add_argument("--pull-always", help="Attempt to pull a newer version of the image, " "raise an error even if the image is present locally.", action='store_true') ``` NOTE: `@cmd_parse` should be after `@cmd_run`. ## Calling a command from another one If you need to call `podman-compose down` from `podman-compose up`, do something like: ```python @cmd_run(podman_compose, 'up', 'up desc') async def compose_up(compose, args): await compose.commands['down'](compose, args) # or await compose.commands['down'](argparse.Namespace(foo=123)) ``` ## Missing Commands (help needed) ``` bundle Generate a Docker bundle from the Compose file create Create services events Receive real time events from containers images List images rm Remove stopped containers scale Set number of containers for a service top Display the running processes ``` podman-compose-1.6.0/Dockerfile000066400000000000000000000012531521011662300164270ustar00rootroot00000000000000# Use a base image with necessary build tools FROM python:3.11-slim AS builder # Install required packages for building RUN apt-get update && apt-get install -y \ gcc \ musl-dev \ build-essential \ python3-dev \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* # Set the working directory WORKDIR /app # Copy the application code COPY . . # Install PyInstaller RUN pip install pyinstaller RUN pip install -r requirements.txt # Create a binary with PyInstaller RUN pyinstaller --onefile --clean podman_compose.py # Create /result dir in case it is not mounted RUN mkdir -p /result # Export binary RUN cp /app/dist/podman_compose /result/podman-compose podman-compose-1.6.0/LICENSE000066400000000000000000000432541521011662300154510ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Ty Coon, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. podman-compose-1.6.0/README.md000066400000000000000000000104521521011662300157150ustar00rootroot00000000000000# Podman Compose ## [![Tests](https://github.com/containers/podman-compose/actions/workflows/test.yml/badge.svg)](https://github.com/containers/podman-compose/actions/workflows/test.yml) An implementation of [Compose Spec](https://compose-spec.io/) with [Podman](https://podman.io/) backend. This project focuses on: * rootless * daemon-less process model, we directly execute podman, no running daemon. This project only depends on: * `podman` * [podman dnsname plugin](https://github.com/containers/dnsname): It is usually found in the `podman-plugins` or `podman-dnsname` distro packages, those packages are not pulled by default and you need to install them. This allows containers to be able to resolve each other if they are on the same CNI network. This is not necessary when podman is using netavark as a network backend. * Python 3.9 or newer * [PyYAML](https://pyyaml.org/) * [python-dotenv](https://pypi.org/project/python-dotenv/) And it's formed as a single Python file script that you can drop into your PATH and run. ## References: * [spec.md](https://github.com/compose-spec/compose-spec/blob/master/spec.md) * [docker-compose compose-file-v3](https://docs.docker.com/compose/compose-file/compose-file-v3/) * [docker-compose compose-file-v2](https://docs.docker.com/compose/compose-file/compose-file-v2/) ## Alternatives As in [this article](https://fedoramagazine.org/use-docker-compose-with-podman-to-orchestrate-containers-on-fedora/) you can setup a `podman.socket` and use unmodified `docker-compose` that talks to that socket but in this case you lose the process-model (ex. `docker-compose build` will send a possibly large context tarball to the daemon) For production-like single-machine containerized environment consider - [k3s](https://k3s.io) | [k3s github](https://github.com/rancher/k3s) - [MiniKube](https://minikube.sigs.k8s.io/) For the real thing (multi-node clusters) check any production OpenShift/Kubernetes distribution like [OKD](https://www.okd.io/). ## Versions If you have legacy version of `podman` (before 3.1.0) you might need to stick with legacy `podman-compose` `0.1.x` branch. The legacy branch 0.1.x uses mappings and workarounds to compensate for rootless limitations. Modern podman versions (>=3.4) do not have those limitations, and thus you can use latest and stable 1.x branch. If you are upgrading from `podman-compose` version `0.1.x` then we no longer have global option `-t` to set mapping type like `hostnet`. If you desire that behavior, pass it the standard way like `network_mode: host` in the YAML. ## Installation ### Pip Install the latest stable version from PyPI: ```bash pip3 install podman-compose ``` pass `--user` to install inside a regular user's home without being root. Or latest development version from GitHub: ```bash pip3 install https://github.com/containers/podman-compose/archive/main.tar.gz ``` ### Package repositories podman-compose is available from the following package repositories: Debian: ```bash sudo apt install podman-compose ``` Fedora (starting from f31) repositories: ```bash sudo dnf install podman-compose ``` Homebrew: ```bash brew install podman-compose ``` ### Generate binary using docker/podman locally This script will download the repo, generate the binary using [this Dockerfile](https://github.com/containers/podman-compose/blob/main/Dockerfile), and place the binary in the directory where you called this script. ```bash sh -c "$(curl -sSL https://raw.githubusercontent.com/containers/podman-compose/main/scripts/download_and_build_podman-compose.sh)" ``` ### Manual ```bash curl -o /usr/local/bin/podman-compose https://raw.githubusercontent.com/containers/podman-compose/main/podman_compose.py chmod +x /usr/local/bin/podman-compose ``` or inside your home ```bash curl -o ~/.local/bin/podman-compose https://raw.githubusercontent.com/containers/podman-compose/main/podman_compose.py chmod +x ~/.local/bin/podman-compose ``` ## Tests podman-compose is tested via unit and integration tests. Unit tests can be run via the following: ```shell python3 -m unittest discover tests/unit ``` Integration tests can be run via the following: ```shell python3 -m unittest discover tests/integration ``` # Contributing guide If you are a user or a developer and want to contribute please check the [CONTRIBUTING](CONTRIBUTING.md) section podman-compose-1.6.0/RELEASING.md000066400000000000000000000027121521011662300162710ustar00rootroot00000000000000Creating a release ================== This file contains instructions for maintainers on how to release new versions of podman-compose. Step 1: Initialize variables for subsequent steps ------------------------------------------------- ``` export VERSION=1.2.3 ``` Step 2: Release notes PR ------------------------ Open a new branch (e.g. `release`) and run the following: ``` ./scripts/make_release_notes.sh $VERSION ``` This collects the release notes using the `towncrier` tool and then commits the result. This step is done as a PR so that CI can check for spelling errors and similar issues. Certain file names are not properly supported by the `towncrier` tool and it ignores them. Check `newsfragments` directory for any forgotten release notes Step 3: Merge the release notes PR ---------------------------------- Step 4: Perform actual release ------------------------------ Pull the merge commit created on the `main` branch during the step 2. Then run: ``` ./scripts/make_release.sh $VERSION ``` This will create release commit, tag and push everything. Step 5: Create a release on Github ---------------------------------- The release notes must be added manually by drafting a release on the GitHub UI at https://github.com/containers/podman-compose/releases. Step 6: Upload to Pypi ---------------------- Run the following command to build artifacts: ``` rm -rf build dist python3 -m build ``` Upload to Pypi ``` python3 -m twine upload dist/* ``` podman-compose-1.6.0/SECURITY.md000066400000000000000000000003751521011662300162320ustar00rootroot00000000000000## Security and Disclosure Information Policy for the Podman Compose Project The Podman Compose Project follows the [Security and Disclosure Information Policy](https://github.com/containers/common/blob/master/SECURITY.md) for the Containers Projects. podman-compose-1.6.0/completion/000077500000000000000000000000001521011662300166055ustar00rootroot00000000000000podman-compose-1.6.0/completion/bash/000077500000000000000000000000001521011662300175225ustar00rootroot00000000000000podman-compose-1.6.0/completion/bash/podman-compose000066400000000000000000000273601521011662300223760ustar00rootroot00000000000000# Naming convention: # * _camelCase for function names # * snake_case for variable names # all functions will return 0 if they successfully complete the argument # (or establish there is no need or no way to complete), and something # other than 0 if that's not the case # complete arguments to global options _completeGlobalOptArgs() { # arguments to options that take paths as arguments: complete paths for el in ${path_arg_global_opts}; do if [[ ${prev} == ${el} ]]; then COMPREPLY=( $(compgen -f -- ${cur}) ) return 0 fi done # arguments to options that take generic arguments: don't complete for el in ${generic_arg_global_opts}; do if [[ ${prev} == ${el} ]]; then return 0 fi done return 1 } # complete root subcommands and options _completeRoot() { # if we're completing an option if [[ ${cur} == -* ]]; then COMPREPLY=( $(compgen -W "${global_opts}" -- ${cur}) ) return 0 fi # complete root commands COMPREPLY=( $(compgen -W "${root_commands}" -- ${cur}) ) return 0 } # complete names of Compose services _completeServiceNames() { # ideally we should complete service names, # but parsing the compose spec file in the # completion script is quite complex return 0 } # complete commands to run inside containers _completeCommand() { # we would need to complete commands to run inside # a container return 0 } # complete the arguments for `podman-compose up` and return 0 _completeUpArgs() { up_opts="${help_opts} -d --detach --no-color --quiet-pull --no-deps --force-recreate --always-recreate-deps --no-recreate --no-build --no-start --build --abort-on-container-exit -t --timeout -V --renew-anon-volumes --remove-orphans --scale --exit-code-from --pull --pull-always --build-arg --no-cache" if [[ ${prev} == "--scale" || ${prev} == "-t" || ${prev} == "--timeout" ]]; then return 0 elif [[ ${cur} == -* ]]; then COMPREPLY=( $(compgen -W "${up_opts}" -- ${cur}) ) return 0 else _completeServiceNames if [[ $? -eq 0 ]]; then return 0 fi return 0 fi } # complete the arguments for `podman-compose exec` and return 0 _completeExecArgs() { exec_opts="${help_opts} -d --detach --privileged -u --user -T --index -e --env -w --workdir" if [[ ${prev} == "-u" || ${prev} == "--user" || ${prev} == "--index" || ${prev} == "-e" || ${prev} == "--env" || ${prev} == "-w" || ${prev} == "--workdir" ]]; then return 0 elif [[ ${cur} == -* ]]; then COMPREPLY=( $(compgen -W "${exec_opts}" -- ${cur}) ) return 0 elif [[ ${comp_cword_adj} -eq 2 ]]; then # complete service name _completeServiceNames if [[ $? -eq 0 ]]; then return 0 fi elif [[ ${comp_cword_adj} -eq 3 ]]; then _completeCommand if [[ $? -eq 0 ]]; then return 0 fi return 0 fi } # complete the arguments for `podman-compose down` and return 0 _completeDownArgs() { down_opts="${help_opts} -v --volumes -t --timeout --remove-orphans" if [[ ${prev} == "-t" || ${prev} == "--timeout" ]]; then return 0 elif [[ ${cur} == -* ]]; then COMPREPLY=( $(compgen -W "${down_opts}" -- ${cur}) ) return 0 else _completeServiceNames if [[ $? -eq 0 ]]; then return 0 fi return 0 fi } # complete the arguments for `podman-compose build` and return 0 _completeBuildArgs() { build_opts="${help_opts} --pull --pull-always --build-arg --no-cache" if [[ ${prev} == "--build-arg" ]]; then return 0 elif [[ ${cur} == -* ]]; then COMPREPLY=( $(compgen -W "${build_opts}" -- ${cur}) ) return 0 else _completeServiceNames if [[ $? -eq 0 ]]; then return 0 fi return 0 fi } # complete the arguments for `podman-compose logs` and return 0 _completeLogsArgs() { logs_opts="${help_opts} -f --follow -l --latest -n --names --since -t --timestamps --tail --until" if [[ ${prev} == "--since" || ${prev} == "--tail" || ${prev} == "--until" ]]; then return 0 elif [[ ${cur} == -* ]]; then COMPREPLY=( $(compgen -W "${logs_opts}" -- ${cur}) ) return 0 else _completeServiceNames if [[ $? -eq 0 ]]; then return 0 fi return 0 fi } # complete the arguments for `podman-compose ps` and return 0 _completePsArgs() { ps_opts="${help_opts} -q --quiet" if [[ ${cur} == -* ]]; then COMPREPLY=( $(compgen -W "${ps_opts}" -- ${cur}) ) return 0 else return 0 fi } # complete the arguments for `podman-compose pull` and return 0 _completePullArgs() { pull_opts="${help_opts} --force-local" if [[ ${cur} == -* ]]; then COMPREPLY=( $(compgen -W "${pull_opts}" -- ${cur}) ) return 0 else return 0 fi } # complete the arguments for `podman-compose push` and return 0 _completePushArgs() { push_opts="${help_opts} --ignore-push-failures" if [[ ${cur} == -* ]]; then COMPREPLY=( $(compgen -W "${push_opts}" -- ${cur}) ) return 0 else _completeServiceNames if [[ $? -eq 0 ]]; then return 0 fi return 0 fi } # complete the arguments for `podman-compose restart` and return 0 _completeRestartArgs() { restart_opts="${help_opts} -t --timeout" if [[ ${prev} == "-t" || ${prev} == "--timeout" ]]; then return 0 elif [[ ${cur} == -* ]]; then COMPREPLY=( $(compgen -W "${restart_opts}" -- ${cur}) ) return 0 else _completeServiceNames if [[ $? -eq 0 ]]; then return 0 fi return 0 fi } # complete the arguments for `podman-compose stop` and return 0 _completeStopArgs() { stop_opts="${help_opts} -t --timeout" if [[ ${prev} == "-t" || ${prev} == "--timeout" ]]; then return 0 elif [[ ${cur} == -* ]]; then COMPREPLY=( $(compgen -W "${stop_opts}" -- ${cur}) ) return 0 else _completeServiceNames if [[ $? -eq 0 ]]; then return 0 fi return 0 fi } # complete the arguments for `podman-compose start` and return 0 _completeStartArgs() { start_opts="${help_opts}" if [[ ${cur} == -* ]]; then COMPREPLY=( $(compgen -W "${start_opts}" -- ${cur}) ) return 0 else _completeServiceNames if [[ $? -eq 0 ]]; then return 0 fi return 0 fi } # complete the arguments for `podman-compose run` and return 0 _completeRunArgs() { run_opts="${help_opts} -d --detach --privileged -u --user -T --index -e --env -w --workdir" if [[ ${prev} == "-u" || ${prev} == "--user" || ${prev} == "--index" || ${prev} == "-e" || ${prev} == "--env" || ${prev} == "-w" || ${prev} == "--workdir" ]]; then return 0 elif [[ ${cur} == -* ]]; then COMPREPLY=( $(compgen -W "${run_opts}" -- ${cur}) ) return 0 elif [[ ${comp_cword_adj} -eq 2 ]]; then # complete service name _completeServiceNames if [[ $? -eq 0 ]]; then return 0 fi elif [[ ${comp_cword_adj} -eq 3 ]]; then _completeCommand if [[ $? -eq 0 ]]; then return 0 fi fi } _podmanCompose() { cur="${COMP_WORDS[COMP_CWORD]}" prev="${COMP_WORDS[COMP_CWORD-1]}" root_commands="help version pull push build up down ps run exec start stop restart logs" # options to output help text (used as global and subcommand options) help_opts="-h --help" # global options that don't take additional arguments basic_global_opts="${help_opts} -v --no-ansi --no-cleanup --dry-run" # global options that take paths as arguments path_arg_global_opts="-f --file --podman-path" path_arg_global_opts_array=($arg_global_opts) # global options that take arguments that are not files generic_arg_global_opts="-p --project-name --podman-path --podman-args --podman-pull-args --podman-push-args --podman-build-args --podman-inspect-args --podman-run-args --podman-start-args --podman-stop-args --podman-rm-args --podman-volume-args" generic_arg_global_opts_array=($generic_arg_global_opts) # all global options that take arguments arg_global_opts="${path_arg_global_opts} ${generic_arg_global_opts}" arg_global_opts_array=($arg_global_opts) # all global options global_opts="${basic_global_opts} ${arg_global_opts}" chosen_root_command="" _completeGlobalOptArgs if [[ $? -eq 0 ]]; then return 0 fi # computing comp_cword_adj, which thruthfully tells us how deep in the subcommands tree we are # additionally, set the chosen_root_command if possible comp_cword_adj=${COMP_CWORD} if [[ ${COMP_CWORD} -ge 2 ]]; then skip_next="no" for el in ${COMP_WORDS[@]}; do # if the user has asked for help text there's no need to complete further if [[ ${el} == "-h" || ${el} == "--help" ]]; then return 0 fi if [[ ${skip_next} == "yes" ]]; then let "comp_cword_adj--" skip_next="no" continue fi if [[ ${el} == -* && ${el} != ${cur} ]]; then let "comp_cword_adj--" for opt in ${arg_global_opts_array[@]}; do if [[ ${el} == ${opt} ]]; then skip_next="yes" fi done elif [[ ${el} != ${cur} && ${el} != ${COMP_WORDS[0]} && ${chosen_root_command} == "" ]]; then chosen_root_command=${el} fi done fi if [[ ${comp_cword_adj} -eq 1 ]]; then _completeRoot # Given that we check the value of comp_cword_adj outside # of it, at the moment _completeRoot should always return # 0, this is just here in case changes are made. The same # will apply to similar functions below if [[ $? -eq 0 ]]; then return 0 fi fi case $chosen_root_command in up) _completeUpArgs if [[ $? -eq 0 ]]; then return 0 fi ;; down) _completeDownArgs if [[ $? -eq 0 ]]; then return 0 fi ;; exec) _completeExecArgs if [[ $? -eq 0 ]]; then return 0 fi ;; build) _completeBuildArgs if [[ $? -eq 0 ]]; then return 0 fi ;; logs) _completeLogsArgs if [[ $? -eq 0 ]]; then return 0 fi ;; ps) _completePsArgs if [[ $? -eq 0 ]]; then return 0 fi ;; pull) _completePullArgs if [[ $? -eq 0 ]]; then return 0 fi ;; push) _completePushArgs if [[ $? -eq 0 ]]; then return 0 fi ;; restart) _completeRestartArgs if [[ $? -eq 0 ]]; then return 0 fi ;; start) _completeStartArgs if [[ $? -eq 0 ]]; then return 0 fi ;; stop) _completeStopArgs if [[ $? -eq 0 ]]; then return 0 fi ;; run) _completeRunArgs if [[ $? -eq 0 ]]; then return 0 fi ;; esac } complete -F _podmanCompose podman-compose podman-compose-1.6.0/docs/000077500000000000000000000000001521011662300153645ustar00rootroot00000000000000podman-compose-1.6.0/docs/Changelog-1.1.0.md000066400000000000000000000031301521011662300202250ustar00rootroot00000000000000Version v1.1.0 (2024-04-17) =========================== Bug fixes --------- - Fixed support for values with equals sign in `-e` argument of `run` and `exec` commands. - Fixed duplicate arguments being emitted in `stop` and `restart` commands. - Removed extraneous debug output. `--verbose` flag has been added to preserve verbose output. - Links aliases are now added to service aliases. - Fixed image build process to use defined environment variables. - Empty list is now allowed to be `COMMAND` and `ENTRYPOINT`. - Environment files are now resolved relative to current working directory. - Exit code of container build is now preserved as return code of `build` command. New features ------------ - Added support for `uidmap`, `gidmap`, `http_proxy` and `runtime` service configuration keys. - Added support for `enable_ipv6` network configuration key. - Added `--parallel` option to support parallel pulling and building of images. - Implemented support for maps in `sysctls` container configuration key. - Implemented `stats` command. - Added `--no-normalize` flag to `config` command. - Added support for `include` global configuration key. - Added support for `build` command. - Added support to start containers with multiple networks. - Added support for `profile` argument. - Added support for starting podman in existing network namespace. - Added IPAM driver support. - Added support for file secrets being passed to `podman build` via `--secret` argument. - Added support for multiple networks with separately specified IP and MAC address. - Added support for `service.build.ulimits` when building image. podman-compose-1.6.0/docs/Changelog-1.2.0.md000066400000000000000000000031111521011662300202250ustar00rootroot00000000000000Version v1.2.0 (2024-06-26) =========================== Bug fixes --------- - Fixed handling of `--in-pod` argument. Previously it was hard to provide false value to it. - podman-compose no longer creates pods when registering systemd unit. - Fixed warning `RuntimeWarning: coroutine 'create_pods' was never awaited` - Fixed error when setting up IPAM network with default driver. - Fixed support for having list and dictionary `depends_on` sections in related compose files. - Fixed logging of failed build message. - Fixed support for multiple entries in `include` section. - Fixed environment variable precedence order. Changes ------- - `x-podman` dictionary in container root has been migrated to `x-podman.*` fields in container root. New features ------------ - Added support for `--publish` in `podman-compose run`. - Added support for Podman external root filesystem management (`--rootfs` option). - Added support for `podman-compose images` command. - Added support for `env_file` being configured via dictionaries. - Added support for enabling GPU access. - Added support for selinux in verbose mount specification. - Added support for `additional_contexts` section. - Added support for multi-line environment files. - Added support for passing contents of `podman-compose.yml` via stdin. - Added support for specifying the value for `--in-pod` setting in `podman-compose.yml` file. - Added support for environment secrets. Documentation ------------- - Added instructions on how to install podman-compose on Homebrew. - Added explanation that netavark is an alternative to dnsname plugin podman-compose-1.6.0/docs/Changelog-1.3.0.md000066400000000000000000000033031521011662300202310ustar00rootroot00000000000000Version 1.3.0 (2025-01-07) ========================== Bug fixes --------- - Fixed support for de-facto alternative `Dockerfile` names (e.g. `Containerfile`) - Fixed a bug that caused attempts to create already existing pods multiple times. - Fixed compatibility with docker-compose in how symlinks to docker-compose.yml are handled. - Fixed freeze caused by too long log lines without a newline. - Fixed support for `network_mode: none`. - Improved error detection by rejecting service definitions that contain both `network_mode` and `networks` keys, which is not allowed. Features -------- - Added support for build labels. - Added support for "platform" property in the build command. - Added support for "ssh" property in the build command. - Added support for cache_from and cache_to fields in build section. - Added support for honoring the condition in the depends_on section of the service, if stated. - Added `x-podman.no_hosts` setting to pass `--no-hosts` to podman run - Added support for compatibility with docker compose for default network behavior when no network defined in service. This is controlled via `default_net_behavior_compat` feature flag. - Added a way to get compatibility of default network names with docker compose. This is selected by setting `default_net_name_compat: true` on `x-podman` global dictionary. - Added support for the `device_cgroup_rules` property in services. - Added support for removing networks in `podman-compose down`. - Added support for network scoped service aliases. - Added support for network level `mac_address` attribute. - Added the ability to substitute variables with the environment of the service. Misc ---- - Declared compatibility with Python 3.13. podman-compose-1.6.0/docs/Changelog-1.4.0.md000066400000000000000000000037261521011662300202430ustar00rootroot00000000000000Version 1.4.0 (2025-05-10) ========================== Bug fixes --------- - Fixed handling of relative includes and extends in compose files - Fixed error when merging arguments in list and dictionary syntax - Fixed issue where short-lived containers could execute twice when using `up` in detached mode - Fixed `up` command hanging on Podman versions earlier than 4.6.0 - Fixed issue where `service_healthy` conditions weren't enforced during `up` command - Fixed support for the `--scale` flag - Fixed bug causing dependent containers to start despite `--no-deps` flag - Fixed port command behavior for dynamic host ports - Fixed interpolation of `COMPOSE_PROJECT_NAME` when set from top-level `name` in compose file - Fixed project name evaluation order to match compose spec - Fixed build context when using git URLs - Fixed `KeyError` when `down` is called with non-existent service - Skip `down` during `up` when no active containers exist - Fixed non-zero exit code on failure when using `up -d` - Fixed SIGINT handling during `up` command for graceful shutdown - Fixed `NotImplementedError` when interrupted on Windows Features -------- - Added `--quiet` flag to `config` command to suppress output - Added support for `pids_limit` and `deploy.resources.limits.pids` - Added `--abort-on-container-failure` option - Added `--rmi` argument to `down` command for image removal - Added support for `x-podman.disable-dns` to disable DNS plugin on defined networks - Added support for `x-podman.dns` to set DNS nameservers for defined networks - Improved file descriptor handling by no longer closing externally created descriptors. This allows descriptors created e.g. via systemd socket activation to be passed to containers. - Added support for `cpuset` configuration - Added support for `reset` and `override` tags when merging compose files - Added support for `x-podman.interface_name` to set network interface names - Added support for `x-podman.pod_args` to override default `--pod-args` podman-compose-1.6.0/docs/Changelog-1.4.1.md000066400000000000000000000002171521011662300202340ustar00rootroot00000000000000Version 1.4.1 (2025-06-05) ========================== Bug fixes --------- - Fixed relative host path resolution for volume bind mount source podman-compose-1.6.0/docs/Changelog-1.5.0.md000066400000000000000000000030771521011662300202430ustar00rootroot00000000000000Version 1.5.0 (2025-07-07) ========================== Bug fixes --------- - Fixed path to a local SSH key to be relative to the directory of compose file during build. - Fixed CMD healthchecks to run the given command directly and not use `/bin/sh -c`. - Fixed regression of dockerfile definition if current directory name ends with ".git". - Fixed exit code from `push` command. - Implemented short syntax for environment variables set in `.env` for compose.yml `environment:` section. - Fixed regression of log output including "text" in detached mode. - Implemented `up --no-recreate` to work as advertised. - Stack traces emitted due to YAML parse errors are now hidden. Features -------- - Added unregister command to remove systemd service registration (`podman-compose systemd -a unregister`) - Added new `docker_compose_compat` `x-podman` meta setting to enable all Docker Compose compatibility settings - Added new `name_separator_compat` `x-podman` setting to change name separator to hyphen, same as Docker Compose. - Added support for environment variable interpolation for YAML keys. - Added `io.podman.compose.service` label to created containers. It contains the same value as `com.docker.compose.service`. - Added relabel option to secret to make possible to read the secret file by the container process. - Added support for setting x-podman values using PODMAN_COMPOSE_* environment variables. - Added support to set `--route` option to `podman network create` via `x-podman.routes` key on network configuration. - Implemented support for custom pod names in `--in-pod`. podman-compose-1.6.0/docs/Changelog-1.6.0.md000066400000000000000000000045251521011662300202430ustar00rootroot00000000000000Version 1.6.0 (2026-06-03) ========================== Bug fixes --------- - Implemented volumes bind `create_host_path` option. - Implemented colored, service-name-only default for `logs` command. - Added `--no-color` flag for `podman-compose logs` command. - Cleaned up error messages when external network or volume is missing, including a suggested `podman network create` / `podman volume create` command. - Fixed crash on parsing empty top-level objects in compose file - Images that have names starting with `localhost/` will no longer be pulled when both image and build sections exist. - Fixed link resolution for mounted volume when volume source is a symlink. - Fixed `podman-compose down service` stops wrong dependents - Fixed the bug in parsing to add identify `extra-hosts` field in build parameters. - Fixed TypeError when parsing 'include' items that are formatted as detailed mappings (dictionaries). - Fixed shell error: `unknown flag: --policy`. - Fixed !reset and !override tags used used with extended compose file - Fixed freeze caused by too long log lines containing multibyte characters. - Fixed crash on merging named volumes with driver options into the uncustomized named volumes - Fixed `--pull` option in `build` and `up` command can not set to pull policy explicitly. - Implemented returning error code from `pull` command. - The `target` suboption on mount-type external secrets is now honored. - Added support for service level configuration change detection on up command - Fixed top-level `name` attribute interpolation not happening. Changes ------- - Better error message when probing for podman fails. - Renamed healthcheck flags to be consistent with Podman options. Features -------- - Implemented --wait and --wait-timeout for podman-compose up and start. - Added support for 'glob' mount type when defining volumes. - Added support for COMPOSE_PROFILES environment variable to enable profiles. - Added support for volume.type=image in services, for mounting files from container images. - Added support for nested variable interpolation - Images are now pull before container teardown on compose up, which reduce the downtime of services. - Added support for `environment` as a secret source for build secrets. - Added support for `service:service_name` contexts in `build.additional_contexts`. - Add support for `start_interval` option. podman-compose-1.6.0/docs/Extensions.md000066400000000000000000000230001521011662300200400ustar00rootroot00000000000000# Podman specific extensions to the docker-compose format Podman-compose supports the following extensions to the docker-compose format. These extensions are generally specified under fields with "x-podman" prefix in the compose file. ## Container management The following extension keys are available under container configuration: * `x-podman.uidmaps` - Run the container in a new user namespace using the supplied UID mapping. * `x-podman.gidmaps` - Run the container in a new user namespace using the supplied GID mapping. * `x-podman.rootfs` - Run the container without requiring any image management; the rootfs of the container is assumed to be managed externally. * `x-podman.no_hosts` - Run the container without creating /etc/hosts file For example, the following docker-compose.yml allows running a podman container with externally managed rootfs. ```yml version: "3" services: my_service: command: ["/bin/busybox"] x-podman.rootfs: "/path/to/rootfs" ``` For explanations of these extensions, please refer to the [Podman Documentation](https://docs.podman.io/). ## Secrets The following extension keys are available under `secret` configuration: x-podman.relabel - Configure SELinux relabeling For example, the following configures custom-secret to use mount with private and unshared content. Only the current container can use a private volume. ```yml secrets: custom-secret: x-podman.relabel: Z ``` For explanations of these extensions, please refer to the [podman-run --volume documentation](https://docs.podman.io/en/latest/markdown/podman-run.1.html#volume-v-source-volume-host-dir-container-dir-options)). ## Network management The following extension keys are available under network configuration: * `x-podman.disable_dns` - Disable the DNS plugin for the network when set to 'true'. * `x-podman.dns` - Specifies a list of nameservers for the network. This cannot be used with x-podman.disable_dns`. For example, the following docker-compose.yml allows all containers on the same network to use the specified nameservers: ```yml version: "3" network: my_network: x-podman.dns: - "10.1.2.3" - "10.1.2.4" ``` * `x-podman.routes` - Specifies a list of additional routes for the network. This corresponds to `--route` option in `podman network create`. For example, the following docker-compose.yml blocks network connectivity to specified subnet from all containers on the network: ```yml version: "3" network: my_network: x-podman.routes: - "10.2.3.4,127.0.0.1" ``` For explanations of these extensions, please refer to the [Podman network create command Documentation](https://docs.podman.io/en/latest/markdown/podman-network-create.1.html). ## Per-network MAC-addresses Generic docker-compose files support specification of the MAC address on the container level. If the container has multiple network interfaces, the specified MAC address is applied to the first specified network. Podman-compose in addition supports the specification of MAC addresses on a per-network basis. This is done by adding a `x-podman.mac_address` key to the network configuration in the container. The value of the `x-podman.mac_address` key is the MAC address to be used for the network interface. Note that the [compose spec](https://github.com/compose-spec/compose-spec/blob/main/05-services.md#mac_address) now supports `mac_address` on the network level, so we recommend using the standard `mac_address` key for setting the MAC address. The `x-podman.mac_address` is still supported for backwards compatibility. Specifying a MAC address for the container and for individual networks at the same time is not supported. Example: ```yaml --- version: "3" networks: net0: driver: "bridge" ipam: config: - subnet: "192.168.0.0/24" net1: driver: "bridge" ipam: config: - subnet: "192.168.1.0/24" services: webserver: image: "busybox" command: ["/bin/busybox", "httpd", "-f", "-h", "/etc", "-p", "8001"] networks: net0: ipv4_address: "192.168.0.10" x-podman.mac_address: "02:aa:aa:aa:aa:aa" net1: ipv4_address: "192.168.1.10" mac_address: "02:bb:bb:bb:bb:bb" # mac_address is supported ``` ## Per-network interface name Using `x-podman.interface_name` within a container's network config you can specify the interface name inside the container. ## Podman-specific network modes Generic docker-compose supports the following values for `network-mode` for a container: - `bridge` - `host` - `none` - `service` - `container` In addition, podman-compose supports the following podman-specific values for `network-mode`: - `slirp4netns[:,...]` - `ns:` - `pasta[:,...]` - `private` The options to the network modes are passed to the `--network` option of the `podman create` command as-is. ## Podman-specific mount types Generic docker-compose supports the following values for mount `type`: - `volume` - `bind` - `tmpfs` In addition, podman-compose supports the following podman-specific values for mount `type`: - `glob` ## Docker Compose Compatibility podman-compose aims to be compatible with docker-compose, but there are some differences in behavior and features. The following sections describe how to enable compatibility with docker-compose and how to handle some of the differences. Compatibility settings can either be set explicitly as described below, or by setting the `docker_compose_compat` meta settings to `true` under the global `x-podman` key: ```yaml x-podman: docker_compose_compat: true ``` This will enable all compatibility settings described below, and is equivalent to setting each of them to `true`. This setting can also be changed by setting the `PODMAN_COMPOSE_DOCKER_COMPOSE_COMPAT` environment variable. ## Compatibility of name separators between docker-compose and podman-compose Currently, podman-compose is using underscores (`_` character) as a separator in names of containers, images, etc., while docker-compose has switched to hyphens (`-` character). This setting allows you to switch podman-compose to use hyphens as well. To enable compatibility between docker-compose and podman-compose, specify `name_separator_compat: true` under global `x-podman` key: ``` x-podman: name_separator_compat: true ``` By default `name_separator_compat` is `false`. This will change to `true` at some point and the setting will be removed. This setting can also be changed by setting `PODMAN_COMPOSE_NAME_SEPARATOR_COMPAT` environment variable. ## Compatibility of default network names between docker-compose and podman-compose Current versions of podman-compose may produce different default external network names than docker-compose under certain conditions. Specifically, docker-compose removes dashes (`-` character) from project name. To enable compatibility between docker-compose and podman-compose, specify `default_net_name_compat: true` under global `x-podman` key: ``` x-podman: default_net_name_compat: true ``` By default `default_net_name_compat` is `false`. This will change to `true` at some point and the setting will be removed. This setting can also be changed by setting `PODMAN_COMPOSE_DEFAULT_NET_NAME_COMPAT` environment variable. ## Compatibility of default network behavior between docker-compose and podman-compose When there is no network defined (neither network-mode nor networks) in service, The behavior of default network in docker-compose and podman-compose is different. | Top-level networks | podman-compose | docker-compose | | ------------------------------ | -------------------------- | -------------- | | No networks | default | default | | One network named net0 | net0 | default | | Two networks named net0, net1 | podman(`--network=bridge`) | default | | Contains network named default | default | default | To enable compatibility between docker-compose and podman-compose, specify `default_net_behavior_compat: true` under global `x-podman` key: ```yaml x-podman: default_net_behavior_compat: true ``` This setting can also be changed by setting `PODMAN_COMPOSE_DEFAULT_NET_BEHAVIOR_COMPAT` environment variable. ## Custom pods management Podman-compose can have containers in pods. This can be controlled by the extension key x-podman in_pod. It allows providing a custom value for --in-pod and is especially relevant when --userns has to be set. For example, the following docker-compose.yml allows using userns_mode by overriding the default value of --in-pod (unless it was specifically provided by "--in-pod=True" in command line interface). ```yml version: "3" services: cont: image: nopush/podman-compose-test userns_mode: keep-id:uid=1000 command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] x-podman: in_pod: false ``` This setting can also be changed by setting `PODMAN_COMPOSE_IN_POD` environment variable. It is also possible to override the default arguments for pod creation that are used when --pod-args is not passed on the command line: ```yml version: "3" services: cont: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] x-podman: pod_args: ["--infra=false", "--share=", "--cpus=1"] ``` When not set in docker-compose.yml or on the command line, the pod args default to `["--infra=false", "--share="]`. This setting can also be changed by setting `PODMAN_COMPOSE_POD_ARGS` environment variable. podman-compose-1.6.0/docs/Mappings.md000066400000000000000000000011141521011662300174610ustar00rootroot00000000000000# Overview * `1podfw` - create all containers in one pod (inter-container communication is done via `localhost`), doing port mapping in that pod * `1pod` - create all containers in one pod, doing port mapping in each container (does not work) * `identity` - no mapping * `hostnet` - use host network, and inter-container communication is done via host gateway and published ports * `cntnet` - create a container and use it via `--network container:name` (inter-container communication via `localhost`) * `publishall` - publish all ports to host (using `-P`) and communicate via gateway podman-compose-1.6.0/examples/000077500000000000000000000000001521011662300162525ustar00rootroot00000000000000podman-compose-1.6.0/examples/awx17/000077500000000000000000000000001521011662300172215ustar00rootroot00000000000000podman-compose-1.6.0/examples/awx17/README.md000066400000000000000000000023321521011662300205000ustar00rootroot00000000000000# AWX Compose The directory roles is taken from [here](https://github.com/ansible/awx/tree/17.1.0/installer/roles/local_docker) also look at https://github.com/ansible/awx/tree/17.1.0/tools/docker-compose ``` mkdir deploy awx17 ansible localhost \ -e host_port=8080 \ -e awx_secret_key='awx,secret.123' \ -e secret_key='awx,secret.123' \ -e admin_user='admin' \ -e admin_password='admin' \ -e pg_password='awx,123.' \ -e pg_username='awx' \ -e pg_database='awx' \ -e pg_port='5432' \ -e redis_image="docker.io/library/redis:6-alpine" \ -e postgres_data_dir="./data/pg" \ -e compose_start_containers=false \ -e dockerhub_base='docker.io/ansible' \ -e awx_image='docker.io/ansible/awx' \ -e awx_version='17.1.0' \ -e dockerhub_version='17.1.0' \ -e docker_deploy_base_path=$PWD/deploy \ -e docker_compose_dir=$PWD/awx17 \ -e awx_task_hostname=awx \ -e awx_web_hostname=awxweb \ -m include_role -a name=local_docker cp awx17/docker-compose.yml awx17/docker-compose.yml.orig sed -i -re "s#- \"$PWD/awx17/(.*):/#- \"./\1:/#" awx17/docker-compose.yml cd awx17 podman-compose run --rm --service-ports task awx-manage migrate --no-input podman-compose up -d ``` podman-compose-1.6.0/examples/awx17/roles/000077500000000000000000000000001521011662300203455ustar00rootroot00000000000000podman-compose-1.6.0/examples/awx17/roles/local_docker/000077500000000000000000000000001521011662300227665ustar00rootroot00000000000000podman-compose-1.6.0/examples/awx17/roles/local_docker/defaults/000077500000000000000000000000001521011662300245755ustar00rootroot00000000000000podman-compose-1.6.0/examples/awx17/roles/local_docker/defaults/main.yml000066400000000000000000000003711521011662300262450ustar00rootroot00000000000000--- dockerhub_version: "{{ lookup('file', playbook_dir + '/../VERSION') }}" awx_image: "awx" redis_image: "redis" postgresql_version: "12" postgresql_image: "postgres:{{postgresql_version}}" compose_start_containers: true upgrade_postgres: false podman-compose-1.6.0/examples/awx17/roles/local_docker/tasks/000077500000000000000000000000001521011662300241135ustar00rootroot00000000000000podman-compose-1.6.0/examples/awx17/roles/local_docker/tasks/compose.yml000066400000000000000000000042751521011662300263130ustar00rootroot00000000000000--- - name: Create {{ docker_compose_dir }} directory file: path: "{{ docker_compose_dir }}" state: directory - name: Create Redis socket directory file: path: "{{ docker_compose_dir }}/redis_socket" state: directory mode: 0777 - name: Create Docker Compose Configuration template: src: "{{ item.file }}.j2" dest: "{{ docker_compose_dir }}/{{ item.file }}" mode: "{{ item.mode }}" loop: - file: environment.sh mode: "0600" - file: credentials.py mode: "0600" - file: docker-compose.yml mode: "0600" - file: nginx.conf mode: "0600" - file: redis.conf mode: "0664" register: awx_compose_config - name: Render SECRET_KEY file copy: content: "{{ secret_key }}" dest: "{{ docker_compose_dir }}/SECRET_KEY" mode: 0600 register: awx_secret_key - block: - name: Remove AWX containers before migrating postgres so that the old postgres container does not get used docker_compose: project_src: "{{ docker_compose_dir }}" state: absent ignore_errors: true - name: Run migrations in task container shell: docker-compose run --rm --service-ports task awx-manage migrate --no-input args: chdir: "{{ docker_compose_dir }}" - name: Start the containers docker_compose: project_src: "{{ docker_compose_dir }}" restarted: "{{ awx_compose_config is changed or awx_secret_key is changed }}" register: awx_compose_start - name: Update CA trust in awx_web container command: docker exec awx_web '/usr/bin/update-ca-trust' when: awx_compose_config.changed or awx_compose_start.changed - name: Update CA trust in awx_task container command: docker exec awx_task '/usr/bin/update-ca-trust' when: awx_compose_config.changed or awx_compose_start.changed - name: Wait for launch script to create user wait_for: timeout: 10 delegate_to: localhost - name: Create Preload data command: docker exec awx_task bash -c "/usr/bin/awx-manage create_preload_data" when: create_preload_data|bool register: cdo changed_when: "'added' in cdo.stdout" when: compose_start_containers|bool podman-compose-1.6.0/examples/awx17/roles/local_docker/tasks/main.yml000066400000000000000000000006161521011662300255650ustar00rootroot00000000000000--- - name: Generate broadcast websocket secret set_fact: broadcast_websocket_secret: "{{ lookup('password', '/dev/null length=128') }}" run_once: true no_log: true when: broadcast_websocket_secret is not defined - import_tasks: upgrade_postgres.yml when: - postgres_data_dir is defined - pg_hostname is not defined - import_tasks: set_image.yml - import_tasks: compose.yml podman-compose-1.6.0/examples/awx17/roles/local_docker/tasks/set_image.yml000066400000000000000000000034761521011662300266050ustar00rootroot00000000000000--- - name: Manage AWX Container Images block: - name: Export Docker AWX image if it isn't local and there isn't a registry defined docker_image: name: "{{ awx_image }}" tag: "{{ awx_version }}" archive_path: "{{ awx_local_base_config_path|default('/tmp') }}/{{ awx_image }}_{{ awx_version }}.tar" when: inventory_hostname != "localhost" and docker_registry is not defined delegate_to: localhost - name: Set docker base path set_fact: docker_deploy_base_path: "{{ awx_base_path|default('/tmp') }}/docker_deploy" when: ansible_connection != "local" and docker_registry is not defined - name: Ensure directory exists file: path: "{{ docker_deploy_base_path }}" state: directory when: ansible_connection != "local" and docker_registry is not defined - name: Copy awx image to docker execution copy: src: "{{ awx_local_base_config_path|default('/tmp') }}/{{ awx_image }}_{{ awx_version }}.tar" dest: "{{ docker_deploy_base_path }}/{{ awx_image }}_{{ awx_version }}.tar" when: ansible_connection != "local" and docker_registry is not defined - name: Load awx image docker_image: name: "{{ awx_image }}" tag: "{{ awx_version }}" load_path: "{{ docker_deploy_base_path }}/{{ awx_image }}_{{ awx_version }}.tar" timeout: 300 when: ansible_connection != "local" and docker_registry is not defined - name: Set full image path for local install set_fact: awx_docker_actual_image: "{{ awx_image }}:{{ awx_version }}" when: docker_registry is not defined when: dockerhub_base is not defined - name: Set DockerHub Image Paths set_fact: awx_docker_actual_image: "{{ dockerhub_base }}/awx:{{ dockerhub_version }}" when: dockerhub_base is defined podman-compose-1.6.0/examples/awx17/roles/local_docker/tasks/upgrade_postgres.yml000066400000000000000000000040741521011662300302200ustar00rootroot00000000000000--- - name: Create {{ postgres_data_dir }} directory file: path: "{{ postgres_data_dir }}" state: directory - name: Get full path of postgres data dir shell: "echo {{ postgres_data_dir }}" register: fq_postgres_data_dir - name: Register temporary docker container set_fact: container_command: "docker run --rm -v '{{ fq_postgres_data_dir.stdout }}:/var/lib/postgresql' centos:8 bash -c " - name: Check for existing Postgres data (run from inside the container for access to file) shell: cmd: | {{ container_command }} "[[ -f /var/lib/postgresql/10/data/PG_VERSION ]] && echo 'exists'" register: pg_version_file ignore_errors: true - name: Record Postgres version shell: | {{ container_command }} "cat /var/lib/postgresql/10/data/PG_VERSION" register: old_pg_version when: pg_version_file is defined and pg_version_file.stdout == 'exists' - name: Determine whether to upgrade postgres set_fact: upgrade_postgres: "{{ old_pg_version.stdout == '10' }}" when: old_pg_version.changed - name: Set up new postgres paths pre-upgrade shell: | {{ container_command }} "mkdir -p /var/lib/postgresql/12/data/" when: upgrade_postgres | bool - name: Stop AWX before upgrading postgres docker_compose: project_src: "{{ docker_compose_dir }}" stopped: true when: upgrade_postgres | bool - name: Upgrade Postgres shell: | docker run --rm \ -v {{ postgres_data_dir }}/10/data:/var/lib/postgresql/10/data \ -v {{ postgres_data_dir }}/12/data:/var/lib/postgresql/12/data \ -e PGUSER={{ pg_username }} -e POSTGRES_INITDB_ARGS="-U {{ pg_username }}" \ tianon/postgres-upgrade:10-to-12 --username={{ pg_username }} when: upgrade_postgres | bool - name: Copy old pg_hba.conf shell: | {{ container_command }} "cp /var/lib/postgresql/10/data/pg_hba.conf /var/lib/postgresql/12/data/pg_hba.conf" when: upgrade_postgres | bool - name: Remove old data directory shell: | {{ container_command }} "rm -rf /var/lib/postgresql/10/data" when: - upgrade_postgres | bool - compose_start_containers|bool podman-compose-1.6.0/examples/awx17/roles/local_docker/templates/000077500000000000000000000000001521011662300247645ustar00rootroot00000000000000podman-compose-1.6.0/examples/awx17/roles/local_docker/templates/credentials.py.j2000066400000000000000000000006271521011662300301520ustar00rootroot00000000000000DATABASES = { 'default': { 'ATOMIC_REQUESTS': True, 'ENGINE': 'django.db.backends.postgresql', 'NAME': "{{ pg_database }}", 'USER': "{{ pg_username }}", 'PASSWORD': "{{ pg_password }}", 'HOST': "{{ pg_hostname | default('postgres') }}", 'PORT': "{{ pg_port }}", } } BROADCAST_WEBSOCKET_SECRET = "{{ broadcast_websocket_secret | b64encode }}" podman-compose-1.6.0/examples/awx17/roles/local_docker/templates/docker-compose.yml.j2000066400000000000000000000174541521011662300307460ustar00rootroot00000000000000#jinja2: lstrip_blocks: True version: '2' services: web: image: {{ awx_docker_actual_image }} container_name: awx_web depends_on: - redis {% if pg_hostname is not defined %} - postgres {% endif %} {% if (host_port is defined) or (host_port_ssl is defined) %} ports: {% if (host_port_ssl is defined) and (ssl_certificate is defined) %} - "{{ host_port_ssl }}:8053" {% endif %} {% if host_port is defined %} - "{{ host_port }}:8052" {% endif %} {% endif %} hostname: {{ awx_web_hostname }} user: root restart: unless-stopped {% if (awx_web_container_labels is defined) and (',' in awx_web_container_labels) %} {% set awx_web_container_labels_list = awx_web_container_labels.split(',') %} labels: {% for awx_web_container_label in awx_web_container_labels_list %} - {{ awx_web_container_label }} {% endfor %} {% elif awx_web_container_labels is defined %} labels: - {{ awx_web_container_labels }} {% endif %} volumes: - supervisor-socket:/var/run/supervisor - rsyslog-socket:/var/run/awx-rsyslog/ - rsyslog-config:/var/lib/awx/rsyslog/ - "{{ docker_compose_dir }}/SECRET_KEY:/etc/tower/SECRET_KEY" - "{{ docker_compose_dir }}/environment.sh:/etc/tower/conf.d/environment.sh" - "{{ docker_compose_dir }}/credentials.py:/etc/tower/conf.d/credentials.py" - "{{ docker_compose_dir }}/nginx.conf:/etc/nginx/nginx.conf:ro" - "{{ docker_compose_dir }}/redis_socket:/var/run/redis/:rw" {% if project_data_dir is defined %} - "{{ project_data_dir +':/var/lib/awx/projects:rw' }}" {% endif %} {% if custom_venv_dir is defined %} - "{{ custom_venv_dir +':'+ custom_venv_dir +':rw' }}" {% endif %} {% if ca_trust_dir is defined %} - "{{ ca_trust_dir +':/etc/pki/ca-trust/source/anchors:ro' }}" {% endif %} {% if (ssl_certificate is defined) and (ssl_certificate_key is defined) %} - "{{ ssl_certificate +':/etc/nginx/awxweb.pem:ro' }}" - "{{ ssl_certificate_key +':/etc/nginx/awxweb_key.pem:ro' }}" {% elif (ssl_certificate is defined) and (ssl_certificate_key is not defined) %} - "{{ ssl_certificate +':/etc/nginx/awxweb.pem:ro' }}" {% endif %} {% if (awx_container_search_domains is defined) and (',' in awx_container_search_domains) %} {% set awx_container_search_domains_list = awx_container_search_domains.split(',') %} dns_search: {% for awx_container_search_domain in awx_container_search_domains_list %} - {{ awx_container_search_domain }} {% endfor %} {% elif awx_container_search_domains is defined %} dns_search: "{{ awx_container_search_domains }}" {% endif %} {% if (awx_alternate_dns_servers is defined) and (',' in awx_alternate_dns_servers) %} {% set awx_alternate_dns_servers_list = awx_alternate_dns_servers.split(',') %} dns: {% for awx_alternate_dns_server in awx_alternate_dns_servers_list %} - {{ awx_alternate_dns_server }} {% endfor %} {% elif awx_alternate_dns_servers is defined %} dns: "{{ awx_alternate_dns_servers }}" {% endif %} {% if (docker_compose_extra_hosts is defined) and (':' in docker_compose_extra_hosts) %} {% set docker_compose_extra_hosts_list = docker_compose_extra_hosts.split(',') %} extra_hosts: {% for docker_compose_extra_host in docker_compose_extra_hosts_list %} - "{{ docker_compose_extra_host }}" {% endfor %} {% endif %} environment: http_proxy: {{ http_proxy | default('') }} https_proxy: {{ https_proxy | default('') }} no_proxy: {{ no_proxy | default('') }} {% if docker_logger is defined %} logging: driver: {{ docker_logger }} {% endif %} task: image: {{ awx_docker_actual_image }} container_name: awx_task depends_on: - redis - web {% if pg_hostname is not defined %} - postgres {% endif %} command: /usr/bin/launch_awx_task.sh hostname: {{ awx_task_hostname }} user: root restart: unless-stopped volumes: - supervisor-socket:/var/run/supervisor - rsyslog-socket:/var/run/awx-rsyslog/ - rsyslog-config:/var/lib/awx/rsyslog/ - "{{ docker_compose_dir }}/SECRET_KEY:/etc/tower/SECRET_KEY" - "{{ docker_compose_dir }}/environment.sh:/etc/tower/conf.d/environment.sh" - "{{ docker_compose_dir }}/credentials.py:/etc/tower/conf.d/credentials.py" - "{{ docker_compose_dir }}/redis_socket:/var/run/redis/:rw" {% if project_data_dir is defined %} - "{{ project_data_dir +':/var/lib/awx/projects:rw' }}" {% endif %} {% if custom_venv_dir is defined %} - "{{ custom_venv_dir +':'+ custom_venv_dir +':rw' }}" {% endif %} {% if ca_trust_dir is defined %} - "{{ ca_trust_dir +':/etc/pki/ca-trust/source/anchors:ro' }}" {% endif %} {% if ssl_certificate is defined %} - "{{ ssl_certificate +':/etc/nginx/awxweb.pem:ro' }}" {% endif %} {% if (awx_container_search_domains is defined) and (',' in awx_container_search_domains) %} {% set awx_container_search_domains_list = awx_container_search_domains.split(',') %} dns_search: {% for awx_container_search_domain in awx_container_search_domains_list %} - {{ awx_container_search_domain }} {% endfor %} {% elif awx_container_search_domains is defined %} dns_search: "{{ awx_container_search_domains }}" {% endif %} {% if (awx_alternate_dns_servers is defined) and (',' in awx_alternate_dns_servers) %} {% set awx_alternate_dns_servers_list = awx_alternate_dns_servers.split(',') %} dns: {% for awx_alternate_dns_server in awx_alternate_dns_servers_list %} - {{ awx_alternate_dns_server }} {% endfor %} {% elif awx_alternate_dns_servers is defined %} dns: "{{ awx_alternate_dns_servers }}" {% endif %} {% if (docker_compose_extra_hosts is defined) and (':' in docker_compose_extra_hosts) %} {% set docker_compose_extra_hosts_list = docker_compose_extra_hosts.split(',') %} extra_hosts: {% for docker_compose_extra_host in docker_compose_extra_hosts_list %} - "{{ docker_compose_extra_host }}" {% endfor %} {% endif %} environment: AWX_SKIP_MIGRATIONS: "1" http_proxy: {{ http_proxy | default('') }} https_proxy: {{ https_proxy | default('') }} no_proxy: {{ no_proxy | default('') }} SUPERVISOR_WEB_CONFIG_PATH: '/etc/supervisord.conf' redis: image: {{ redis_image }} container_name: awx_redis restart: unless-stopped environment: http_proxy: {{ http_proxy | default('') }} https_proxy: {{ https_proxy | default('') }} no_proxy: {{ no_proxy | default('') }} command: ["/usr/local/etc/redis/redis.conf"] volumes: - "{{ docker_compose_dir }}/redis.conf:/usr/local/etc/redis/redis.conf:ro" - "{{ docker_compose_dir }}/redis_socket:/var/run/redis/:rw" {% if docker_logger is defined %} logging: driver: {{ docker_logger }} {% endif %} {% if pg_hostname is not defined %} postgres: image: {{ postgresql_image }} container_name: awx_postgres restart: unless-stopped volumes: - "{{ postgres_data_dir }}/12/data/:/var/lib/postgresql/data:Z" environment: POSTGRES_USER: {{ pg_username }} POSTGRES_PASSWORD: {{ pg_password }} POSTGRES_DB: {{ pg_database }} http_proxy: {{ http_proxy | default('') }} https_proxy: {{ https_proxy | default('') }} no_proxy: {{ no_proxy | default('') }} {% if docker_logger is defined %} logging: driver: {{ docker_logger }} {% endif %} {% endif %} {% if docker_compose_subnet is defined %} networks: default: driver: bridge ipam: driver: default config: - subnet: {{ docker_compose_subnet }} {% endif %} volumes: supervisor-socket: rsyslog-socket: rsyslog-config: podman-compose-1.6.0/examples/awx17/roles/local_docker/templates/environment.sh.j2000066400000000000000000000006611521011662300302010ustar00rootroot00000000000000DATABASE_USER={{ pg_username|quote }} DATABASE_NAME={{ pg_database|quote }} DATABASE_HOST={{ pg_hostname|default('postgres')|quote }} DATABASE_PORT={{ pg_port|default('5432')|quote }} DATABASE_PASSWORD={{ pg_password|default('awxpass')|quote }} {% if pg_admin_password is defined %} DATABASE_ADMIN_PASSWORD={{ pg_admin_password|quote }} {% endif %} AWX_ADMIN_USER={{ admin_user|quote }} AWX_ADMIN_PASSWORD={{ admin_password|quote }} podman-compose-1.6.0/examples/awx17/roles/local_docker/templates/nginx.conf.j2000066400000000000000000000072731521011662300273010ustar00rootroot00000000000000#user awx; worker_processes 1; pid /tmp/nginx.pid; events { worker_connections 1024; } http { include /etc/nginx/mime.types; default_type application/octet-stream; server_tokens off; log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /dev/stdout main; map $http_upgrade $connection_upgrade { default upgrade; '' close; } sendfile on; #tcp_nopush on; #gzip on; upstream uwsgi { server 127.0.0.1:8050; } upstream daphne { server 127.0.0.1:8051; } {% if ssl_certificate is defined %} server { listen 8052 default_server; server_name _; # Redirect all HTTP links to the matching HTTPS page return 301 https://$host$request_uri; } {%endif %} server { {% if (ssl_certificate is defined) and (ssl_certificate_key is defined) %} listen 8053 ssl; ssl_certificate /etc/nginx/awxweb.pem; ssl_certificate_key /etc/nginx/awxweb_key.pem; {% elif (ssl_certificate is defined) and (ssl_certificate_key is not defined) %} listen 8053 ssl; ssl_certificate /etc/nginx/awxweb.pem; ssl_certificate_key /etc/nginx/awxweb.pem; {% else %} listen 8052 default_server; {% endif %} # If you have a domain name, this is where to add it server_name _; keepalive_timeout 65; # HSTS (ngx_http_headers_module is required) (15768000 seconds = 6 months) add_header Strict-Transport-Security max-age=15768000; # Protect against click-jacking https://www.owasp.org/index.php/Testing_for_Clickjacking_(OTG-CLIENT-009) add_header X-Frame-Options "DENY"; location /nginx_status { stub_status on; access_log off; allow 127.0.0.1; deny all; } location /static/ { alias /var/lib/awx/public/static/; } location /favicon.ico { alias /var/lib/awx/public/static/favicon.ico; } location /websocket { # Pass request to the upstream alias proxy_pass http://daphne; # Require http version 1.1 to allow for upgrade requests proxy_http_version 1.1; # We want proxy_buffering off for proxying to websockets. proxy_buffering off; # http://en.wikipedia.org/wiki/X-Forwarded-For proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # enable this if you use HTTPS: proxy_set_header X-Forwarded-Proto https; # pass the Host: header from the client for the sake of redirects proxy_set_header Host $http_host; # We've set the Host header, so we don't need Nginx to muddle # about with redirects proxy_redirect off; # Depending on the request value, set the Upgrade and # connection headers proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; } location / { # Add trailing / if missing rewrite ^(.*)$http_host(.*[^/])$ $1$http_host$2/ permanent; uwsgi_read_timeout 120s; uwsgi_pass uwsgi; include /etc/nginx/uwsgi_params; {%- if extra_nginx_include is defined %} include {{ extra_nginx_include }}; {%- endif %} proxy_set_header X-Forwarded-Port 443; uwsgi_param HTTP_X_FORWARDED_PORT 443; } } } podman-compose-1.6.0/examples/awx17/roles/local_docker/templates/redis.conf.j2000066400000000000000000000001161521011662300272510ustar00rootroot00000000000000unixsocket /var/run/redis/redis.sock unixsocketperm 660 port 0 bind 127.0.0.1 podman-compose-1.6.0/examples/awx3/000077500000000000000000000000001521011662300171345ustar00rootroot00000000000000podman-compose-1.6.0/examples/awx3/docker-compose.yml000066400000000000000000000027131521011662300225740ustar00rootroot00000000000000version: '3' services: postgres: image: "postgres:9.6" environment: POSTGRES_USER: awx POSTGRES_PASSWORD: awxpass POSTGRES_DB: awx rabbitmq: image: "rabbitmq:3" environment: RABBITMQ_DEFAULT_VHOST: awx memcached: image: "memcached:alpine" awx_web: # image: "geerlingguy/awx_web:latest" image: "ansible/awx_web:3.0.1" links: - rabbitmq - memcached - postgres ports: - "8080:8052" hostname: awxweb user: root environment: SECRET_KEY: aabbcc DATABASE_USER: awx DATABASE_PASSWORD: awxpass DATABASE_NAME: awx DATABASE_PORT: 5432 DATABASE_HOST: postgres RABBITMQ_USER: guest RABBITMQ_PASSWORD: guest RABBITMQ_HOST: rabbitmq RABBITMQ_PORT: 5672 RABBITMQ_VHOST: awx MEMCACHED_HOST: memcached MEMCACHED_PORT: 11211 awx_task: # image: "geerlingguy/awx_task:latest" image: "ansible/awx_task:3.0.1" links: - rabbitmq - memcached - awx_web:awxweb - postgres hostname: awx user: root environment: SECRET_KEY: aabbcc DATABASE_USER: awx DATABASE_PASSWORD: awxpass DATABASE_NAME: awx DATABASE_PORT: 5432 DATABASE_HOST: postgres RABBITMQ_USER: guest RABBITMQ_PASSWORD: guest RABBITMQ_HOST: rabbitmq RABBITMQ_PORT: 5672 RABBITMQ_VHOST: awx MEMCACHED_HOST: memcached MEMCACHED_PORT: 11211 podman-compose-1.6.0/examples/azure-vote/000077500000000000000000000000001521011662300203535ustar00rootroot00000000000000podman-compose-1.6.0/examples/azure-vote/README.md000066400000000000000000000005271521011662300216360ustar00rootroot00000000000000# Azure Vote Example This example has two containers: * backend: `redis` used as storage * frontend: having supervisord, nginx, uwsgi/python ``` echo "HOST_PORT=8080" > .env podman-compose up ``` After typing the commands above, open your browser on the host port you picked above, like [http://localhost:8080/](http://localhost:8080/) podman-compose-1.6.0/examples/azure-vote/docker-compose.yaml000066400000000000000000000007041521011662300241520ustar00rootroot00000000000000--- # from https://github.com/Azure-Samples/azure-voting-app-redis/blob/master/docker-compose.yaml version: '3' services: azure-vote-back: image: mcr.microsoft.com/oss/bitnami/redis:6.0.8 container_name: azure-vote-back environment: ALLOW_EMPTY_PASSWORD: "yes" azure-vote-front: image: mcr.microsoft.com/azuredocs/azure-vote-front:v1 environment: REDIS: azure-vote-back ports: - "${HOST_PORT:-8080}:80" podman-compose-1.6.0/examples/busybox/000077500000000000000000000000001521011662300177455ustar00rootroot00000000000000podman-compose-1.6.0/examples/busybox/docker-compose.yaml000066400000000000000000000013111521011662300235370ustar00rootroot00000000000000version: "2" services: redis: image: redis:alpine ports: - "6379" environment: - SECRET_KEY=aabbcc - ENV_IS_SET frontend: image: busybox #entrypoint: [] command: ["/bin/busybox", "httpd", "-f", "-p", "8080"] working_dir: / environment: SECRET_KEY2: aabbcc ENV_IS_SET2: ports: - "8080" links: - redis:myredis labels: my.label: my_value #tmpfs: /run #tmpfs: # - /run # - /tmp #user: postgresql #working_dir: /code #domainname: foo.com #hostname: foo #ipc: host #mac_address: 02:42:ac:11:65:43 #privileged: true #read_only: true #shm_size: 64M #stdin_open: true #tty: true podman-compose-1.6.0/examples/docker-inline/000077500000000000000000000000001521011662300207755ustar00rootroot00000000000000podman-compose-1.6.0/examples/docker-inline/docker-compose.yml000066400000000000000000000002161521011662300244310ustar00rootroot00000000000000--- version: '3' services: dummy: build: context: . dockerfile_inline: | FROM alpine RUN echo "hello world" podman-compose-1.6.0/examples/echo/000077500000000000000000000000001521011662300171705ustar00rootroot00000000000000podman-compose-1.6.0/examples/echo/README.md000066400000000000000000000007561521011662300204570ustar00rootroot00000000000000# Echo Service example ``` podman-compose up ``` Test the service with curl like this: ``` $ curl -X POST -d "foobar" http://localhost:8080/; echo CLIENT VALUES: client_address=10.89.31.2 command=POST real path=/ query=nil request_version=1.1 request_uri=http://localhost:8080/ SERVER VALUES: server_version=nginx: 1.10.0 - lua: 10001 HEADERS RECEIVED: accept=*/* content-length=6 content-type=application/x-www-form-urlencoded host=localhost:8080 user-agent=curl/7.76.1 BODY: foobar ``` podman-compose-1.6.0/examples/echo/docker-compose.yaml000066400000000000000000000001671521011662300227720ustar00rootroot00000000000000--- version: '3' services: web: image: k8s.gcr.io/echoserver:1.4 ports: - "${HOST_PORT:-8080}:8080" podman-compose-1.6.0/examples/hello-app-redis/000077500000000000000000000000001521011662300212375ustar00rootroot00000000000000podman-compose-1.6.0/examples/hello-app-redis/README.md000066400000000000000000000005741521011662300225240ustar00rootroot00000000000000# GCR Hello App Redis A 6-node redis cluster using [Bitnami](https://github.com/bitnami/bitnami-docker-redis-cluster) with a [simple hit counter](https://github.com/GoogleCloudPlatform/kubernetes-engine-samples/tree/main/hello-app-redis) that persists on that redis cluster ``` podman-compose up ``` then open your browser on [http://localhost:8080/](http://localhost:8080/) podman-compose-1.6.0/examples/hello-app-redis/docker-compose.yaml000066400000000000000000000037531521011662300250450ustar00rootroot00000000000000--- version: '3' volumes: redis-node1-data: redis-node2-data: redis-node3-data: redis-node4-data: redis-node5-data: redis-data: services: web: image: gcr.io/google-samples/hello-app-redis:1.0 depends_on: - redis-cluster ports: - "${HOST_PORT:-8080}:8080" redis-node1: image: docker.io/bitnami/redis-cluster:6.2 volumes: - redis-node1-data:/bitnami/redis/data environment: - ALLOW_EMPTY_PASSWORD=yes - REDIS_NODES=redis-node1 redis-node2 redis-node3 redis-node4 redis-node5 redis-cluster redis-node2: image: docker.io/bitnami/redis-cluster:6.2 volumes: - redis-node2-data:/bitnami/redis/data environment: - ALLOW_EMPTY_PASSWORD=yes - REDIS_NODES=redis-node1 redis-node2 redis-node3 redis-node4 redis-node5 redis-cluster redis-node3: image: docker.io/bitnami/redis-cluster:6.2 volumes: - redis-node3-data:/bitnami/redis/data environment: - ALLOW_EMPTY_PASSWORD=yes - REDIS_NODES=redis-node1 redis-node2 redis-node3 redis-node4 redis-node5 redis-cluster redis-node4: image: docker.io/bitnami/redis-cluster:6.2 volumes: - redis-node4-data:/bitnami/redis/data environment: - ALLOW_EMPTY_PASSWORD=yes - REDIS_NODES=redis-node1 redis-node2 redis-node3 redis-node4 redis-node5 redis-cluster redis-node5: image: docker.io/bitnami/redis-cluster:6.2 volumes: - redis-node5-data:/bitnami/redis/data environment: - ALLOW_EMPTY_PASSWORD=yes - REDIS_NODES=redis-node1 redis-node2 redis-node3 redis-node4 redis-node5 redis-cluster redis-cluster: image: docker.io/bitnami/redis-cluster:6.2 volumes: - redis-data:/bitnami/redis/data depends_on: - redis-node1 - redis-node2 - redis-node3 - redis-node4 - redis-node5 environment: - ALLOW_EMPTY_PASSWORD=yes - REDIS_NODES=redis-node1 redis-node2 redis-node3 redis-node4 redis-node5 redis-cluster - REDIS_CLUSTER_CREATOR=yes podman-compose-1.6.0/examples/hello-app/000077500000000000000000000000001521011662300201335ustar00rootroot00000000000000podman-compose-1.6.0/examples/hello-app/README.md000066400000000000000000000002221521011662300214060ustar00rootroot00000000000000# GCR Hello App A small ~2MB image, type ``` podman-compose up ``` then open your browser on [http://localhost:8080/](http://localhost:8080/) podman-compose-1.6.0/examples/hello-app/docker-compose.yaml000066400000000000000000000002011521011662300237220ustar00rootroot00000000000000--- version: '3' services: web: image: gcr.io/google-samples/hello-app:1.0 ports: - "${HOST_PORT:-8080}:8080" podman-compose-1.6.0/examples/hello-python/000077500000000000000000000000001521011662300206745ustar00rootroot00000000000000podman-compose-1.6.0/examples/hello-python/Dockerfile000066400000000000000000000002641521011662300226700ustar00rootroot00000000000000FROM python:3.9-alpine WORKDIR /usr/src/app COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD [ "python", "-m", "app.web" ] EXPOSE 8080 podman-compose-1.6.0/examples/hello-python/README.md000066400000000000000000000001721521011662300221530ustar00rootroot00000000000000# Simple Python Demo ## A Redis counter ``` podman-compose up -d curl localhost:8080/ curl localhost:8080/hello.json ``` podman-compose-1.6.0/examples/hello-python/app/000077500000000000000000000000001521011662300214545ustar00rootroot00000000000000podman-compose-1.6.0/examples/hello-python/app/__init__.py000066400000000000000000000000001521011662300235530ustar00rootroot00000000000000podman-compose-1.6.0/examples/hello-python/app/web.py000066400000000000000000000020411521011662300226000ustar00rootroot00000000000000# pylint: disable=import-error # pylint: disable=unused-import import asyncio # noqa: F401 import os import aioredis # type: ignore[import-not-found] from aiohttp import web # type: ignore[import-not-found] REDIS_HOST = os.environ.get("REDIS_HOST", "localhost") REDIS_PORT = int(os.environ.get("REDIS_PORT", "6379")) REDIS_DB = int(os.environ.get("REDIS_DB", "0")) redis = aioredis.from_url(f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}") app = web.Application() routes = web.RouteTableDef() @routes.get("/") async def hello(request: web.Request) -> web.Response: # pylint: disable=unused-argument counter = await redis.incr("mycounter") return web.Response(text=f"counter={counter}") @routes.get("/hello.json") async def hello_json(request: web.Request) -> web.Response: # pylint: disable=unused-argument counter = await redis.incr("mycounter") data = {"counter": counter} return web.json_response(data) app.add_routes(routes) def main() -> None: web.run_app(app, port=8080) if __name__ == "__main__": main() podman-compose-1.6.0/examples/hello-python/docker-compose.yaml000066400000000000000000000005711521011662300244750ustar00rootroot00000000000000--- version: '3' volumes: redis: services: redis: read_only: true image: docker.io/redis:alpine command: ["redis-server", "--appendonly", "yes", "--notify-keyspace-events", "Ex"] volumes: - redis:/data web: read_only: true build: context: . image: hello-py-aioweb ports: - 8080:8080 environment: REDIS_HOST: redis podman-compose-1.6.0/examples/hello-python/requirements.txt000066400000000000000000000000451521011662300241570ustar00rootroot00000000000000aiohttp aioredis # aioredis[hiredis] podman-compose-1.6.0/examples/nodeproj/000077500000000000000000000000001521011662300200725ustar00rootroot00000000000000podman-compose-1.6.0/examples/nodeproj/.eslintrc.json000066400000000000000000000030621521011662300226670ustar00rootroot00000000000000{ "env": { "node": true, "es6": true }, "settings": { "import/resolver": { "node": { "extensions": [".js", ".mjs", ".ts", ".cjs"] } } }, "parser": "@typescript-eslint/parser", "parserOptions": { "ecmaVersion": 2020, "sourceType": "module", "allowImportExportEverywhere": true }, "extends": [ "eslint:recommended", "plugin:import/errors", "plugin:import/warnings", "plugin:import/typescript", "plugin:promise/recommended", "google", "plugin:security/recommended" ], "plugins": ["promise", "security", "import"], "overrides": [ { "files": "public/**/*.min.js", "env": { "browser": true, "node": false, "es6": false }, "parserOptions": { "sourceType": "script" }, "extends": ["plugin:compat/recommended"], "plugins": [], "rules": { "no-var": ["off"] } } ], "rules": { "security/detect-non-literal-fs-filename":["off"], "security/detect-object-injection":["off"], "camelcase": ["off"], "no-console": ["off"], "require-jsdoc": ["off"], "one-var": ["off"], "guard-for-in": ["off"], "max-len": [ "warn", { "ignoreComments": true, "ignoreTrailingComments": true, "ignoreUrls": true, "code": 200 } ], "indent": ["warn", 4], "no-unused-vars": ["warn"], "no-extra-semi": ["warn"], "linebreak-style": ["error", "unix"], "quotes": ["warn", "double"], "semi": ["error", "always"] } } podman-compose-1.6.0/examples/nodeproj/.gitignore000066400000000000000000000000431521011662300220570ustar00rootroot00000000000000local.env .env *.pid node_modules podman-compose-1.6.0/examples/nodeproj/.home/000077500000000000000000000000001521011662300211005ustar00rootroot00000000000000podman-compose-1.6.0/examples/nodeproj/.home/.gitignore000066400000000000000000000000021521011662300230600ustar00rootroot00000000000000* podman-compose-1.6.0/examples/nodeproj/README.md000066400000000000000000000003321521011662300213470ustar00rootroot00000000000000# How to run example ``` cp example.local.env local.env cp example.env .env cat local.env cat .env echo "UID=$UID" >> .env cat .env podman-compose build podman-compose run --rm --no-deps init podman-compose up ``` podman-compose-1.6.0/examples/nodeproj/containers/000077500000000000000000000000001521011662300222375ustar00rootroot00000000000000podman-compose-1.6.0/examples/nodeproj/containers/node16-runtime/000077500000000000000000000000001521011662300250145ustar00rootroot00000000000000podman-compose-1.6.0/examples/nodeproj/containers/node16-runtime/Dockerfile000066400000000000000000000010641521011662300270070ustar00rootroot00000000000000FROM registry.fedoraproject.org/fedora-minimal:35 ARG NODE_VER=16 # microdnf -y module enable nodejs:${NODE_VER} RUN \ echo -e "[nodejs]\nname=nodejs\nstream=${NODE_VER}\nprofiles=\nstate=enabled\n" > /etc/dnf/modules.d/nodejs.module && \ microdnf -y install shadow-utils nodejs zopfli findutils busybox && \ microdnf clean all RUN adduser -d /app app && mkdir -p /app/code/.home && chown app:app -R /app/code && chmod 711 /app /app/code/.home && usermod -d /app/code/.home app ENV XDG_CONFIG_HOME=/app/code/.home ENV HOME=/app/code/.home WORKDIR /app/code podman-compose-1.6.0/examples/nodeproj/docker-compose.yml000066400000000000000000000017441521011662300235350ustar00rootroot00000000000000version: '3' volumes: redis: services: redis: read_only: true image: docker.io/redis:alpine command: ["redis-server", "--appendonly", "yes", "--notify-keyspace-events", "Ex"] volumes: - redis:/data tmpfs: - /tmp - /var/run - /run init: read_only: true #userns_mode: keep-id user: ${UID:-1000} build: context: ./containers/${NODE_IMG:-node16-runtime} image: ${NODE_IMG:-node16-runtime} env_file: - local.env volumes: - .:/app/code command: ["/bin/sh", "-c", "mkdir -p ~/; [ -d ./node_modules ] && echo '** node_modules exists' || npm install"] tmpfs: - /tmp - /run task: extends: service: init command: ["npm", "run", "cli", "--", "task"] links: - redis depends_on: - redis web: extends: service: init command: ["npm", "run", "cli", "--", "web"] ports: - ${WEB_LISTEN_PORT:-3000}:3000 depends_on: - redis links: - mongo podman-compose-1.6.0/examples/nodeproj/example.env000066400000000000000000000000601521011662300222330ustar00rootroot00000000000000WEB_LISTEN_PORT=3000 # pass UID= your IDE user podman-compose-1.6.0/examples/nodeproj/example.local.env000066400000000000000000000000221521011662300233220ustar00rootroot00000000000000REDIS_HOST=redis podman-compose-1.6.0/examples/nodeproj/index.js000066400000000000000000000001131521011662300215320ustar00rootroot00000000000000#! /usr/bin/env node "use strict"; import {start} from "./lib"; start(); podman-compose-1.6.0/examples/nodeproj/jsconfig.json000066400000000000000000000004021521011662300225630ustar00rootroot00000000000000{ "compilerOptions": { "target": "es2020", "module": "es2020", "moduleResolution": "node", "allowSyntheticDefaultImports": true }, "files": [ "index.js" ], "include": [ "lib/**/*.js" ] }podman-compose-1.6.0/examples/nodeproj/lib/000077500000000000000000000000001521011662300206405ustar00rootroot00000000000000podman-compose-1.6.0/examples/nodeproj/lib/commands/000077500000000000000000000000001521011662300224415ustar00rootroot00000000000000podman-compose-1.6.0/examples/nodeproj/lib/commands/task.js000066400000000000000000000013061521011662300237410ustar00rootroot00000000000000"use strict"; import {proj} from "../proj"; async function loop() { const poped = await proj.predis.blpop("queue", 5); const task_desc_s = poped[1]; let task_desc; try { task_desc = JSON.parse(task_desc_s); } catch (e) { console.exception(e); } console.info("got task "+task_desc.func); const func = task_desc.func; const args = task_desc.args; if (typeof(proj.tasks[func])!="function") { console.log(`task ${func} not found`); process.exit(-1) } try { await ((this.tasks[func])(...args)); } catch (e) { console.exception(e); } } export async function start() { while(true) { loop(); } } podman-compose-1.6.0/examples/nodeproj/lib/commands/web.js000066400000000000000000000010261521011662300235530ustar00rootroot00000000000000"use strict"; import {proj} from "../proj"; import http from "http"; import express from "express"; export async function start() { const app = express(); const server = http.createServer(app); // Routing app.use(express.static(proj.config.basedir + "/public")); app.get("/healthz", function(req, res) { res.send("ok@"+Date.now()); }); server.listen(proj.config.LISTEN_PORT, proj.config.LISTEN_HOST, function() { console.warn(`listening at port ${proj.config.LISTEN_PORT}`); }); } podman-compose-1.6.0/examples/nodeproj/package.json000066400000000000000000000007561521011662300223700ustar00rootroot00000000000000{ "name": "nodeproj", "version": "0.0.1", "description": "nodejs example project", "exports": { ".": "./index.js", "./lib": "./lib" }, "main": "index.js", "type": "module", "scripts": { "cli": "nodemon -w lib -w index.js --es-module-specifier-resolution=node ./index.js" }, "dependencies": { "express": "~4.16.4", "redis": "^3.1.2" }, "private": true, "author": "", "license": "proprietary", "devDependencies": { "nodemon": "^2.0.14" } } podman-compose-1.6.0/examples/nodeproj/public/000077500000000000000000000000001521011662300213505ustar00rootroot00000000000000podman-compose-1.6.0/examples/nodeproj/public/index.html000066400000000000000000000006271521011662300233520ustar00rootroot00000000000000 Vote

This is a Heading

This is a paragraph.

podman-compose-1.6.0/examples/nvidia-smi/000077500000000000000000000000001521011662300203125ustar00rootroot00000000000000podman-compose-1.6.0/examples/nvidia-smi/docker-compose.yaml000066400000000000000000000003651521011662300241140ustar00rootroot00000000000000services: test: image: nvidia/cuda:12.3.1-base-ubuntu20.04 command: nvidia-smi deploy: resources: reservations: devices: - driver: nvidia count: 1 capabilities: [gpu] podman-compose-1.6.0/examples/wordpress/000077500000000000000000000000001521011662300203025ustar00rootroot00000000000000podman-compose-1.6.0/examples/wordpress/docker-compose.yaml000066400000000000000000000011301521011662300240730ustar00rootroot00000000000000--- volumes: db_data: services: wordpress: image: docker.io/library/wordpress:latest ports: - 8080:80 environment: - WORDPRESS_DB_HOST=db - WORDPRESS_DB_USER=wordpress - WORDPRESS_DB_PASSWORD=password - WORDPRESS_DB_NAME=wordpress db: image: docker.io/library/mariadb:10.6.4-focal command: '--default-authentication-plugin=mysql_native_password' volumes: - db_data:/var/lib/mysql environment: - MYSQL_ROOT_PASSWORD=somewordpress - MYSQL_DATABASE=wordpress - MYSQL_USER=wordpress - MYSQL_PASSWORD=password podman-compose-1.6.0/newsfragments/000077500000000000000000000000001521011662300173175ustar00rootroot00000000000000podman-compose-1.6.0/newsfragments/README.txt000066400000000000000000000011101521011662300210060ustar00rootroot00000000000000This is the directory for news fragments used by towncrier: https://github.com/hawkowl/towncrier You create a news fragment in this directory when you make a change, and the file gets removed from this directory when the news is published. towncrier has a few standard types of news fragments, signified by the file extension. These are: .feature: Signifying a new feature. .bugfix: Signifying a bug fix. .doc: Signifying a documentation improvement. .removal: Signifying a deprecation or removal of public API. .change: Signifying a change of behavior .misc: Miscellaneous change podman-compose-1.6.0/podman_compose.py000077500000000000000000005521701521011662300200260ustar00rootroot00000000000000#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-2.0 # https://docs.docker.com/compose/compose-file/#service-configuration-reference # https://docs.docker.com/samples/ # https://docs.docker.com/compose/gettingstarted/ # https://docs.docker.com/compose/django/ # https://docs.docker.com/compose/wordpress/ # TODO: podman pod logs --color -n -f pod_testlogs from __future__ import annotations # If you see an error here, use Python 3.7 or greater import argparse import asyncio.exceptions import asyncio.subprocess import codecs import getpass import glob import hashlib import inspect import json import logging import os import random import re import shlex import signal import string import subprocess import sys import tempfile import urllib.parse from asyncio import Task from dataclasses import dataclass from enum import Enum from typing import Any from typing import Callable from typing import ClassVar from typing import Iterable from typing import Sequence from typing import overload from urllib.parse import quote # import fnmatch # fnmatch.fnmatchcase(env, "*_HOST") import yaml from dotenv import dotenv_values __version__ = "1.6.0" script = os.path.realpath(sys.argv[0]) # helper functions def is_list(list_object: Any) -> bool: return ( not isinstance(list_object, str) and not isinstance(list_object, dict) and hasattr(list_object, "__iter__") ) def is_relative_ref(path: str) -> bool: return ( path.startswith("./") or path.startswith(".:") or path.startswith("../") or path.startswith("..:") ) # identity filter def filteri(a: list[str]) -> list[str]: return list(filter(lambda i: i, a)) @overload def try_int(i: int | str, fallback: int) -> int: ... @overload def try_int(i: int | str, fallback: None) -> int | None: ... def try_int(i: int | str, fallback: int | None = None) -> int | None: try: return int(i) except ValueError: pass except TypeError: pass return fallback def try_float(i: int | str, fallback: float | None = None) -> float | None: try: return float(i) except ValueError: pass except TypeError: pass return fallback log = logging.getLogger(__name__) dir_re = re.compile(r"^[~/\.]") propagation_re = re.compile( "^(?:z|Z|O|U|r?shared|r?slave|r?private|r?unbindable|r?bind|(?:no)?(?:exec|dev|suid))$" ) norm_re = re.compile("[^-_a-z0-9]") num_split_re = re.compile(r"(\d+|\D+)") PODMAN_CMDS = ( "pull", "push", "build", "inspect", "run", "start", "stop", "rm", "volume", ) t_re = re.compile(r"^(?:(\d+)[m:])?(?:(\d+(?:\.\d+)?)s?)?$") STOP_GRACE_PERIOD = "10" def str_to_seconds(txt: int | str | None) -> int | None: if not txt: return None if isinstance(txt, (int, float)): return txt match = t_re.match(txt.strip()) if not match: return None mins, sec = match[1], match[2] mins = int(mins) if mins else 0 sec = float(sec) if sec else 0 # "podman stop" takes only int # Error: invalid argument "3.0" for "-t, --time" flag: strconv.ParseUint: parsing "3.0": # invalid syntax return int(mins * 60.0 + sec) def ver_as_list(a: str) -> list[int]: return [try_int(i, i) for i in num_split_re.findall(a)] def strverscmp_lt(a: str, b: str) -> bool: a_ls = ver_as_list(a or "") b_ls = ver_as_list(b or "") return a_ls < b_ls def parse_short_mount(mount_str: str, basedir: str) -> dict[str, Any]: mount_a = mount_str.split(":") mount_opt_dict: dict[str, Any] = {} mount_opt = None if len(mount_a) == 1: # Anonymous: Just specify a path and let the engine create the volume # - /var/lib/mysql mount_src, mount_dst = None, mount_str elif len(mount_a) == 2: mount_src, mount_dst = mount_a # dest must start with / like /foo:/var/lib/mysql # otherwise it's option like /var/lib/mysql:rw if not mount_dst.startswith("/"): mount_dst, mount_opt = mount_a mount_src = None elif len(mount_a) == 3: mount_src, mount_dst, mount_opt = mount_a else: raise ValueError("could not parse mount " + mount_str) if mount_src and dir_re.match(mount_src): # Specify an absolute path mapping # - /opt/data:/var/lib/mysql # Path on the host, relative to the Compose file # - ./cache:/tmp/cache # User-relative path # - ~/configs:/etc/configs/:ro mount_type = "bind" if os.name != 'nt' or (os.name == 'nt' and ".sock" not in mount_src): mount_src = os.path.abspath(os.path.join(basedir, os.path.expanduser(mount_src))) else: # Named volume # - datavolume:/var/lib/mysql mount_type = "volume" mount_opts = filteri((mount_opt or "").split(",")) propagation_opts = [] for opt in mount_opts: if opt == "ro": mount_opt_dict["read_only"] = True elif opt == "rw": mount_opt_dict["read_only"] = False elif opt in ("consistent", "delegated", "cached"): mount_opt_dict["consistency"] = opt elif propagation_re.match(opt): propagation_opts.append(opt) else: # TODO: ignore raise ValueError("unknown mount option " + opt) mount_opt_dict["bind"] = {"propagation": ",".join(propagation_opts)} return { "type": mount_type, "source": mount_src, "target": mount_dst, **mount_opt_dict, } # NOTE: if a named volume is used but not defined it # gives ERROR: Named volume "abc" is used in service "xyz" # but no declaration was found in the volumes section. # unless it's anonymous-volume def fix_mount_dict( compose: PodmanCompose, mount_dict: dict[str, Any], srv_name: str ) -> dict[str, Any]: """ in-place fix mount dictionary to: - define _vol to be the corresponding top-level volume - if name is missing it would be source prefixed with project - if no source it would be generated """ # if already applied nothing to do assert compose.project_name is not None if "_vol" in mount_dict: return mount_dict if mount_dict["type"] == "volume": vols = compose.vols source = mount_dict.get("source") vol = (vols.get(source, {}) or {}) if source else {} # type: ignore[union-attr] name = vol.get("name") mount_dict["_vol"] = vol # handle anonymous or implied volume if not source: # missing source vol["name"] = compose.format_name( srv_name, hashlib.sha256(mount_dict["target"].encode("utf-8")).hexdigest(), ) elif not name: external = vol.get("external") if isinstance(external, dict): vol["name"] = external.get("name", f"{source}") elif external: vol["name"] = f"{source}" else: vol["name"] = f"{compose.project_name}_{source}" return mount_dict # docker and docker-compose support subset of bash variable substitution # https://docs.docker.com/compose/compose-file/#variable-substitution # https://docs.docker.com/compose/env-file/ # https://www.gnu.org/software/bash/manual/html_node/Shell-Parameter-Expansion.html # $VARIABLE # ${VARIABLE} # ${VARIABLE:-default} default if not set or empty # ${VARIABLE-default} default if not set # ${VARIABLE:?err} raise error if not set or empty # ${VARIABLE?err} raise error if not set # $$ means $ def var_interpolate(value: str, env: dict[str, Any]) -> str: var_name_chars = string.ascii_letters + string.digits + "_" var_name_start_chars = string.ascii_letters + "_" class VarInterpolationOperators(Enum): VAR_IF_NONEMPTY = ':-' VAR_IF_SET = '-' REQUIRED_SET = '?' REQUIRED_NONEMPTY = ':?' ALTERNATIVE1 = ':+' ALTERNATIVE2 = '+' operators = {op.value for op in VarInterpolationOperators} @dataclass class Token: def resolve(self, _: dict[str, str | None]) -> str: raise NotImplementedError() @dataclass class LiteralToken(Token): value: str def resolve(self, _: dict[str, str | None]) -> str: return self.value @dataclass class VarToken(Token): name: str operator: str | None operand: str | None def resolve(self, env: dict[str, str | None]) -> str: var_value = env.get(self.name) # This is only the case for simple $VAR or ${VAR} without any operator, # in which case we just return the variable value or empty string if not set if self.operator is None or self.operand is None: return var_value if var_value is not None else '' if self.operator == VarInterpolationOperators.REQUIRED_NONEMPTY.value: if var_value is None or var_value == '': interpolated_operand = ( interpolate_str(self.operand, env) if self.operand else '' ) raise ValueError( f"required variable {self.name} is missing a value: {interpolated_operand}" ) return var_value if self.operator == VarInterpolationOperators.REQUIRED_SET.value: if var_value is None: interpolated_operand = ( interpolate_str(self.operand, env) if self.operand else '' ) raise ValueError( f"required variable {self.name} is missing a value: {interpolated_operand}" ) return var_value if self.operator == VarInterpolationOperators.VAR_IF_NONEMPTY.value: condition = var_value is None or var_value == '' alternative = var_value if var_value is not None else '' elif self.operator == VarInterpolationOperators.VAR_IF_SET.value: condition = var_value is None alternative = var_value if var_value is not None else '' elif self.operator == VarInterpolationOperators.ALTERNATIVE1.value: condition = var_value is not None and var_value != '' alternative = '' elif self.operator == VarInterpolationOperators.ALTERNATIVE2.value: condition = var_value is not None alternative = '' else: raise ValueError(f"Unknown operator in variable interpolation: {self.operator}") return interpolate_str(self.operand, env) if condition else alternative def var_name_lookahead(start: int, chars: list[str]) -> tuple[int, str]: """ moves the index to the end of the variable name returns variable name and position after variable name """ var_name = '' i = start while i < len(chars) and chars[i] in var_name_chars: var_name += chars[i] i += 1 return i, var_name def advance_to_closing_brace(start: int, chars: list[str]) -> int: i = start brace_level = 1 while i < len(chars): char = chars[i] if char == '}': brace_level -= 1 if brace_level == 0: return i # position of the closing brace elif char == '{': brace_level += 1 i += 1 raise ValueError("No closing brace found for variable interpolation") def resolve_brace_content(content: str) -> VarToken: operator = None operand = None # Check that the brace content starts with a valid variable name character. # Refuse to interpolate otherwise. This is how Docker behaves. if len(content) == 0 or content[0] not in var_name_start_chars: raise ValueError( f"Invalid interpolation format: ${{{content}}}." " You may need to escape any $ with another $" ) i, name = var_name_lookahead(0, list(content)) rest = content[i:] if rest: for op in operators: if rest.startswith(op): operator = op operand = rest[len(op) :] break if operator is None: raise ValueError(f"Invalid variable interpolation syntax: ${{{content}}}") return VarToken(name=name, operator=operator, operand=operand) def tokenize(value: str) -> list[Token]: chars = list(value) tokens: list[Token] = [] in_brace = False def append_text_char(char: str) -> None: if tokens and isinstance(tokens[-1], LiteralToken): tokens[-1].value += char else: tokens.append(LiteralToken(value=char)) i = 0 while i < len(chars): char = chars[i] if not in_brace: if char == '$': # There is no lookahead, treat $ as literal if i + 1 >= len(chars): append_text_char(char) i += 1 continue lookahead_char = chars[i + 1] if lookahead_char == '{': in_brace = True i += 2 # skip $ and { continue if lookahead_char == '$': append_text_char('$') i += 2 # skip both $$ continue # If the lookahead char is valid for starting a variable name, parse the name. # Otherwise, treat $ as literal (e.g. in "price is $5", $ should be literal) if lookahead_char in var_name_start_chars: i, var_name = var_name_lookahead(i + 1, chars) tokens.append(VarToken(name=var_name, operator=None, operand=None)) continue # already advanced i to a char after var name # Regular character append_text_char(char) i += 1 continue # in_brace == True closing_index = advance_to_closing_brace(i, chars) brace_content = ''.join(chars[i:closing_index]) tokens.append(resolve_brace_content(brace_content)) i = closing_index + 1 # move past the closing brace in_brace = False continue return tokens def interpolate_str(s: str, env: dict[str, str | None]) -> str: tokens = tokenize(s) resolved_parts = [token.resolve(env) for token in tokens] return ''.join(resolved_parts) return interpolate_str(value, env) @overload def rec_subs(value: dict, subs_dict: dict[str, Any]) -> dict: ... @overload def rec_subs(value: str, subs_dict: dict[str, Any]) -> str: ... @overload def rec_subs(value: Iterable, subs_dict: dict[str, Any]) -> Iterable: ... def rec_subs(value: dict | str | Iterable, subs_dict: dict[str, Any]) -> dict | str | Iterable: """ do bash-like substitution in value and if list of dictionary do that recursively """ if isinstance(value, dict): if 'environment' in value and isinstance(value['environment'], dict): # Load service's environment variables subs_dict = subs_dict.copy() svc_envs = {k: v for k, v in value['environment'].items() if k not in subs_dict} # we need to add `svc_envs` to the `subs_dict` so that it can evaluate the # service environment that references another service environment. svc_envs = rec_subs(svc_envs, subs_dict) subs_dict.update(svc_envs) value = {rec_subs(k, subs_dict): rec_subs(v, subs_dict) for k, v in value.items()} elif isinstance(value, str): value = var_interpolate(value, subs_dict) elif hasattr(value, "__iter__"): value = [rec_subs(i, subs_dict) for i in value] return value def norm_as_list(src: dict[str, Any] | list[Any] | None) -> list[Any]: """ given a dictionary {key1:value1, key2: None} or list return a list of ["key1=value1", "key2"] """ dst: list[Any] if src is None: dst = [] elif isinstance(src, dict): dst = [(f"{k}={v}" if v is not None else k) for k, v in src.items()] elif is_list(src): dst = list(src) else: dst = [src] return dst def norm_as_dict(src: None | dict[str, str | None] | list[str] | str) -> dict[str, str | None]: """ given a list ["key1=value1", "key2"] return a dictionary {key1:value1, key2: None} """ if src is None: dst = {} elif isinstance(src, dict): dst = dict(src) elif is_list(src): dst = [i.split("=", 1) for i in src if i] # type: ignore[assignment] dst = [(a if len(a) == 2 else (a[0], None)) for a in dst] # type: ignore[assignment] dst = dict(dst) elif isinstance(src, str): key, value = src.split("=", 1) if "=" in src else (src, None) dst = {key: value} else: raise ValueError("dictionary or iterable is expected") return dst def norm_ulimit(inner_value: dict | list | int | str) -> str: if isinstance(inner_value, dict): if not inner_value.keys() & {"soft", "hard"}: raise ValueError("expected at least one soft or hard limit") soft = inner_value.get("soft", inner_value.get("hard")) hard = inner_value.get("hard", inner_value.get("soft")) return f"{soft}:{hard}" if is_list(inner_value): return norm_ulimit(norm_as_dict(inner_value)) # type: ignore[arg-type] # if int or string return as is return inner_value # type: ignore[return-value] def default_network_name_for_project(compose: PodmanCompose, net: str, is_ext: Any) -> str: if is_ext: return net assert compose.project_name is not None default_net_name_compat = compose.x_podman.get( PodmanCompose.XPodmanSettingKey.DEFAULT_NET_NAME_COMPAT, False ) if default_net_name_compat is True: return compose.join_name_parts(compose.project_name.replace('-', ''), net) return compose.format_name(net) def try_parse_bool(value: Any) -> bool | None: if isinstance(value, bool): return value if isinstance(value, str): value = value.lower() if value in ('true', '1'): return True if value in ('false', '0'): return False if isinstance(value, int): if value == 1: return True if value == 0: return False return None async def assert_volume(compose: PodmanCompose, mount_dict: dict[str, Any]) -> None: """ inspect volume to get directory create volume if needed """ vol = mount_dict.get("_vol") if mount_dict["type"] == "bind": basedir = os.path.realpath(compose.dirname) mount_src = mount_dict["source"] mount_src = os.path.abspath(os.path.join(basedir, os.path.expanduser(mount_src))) if not os.path.exists(mount_src): bind_opts = mount_dict.get("bind", {}) if "create_host_path" in bind_opts and not bind_opts["create_host_path"]: raise ValueError( "invalid mount config for type 'bind': bind source path does not exist: " f"{mount_src}" ) try: os.makedirs(mount_src, exist_ok=True) except OSError: pass mount_dict["source"] = mount_src return if mount_dict["type"] != "volume" or not vol or not vol.get("name"): return vol_name = vol["name"] is_ext = vol.get("external") log.debug("podman volume inspect %s || podman volume create %s", vol_name, vol_name) # TODO: might move to using "volume list" # podman volume list --format '{{.Name}}\t{{.MountPoint}}' \ # -f 'label=io.podman.compose.project=HERE' try: await compose.podman.output([], "volume", ["inspect", vol_name]) except subprocess.CalledProcessError as e: if is_ext: raise PodmanComposeError( f"External volume [{vol_name}] does not exist. " f"Create it first with: podman volume create '{vol_name}'" ) from e labels = vol.get("labels", []) args = [ "create", "--label", f"io.podman.compose.project={compose.project_name}", "--label", f"com.docker.compose.project={compose.project_name}", ] for item in norm_as_list(labels): args.extend(["--label", item]) driver = vol.get("driver") if driver: args.extend(["--driver", driver]) driver_opts = vol.get("driver_opts", {}) for opt, value in driver_opts.items(): args.extend(["--opt", f"{opt}={value}"]) args.append(vol_name) await compose.podman.output([], "volume", args) await compose.podman.output([], "volume", ["inspect", vol_name]) def mount_desc_to_mount_args(mount_desc: dict[str, Any]) -> str: mount_type: str | None = mount_desc.get("type") assert mount_type is not None vol = mount_desc.get("_vol") if mount_type == "volume" else None source = vol["name"] if vol else mount_desc.get("source") target = mount_desc["target"] opts = [] if mount_desc.get(mount_type, None): # TODO: we might need to add mount_dict[mount_type]["propagation"] = "z" mount_prop = mount_desc.get(mount_type, {}).get("propagation") if mount_prop: opts.append(f"{mount_type}-propagation={mount_prop}") if mount_desc.get("read_only", False): opts.append("ro") if mount_type == "tmpfs": tmpfs_opts = mount_desc.get("tmpfs", {}) tmpfs_size = tmpfs_opts.get("size") if tmpfs_size: opts.append(f"tmpfs-size={tmpfs_size}") tmpfs_mode = tmpfs_opts.get("mode") if tmpfs_mode: opts.append(f"tmpfs-mode={tmpfs_mode}") if mount_type == "bind": bind_opts = mount_desc.get("bind", {}) selinux = bind_opts.get("selinux") if selinux is not None: opts.append(selinux) # According to compose specifications https://docs.docker.com/reference/compose-file/services/#volumes # subpath can be used in image and volume mount type if mount_type in ["volume", "image"] and mount_desc.get(mount_type): subpath = mount_desc.get(mount_type, {}).get("subpath") if subpath is not None: opts.append(f"subpath={subpath}") opts_str = ",".join(opts) if mount_type == "bind": return f"type=bind,source={source},destination={target},{opts_str}".rstrip(",") if mount_type == "glob": return f"type=glob,source={source},destination={target},{opts_str}".rstrip(",") if mount_type == "volume": return f"type=volume,source={source},destination={target},{opts_str}".rstrip(",") if mount_type == "tmpfs": return f"type=tmpfs,destination={target},{opts_str}".rstrip(",") if mount_type == "image": return f"type=image,source={source},destination={target},{opts_str}".rstrip(",") raise ValueError("unknown mount type:" + mount_type) def ulimit_to_ulimit_args(ulimit: str | dict[str, Any] | list[Any], podman_args: list[str]) -> None: if ulimit is not None: # ulimit can be a single value, i.e. ulimit: host if isinstance(ulimit, str): podman_args.extend(["--ulimit", ulimit]) # or a dictionary or list: else: ulimit = norm_as_dict(ulimit) ulimit = [ f"{ulimit_key}={norm_ulimit(inner_value)}" # type: ignore[arg-type] for ulimit_key, inner_value in ulimit.items() # type: ignore[union-attr] ] for i in ulimit: podman_args.extend(["--ulimit", i]) def container_to_ulimit_args(cnt: dict[str, Any], podman_args: list[str]) -> None: ulimit_to_ulimit_args(cnt.get("ulimits", []), podman_args) def container_to_ulimit_build_args(cnt: dict[str, Any], podman_args: list[str]) -> None: build = cnt.get("build") if build is not None: ulimit_to_ulimit_args(build.get("ulimits", []), podman_args) def mount_desc_to_volume_args(mount_desc: dict[str, Any], srv_name: str) -> str: mount_type = mount_desc["type"] if mount_type not in ("bind", "volume", "glob"): raise ValueError("unknown mount type:" + mount_type) vol = mount_desc.get("_vol") if mount_type == "volume" else None source = vol["name"] if vol else mount_desc.get("source") if not source: raise ValueError(f"missing mount source for {mount_type} on {srv_name}") target = mount_desc["target"] opts: list[str] = [] propagations = set(filteri(mount_desc.get(mount_type, {}).get("propagation", "").split(","))) if mount_type != "bind": propagations.update(filteri(mount_desc.get("bind", {}).get("propagation", "").split(","))) opts.extend(propagations) # --volume, -v[=[[SOURCE-VOLUME|HOST-DIR:]CONTAINER-DIR[:OPTIONS]]] # [rw|ro] # [z|Z] # [[r]shared|[r]slave|[r]private]|[r]unbindable # [[r]bind] # [noexec|exec] # [nodev|dev] # [nosuid|suid] # [O] # [U] read_only = mount_desc.get("read_only") if read_only is not None: opts.append("ro" if read_only else "rw") if mount_type == "bind": bind_opts = mount_desc.get("bind", {}) selinux = bind_opts.get("selinux") if selinux is not None: opts.append(selinux) args = f"{source}:{target}" if opts: args += ":" + ",".join(opts) return args def get_mnt_dict( compose: PodmanCompose, cnt: dict[str, Any], volume: str | dict[str, Any] ) -> dict[str, Any]: srv_name = cnt["_service"] basedir = compose.dirname if isinstance(volume, str): volume = parse_short_mount(volume, basedir) return fix_mount_dict(compose, volume, srv_name) async def get_mount_args( compose: PodmanCompose, cnt: dict[str, Any], volume: str | dict[str, Any] ) -> list[str]: volume = get_mnt_dict(compose, cnt, volume) srv_name = cnt["_service"] mount_type = volume["type"] # By default, mount using -v is actually preferred over --mount. # In some case, options can only be set using --mount. # --mount is forced for type set in mount_over_volume_needed var. # mount_over_volume_needed = {"image", "glob", "volume"} await assert_volume(compose, volume) if compose.prefer_volume_over_mount and mount_type not in mount_over_volume_needed: if mount_type == "tmpfs": # TODO: --tmpfs /tmp:rw,size=787448k,mode=1777 args = volume["target"] tmpfs_opts = volume.get("tmpfs", {}) opts = [] size = tmpfs_opts.get("size") if size: opts.append(f"size={size}") mode = tmpfs_opts.get("mode") if mode: opts.append(f"mode={mode}") if opts: args += ":" + ",".join(opts) return ["--tmpfs", args] args = mount_desc_to_volume_args(volume, srv_name) return ["-v", args] args = mount_desc_to_mount_args(volume) return ["--mount", args] def get_secret_args( compose: PodmanCompose, cnt: dict[str, Any], secret: str | dict[str, Any], podman_is_building: bool = False, ) -> list[str]: """ podman_is_building: True if we are preparing arguments for an invocation of "podman build" False if we are preparing for something else like "podman run" """ assert compose.declared_secrets is not None secret_name = secret if isinstance(secret, str) else secret.get("source") if not secret_name or secret_name not in compose.declared_secrets.keys(): raise ValueError(f'ERROR: undeclared secret: "{secret}", service: {cnt["_service"]}') declared_secret = compose.declared_secrets[secret_name] source_file = declared_secret.get("file") x_podman_relabel = declared_secret.get("x-podman.relabel") dest_file = "" secret_opts = "" secret_target = None secret_uid = None secret_gid = None secret_mode = None secret_type = None if isinstance(secret, dict): secret_target = secret.get("target") secret_uid = secret.get("uid") secret_gid = secret.get("gid") secret_mode = secret.get("mode") secret_type = secret.get("type") source_env = declared_secret.get("environment") if source_env: if podman_is_building: secret_id = secret_target if secret_target else secret_name return ["--secret", f"id={secret_id},env={source_env}"] # TODO: Runtime support for environment secrets requires injecting the # value into the container (e.g. via podman cp or podman secret create), # which is beyond the scope of this arg-generating function. raise ValueError( f'ERROR: Secret "{secret_name}" uses environment source, ' "which is not supported for runtime secrets in podman-compose." ) if source_file: # assemble path for source file first, because we need it for all cases basedir = compose.dirname source_file = os.path.realpath(os.path.join(basedir, os.path.expanduser(source_file))) if podman_is_building: # pass file secrets to "podman build" with param --secret if not secret_target: secret_id = secret_name elif "/" in secret_target: raise ValueError( f'ERROR: Build secret "{secret_name}" has invalid target "{secret_target}". ' + "(Expected plain filename without directory as target.)" ) else: secret_id = secret_target volume_ref = ["--secret", f"id={secret_id},src={source_file}"] else: # pass file secrets to "podman run" as volumes if not secret_target: dest_file = f"/run/secrets/{secret_name}" elif not secret_target.startswith("/"): sec = secret_target if secret_target else secret_name dest_file = f"/run/secrets/{sec}" else: dest_file = secret_target mount_options = 'ro,rprivate,rbind' selinux_relabel_to_mount_option_map = {None: "", "z": ",z", "Z": ",Z"} try: mount_options += selinux_relabel_to_mount_option_map[x_podman_relabel] except KeyError as exc: raise ValueError( f'ERROR: Run secret "{secret_name}" has invalid "relabel" option related ' + f' to SELinux "{x_podman_relabel}". Expected "z" "Z" or nothing.' ) from exc volume_ref = ["--volume", f"{source_file}:{dest_file}:{mount_options}"] if secret_uid or secret_gid or secret_mode: sec = secret_target if secret_target else secret_name log.warning( "WARNING: Service %s uses secret %s with uid, gid, or mode." + " These fields are not supported by this implementation of the Compose file", cnt["_service"], sec, ) return volume_ref # v3.5 and up added external flag, earlier the spec # only required a name to be specified. # docker-compose does not support external secrets outside of swarm mode. # However accessing these via podman is trivial # since these commands are directly translated to # podman-create commands, albeit we can only support a 1:1 mapping # at the moment if declared_secret.get("external", False) or declared_secret.get("name"): secret_opts += f",uid={secret_uid}" if secret_uid else "" secret_opts += f",gid={secret_gid}" if secret_gid else "" secret_opts += f",mode={secret_mode}" if secret_mode else "" secret_opts += f",type={secret_type}" if secret_type else "" secret_opts += f",target={secret_target}" if secret_target else "" # having a custom name for the external secret is not supported ext_name = declared_secret.get("name") if ext_name and ext_name != secret_name: raise ValueError( f'ERROR: Custom name/target reference "{secret_name}" ' f'for mounted external secret "{ext_name}" is not supported' ) return ["--secret", f"{secret_name}{secret_opts}"] raise ValueError( 'ERROR: unparsable secret: "{}", service: "{}"'.format(secret_name, cnt["_service"]) ) def container_to_res_args(cnt: dict[str, Any], podman_args: list[str]) -> None: container_to_cpu_res_args(cnt, podman_args) container_to_gpu_res_args(cnt, podman_args) def container_to_gpu_res_args(cnt: dict[str, Any], podman_args: list[str]) -> None: # https://docs.docker.com/compose/gpu-support/ # https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/cdi-support.html deploy = cnt.get("deploy", {}) res = deploy.get("resources", {}) reservations = res.get("reservations", {}) devices = reservations.get("devices", []) gpu_on = False for device in devices: driver = device.get("driver") if driver is None: continue capabilities = device.get("capabilities") if capabilities is None: continue if driver != "nvidia" or "gpu" not in capabilities: continue count = device.get("count", "all") device_ids = device.get("device_ids", "all") if device_ids != "all" and len(device_ids) > 0: for device_id in device_ids: podman_args.extend(( "--device", f"nvidia.com/gpu={device_id}", )) gpu_on = True continue if count != "all": for device_id in range(count): podman_args.extend(( "--device", f"nvidia.com/gpu={device_id}", )) gpu_on = True continue podman_args.extend(( "--device", "nvidia.com/gpu=all", )) gpu_on = True if gpu_on: podman_args.append("--security-opt=label=disable") def container_to_cpu_res_args(cnt: dict[str, Any], podman_args: list[str]) -> None: # v2: https://docs.docker.com/compose/compose-file/compose-file-v2/#cpu-and-other-resources # cpus, cpu_shares, mem_limit, mem_reservation cpus_limit_v2 = try_float(cnt.get("cpus"), None) # type: ignore[arg-type] cpu_shares_v2 = try_int(cnt.get("cpu_shares"), None) # type: ignore[arg-type] mem_limit_v2 = cnt.get("mem_limit") mem_res_v2 = cnt.get("mem_reservation") # v3: https://docs.docker.com/compose/compose-file/compose-file-v3/#resources # spec: https://github.com/compose-spec/compose-spec/blob/master/deploy.md#resources # deploy.resources.{limits,reservations}.{cpus, memory} deploy = cnt.get("deploy", {}) res = deploy.get("resources", {}) limits = res.get("limits", {}) cpus_limit_v3 = try_float(limits.get("cpus"), None) mem_limit_v3 = limits.get("memory") reservations = res.get("reservations", {}) # cpus_res_v3 = try_float(reservations.get('cpus', None), None) mem_res_v3 = reservations.get("memory") # add args cpus = cpus_limit_v3 or cpus_limit_v2 if cpus: podman_args.extend(( "--cpus", str(cpus), )) if cpu_shares_v2: podman_args.extend(( "--cpu-shares", str(cpu_shares_v2), )) mem = mem_limit_v3 or mem_limit_v2 if mem: podman_args.extend(( "-m", str(mem).lower(), )) mem_res = mem_res_v3 or mem_res_v2 if mem_res: podman_args.extend(( "--memory-reservation", str(mem_res).lower(), )) # Handle pids limit from both container level and deploy section pids_limit = cnt.get("pids_limit") deploy_pids = limits.get("pids") # Ensure consistency between pids_limit and deploy.resources.limits.pids if pids_limit is not None and deploy_pids is not None: if str(pids_limit) != str(deploy_pids): raise ValueError( f"Inconsistent PIDs limit: pids_limit ({pids_limit}) and " f"deploy.resources.limits.pids ({deploy_pids}) must be the same" ) final_pids_limit = pids_limit if pids_limit is not None else deploy_pids if final_pids_limit is not None: podman_args.extend(["--pids-limit", str(final_pids_limit)]) def port_dict_to_str(port_desc: dict[str, Any]) -> str: # NOTE: `mode: host|ingress` is ignored cnt_port = port_desc.get("target") published = port_desc.get("published", "") host_ip = port_desc.get("host_ip") protocol = port_desc.get("protocol", "tcp") if not cnt_port: raise ValueError("target container port must be specified") if host_ip: ret = f"{host_ip}:{published}:{cnt_port}" else: ret = f"{published}:{cnt_port}" if published else f"{cnt_port}" if protocol != "tcp": ret += f"/{protocol}" return ret def norm_ports( ports_in: None | str | list[str | dict[str, Any] | int] | dict[str, Any] | int, ) -> list[str]: if not ports_in: ports_in = [] if isinstance(ports_in, str): ports_in = [ports_in] assert isinstance(ports_in, list) ports_out = [] for port in ports_in: if isinstance(port, dict): port = port_dict_to_str(port) elif isinstance(port, int): port = str(port) elif not isinstance(port, str): raise TypeError("port should be either string or dict") ports_out.append(port) return ports_out def get_network_create_args(net_desc: dict[str, Any], proj_name: str, net_name: str) -> list[str]: args = [ "create", "--label", f"io.podman.compose.project={proj_name}", "--label", f"com.docker.compose.project={proj_name}", ] # TODO: add more options here, like dns, ipv6, etc. labels = net_desc.get("labels", []) for item in norm_as_list(labels): args.extend(["--label", item]) if net_desc.get("internal"): args.append("--internal") driver = net_desc.get("driver") if driver: args.extend(("--driver", driver)) driver_opts = net_desc.get("driver_opts", {}) for key, value in driver_opts.items(): args.extend(("--opt", f"{key}={value}")) ipam = net_desc.get("ipam", {}) ipam_driver = ipam.get("driver") if ipam_driver and ipam_driver != "default": args.extend(("--ipam-driver", ipam_driver)) ipam_config_ls = ipam.get("config", []) if net_desc.get("enable_ipv6"): args.append("--ipv6") if net_desc.get("x-podman.disable_dns"): args.append("--disable-dns") if net_desc.get("x-podman.dns"): args.extend(( "--dns", ",".join(norm_as_list(net_desc.get("x-podman.dns"))), )) if net_desc.get("x-podman.routes"): routes = norm_as_list(net_desc.get("x-podman.routes")) for route in routes: args.extend(["--route", route]) if isinstance(ipam_config_ls, dict): ipam_config_ls = [ipam_config_ls] for ipam_config in ipam_config_ls: subnet = ipam_config.get("subnet") ip_range = ipam_config.get("ip_range") gateway = ipam_config.get("gateway") if subnet: args.extend(("--subnet", subnet)) if ip_range: args.extend(("--ip-range", ip_range)) if gateway: args.extend(("--gateway", gateway)) args.append(net_name) return args async def assert_cnt_nets(compose: PodmanCompose, cnt: dict[str, Any]) -> None: """ create missing networks """ net = cnt.get("network_mode") if net: return assert compose.project_name is not None cnt_nets = cnt.get("networks") if cnt_nets and isinstance(cnt_nets, dict): cnt_nets = list(cnt_nets.keys()) cnt_nets = norm_as_list(cnt_nets or compose.default_net) # type: ignore[arg-type] for net in cnt_nets: net_desc = compose.networks[net] or {} is_ext = net_desc.get("external") ext_desc = is_ext if isinstance(is_ext, dict) else {} default_net_name = default_network_name_for_project(compose, net, is_ext) net_name = ext_desc.get("name") or net_desc.get("name") or default_net_name try: await compose.podman.output([], "network", ["exists", net_name]) except subprocess.CalledProcessError as e: if is_ext: raise PodmanComposeError( f"External network [{net_name}] does not exist. " f"Create it first with: podman network create '{net_name}'" ) from e args = get_network_create_args(net_desc, compose.project_name, net_name) await compose.podman.output([], "network", args) await compose.podman.output([], "network", ["exists", net_name]) def get_net_args_from_network_mode(compose: PodmanCompose, cnt: dict[str, Any]) -> list[str]: net_args = [] net = cnt.get("network_mode") assert isinstance(net, str) service_name = cnt["service_name"] if "networks" in cnt: raise ValueError( f"networks and network_mode must not be present in the same service [{service_name}]" ) if net == "none": net_args.append("--network=none") elif net == "host": net_args.append(f"--network={net}") elif net.startswith("slirp4netns"): # Note: podman-specific network mode net_args.append(f"--network={net}") elif net == "private": # Note: podman-specific network mode net_args.append("--network=private") elif net.startswith("pasta"): # Note: podman-specific network mode net_args.append(f"--network={net}") elif net.startswith("ns:"): # Note: podman-specific network mode net_args.append(f"--network={net}") elif net.startswith("service:"): other_srv = net.split(":", 1)[1].strip() other_cnt = compose.container_names_by_service[other_srv][0] net_args.append(f"--network=container:{other_cnt}") elif net.startswith("container:"): other_cnt = net.split(":", 1)[1].strip() net_args.append(f"--network=container:{other_cnt}") elif net.startswith("bridge"): aliases_on_container = [service_name] if cnt.get("_aliases"): aliases_on_container.extend(cnt.get("_aliases")) # type: ignore[arg-type] net_options = [f"alias={alias}" for alias in aliases_on_container] mac_address = cnt.get("mac_address") if mac_address: net_options.append(f"mac={mac_address}") net = f"{net}," if ":" in net else f"{net}:" # type: ignore[operator] net_args.append(f"--network={net}{','.join(net_options)}") else: log.fatal("unknown network_mode [%s]", net) sys.exit(1) return net_args def get_net_args(compose: PodmanCompose, cnt: dict[str, Any]) -> list[str]: net = cnt.get("network_mode") if net: return get_net_args_from_network_mode(compose, cnt) return get_net_args_from_networks(compose, cnt) def get_net_args_from_networks(compose: PodmanCompose, cnt: dict[str, Any]) -> list[str]: net_args = [] mac_address = cnt.get("mac_address") service_name = cnt["service_name"] aliases_on_container = [service_name] aliases_on_container.extend(cnt.get("_aliases", [])) multiple_nets = cnt.get("networks", {}) if not multiple_nets: if not compose.default_net: # The bridge mode in podman is using the `podman` network. # It seems weird, but we should keep this behavior to avoid # breaking changes. net_options = [f"alias={alias}" for alias in aliases_on_container] if mac_address: net_options.append(f"mac={mac_address}") net_args.append(f"--network=bridge:{','.join(net_options)}") return net_args multiple_nets = {compose.default_net: {}} # networks can be specified as a dict with config per network or as a plain list without # config. Support both cases by converting the plain list to a dict with empty config. if is_list(multiple_nets): multiple_nets = {net: {} for net in multiple_nets} else: multiple_nets = {net: net_config or {} for net, net_config in multiple_nets.items()} # if a mac_address was specified on the container level, we need to check that it is not # specified on the network level as well if mac_address is not None: for net_config in multiple_nets.values(): network_mac = net_config.get("mac_address", net_config.get("x-podman.mac_address")) if network_mac is not None: raise RuntimeError( f"conflicting mac addresses {mac_address} and {network_mac}:" "specifying mac_address on both container and network level " "is not supported" ) for net_, net_config_ in multiple_nets.items(): net_desc = compose.networks.get(net_) or {} is_ext = net_desc.get("external") ext_desc: dict[str, Any] = is_ext if isinstance(is_ext, str) else {} # type: ignore[assignment] default_net_name = default_network_name_for_project(compose, net_, is_ext) # type: ignore[arg-type] net_name = ext_desc.get("name") or net_desc.get("name") or default_net_name interface_name = net_config_.get("x-podman.interface_name") ipv4 = net_config_.get("ipv4_address") ipv6 = net_config_.get("ipv6_address") # Note: mac_address is supported by compose spec now, and x-podman.mac_address # is only for backward compatibility # https://github.com/compose-spec/compose-spec/blob/main/05-services.md#mac_address mac = net_config_.get("mac_address", net_config_.get("x-podman.mac_address")) aliases_on_net = norm_as_list(net_config_.get("aliases", [])) # if a mac_address was specified on the container level, apply it to the first network # This works for Python > 3.6, because dict insert ordering is preserved, so we are # sure that the first network we encounter here is also the first one specified by # the user if mac is None and mac_address is not None: mac = mac_address mac_address = None net_options = [] if interface_name: net_options.append(f"interface_name={interface_name}") if ipv4: net_options.append(f"ip={ipv4}") if ipv6: net_options.append(f"ip6={ipv6}") if mac: net_options.append(f"mac={mac}") # Container level service aliases net_options.extend([f"alias={alias}" for alias in aliases_on_container]) # network level service aliases if aliases_on_net: net_options.extend([f"alias={alias}" for alias in aliases_on_net]) if net_options: net_args.append(f"--network={net_name}:" + ",".join(net_options)) else: net_args.append(f"--network={net_name}") return net_args async def container_to_args( compose: PodmanCompose, cnt: dict[str, Any], detached: bool = True, no_deps: bool = False ) -> list[str]: # TODO: double check -e , --add-host, -v, --read-only dirname = compose.dirname name = cnt["name"] podman_args = [f"--name={name}"] if detached: podman_args.append("-d") pod = cnt.get("pod", "") if pod: podman_args.append(f"--pod={pod}") deps = [] for dep_srv in cnt.get("_deps", []): deps.extend(compose.container_names_by_service.get(dep_srv.name, [])) if deps and not no_deps: deps_csv = ",".join(deps) podman_args.append(f"--requires={deps_csv}") sec = norm_as_list(cnt.get("security_opt")) for sec_item in sec: podman_args.extend(["--security-opt", sec_item]) ann = norm_as_list(cnt.get("annotations")) for a in ann: podman_args.extend(["--annotation", a]) if cnt.get("read_only"): podman_args.append("--read-only") if cnt.get("http_proxy") is False: podman_args.append("--http-proxy=false") for i in cnt.get("labels", []): podman_args.extend(["--label", i]) for c in cnt.get("cap_add", []): podman_args.extend(["--cap-add", c]) for c in cnt.get("cap_drop", []): podman_args.extend(["--cap-drop", c]) for item in cnt.get("group_add", []): podman_args.extend(["--group-add", item]) for item in cnt.get("devices", []): podman_args.extend(["--device", item]) for item in cnt.get("device_cgroup_rules", []): podman_args.extend(["--device-cgroup-rule", item]) for item in norm_as_list(cnt.get("dns")): podman_args.extend(["--dns", item]) for item in norm_as_list(cnt.get("dns_opt")): podman_args.extend(["--dns-opt", item]) for item in norm_as_list(cnt.get("dns_search")): podman_args.extend(["--dns-search", item]) env_file = cnt.get("env_file", []) if isinstance(env_file, (dict, str)): env_file = [env_file] for i in env_file: if isinstance(i, str): i = {"path": i} path = i["path"] required = i.get("required", True) i = os.path.realpath(os.path.join(dirname, path)) if not os.path.exists(i): if not required: continue raise ValueError(f"Env file at {i} does not exist") dotenv_dict = {} dotenv_dict = dotenv_to_dict(i) env = norm_as_list(dotenv_dict) for e in env: podman_args.extend(["-e", e]) env = norm_as_list(cnt.get("environment", {})) for e in env: # new environment variable is set if "=" in e: podman_args.extend(["-e", e]) else: # environment variable already exists in environment so pass its value if e in compose.environ.keys(): podman_args.extend(["-e", f"{e}={compose.environ[e]}"]) tmpfs_ls = cnt.get("tmpfs", []) if isinstance(tmpfs_ls, str): tmpfs_ls = [tmpfs_ls] for i in tmpfs_ls: podman_args.extend(["--tmpfs", i]) for volume in cnt.get("volumes", []): podman_args.extend(await get_mount_args(compose, cnt, volume)) await assert_cnt_nets(compose, cnt) podman_args.extend(get_net_args(compose, cnt)) log_config = cnt.get("logging") if log_config is not None: podman_args.append(f'--log-driver={log_config.get("driver", "k8s-file")}') log_opts = log_config.get("options", {}) podman_args += [f"--log-opt={name}={value}" for name, value in log_opts.items()] for secret in cnt.get("secrets", []): podman_args.extend(get_secret_args(compose, cnt, secret)) for i in cnt.get("extra_hosts", []): podman_args.extend(["--add-host", i]) for i in cnt.get("expose", []): podman_args.extend(["--expose", i]) if cnt.get("publishall"): podman_args.append("-P") ports = cnt.get("ports", []) if isinstance(ports, str): ports = [ports] for port in ports: if isinstance(port, dict): port = port_dict_to_str(port) elif not isinstance(port, str): raise TypeError("port should be either string or dict") podman_args.extend(["-p", port]) userns_mode = cnt.get("userns_mode") if userns_mode is not None: podman_args.extend(["--userns", userns_mode]) user = cnt.get("user") if user is not None: podman_args.extend(["-u", user]) if cnt.get("working_dir") is not None: podman_args.extend(["-w", cnt["working_dir"]]) if cnt.get("hostname"): podman_args.extend(["--hostname", cnt["hostname"]]) if cnt.get("shm_size"): podman_args.extend(["--shm-size", str(cnt["shm_size"])]) if cnt.get("stdin_open"): podman_args.append("-i") if cnt.get("stop_signal"): podman_args.extend(["--stop-signal", cnt["stop_signal"]]) sysctls = cnt.get("sysctls") if sysctls is not None: if isinstance(sysctls, dict): for sysctl, value in sysctls.items(): podman_args.extend(["--sysctl", f"{sysctl}={value}"]) elif isinstance(sysctls, list): for i in sysctls: podman_args.extend(["--sysctl", i]) else: raise TypeError("sysctls should be either dict or list") if cnt.get("tty"): podman_args.append("--tty") if cnt.get("privileged"): podman_args.append("--privileged") if cnt.get("pid"): podman_args.extend(["--pid", cnt["pid"]]) pull_policy = cnt.get("pull_policy") if pull_policy is not None and pull_policy != "build": podman_args.append(f"--pull={pull_policy}") if cnt.get("restart") is not None: podman_args.extend(["--restart", cnt["restart"]]) container_to_ulimit_args(cnt, podman_args) container_to_res_args(cnt, podman_args) # currently podman shipped by fedora does not package this if cnt.get("init"): podman_args.append("--init") if cnt.get("init-path"): podman_args.extend(["--init-path", cnt["init-path"]]) entrypoint = cnt.get("entrypoint") if entrypoint is not None: if isinstance(entrypoint, str): entrypoint = shlex.split(entrypoint) podman_args.extend(["--entrypoint", json.dumps(entrypoint)]) platform = cnt.get("platform") if platform is not None: podman_args.extend(["--platform", platform]) if cnt.get("runtime"): podman_args.extend(["--runtime", cnt["runtime"]]) cpuset = cnt.get("cpuset") if cpuset is not None: podman_args.extend(["--cpuset-cpus", cpuset]) # WIP: healthchecks are still work in progress healthcheck = cnt.get("healthcheck", {}) if not isinstance(healthcheck, dict): raise ValueError("'healthcheck' must be a key-value mapping") healthcheck_disable = healthcheck.get("disable", False) healthcheck_test = healthcheck.get("test") if healthcheck_disable: healthcheck_test = ["NONE"] if healthcheck_test: # If it's a string, it's equivalent to specifying CMD-SHELL if isinstance(healthcheck_test, str): # podman does not add shell to handle command with whitespace podman_args.extend([ "--health-cmd", json.dumps(["CMD-SHELL", healthcheck_test]), ]) elif is_list(healthcheck_test): healthcheck_test = healthcheck_test.copy() # If it's a list, first item is either NONE, CMD or CMD-SHELL. healthcheck_type = healthcheck_test.pop(0) if healthcheck_type == "NONE": podman_args.append("--no-healthcheck") elif healthcheck_type == "CMD": podman_args.extend(["--health-cmd", json.dumps(healthcheck_test)]) elif healthcheck_type == "CMD-SHELL": if len(healthcheck_test) != 1: raise ValueError("'CMD-SHELL' takes a single string after it") podman_args.extend(["--health-cmd", json.dumps(healthcheck_test)]) else: raise ValueError( f"unknown healthcheck test type [{healthcheck_type}], " "expecting NONE, CMD or CMD-SHELL." ) else: raise ValueError("'healthcheck.test' either a string or a list") # interval, timeout, start_period, and start_interval are specified as durations. if "interval" in healthcheck: podman_args.extend(["--health-interval", healthcheck["interval"]]) if "timeout" in healthcheck: podman_args.extend(["--health-timeout", healthcheck["timeout"]]) if "start_period" in healthcheck: podman_args.extend(["--health-start-period", healthcheck["start_period"]]) if "start_interval" in healthcheck: podman_args.extend(["--health-startup-interval", healthcheck["start_interval"]]) # convert other parameters to string if "retries" in healthcheck: podman_args.extend(["--health-retries", str(healthcheck["retries"])]) # handle podman extension if 'x-podman' in cnt: raise ValueError( 'Configuration under x-podman has been migrated to x-podman.uidmaps and ' 'x-podman.gidmaps fields' ) rootfs_mode = False for uidmap in cnt.get('x-podman.uidmaps', []): podman_args.extend(["--uidmap", uidmap]) for gidmap in cnt.get('x-podman.gidmaps', []): podman_args.extend(["--gidmap", gidmap]) if cnt.get("x-podman.no_hosts", False): podman_args.extend(["--no-hosts"]) rootfs = cnt.get('x-podman.rootfs') if rootfs is not None: rootfs_mode = True podman_args.extend(["--rootfs", rootfs]) log.warning("WARNING: x-podman.rootfs and image both specified, image field ignored") if not rootfs_mode: podman_args.append(cnt["image"]) # command, ..etc. command = cnt.get("command") if command is not None: if isinstance(command, str): podman_args.extend(shlex.split(command)) else: podman_args.extend([str(i) for i in command]) return podman_args class ServiceDependencyCondition(Enum): CONFIGURED = "configured" CREATED = "created" EXITED = "exited" HEALTHY = "healthy" INITIALIZED = "initialized" PAUSED = "paused" REMOVING = "removing" RUNNING = "running" STOPPED = "stopped" STOPPING = "stopping" UNHEALTHY = "unhealthy" @classmethod def from_value(cls, value: str) -> ServiceDependencyCondition: # Check if the value exists in the enum for member in cls: if member.value == value: return member # Check if this is a value coming from a reference docker_to_podman_cond = { "service_healthy": ServiceDependencyCondition.HEALTHY, "service_started": ServiceDependencyCondition.RUNNING, "service_completed_successfully": ServiceDependencyCondition.STOPPED, } try: return docker_to_podman_cond[value] except KeyError: # pylint: disable-next=raise-missing-from raise ValueError(f"Value '{value}' is not a valid condition for a service dependency") class ServiceDependency: def __init__(self, name: str, condition: str) -> None: self._name = name self._condition = ServiceDependencyCondition.from_value(condition) @property def name(self) -> str: return self._name @property def condition(self) -> ServiceDependencyCondition: return self._condition def __hash__(self) -> int: # Compute hash based on the frozenset of items to ensure order does not matter return hash(('name', self._name) + ('condition', self._condition)) def __eq__(self, other: Any) -> bool: # Compare equality based on dictionary content if isinstance(other, ServiceDependency): return self._name == other.name and self._condition == other.condition return False def rec_deps( services: dict[str, Any], service_name: str, start_point: str | None = None ) -> set[ServiceDependency]: """ return all dependencies of service_name recursively """ if not start_point: start_point = service_name deps = services[service_name]["_deps"] for dep_name in deps.copy(): # avoid A depends on A if dep_name.name == service_name: continue dep_srv = services.get(dep_name.name) if not dep_srv: continue # NOTE: avoid creating loops, A->B->A if any(start_point == x.name for x in dep_srv["_deps"]): continue new_deps = rec_deps(services, dep_name.name, start_point) deps.update(new_deps) return deps def calc_dependents(services: dict[str, Any]) -> None: for name, srv in services.items(): deps: set[ServiceDependency] = srv.get("_deps", set()) for dep in deps: if dep.name in services: services[dep.name].setdefault(DependField.DEPENDENTS, set()).add( ServiceDependency(name, dep.condition.value) ) def flat_deps(services: dict[str, Any], with_extends: bool = False) -> None: """ create dependencies "_deps" or update it recursively for all services """ for name, srv in services.items(): # parse dependencies for each service deps: set[ServiceDependency] = set() srv["_deps"] = deps # TODO: manage properly the dependencies coming from base services when extended if with_extends: ext = srv.get("extends", {}).get("service") if ext: if ext != name: deps.add(ServiceDependency(ext, "service_started")) continue # the compose file has been normalized. depends_on, if exists, can only be a dictionary # the normalization adds a "service_started" condition by default deps_ls = srv.get("depends_on", {}) deps_ls = [ServiceDependency(k, v["condition"]) for k, v in deps_ls.items()] deps.update(deps_ls) # parse link to get service name and remove alias links_ls = srv.get("links", []) if not is_list(links_ls): links_ls = [links_ls] deps.update([ServiceDependency(c.split(":")[0], "service_started") for c in links_ls]) for c in links_ls: if ":" in c: dep_name, dep_alias = c.split(":") if "_aliases" not in services[dep_name]: services[dep_name]["_aliases"] = set() services[dep_name]["_aliases"].add(dep_alias) # expand the dependencies on each service for name, srv in services.items(): rec_deps(services, name) calc_dependents(services) ################### # Override and reset tags ################### class OverrideTag(yaml.YAMLObject): yaml_dumper = yaml.SafeDumper yaml_loader = yaml.SafeLoader yaml_tag = '!override' def __init__(self, value: Any) -> None: self.value: dict[Any, Any] | list[Any] # type: ignore[no-redef] if len(value) > 0 and isinstance(value[0], tuple): self.value = {} # item is a tuple representing service's lower level key and value for item in value: # value can actually be a list, then all the elements from the list have to be # collected if isinstance(item[1].value, list): self.value[item[0].value] = [i.value for i in item[1].value] # type: ignore[index] else: self.value[item[0].value] = item[1].value # type: ignore[index] else: self.value = [item.value for item in value] # type: ignore[union-attr] @classmethod def from_yaml(cls, loader: Any, node: Any) -> OverrideTag: return OverrideTag(node.value) @classmethod def to_yaml(cls, dumper: Any, data: OverrideTag) -> str: return dumper.represent_scalar(cls.yaml_tag, data.value) class ResetTag(yaml.YAMLObject): yaml_dumper = yaml.SafeDumper yaml_loader = yaml.SafeLoader yaml_tag = '!reset' @classmethod def to_json(cls) -> str: return cls.yaml_tag @classmethod def from_yaml(cls, loader: Any, node: Any) -> ResetTag: return ResetTag() @classmethod def to_yaml(cls, dumper: Any, data: ResetTag) -> str: return dumper.represent_scalar(cls.yaml_tag, '') async def wait_with_timeout(coro: Any, timeout: int | float) -> Any: """ Asynchronously waits for the given coroutine to complete with a timeout. Args: coro: The coroutine to wait for. timeout (int or float): The maximum number of seconds to wait for. Raises: TimeoutError: If the coroutine does not complete within the specified timeout. """ try: return await asyncio.wait_for(coro, timeout) except asyncio.TimeoutError as exc: raise TimeoutError from exc ################### # podman and compose classes ################### @dataclass class ExistingContainer: name: str id: str service_name: str config_hash: str exited: bool state: str status: str class Podman: def __init__( self, compose: PodmanCompose, podman_path: str = "podman", dry_run: bool = False, semaphore: asyncio.Semaphore = asyncio.Semaphore(sys.maxsize), ) -> None: self.compose = compose self.podman_path = podman_path self.dry_run = dry_run self.semaphore = semaphore async def output( self, podman_args: list[str], cmd: str = "", cmd_args: list[str] | None = None ) -> bytes: async with self.semaphore: cmd_args = cmd_args or [] xargs = self.compose.get_podman_args(cmd) if cmd else [] cmd_ls = [self.podman_path, *podman_args, cmd] + xargs + cmd_args log.info(str(cmd_ls)) p = await asyncio.create_subprocess_exec( *cmd_ls, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) stdout_data, stderr_data = await p.communicate() assert p.returncode is not None if p.returncode == 0: return stdout_data raise subprocess.CalledProcessError(p.returncode, " ".join(cmd_ls), stderr_data) async def _readchunk(self, reader: asyncio.StreamReader) -> bytes: try: return await reader.readuntil(b"\n") except asyncio.exceptions.IncompleteReadError as e: return e.partial except asyncio.exceptions.LimitOverrunError as e: return await reader.read(e.consumed) async def _format_stream( self, reader: asyncio.StreamReader, sink: Any, log_formatter: str ) -> None: line_ongoing = False decoder = codecs.getincrementaldecoder("utf-8")() def _formatted_print_with_nl(s: str) -> None: if line_ongoing: print(s, file=sink, end="\n") else: print(log_formatter, s, file=sink, end="\n") def _formatted_print_without_nl(s: str) -> None: if line_ongoing: print(s, file=sink, end="") else: print(log_formatter, s, file=sink, end="") while not reader.at_eof(): chunk = await self._readchunk(reader) parts = chunk.split(b"\n") for i, part in enumerate(parts): # Iff part is last and non-empty, we leave an ongoing line to be completed later if i < len(parts) - 1: _formatted_print_with_nl(decoder.decode(part)) line_ongoing = False elif len(part) > 0: _formatted_print_without_nl(decoder.decode(part)) line_ongoing = True else: # When we have an empty string as the last part: # Do nothing if it's the only part (=the chunk is empty). # If it's 2nd or later part, an empty new line will be redundant. pass buf, _ = decoder.getstate() if len(buf) > 0: log.error("Incomplete multibyte character ignored in log output: %s", buf) if line_ongoing: # Make sure the last line ends with EOL print(file=sink, end="\n") def exec( self, podman_args: list[str], cmd: str = "", cmd_args: list[str] | None = None, ) -> None: cmd_args = list(map(str, cmd_args or [])) xargs = self.compose.get_podman_args(cmd) if cmd else [] cmd_ls = [self.podman_path, *podman_args, cmd] + xargs + cmd_args log.info(" ".join([str(i) for i in cmd_ls])) os.execlp(self.podman_path, *cmd_ls) async def run( # pylint: disable=dangerous-default-value self, podman_args: list[str], cmd: str = "", cmd_args: list[str] | None = None, log_formatter: str | None = None, *, # Intentionally mutable default argument to hold references to tasks task_reference: set[asyncio.Task] = set(), ) -> int | None: async with self.semaphore: cmd_args = list(map(str, cmd_args or [])) xargs = self.compose.get_podman_args(cmd) if cmd else [] cmd_ls = [self.podman_path, *podman_args, cmd] + xargs + cmd_args log.info(" ".join([str(i) for i in cmd_ls])) if self.dry_run: return None if log_formatter is not None: p = await asyncio.create_subprocess_exec( *cmd_ls, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, close_fds=False, ) # pylint: disable=consider-using-with assert p.stdout is not None assert p.stderr is not None # This is hacky to make the tasks not get garbage collected # https://github.com/python/cpython/issues/91887 out_t = asyncio.create_task( self._format_stream(p.stdout, sys.stdout, log_formatter) ) task_reference.add(out_t) out_t.add_done_callback(task_reference.discard) err_t = asyncio.create_task( self._format_stream(p.stderr, sys.stdout, log_formatter) ) task_reference.add(err_t) err_t.add_done_callback(task_reference.discard) else: p = await asyncio.create_subprocess_exec(*cmd_ls, close_fds=False) # pylint: disable=consider-using-with try: exit_code = await p.wait() except asyncio.CancelledError: log.info("Sending termination signal") p.terminate() try: exit_code = await wait_with_timeout(p.wait(), 10) except TimeoutError: log.warning("container did not shut down after 10 seconds, killing") p.kill() exit_code = await p.wait() log.info("exit code: %s", exit_code) return exit_code async def network_ls(self) -> list[str]: output = ( await self.output( [], "network", [ "ls", "--noheading", "--filter", f"label=io.podman.compose.project={self.compose.project_name}", "--format", "{{.Name}}", ], ) ).decode() networks = output.splitlines() return networks async def volume_ls(self) -> list[str]: output = ( await self.output( [], "volume", [ "ls", "--noheading", "--filter", f"label=io.podman.compose.project={self.compose.project_name}", "--format", "{{.Name}}", ], ) ).decode("utf-8") volumes = output.splitlines() return volumes async def existing_containers(self, project_name: str) -> dict[str, ExistingContainer]: output = await self.output( [], "ps", [ "--filter", f"label=io.podman.compose.project={project_name}", "-a", "--format", "json", ], ) containers = json.loads(output) return { c.get("Names")[0]: ExistingContainer( name=c.get("Names")[0], id=c.get("Id"), service_name=( c.get("Labels", {}).get("io.podman.compose.service", "") or c.get("Labels", {}).get("com.docker.compose.service", "") ), config_hash=c.get("Labels", {}).get("io.podman.compose.config-hash", ""), exited=c.get("Exited", False), state=c.get("State", ""), status=c.get("Status", ""), ) for c in containers } def normalize_service(service: dict[str, Any], sub_dir: str = "") -> dict[str, Any]: if isinstance(service, ResetTag): return service if isinstance(service, OverrideTag): service = service.value # type: ignore[assignment] if "build" in service: build = service["build"] if isinstance(build, str): service["build"] = {"context": build} if sub_dir and "build" in service: build = service["build"] context = build.get("context", "") if context or sub_dir: if context.startswith("./"): context = context[2:] if sub_dir: context = os.path.join(sub_dir, context) context = context.rstrip("/") if not context: context = "." service["build"]["context"] = context if "build" in service and "additional_contexts" in service["build"]: if isinstance(build["additional_contexts"], dict): new_additional_contexts = [] for k, v in build["additional_contexts"].items(): new_additional_contexts.append(f"{k}={v}") build["additional_contexts"] = new_additional_contexts if "build" in service and "args" in service["build"]: if isinstance(build["args"], dict): build["args"] = norm_as_list(build["args"]) for key in ("command", "entrypoint"): if key in service: if isinstance(service[key], str): service[key] = shlex.split(service[key]) for key in ("env_file", "security_opt", "volumes"): if key not in service: continue if isinstance(service[key], str): service[key] = [service[key]] if "security_opt" in service: sec_ls = service["security_opt"] for ix, item in enumerate(sec_ls): if item in ("seccomp:unconfined", "apparmor:unconfined"): sec_ls[ix] = item.replace(":", "=") for key in ("environment", "labels"): if key not in service: continue service[key] = norm_as_dict(service[key]) if "extends" in service: extends = service["extends"] if isinstance(extends, str): extends = {"service": extends} service["extends"] = extends if "depends_on" in service: # deps should become a dictionary of dependencies deps = service["depends_on"] if isinstance(deps, ResetTag): return service if isinstance(deps, str): deps = {deps: {}} elif is_list(deps): deps = {x: {} for x in deps} elif isinstance(deps, OverrideTag): deps.value = {x: {} for x in deps.value} # the dependency service_started is set by default # unless requested otherwise. for k, v in deps.items() if not isinstance(deps, OverrideTag) else deps.value.items(): # type: ignore[union-attr] v.setdefault('condition', 'service_started') service["depends_on"] = deps if "volumes" in service and sub_dir: new_volumes = [] for v in service["volumes"]: if isinstance(v, str): if is_relative_ref(v): v = os.path.join(sub_dir, v) elif isinstance(v, dict): source = v["source"] if is_relative_ref(source): v["source"] = os.path.join(sub_dir, source) new_volumes.append(v) service["volumes"] = new_volumes return service def normalize(compose: dict[str, Any]) -> dict[str, Any]: """ convert compose dict of some keys from string or dicts into arrays """ services = compose.get("services", {}) or {} for service in services.values(): normalize_service(service) return compose def normalize_service_final(service: dict[str, Any], project_dir: str) -> dict[str, Any]: if "build" in service: build = service["build"] context = build if isinstance(build, str) else build.get("context", ".") if not is_context_git_url(context): context = os.path.normpath(os.path.join(project_dir, context)) if not isinstance(service["build"], dict): service["build"] = {} service["build"]["context"] = context return service def normalize_final(compose: dict[str, Any], project_dir: str) -> dict[str, Any]: services = compose.get("services", {}) for service in services.values(): normalize_service_final(service, project_dir) return compose def clone(value: Any) -> Any: return value.copy() if is_list(value) or isinstance(value, dict) else value def rec_merge_one(target: dict[str, Any], source: dict[str, Any]) -> dict[str, Any]: """ update target from source recursively """ done = set() remove = set() for key, value in source.items(): if key in target: continue target[key] = clone(value) done.add(key) for key, value in target.items(): if key in done: continue if key not in source: if isinstance(value, ResetTag): log.info("Unneeded !reset found for [%s]", key) remove.add(key) if isinstance(value, OverrideTag): log.info("Unneeded !override found for [%s] with value '%s'", key, value) target[key] = clone(value.value) continue value2 = source[key] if isinstance(value, ResetTag) or isinstance(value2, ResetTag): remove.add(key) continue if isinstance(value, OverrideTag) or isinstance(value2, OverrideTag): target[key] = ( clone(value.value) if isinstance(value, OverrideTag) else clone(value2.value) ) continue if key in ("command", "entrypoint"): target[key] = clone(value2) continue # We can merge dicts into an empty tag. E.g.: # vol_1: # and # vol_1: # driver: "abcdef" if value is None and isinstance(value2, dict): target[key] = value = {} if not isinstance(value2, type(value)): value_type = type(value) value2_type = type(value2) raise ValueError(f"can't merge value of [{key}] of type {value_type} and {value2_type}") if is_list(value2): if key == "volumes": # clean duplicate mount targets pts = {v.split(":", 2)[1] for v in value2 if ":" in v} del_ls = [ ix for (ix, v) in enumerate(value) if ":" in v and v.split(":", 2)[1] in pts ] for ix in reversed(del_ls): del value[ix] value.extend(value2) else: value.extend(value2) elif isinstance(value2, dict): rec_merge_one(value, value2) else: target[key] = value2 for key in remove: del target[key] return target def rec_merge(target: dict[str, Any], *sources: dict[str, Any]) -> dict[str, Any]: """ update target recursively from sources """ for source in sources: ret = rec_merge_one(target, source) return ret def load_yaml_or_die(file_path: str, stream: Any) -> dict[str, Any]: try: return yaml.safe_load(stream) except yaml.scanner.ScannerError as e: log.fatal("Compose file contains an error:\n%s", e) log.info("Compose file %s contains an error:", file_path, exc_info=e) sys.exit(1) def resolve_extends( services: dict[str, Any], service_names: list[str], environ: dict[str, Any] ) -> None: for name in service_names: service = services[name] ext = service.get("extends", {}) if isinstance(ext, str): ext = {"service": ext} from_service_name = ext.get("service") if not from_service_name: continue filename = ext.get("file") if filename: if filename.startswith("./"): filename = filename[2:] with open(filename, encoding="utf-8") as f: content = load_yaml_or_die(filename, f) or {} if "services" in content: content = content["services"] subdirectory = os.path.dirname(filename) content = rec_subs(content, environ) from_service = content.get(from_service_name, {}) or {} normalize_service(from_service, subdirectory) else: from_service = services.get(from_service_name, {}).copy() try: del from_service["_deps"] except KeyError as e: raise KeyError( f"{from_service_name} not found at services.{name}.extends definition" ) from e try: del from_service["extends"] except KeyError: pass new_service = rec_merge({}, from_service, service) services[name] = new_service def dotenv_to_dict(dotenv_path: str) -> dict[str, str | None]: if not os.path.isfile(dotenv_path): return {} return dotenv_values(dotenv_path) COMPOSE_DEFAULT_LS = [ "compose.yaml", "compose.yml", "compose.override.yaml", "compose.override.yml", "podman-compose.yaml", "podman-compose.yml", "docker-compose.yml", "docker-compose.yaml", "docker-compose.override.yml", "docker-compose.override.yaml", "container-compose.yml", "container-compose.yaml", "container-compose.override.yml", "container-compose.override.yaml", ] class PodmanCompose: class XPodmanSettingKey(Enum): DOCKER_COMPOSE_COMPAT = "docker_compose_compat" DEFAULT_NET_NAME_COMPAT = "default_net_name_compat" DEFAULT_NET_BEHAVIOR_COMPAT = "default_net_behavior_compat" NAME_SEPARATOR_COMPAT = "name_separator_compat" IN_POD = "in_pod" POD_ARGS = "pod_args" def __init__(self) -> None: self.podman: Podman self.podman_version: str | None = None self.environ: dict[str, str] = {} self.exit_code = None self.commands: dict[str, Any] = {} self.global_args = argparse.Namespace() self.project_name: str | None = None self.dirname: str self.pods: list[Any] self.containers: list[Any] = [] self.vols: dict[str, Any] | None = None self.networks: dict[str, Any] = {} self.default_net: str | None = "default" self.declared_secrets: dict[str, Any] | None = None self.container_names_by_service: dict[str, list[str]] self.container_by_name: dict[str, Any] self.services: dict[str, Any] self.all_services: set[Any] = set() self.prefer_volume_over_mount = True self.x_podman: dict[PodmanCompose.XPodmanSettingKey, Any] = {} self.merged_yaml: Any self.yaml_hash = "" self.console_colors = [ "\x1b[1;32m", "\x1b[1;33m", "\x1b[1;34m", "\x1b[1;35m", "\x1b[1;36m", ] def assert_services(self, services: dict[str, Any]) -> None: if isinstance(services, str): services = [services] given = set(services or []) missing = given - self.all_services if missing: missing_csv = ",".join(missing) log.warning("missing services [%s]", missing_csv) sys.exit(1) def get_podman_args(self, cmd: str) -> list[str]: xargs = [] for args in self.global_args.podman_args: xargs.extend(shlex.split(args)) cmd_norm = cmd if cmd != "create" else "run" cmd_args = self.global_args.__dict__.get(f"podman_{cmd_norm}_args", []) for args in cmd_args: xargs.extend(shlex.split(args)) return xargs async def run(self, argv: list[str] | None = None) -> None: log.info("podman-compose version: %s", __version__) args = self._parse_args(argv) podman_path = args.podman_path if podman_path != "podman": if os.path.isfile(podman_path) and os.access(podman_path, os.X_OK): podman_path = os.path.realpath(podman_path) else: # this also works if podman hasn't been installed now if args.dry_run is False: log.fatal("Binary %s has not been found.", podman_path) sys.exit(1) self.podman = Podman(self, podman_path, args.dry_run, asyncio.Semaphore(args.parallel)) if not args.dry_run: # just to make sure podman is running try: self.podman_version = (await self.podman.output(["--version"], "", [])).decode( "utf-8" ).strip() or "" self.podman_version = (self.podman_version.split() or [""])[-1] except (subprocess.CalledProcessError, FileNotFoundError) as e: msg = str(e) if isinstance(e, subprocess.CalledProcessError) and e.output: msg += f": {e.output.decode('utf-8')}" log.error("failed to check if podman is installed: %s", msg) self.podman_version = None if not self.podman_version: log.fatal( "It seems that you either do not have `podman` installed " "or the `podman version` command failed." ) sys.exit(1) log.info("using podman version: %s", self.podman_version) cmd_name = args.command compose_required = cmd_name != "version" and ( cmd_name != "systemd" or args.action != "create-unit" ) if compose_required: self._parse_compose_file() cmd = self.commands[cmd_name] retcode = await cmd(self, args) if isinstance(retcode, int): sys.exit(retcode) def config_hash(self, service: dict[str, Any]) -> str: """ Returns a hash of the service configuration. This is used to detect changes in the service configuration. """ if "_config_hash" in service: return service["_config_hash"] # Use a stable representation of the service configuration jsonable_service = self.original_configuration(service) config_str = json.dumps(jsonable_service, sort_keys=True) service["_config_hash"] = hashlib.sha256(config_str.encode('utf-8')).hexdigest() return service["_config_hash"] def original_configuration(self, configuration: dict[Any, Any]) -> dict[str, Any]: """ Returns the original configuration without any overrides or resets. This is used to get a stable representation of the configuration. (can be converted to a JSON string) """ return { # recurse if the value is also a dictionary key: self.original_configuration(value) if isinstance(value, dict) else value # iterate over the configuration items for key, value in configuration.items() # filter if ( # only string keys isinstance(key, str) # which do not start with an underscore and not key.startswith("_") # also exclude !override and not isinstance(value, OverrideTag) # and !reset tags and not isinstance(value, ResetTag) ) } def resolve_pod_name(self) -> str | None: # Priorities: # - Command line --in-pod # - docker-compose.yml x-podman.in_pod # - Default value of true in_pod_arg = self.global_args.in_pod or self.x_podman.get( PodmanCompose.XPodmanSettingKey.IN_POD, True ) in_pod_arg_parsed = try_parse_bool(in_pod_arg) if in_pod_arg_parsed is True: return f"pod_{self.project_name}" if in_pod_arg_parsed is False: return None assert isinstance(in_pod_arg, str) and in_pod_arg return in_pod_arg def resolve_pod_args(self) -> list[str]: # Priorities: # - Command line --pod-args # - docker-compose.yml x-podman.pod_args # - Default value if self.global_args.pod_args is not None: return shlex.split(self.global_args.pod_args) return self.x_podman.get( PodmanCompose.XPodmanSettingKey.POD_ARGS, ["--infra=false", "--share="] ) def join_name_parts(self, *parts: str) -> str: setting = self.x_podman.get(PodmanCompose.XPodmanSettingKey.NAME_SEPARATOR_COMPAT, False) if try_parse_bool(setting): sep = "-" else: sep = "_" return sep.join(parts) def format_name(self, *parts: str) -> str: assert self.project_name is not None return self.join_name_parts(self.project_name, *parts) def _parse_x_podman_settings(self, compose: dict[str, Any], environ: dict[str, str]) -> None: known_keys = {s.value: s for s in PodmanCompose.XPodmanSettingKey} self.x_podman = {} for k, v in compose.get("x-podman", {}).items(): known_key = known_keys.get(k) if known_key: self.x_podman[known_key] = v else: log.warning( "unknown x-podman key [%s] in compose file, supported keys: %s", k, ", ".join(known_keys.keys()), ) env = { key.removeprefix("PODMAN_COMPOSE_").lower(): value for key, value in environ.items() if key.startswith("PODMAN_COMPOSE_") and key not in {"PODMAN_COMPOSE_PROVIDER", "PODMAN_COMPOSE_WARNING_LOGS"} } for k, v in env.items(): known_key = known_keys.get(k) if known_key: self.x_podman[known_key] = v else: log.warning( "unknown PODMAN_COMPOSE_ key [%s] in environment, supported keys: %s", k, ", ".join(known_keys.keys()), ) # If Docker Compose compatibility is enabled, set compatibility settings # that are not explicitly set already. if self.x_podman.get(PodmanCompose.XPodmanSettingKey.DOCKER_COMPOSE_COMPAT, False): def set_if_not_already_set(key: PodmanCompose.XPodmanSettingKey, value: bool) -> None: if key not in self.x_podman: self.x_podman[key] = value set_if_not_already_set( PodmanCompose.XPodmanSettingKey.DEFAULT_NET_BEHAVIOR_COMPAT, True ) set_if_not_already_set(PodmanCompose.XPodmanSettingKey.NAME_SEPARATOR_COMPAT, True) set_if_not_already_set(PodmanCompose.XPodmanSettingKey.IN_POD, False) def _parse_compose_file(self) -> None: args = self.global_args # cmd = args.command project_dir = os.environ.get("COMPOSE_PROJECT_DIR") if project_dir and os.path.isdir(project_dir): os.chdir(project_dir) pathsep = os.environ.get("COMPOSE_PATH_SEPARATOR", os.pathsep) if not args.file: default_str = os.environ.get("COMPOSE_FILE") if default_str: default_ls = default_str.split(pathsep) else: default_ls = COMPOSE_DEFAULT_LS args.file = list(filter(os.path.exists, default_ls)) files = args.file if not files: log.fatal( "no compose.yaml, docker-compose.yml or container-compose.yml file found, " "pass files with -f" ) sys.exit(-1) ex = map(lambda x: x == '-' or os.path.exists(x), files) missing = [fn0 for ex0, fn0 in zip(ex, files) if not ex0] if missing: log.fatal("missing files: %s", missing) sys.exit(1) # make absolute relative_files = files filename = files[0] project_name = args.project_name # no_ansi = args.no_ansi # no_cleanup = args.no_cleanup # dry_run = args.dry_run # host_env = None dirname: str = os.path.realpath(os.path.dirname(filename)) dir_basename = os.path.basename(dirname) self.dirname = dirname # env-file is relative to the CWD dotenv_dict = {} if args.env_file: # Load .env from the Compose file's directory to preserve # behavior prior to 1.1.0 and to match with Docker Compose (v2). if ".env" == args.env_file: project_dotenv_file = os.path.realpath(os.path.join(dirname, ".env")) if os.path.exists(project_dotenv_file): dotenv_dict.update(dotenv_to_dict(project_dotenv_file)) dotenv_path = os.path.realpath(args.env_file) dotenv_dict.update(dotenv_to_dict(dotenv_path)) os.environ.update({ key: value # type: ignore[misc] for key, value in dotenv_dict.items() if key.startswith("PODMAN_") # type: ignore[misc] }) self.environ = dotenv_dict # type: ignore[assignment] self.environ.update(dict(os.environ)) # see: https://docs.docker.com/compose/reference/envvars/ # see: https://docs.docker.com/compose/env-file/ self.environ.update({ "COMPOSE_PROJECT_DIR": dirname, "COMPOSE_FILE": pathsep.join(relative_files), "COMPOSE_PATH_SEPARATOR": pathsep, }) if args and 'env' in args and args.env: env_vars = norm_as_dict(args.env) self.environ.update(env_vars) # type: ignore[arg-type] profiles_from_env = { p.strip() for p in self.environ.get("COMPOSE_PROFILES", "").split(",") if p.strip() } requested_profiles = set(args.profile).union(profiles_from_env) compose: dict[str, Any] = {} # Iterate over files primitively to allow appending to files in-loop files_iter = iter(files) while True: try: filename = next(files_iter) except StopIteration: break if filename.strip().split('/')[-1] == '-': content = load_yaml_or_die(filename, sys.stdin) else: with open(filename, encoding="utf-8") as f: content = load_yaml_or_die(filename, f) # log(filename, json.dumps(content, indent = 2)) if not isinstance(content, dict): log.fatal("Compose file does not contain a top level object: %s", filename) sys.exit(1) content = normalize(content) # log(filename, json.dumps(content, indent = 2)) # See also https://docs.docker.com/compose/how-tos/project-name/#set-a-project-name # **project_name** is initialized to the argument of the `-p` command line flag. if not project_name: project_name = self.environ.get("COMPOSE_PROJECT_NAME") if not project_name: project_name = content.get("name") if not project_name: project_name = dir_basename.lower() project_name = rec_subs(project_name, self.environ) # More strict then actually needed for simplicity: # podman requires [a-zA-Z0-9][a-zA-Z0-9_.-]* project_name_normalized = norm_re.sub("", project_name) if not project_name_normalized: raise RuntimeError(f"Project name [{project_name}] normalized to empty") project_name = project_name_normalized self.project_name = project_name assert self.project_name is not None self.environ.update({"COMPOSE_PROJECT_NAME": self.project_name}) content = rec_subs(content, self.environ) if isinstance(content_services := content.get('services'), dict): for service in content_services.values(): if not isinstance(service, OverrideTag) and not isinstance(service, ResetTag): if 'extends' in service and ( service_file := service['extends'].get('file') ): service['extends']['file'] = os.path.join( os.path.dirname(filename), service_file ) rec_merge(compose, content) # If `include` is used, append included files to files include = compose.get("include") if include: included_files = [] for i in include: if isinstance(i, dict): rel_path = i.get("path") elif isinstance(i, str): rel_path = i else: rel_path = None if rel_path: included_files.append(os.path.join(os.path.dirname(filename), rel_path)) files.extend(included_files) # As compose obj is updated and tested with every loop, not deleting `include` # from it, results in it being tested again and again, original values for # `include` be appended to `files`, and, included files be processed for ever. # Solution is to remove 'include' key from compose obj. This doesn't break # having `include` present and correctly processed in included files del compose["include"] resolved_services = self._resolve_profiles( compose.get("services") or {}, requested_profiles ) compose["services"] = resolved_services if not getattr(args, "no_normalize", None): compose = normalize_final(compose, self.dirname) self.merged_yaml = yaml.safe_dump(compose) merged_json_b = json.dumps( self.original_configuration(compose), separators=(",", ":") ).encode("utf-8") self.yaml_hash = hashlib.sha256(merged_json_b).hexdigest() compose["_dirname"] = dirname # debug mode if len(files) > 1: log.debug(" ** merged:\n%s", json.dumps(self.original_configuration(compose), indent=2)) # ver = compose.get('version') self._parse_x_podman_settings(compose, self.environ) pod_name = self.resolve_pod_name() services: dict = compose.get("services", {}) if not services: log.warning("WARNING: No services defined") # include services with no profile defined or the selected profiles services = self._resolve_profiles(services, requested_profiles) # NOTE: maybe add "extends.service" to _deps at this stage flat_deps(services, with_extends=True) service_names = sorted([(len(srv["_deps"]), name) for name, srv in services.items()]) resolve_extends(services, [name for _, name in service_names], self.environ) flat_deps(services) # networks: [...] nets = compose.get("networks") or {} if not nets: nets["default"] = None # Resolve the inter-service build dependencies in additional contexts self._resolve_context_dependencies(services) self.networks = nets if self.x_podman.get(PodmanCompose.XPodmanSettingKey.DEFAULT_NET_BEHAVIOR_COMPAT, False): # If there is no network_mode and networks in service, # docker-compose will create default network named '_default' # and add the service to the default network. # So we always set `default_net = 'default'` for compatibility if "default" not in self.networks: self.networks["default"] = None else: if len(self.networks) == 1: self.default_net = list(nets.keys())[0] elif "default" in nets: self.default_net = "default" else: self.default_net = None allnets = set() for name, srv in services.items(): srv_nets = srv.get("networks", self.default_net) srv_nets = ( list(srv_nets.keys()) if isinstance(srv_nets, dict) else norm_as_list(srv_nets) ) allnets.update(srv_nets) given_nets = set(nets.keys()) missing_nets = allnets - given_nets unused_nets = given_nets - allnets - set(["default"]) if len(unused_nets): unused_nets_str = ",".join(unused_nets) log.warning("WARNING: unused networks: %s", unused_nets_str) if len(missing_nets): missing_nets_str = ",".join(missing_nets) raise RuntimeError(f"missing networks: {missing_nets_str}") # volumes: [...] self.vols = compose.get("volumes", {}) or {} podman_compose_labels = [ "io.podman.compose.project=" + project_name, "io.podman.compose.version=" + __version__, f"PODMAN_SYSTEMD_UNIT=podman-compose@{project_name}.service", "com.docker.compose.project=" + project_name, "com.docker.compose.project.working_dir=" + dirname, "com.docker.compose.project.config_files=" + ",".join(relative_files), ] # other top-levels: # configs: {...} self.declared_secrets = compose.get("secrets", {}) given_containers = [] container_names_by_service: dict[str, list[str]] = {} self.services = services for service_name, service_desc in services.items(): replicas = 1 if "scale" in args and args.scale is not None: # Check `--scale` args from CLI command scale_args = args.scale.split('=') if service_name == scale_args[0]: replicas = try_int(scale_args[1], fallback=1) elif "scale" in service_desc: # Check `scale` value from compose yaml file replicas = try_int(service_desc.get("scale"), fallback=1) elif ( "deploy" in service_desc and "replicas" in service_desc.get("deploy", {}) and "replicated" == service_desc.get("deploy", {}).get("mode", '') ): # Check `deploy: replicas:` value from compose yaml file # Note: All conditions are necessary to handle case replicas = try_int(service_desc.get("deploy", {}).get("replicas"), fallback=1) container_names_by_service[service_name] = [] for num in range(1, replicas + 1): name0 = self.format_name(service_name, str(num)) if num == 1: name = service_desc.get("container_name", name0) else: name = name0 container_names_by_service[service_name].append(name) # log(service_name,service_desc) cnt = { "pod": pod_name, "name": name, "num": num, "service_name": service_name, **service_desc, } x_podman = service_desc.get("x-podman") rootfs_mode = x_podman is not None and x_podman.get("rootfs") is not None if "image" not in cnt and not rootfs_mode: cnt["image"] = self.format_name(service_name) labels = norm_as_list(cnt.get("labels")) cnt["ports"] = norm_ports(cnt.get("ports")) labels.extend(podman_compose_labels) labels.extend([ f"io.podman.compose.config-hash={self.config_hash(service_desc)}", f"com.docker.compose.container-number={num}", f"io.podman.compose.service={service_name}", f"com.docker.compose.service={service_name}", ]) cnt["labels"] = labels cnt["_service"] = service_name cnt["_project"] = project_name given_containers.append(cnt) volumes = cnt.get("volumes", []) for volume in volumes: mnt_dict = get_mnt_dict(self, cnt, volume) if ( mnt_dict.get("type") == "volume" and mnt_dict["source"] and mnt_dict["source"] not in self.vols # type: ignore[operator] ): vol_name = mnt_dict["source"] raise RuntimeError(f"volume [{vol_name}] not defined in top level") self.container_names_by_service = container_names_by_service self.all_services = set(container_names_by_service.keys()) container_by_name = {c["name"]: c for c in given_containers} # log("deps:", [(c["name"], c["_deps"]) for c in given_containers]) given_containers = list(container_by_name.values()) given_containers.sort(key=lambda c: len(c.get("_deps", []))) # log("sorted:", [c["name"] for c in given_containers]) self.pods = [{"name": pod_name}] if pod_name else [] self.containers = given_containers self.container_by_name = {c["name"]: c for c in given_containers} def _resolve_profiles( self, defined_services: dict[str, Any], requested_profiles: set[str] | None = None ) -> dict[str, Any]: """ Returns a service dictionary (key = service name, value = service config) compatible with the requested_profiles list. The returned service dictionary contains all services which do not include/reference a profile in addition to services that match the requested_profiles. :param defined_services: The service dictionary :param requested_profiles: The profiles requested using the --profile arg. """ if requested_profiles is None: requested_profiles = set() services = {} for name, config in defined_services.items(): service_profiles = set(config.get("profiles", [])) if not service_profiles or requested_profiles.intersection(service_profiles): services[name] = config return services # Docker Compose specifies that services can have build-time dependencies on each other # through the "additional_contexts" that can refer to the other services' images. def _resolve_context_dependencies(self, services: dict[str, Any]) -> None: for name, service in services.items(): additional_build_contexts = service.get("build", {}).get("additional_contexts") if not additional_build_contexts: continue deps = set() processed = [] for context in additional_build_contexts: parts = str(context).split("=", 1) if len(parts) != 2: processed.append(context) # Something unknown. Just ignore. continue context_name, val = parts if not val.startswith("service:"): processed.append(context) # Not a service reference continue # This is a reference in the form of "service:target_service" target_service_name = val.removeprefix("service:") target_service = services[target_service_name] if target_service is None: raise ValueError( f"Service '{name}' references non-existent service " f"'{val.removeprefix('service:')}' in the " f"additional context '{context_name}'" ) # Get the image name for that service target_image = target_service.get("image") if not target_image: target_image = "localhost/" + self.format_name(target_service_name) # Replace the context with the docker image reference image_url = "docker://" + quote(target_image) processed.append(f"{context_name}={image_url}") deps.add(target_service_name) service["build"]["additional_contexts"] = processed service["build"]["build_deps"] = sorted(list(deps)) # Verify that there are no (possibly recursive) circular dependencies between services def check_circular(current: str, path: list[str]) -> None: if current in path: cycle = " -> ".join(path + [current]) raise ValueError(f"Circular dependency in additional build contexts: {cycle}") path.append(current) # Retrieve dependencies stored during the resolution phase service_deps = services[current].get("build", {}).get("build_deps", []) for dep in service_deps: check_circular(dep, path) path.pop() for service_name in services: check_circular(service_name, []) def _parse_args(self, argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(formatter_class=argparse.RawTextHelpFormatter) self._init_global_parser(parser) subparsers = parser.add_subparsers(title="command", dest="command") _ = subparsers.add_parser("help", help="show help") for cmd_name, cmd in self.commands.items(): subparser = subparsers.add_parser(cmd_name, help=cmd.help, description=cmd.desc) # pylint: disable=protected-access for cmd_parser in cmd._parse_args: # pylint: disable=protected-access cmd_parser(subparser) self.global_args = parser.parse_args(argv) if self.global_args.version: self.global_args.command = "version" if not self.global_args.command or self.global_args.command == "help": parser.print_help() sys.exit(-1) logging.basicConfig(level=("DEBUG" if self.global_args.verbose else "WARN")) return self.global_args @staticmethod def _init_global_parser(parser: argparse.ArgumentParser) -> None: parser.add_argument("-v", "--version", help="show version", action="store_true") parser.add_argument( "--in-pod", help=( "Specify pod usage:\n" " 'true' - create/use a pod named pod_\n" " 'false' - do not use a pod\n" " '' - create/use a custom pod with the given name" ), metavar="in_pod", type=str, default=None, ) parser.add_argument( "--pod-args", help="custom arguments to be passed to `podman pod`", metavar="pod_args", type=str, default=None, ) parser.add_argument( "--env-file", help="Specify an alternate environment file", metavar="env_file", type=str, default=".env", ) parser.add_argument( "-f", "--file", help="Specify an compose file (default: docker-compose.yml) or '-' to read from stdin.", metavar="file", action="append", default=[], ) parser.add_argument( "--profile", help="Specify a profile to enable", metavar="profile", action="append", default=[], ) parser.add_argument( "-p", "--project-name", help="Specify an alternate project name (default: directory name)", type=str, default=None, ) parser.add_argument( "--podman-path", help="Specify an alternate path to podman (default: use location in $PATH variable)", type=str, default="podman", ) parser.add_argument( "--podman-args", help="custom global arguments to be passed to `podman`", metavar="args", action="append", default=[], ) for podman_cmd in PODMAN_CMDS: parser.add_argument( f"--podman-{podman_cmd}-args", help=f"custom arguments to be passed to `podman {podman_cmd}`", metavar="args", action="append", default=[], ) parser.add_argument( "--no-ansi", help="Do not print ANSI control characters", action="store_true", ) parser.add_argument( "--no-cleanup", help="Do not stop and remove existing pod & containers", action="store_true", ) parser.add_argument( "--dry-run", help="No action; perform a simulation of commands", action="store_true", ) parser.add_argument( "--parallel", type=int, default=os.environ.get("COMPOSE_PARALLEL_LIMIT", sys.maxsize) ) parser.add_argument( "--verbose", help="Print debugging output", action="store_true", ) podman_compose = PodmanCompose() ################### # decorators to add commands and parse options ################### class PodmanComposeError(Exception): pass class cmd_run: # pylint: disable=invalid-name,too-few-public-methods def __init__(self, compose: PodmanCompose, cmd_name: str, cmd_desc: str | None = None) -> None: self.compose = compose self.cmd_name = cmd_name self.cmd_desc = cmd_desc def __call__(self, func: Callable) -> Callable: def wrapped(*args: Any, **kw: Any) -> Any: return func(*args, **kw) if not inspect.iscoroutinefunction(func): raise PodmanComposeError("Command must be async") wrapped._compose = self.compose # type: ignore[attr-defined] # Trim extra indentation at start of multiline docstrings. help_desc = self.cmd_desc or re.sub(r"^\s+", "", func.__doc__ or "") # type: ignore[attr-defined] if "\n" in help_desc: wrapped.help, wrapped.desc = help_desc.split("\n", 1) # type: ignore[attr-defined] else: wrapped.help = help_desc # type: ignore[attr-defined] wrapped.desc = None # type: ignore[attr-defined] wrapped._parse_args = [] # type: ignore[attr-defined] self.compose.commands[self.cmd_name] = wrapped return wrapped class cmd_parse: # pylint: disable=invalid-name,too-few-public-methods def __init__(self, compose: PodmanCompose, cmd_names: list[str] | str) -> None: self.compose = compose self.cmd_names = cmd_names if is_list(cmd_names) else [cmd_names] # type: ignore[list-item] def __call__(self, func: Callable) -> Callable: def wrapped(*args: Any, **kw: Any) -> Any: return func(*args, **kw) for cmd_name in self.cmd_names: self.compose.commands[cmd_name]._parse_args.append(wrapped) return wrapped ################### # actual commands ################### @cmd_run(podman_compose, "version", "show version") async def compose_version(compose: PodmanCompose, args: argparse.Namespace) -> None: if getattr(args, "short", False): print(__version__) return if getattr(args, "format", "pretty") == "json": res = {"version": __version__} print(json.dumps(res)) return print("podman-compose version", __version__) await compose.podman.run(["--version"], "", []) def is_local(container: dict) -> bool: """Test if a container is local, i.e. if it is * prefixed with localhost/ * has a build section and is not prefixed """ image = container.get("image", "") return image.startswith("localhost/") or ("build" in container and "/" not in image) @cmd_run(podman_compose, "wait", "wait running containers to stop") async def compose_wait( compose: PodmanCompose, args: argparse.Namespace, # pylint: disable=unused-argument ) -> None: containers = [cnt["name"] for cnt in compose.containers] cmd_args = ["--"] cmd_args.extend(containers) compose.podman.exec([], "wait", cmd_args) @cmd_run(podman_compose, "systemd") async def compose_systemd(compose: PodmanCompose, args: argparse.Namespace) -> None: """ create systemd unit file and register its compose stacks When first installed type `sudo podman-compose systemd -a create-unit` later you can add a compose stack by running `podman-compose systemd -a register` then you can start/stop your stack with `systemctl --user start podman-compose@` """ stacks_dir = ".config/containers/compose/projects" if args.action == "register": proj_name = compose.project_name fn = os.path.expanduser(f"~/{stacks_dir}/{proj_name}.env") os.makedirs(os.path.dirname(fn), exist_ok=True) log.debug("writing [%s]: ...", fn) with open(fn, "w", encoding="utf-8") as f: for k, v in compose.environ.items(): if k.startswith("COMPOSE_") or k.startswith("PODMAN_"): f.write(f"{k}={v}\n") log.debug("writing [%s]: done.", fn) log.info("\n\ncreating the pod without starting it: ...\n\n") username = getpass.getuser() print( f""" you can use systemd commands like enable, start, stop, status, cat all without `sudo` like this: \t\tsystemctl --user enable --now 'podman-compose@{proj_name}' \t\tsystemctl --user status 'podman-compose@{proj_name}' \t\tjournalctl --user -xeu 'podman-compose@{proj_name}' and for that to work outside a session you might need to run the following command *once* \t\tsudo loginctl enable-linger '{username}' you can use podman commands like: \t\tpodman pod ps \t\tpodman pod stats 'pod_{proj_name}' \t\tpodman pod logs --tail=10 -f 'pod_{proj_name}' """ ) elif args.action == "unregister": proj_name = compose.project_name fn = os.path.expanduser(f"~/{stacks_dir}/{proj_name}.env") if os.path.exists(fn): try: log.debug("removing [%s]: ...", fn) os.remove(fn) log.debug("removing [%s]: done.", fn) print( f""" project '{proj_name}' successfully unregistered you can stop and disable the service with: \t\tsystemctl --user disable --now 'podman-compose@{proj_name}' """ ) except OSError as e: log.error("failed to remove file %s: %s", fn, e) print(f"Failed to remove registration file for project '{proj_name}'") sys.exit(1) else: log.warning("registration file not found: %s", fn) print(f"Project '{proj_name}' is not registered") elif args.action in ("list", "ls"): ls = glob.glob(os.path.expanduser(f"~/{stacks_dir}/*.env")) for i in ls: print(os.path.basename(i[:-4])) elif args.action == "create-unit": fn = "/etc/systemd/user/podman-compose@.service" out = f"""\ # {fn} [Unit] Description=%i rootless pod (podman-compose) [Service] Type=simple EnvironmentFile=%h/{stacks_dir}/%i.env ExecStartPre=-{script} up --no-start ExecStartPre=/usr/bin/env podman pod start pod_%i ExecStart={script} wait ExecStop=/usr/bin/env podman pod stop pod_%i [Install] WantedBy=default.target """ if os.access(os.path.dirname(fn), os.W_OK): log.debug("writing [%s]: ...", fn) with open(fn, "w", encoding="utf-8") as f: f.write(out) log.debug("writing [%s]: done.", fn) print( """ while in your project type `podman-compose systemd -a register` """ ) else: print(out) log.warning("Could not write to [%s], use 'sudo'", fn) @cmd_run(podman_compose, "pull", "pull stack images") async def compose_pull(compose: PodmanCompose, args: argparse.Namespace) -> int | None: img_containers = [cnt for cnt in compose.containers if "image" in cnt] if args.services: services = set(args.services) img_containers = [cnt for cnt in img_containers if cnt["_service"] in services] images = {cnt["image"] for cnt in img_containers} if not args.force_local: local_images = {cnt["image"] for cnt in img_containers if is_local(cnt)} images -= local_images status = 0 statuses = await asyncio.gather(*[compose.podman.run([], "pull", [image]) for image in images]) for s in statuses: if s is not None and s != 0: status = s return status @cmd_run(podman_compose, "push", "push stack images") async def compose_push(compose: PodmanCompose, args: argparse.Namespace) -> int | None: services = set(args.services) status = 0 for cnt in compose.containers: if "build" not in cnt: continue if services and cnt["_service"] not in services: continue s = await compose.podman.run([], "push", [cnt["image"]]) if s is not None and s != 0: status = s return status def is_context_git_url(path: str) -> bool: r = urllib.parse.urlparse(path) if r.scheme in ('git', 'http', 'https', 'ssh', 'file', 'rsync'): return True # URL contains a ":" character, a hint of a valid URL if r.scheme != "" and r.netloc == "" and r.path != "": return True if r.scheme == "": # tweak path URL to get username from url parser r = urllib.parse.urlparse("ssh://" + path) if r.username is not None and r.username != "": return True return False def adjust_build_ssh_key_paths(compose: PodmanCompose, agent_or_key: str) -> str: # when using a custom id for ssh property, path to a local SSH key is provided after "=" parts = agent_or_key.split("=", 1) if len(parts) == 1: return agent_or_key name, path = parts path = os.path.expanduser(path) return name + "=" + os.path.join(compose.dirname, path) def container_to_build_args( compose: PodmanCompose, cnt: dict[str, Any], args: argparse.Namespace, path_exists: Callable[[str], bool], cleanup_callbacks: list[Callable] | None = None, ) -> list[str]: build_desc = cnt["build"] if not hasattr(build_desc, "items"): build_desc = {"context": build_desc} ctx = build_desc.get("context", ".") dockerfile = build_desc.get("dockerfile", "") dockerfile_inline = build_desc.get("dockerfile_inline") if dockerfile_inline is not None: dockerfile_inline = str(dockerfile_inline) # Error if both `dockerfile_inline` and `dockerfile` are set if dockerfile and dockerfile_inline: raise OSError("dockerfile_inline and dockerfile can't be used simultaneously") dockerfile = tempfile.NamedTemporaryFile(delete=False, suffix=".containerfile") dockerfile.write(dockerfile_inline.encode()) dockerfile.close() dockerfile = dockerfile.name def cleanup_temp_dockfile() -> None: if os.path.exists(dockerfile): os.remove(dockerfile) if cleanup_callbacks is not None: cleanup_callbacks.append(cleanup_temp_dockfile) build_args = [] # if given context was not recognized as git url, try joining paths to get a file locally if not is_context_git_url(ctx): custom_dockerfile_given = False if dockerfile: dockerfile = os.path.join(ctx, dockerfile) custom_dockerfile_given = True else: dockerfile_alts = [ "Containerfile", "ContainerFile", "containerfile", "Dockerfile", "DockerFile", "dockerfile", ] for dockerfile in dockerfile_alts: dockerfile = os.path.join(ctx, dockerfile) if path_exists(dockerfile): break if path_exists(dockerfile): # normalize dockerfile path, as the user could have provided unpredictable file formats dockerfile = os.path.normpath(dockerfile) build_args.extend(["-f", dockerfile]) else: if custom_dockerfile_given: # custom dockerfile name was also not found in the file system raise OSError(f"Dockerfile not found in {dockerfile}") raise OSError(f"Dockerfile not found in {ctx}") build_args.extend(["-t", cnt["image"]]) if "platform" in cnt: build_args.extend(["--platform", cnt["platform"]]) for secret in build_desc.get("secrets", []): build_args.extend(get_secret_args(compose, cnt, secret, podman_is_building=True)) for i in build_desc.get("extra_hosts", []): build_args.extend(["--add-host", i]) for tag in build_desc.get("tags", []): build_args.extend(["-t", tag]) labels = build_desc.get("labels", []) if isinstance(labels, dict): labels = [f"{k}={v}" for (k, v) in labels.items()] for label in labels: build_args.extend(["--label", label]) for additional_ctx in build_desc.get("additional_contexts", {}): build_args.extend([f"--build-context={additional_ctx}"]) if "target" in build_desc: build_args.extend(["--target", build_desc["target"]]) for agent_or_key in norm_as_list(build_desc.get("ssh", {})): agent_or_key = adjust_build_ssh_key_paths(compose, agent_or_key) build_args.extend(["--ssh", agent_or_key]) container_to_ulimit_build_args(cnt, build_args) if getattr(args, "no_cache", None): build_args.append("--no-cache") pull_policy = getattr(args, "pull", None) if pull_policy: build_args.append(f"--pull={pull_policy}") args_list = norm_as_list(build_desc.get("args", {})) for build_arg in args_list + args.build_arg: build_args.extend(( "--build-arg", build_arg, )) for cache_img in build_desc.get("cache_from", []): build_args.extend(["--cache-from", cache_img]) for cache_img in build_desc.get("cache_to", []): build_args.extend(["--cache-to", cache_img]) build_args.append(ctx) return build_args async def build_one(compose: PodmanCompose, args: argparse.Namespace, cnt: dict) -> int | None: if "build" not in cnt: return None if getattr(args, "if_not_exists", None): try: img_id = await compose.podman.output( [], "inspect", ["-t", "image", "-f", "{{.Id}}", cnt["image"]] ) except subprocess.CalledProcessError: img_id = None if img_id: return None cleanup_callbacks: list[Callable] = [] build_args = container_to_build_args( compose, cnt, args, os.path.exists, cleanup_callbacks=cleanup_callbacks ) status = await compose.podman.run([], "build", build_args) for c in cleanup_callbacks: c() return status @cmd_run(podman_compose, "build", "build stack images") async def compose_build(compose: PodmanCompose, args: argparse.Namespace) -> int: pending_builds: dict[str, list[Any]] = {} def _add_build(cnt: dict[str, Any]) -> None: cur_builds = pending_builds.get(cnt["service_name"]) if cur_builds: cur_builds.append(cnt) else: pending_builds[cnt["service_name"]] = [cnt] if args.services: container_names_by_service = compose.container_names_by_service compose.assert_services(args.services) for service in args.services: cnt = compose.container_by_name[container_names_by_service[service][0]] _add_build(cnt) else: for cnt in compose.containers: _add_build(cnt) # Continue building until there are no more pending tasks while pending_builds: # Find the tasks that are not waiting for any dependencies current_builds = [] currently_built_services = [] for service_name, containers in pending_builds.items(): cur_srv = compose.services[service_name] build_deps = cur_srv.get("build", {}).get("build_deps", []) # Check if the task depends on any pending builds if set(build_deps).isdisjoint(pending_builds): for c in containers: current_builds.append(asyncio.create_task(build_one(compose, args, c))) currently_built_services.append(service_name) # This should not happen because we check for circular references during the compose # file parsing. But just in case... if not current_builds: log.error("Found no buildable services due to additional_context dependencies") return 1 status = 0 for t in asyncio.as_completed(current_builds): s = await t if s is not None and s != 0: status = s if status != 0: return status for service_name in currently_built_services: # noinspection PyAsyncCall pending_builds.pop(service_name) return 0 async def pod_exists(compose: PodmanCompose, name: str) -> bool: exit_code = await compose.podman.run([], "pod", ["exists", name]) return exit_code == 0 async def create_pods(compose: PodmanCompose) -> None: for pod in compose.pods: if await pod_exists(compose, pod["name"]): continue podman_args = [ "create", "--name=" + pod["name"], ] + compose.resolve_pod_args() ports = pod.get("ports", []) if isinstance(ports, str): ports = [ports] for i in ports: podman_args.extend(["-p", str(i)]) await compose.podman.run([], "pod", podman_args) class DependField(str, Enum): DEPENDENCIES = "_deps" DEPENDENTS = "_dependents" def get_excluded( compose: PodmanCompose, args: argparse.Namespace, dep_field: DependField = DependField.DEPENDENCIES, ) -> set[str]: excluded = set() if args.services: excluded = set(compose.services) for service in args.services: # we need 'getattr' as compose_down_parse does not configure 'no_deps' if service in compose.services and not getattr(args, "no_deps", False): excluded -= set(x.name for x in compose.services[service].get(dep_field, set())) excluded.discard(service) log.debug("** excluding: %s", excluded) return excluded async def check_dep_conditions(compose: PodmanCompose, deps: set) -> None: """Enforce that all specified conditions in deps are met""" if not deps: return for condition in ServiceDependencyCondition: deps_cd = [] for d in deps: if d.condition == condition: if ( d.condition in (ServiceDependencyCondition.HEALTHY, ServiceDependencyCondition.UNHEALTHY) ) and ( compose.podman_version is not None and strverscmp_lt(compose.podman_version, "4.6.0") ): log.warning( "Ignored %s condition check due to podman %s doesn't support %s!", d.name, compose.podman_version, condition.value, ) continue deps_cd.extend(compose.container_names_by_service[d.name]) if deps_cd: # podman wait will return always with a rc -1. while True: try: await compose.podman.output( [], "wait", [f"--condition={condition.value}"] + deps_cd ) log.debug( "dependencies for condition %s have been fulfilled on containers %s", condition.value, ', '.join(deps_cd), ) break except subprocess.CalledProcessError as _exc: output = list( ((_exc.stdout or b"") + (_exc.stderr or b"")).decode().split('\n') ) log.debug( 'Podman wait returned an error (%d) when executing "%s": %s', _exc.returncode, _exc.cmd, output, ) await asyncio.sleep(1) async def run_container( compose: PodmanCompose, name: str, deps: set, command: tuple, log_formatter: str | None = None ) -> int | None: """runs a container after waiting for its dependencies to be fulfilled""" # wait for the dependencies to be fulfilled if "start" in command: log.debug("Checking dependencies prior to container %s start", name) await check_dep_conditions(compose, deps) # start the container log.debug("Starting task for container %s", name) return await compose.podman.run(*command, log_formatter=log_formatter) # type: ignore[misc] def deps_from_container(args: argparse.Namespace, cnt: dict) -> set: if args.no_deps: return set() return cnt['_deps'] @dataclass class PullImageSettings: POLICY_PRIORITY: ClassVar[dict[str, int]] = { "always": 3, "newer": 2, "missing": 1, "never": 0, "build": 0, } image: str policy: str = "missing" quiet: bool = False ignore_pull_error: bool = False def __post_init__(self) -> None: if self.policy not in self.POLICY_PRIORITY: log.debug("Pull policy %s is not valid, using 'missing' instead", self.policy) self.policy = "missing" def update_policy(self, new_policy: str) -> None: if new_policy not in self.POLICY_PRIORITY: log.debug("Pull policy %s is not valid, ignoring it", new_policy) return if self.POLICY_PRIORITY[new_policy] > self.POLICY_PRIORITY[self.policy]: self.policy = new_policy def settings_to_pull_args(settings: PullImageSettings) -> list[str]: args = ["--policy", settings.policy] if settings.quiet: args.append("--quiet") args.append(settings.image) return args async def pull_image(podman: Podman, settings: PullImageSettings) -> int | None: if settings.policy in ("never", "build"): log.debug("Skipping pull of image %s due to policy %s", settings.image, settings.policy) return 0 ret = await podman.run([], "pull", settings_to_pull_args(settings)) return ret if not settings.ignore_pull_error else 0 async def pull_images( podman: Podman, args: argparse.Namespace, services: list[dict[str, Any]], ) -> int | None: pull_tasks = [] settings: dict[str, PullImageSettings] = {} for pull_service in services: if not is_local(pull_service): image = str(pull_service.get("image", "")) policy = getattr(args, "pull", None) or pull_service.get("pull_policy", "missing") if image in settings: settings[image].update_policy(policy) else: settings[image] = PullImageSettings( image, policy, getattr(args, "quiet_pull", False) ) if "build" in pull_service: # From https://github.com/compose-spec/compose-spec/blob/main/build.md#using-build-and-image # When both image and build are specified, # we should try to pull the image first, # and then build it if it does not exist. # we should not stop here if pull fails. settings[image].ignore_pull_error = True for s in settings.values(): pull_tasks.append(pull_image(podman, s)) if pull_tasks: ret = await asyncio.gather(*pull_tasks) return next((r for r in ret if not r), 0) return 0 async def prepare_images( compose: PodmanCompose, args: argparse.Namespace, excluded: set[str] ) -> int | None: # When creating containers, podman create internally invokes podman pull with the default # policy of --pull=missing. # To minimize downtime during container up command, we explicitly run podman pull before # tearing down the old container, ensuring the image is already cached when we subsequently # call podman create. # However, the pull --policy flag was only introduced to podman in version 5.6.0, so we can # only perform this pre-teardown optimization when using podman >= 5.6.0. if compose.podman_version is not None and not strverscmp_lt(compose.podman_version, "5.6.0"): log.info("pulling images: ...") pull_services = [v for k, v in compose.services.items() if k not in excluded] err = await pull_images(compose.podman, args, pull_services) if err: log.error("Pull image failed") return err log.info("building images: ...") if not args.no_build: # `podman build` does not cache, so don't always build build_args = argparse.Namespace(if_not_exists=(not args.build), **args.__dict__) build_exit_code = await compose.commands["build"](compose, build_args) if build_exit_code != 0: log.error("Build command failed") return build_exit_code return 0 async def wait_for_container_running_healthy( compose: PodmanCompose, args: argparse.Namespace ) -> None: if compose.podman_version is not None and strverscmp_lt(compose.podman_version, "4.6.0"): log.warning("Ignore --wait due to podman %s doesn't support it!", compose.podman_version) return log.info("waiting for all containers to be running|healthy") # distinguish between containers that have a healthcheck and those that don't cnt_with_healthcheck = [] cnt_without_healthcheck = [] for cnt in compose.containers: if "healthcheck" in cnt: cnt_with_healthcheck.append(cnt["name"]) else: cnt_without_healthcheck.append(cnt["name"]) async def run_podman_wait() -> None: # wait for running state of containers without a healthcheck if cnt_without_healthcheck: await compose.podman.run( [], "wait", [ "--condition=running", "--ignore", *cnt_without_healthcheck, ], ) # wait for healthy state of containers with a healthcheck if cnt_with_healthcheck: await compose.podman.run( [], "wait", [ "--condition=healthy", "--ignore", *cnt_with_healthcheck, ], ) # if --wait-timeout is not set None is used, which means no timeout # https://docs.python.org/3/library/asyncio-task.html#asyncio.wait_for # the CancelledError is handled in the compose.podman.run() method await wait_with_timeout(run_podman_wait(), timeout=args.wait_timeout) @cmd_run(podman_compose, "up", "Create and start the entire stack or some of its services") async def compose_up(compose: PodmanCompose, args: argparse.Namespace) -> int | None: # pylint: disable=too-many-return-statements excluded = get_excluded(compose, args) exit_code = await prepare_images(compose, args, excluded) if exit_code != 0: log.error("Prepare images failed") if not args.dry_run: return exit_code # if needed, tear down existing containers assert compose.project_name is not None, "Project name must be set before running up command" existing_containers = await compose.podman.existing_containers(compose.project_name) recreate_services: set[str] = set() running_services = {c.service_name for c in existing_containers.values() if not c.exited} if existing_containers: if args.force_recreate and args.no_recreate: log.error( "Cannot use --force-recreate and --no-recreate at the same time, " "please remove one of them" ) return 1 if not args.no_recreate: for c in existing_containers.values(): if ( c.service_name in excluded or c.service_name not in compose.services # orphaned container ): continue service = compose.services[c.service_name] if args.force_recreate or c.config_hash != compose.config_hash(service): recreate_services.add(c.service_name) # Running dependents of service are removed by down command # so we need to recreate and start them too dependents = { dep.name for dep in service.get(DependField.DEPENDENTS, []) if dep.name in running_services } if dependents: log.debug( "Service %s's dependents should be recreated and running again: %s", c.service_name, dependents, ) recreate_services.update(dependents) excluded = excluded - dependents log.debug("** excluding update: %s", excluded) log.debug("Prepare to recreate services: %s", recreate_services) teardown_needed = bool(recreate_services) if teardown_needed: log.info("tearing down existing containers: ...") down_args = argparse.Namespace( **dict(args.__dict__, volumes=False, rmi=None, services=recreate_services) ) await compose.commands["down"](compose, down_args) log.info("tearing down existing containers: done\n\n") await create_pods(compose) log.info("creating missing containers: ...") create_error_codes: list[int | None] = [] for cnt in compose.containers: if cnt["_service"] in excluded or ( cnt["name"] in existing_containers and cnt["_service"] not in recreate_services ): log.debug("** skipping create: %s", cnt["name"]) continue podman_args = await container_to_args(compose, cnt, detached=False, no_deps=args.no_deps) exit_code = await compose.podman.run([], "create", podman_args) create_error_codes.append(exit_code) if args.dry_run: return None if args.no_start: # return first error code from create calls, if any return next((code for code in create_error_codes if code is not None and code != 0), 0) if args.detach: log.info("starting containers (detached): ...") start_error_codes: list[int | None] = [] for cnt in compose.containers: if cnt["_service"] in excluded: log.debug("** skipping start: %s", cnt["name"]) continue exit_code = await run_container( compose, cnt["name"], deps_from_container(args, cnt), ([], "start", [cnt["name"]]) ) start_error_codes.append(exit_code) if args.wait: await wait_for_container_running_healthy(compose, args) # return first error code from start calls, if any return next((code for code in start_error_codes if code is not None and code != 0), 0) log.info("starting containers (attached): ...") # TODO: handle already existing # TODO: if error creating do not enter loop # TODO: colors if sys.stdout.isatty() exit_code_from = args.__dict__.get("exit_code_from") if exit_code_from: args.abort_on_container_exit = True max_service_length = 0 for cnt in compose.containers: curr_length = len(cnt["_service"]) max_service_length = curr_length if curr_length > max_service_length else max_service_length tasks: set[asyncio.Task] = set() async def handle_sigint() -> None: log.info("Caught SIGINT or Ctrl+C, shutting down...") try: log.info("Shutting down gracefully, please wait...") down_args = argparse.Namespace(**dict(args.__dict__, volumes=False, rmi=None)) await compose.commands["down"](compose, down_args) except Exception as e: log.error("Error during shutdown: %s", e) finally: for task in tasks: task.cancel() if sys.platform != 'win32': loop = asyncio.get_event_loop() loop.add_signal_handler(signal.SIGINT, lambda: asyncio.create_task(handle_sigint())) for i, cnt in enumerate(compose.containers): # Add colored service prefix to output by piping output through sed color_idx = i % len(compose.console_colors) color = compose.console_colors[color_idx] space_suffix = " " * (max_service_length - len(cnt["_service"]) + 1) log_formatter = "{}[{}]{}|\x1b[0m".format(color, cnt["_service"], space_suffix) if cnt["_service"] in excluded: log.debug("** skipping: %s", cnt["name"]) continue tasks.add( asyncio.create_task( run_container( compose, cnt["name"], deps_from_container(args, cnt), ([], "start", ["-a", cnt["name"]]), log_formatter=log_formatter, ), name=cnt["_service"], ) ) def _task_cancelled(task: Task) -> bool: if task.cancelled(): return True # Task.cancelling() is new in python 3.11 if sys.version_info >= (3, 11) and task.cancelling(): return True return False exit_code = 0 exiting = False first_failed_task = None while tasks: done, tasks = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) if args.abort_on_container_failure and first_failed_task is None: # Generally a single returned item when using asyncio.FIRST_COMPLETED, but that's not # guaranteed. If multiple tasks finish at the exact same time the choice of which # finished "first" is arbitrary for t in done: if t.result() != 0: first_failed_task = t if args.abort_on_container_exit or first_failed_task: if not exiting: # If 2 containers exit at the exact same time, the cancellation of the other ones # cause the status to overwrite. Sleeping for 1 seems to fix this and make it match # docker-compose await asyncio.sleep(1) for t in tasks: if not _task_cancelled(t): t.cancel() t_: Task exiting = True if first_failed_task: # Matches docker-compose behaviour, where the exit code of the task that triggered # the cancellation is always propagated when aborting on failure exit_code = first_failed_task.result() else: for t_ in done: if t_.get_name() == exit_code_from: exit_code = t_.result() return exit_code def get_volume_names(compose: PodmanCompose, cnt: dict) -> list[str]: basedir = compose.dirname srv_name = cnt["_service"] ls = [] for volume in cnt.get("volumes", []): if isinstance(volume, str): volume = parse_short_mount(volume, basedir) volume = fix_mount_dict(compose, volume, srv_name) mount_type = volume["type"] if mount_type != "volume": continue volume_name = volume.get("_vol", {}).get("name") ls.append(volume_name) return ls @cmd_run(podman_compose, "down", "tear down entire stack") async def compose_down(compose: PodmanCompose, args: argparse.Namespace) -> None: excluded = get_excluded(compose, args, DependField.DEPENDENTS) podman_args: list[str] = [] timeout_global = getattr(args, "timeout", None) containers = list(reversed(compose.containers)) down_tasks = [] for cnt in containers: if cnt["_service"] in excluded: continue podman_stop_args = [*podman_args] timeout = timeout_global if timeout is None: timeout_str = cnt.get("stop_grace_period", STOP_GRACE_PERIOD) timeout = str_to_seconds(timeout_str) if timeout is not None: podman_stop_args.extend(["-t", str(timeout)]) down_tasks.append( asyncio.create_task( compose.podman.run([], "stop", [*podman_stop_args, cnt["name"]]), name=cnt["name"] ) ) await asyncio.gather(*down_tasks) for cnt in containers: if cnt["_service"] in excluded: continue await compose.podman.run([], "rm", [cnt["name"]]) orphaned_images = set() if args.remove_orphans: orphaned_containers = ( ( await compose.podman.output( [], "ps", [ "--filter", f"label=io.podman.compose.project={compose.project_name}", "-a", "--format", "{{ .Image }} {{ .Names }}", ], ) ) .decode("utf-8") .splitlines() ) orphaned_images = {item.split()[0] for item in orphaned_containers} names = {item.split()[1] for item in orphaned_containers} for name in names: await compose.podman.run([], "stop", [*podman_args, name]) for name in names: await compose.podman.run([], "rm", [name]) if args.volumes: vol_names_to_keep = set() for cnt in containers: if cnt["_service"] not in excluded: continue vol_names_to_keep.update(get_volume_names(compose, cnt)) log.debug("keep %s", vol_names_to_keep) for volume_name in await compose.podman.volume_ls(): if volume_name in vol_names_to_keep: continue await compose.podman.run([], "volume", ["rm", volume_name]) if args.rmi: images_to_remove = set() for cnt in containers: if cnt["_service"] in excluded: continue if args.rmi == "local" and not is_local(cnt): continue images_to_remove.add(cnt["image"]) images_to_remove.update(orphaned_images) log.debug("images to remove: %s", images_to_remove) await compose.podman.run([], "rmi", ["--ignore", "--force"] + list(images_to_remove)) if excluded: return for pod in compose.pods: await compose.podman.run([], "pod", ["rm", pod["name"]]) for network in await compose.podman.network_ls(): await compose.podman.run([], "network", ["rm", network]) @cmd_run(podman_compose, "ps", "show status of containers") async def compose_ps(compose: PodmanCompose, args: argparse.Namespace) -> None: ps_args = ["-a", "--filter", f"label=io.podman.compose.project={compose.project_name}"] if args.quiet is True: ps_args.extend(["--format", "{{.ID}}"]) elif args.format: ps_args.extend(["--format", args.format]) await compose.podman.run( [], "ps", ps_args, ) @cmd_run( podman_compose, "run", "create a container similar to a service to run a one-off command", ) async def compose_run(compose: PodmanCompose, args: argparse.Namespace) -> None: await create_pods(compose) compose.assert_services(args.service) container_names = compose.container_names_by_service[args.service] container_name = container_names[0] cnt = dict(compose.container_by_name[container_name]) deps = cnt["_deps"] if deps and not args.no_deps: up_args = argparse.Namespace( **dict( args.__dict__, detach=True, services=[x.name for x in deps], # defaults no_build=False, build=None, force_recreate=False, no_recreate=False, no_start=False, no_cache=False, build_arg=[], parallel=1, remove_orphans=True, wait=False, ) ) await compose.commands["up"](compose, up_args) build_args = argparse.Namespace( services=[args.service], if_not_exists=(not args.build), build_arg=[], **args.__dict__ ) await compose.commands["build"](compose, build_args) compose_run_update_container_from_args(compose, cnt, args) # run podman podman_args = await container_to_args(compose, cnt, args.detach, args.no_deps) if not args.detach: podman_args.insert(1, "-i") if args.rm: podman_args.insert(1, "--rm") p = await compose.podman.run([], "run", podman_args) sys.exit(p) def compose_run_update_container_from_args( compose: PodmanCompose, cnt: dict, args: argparse.Namespace ) -> None: # adjust one-off container options name0 = compose.format_name(args.service, f'tmp{random.randrange(0, 65536)}') cnt["name"] = args.name or name0 if args.entrypoint: cnt["entrypoint"] = args.entrypoint if args.user: cnt["user"] = args.user if args.workdir: cnt["working_dir"] = args.workdir env = dict(cnt.get("environment", {})) if args.env: additional_env_vars = dict(map(lambda each: each.split("=", maxsplit=1), args.env)) env.update(additional_env_vars) cnt["environment"] = env if not args.service_ports: for k in ("expose", "publishall", "ports"): try: del cnt[k] except KeyError: pass if args.publish: ports = cnt.get("ports", []) ports.extend(norm_ports(args.publish)) cnt["ports"] = ports if args.volume: # TODO: handle volumes volumes = clone(cnt.get("volumes", [])) volumes.extend(args.volume) cnt["volumes"] = volumes cnt["tty"] = not args.T if args.cnt_command is not None and len(args.cnt_command) > 0: cnt["command"] = args.cnt_command # can't restart and --rm if args.rm and "restart" in cnt: del cnt["restart"] @cmd_run(podman_compose, "exec", "execute a command in a running container") async def compose_exec(compose: PodmanCompose, args: argparse.Namespace) -> None: compose.assert_services(args.service) container_names = compose.container_names_by_service[args.service] container_name = container_names[args.index - 1] cnt = compose.container_by_name[container_name] podman_args = compose_exec_args(cnt, container_name, args) p = await compose.podman.run([], "exec", podman_args) sys.exit(p) def compose_exec_args(cnt: dict, container_name: str, args: argparse.Namespace) -> list[str]: podman_args = ["--interactive"] if args.privileged: podman_args += ["--privileged"] if args.user: podman_args += ["--user", args.user] if args.workdir: podman_args += ["--workdir", args.workdir] if not args.T: podman_args += ["--tty"] env = dict(cnt.get("environment", {})) if args.env: additional_env_vars = dict( map(lambda each: each.split("=", maxsplit=1) if "=" in each else (each, None), args.env) ) env.update(additional_env_vars) for name, value in env.items(): podman_args += ["--env", f"{name}" if value is None else f"{name}={value}"] podman_args += [container_name] if args.cnt_command is not None and len(args.cnt_command) > 0: podman_args += args.cnt_command return podman_args async def transfer_service_status( compose: PodmanCompose, args: argparse.Namespace, action: str ) -> None: # TODO: handle dependencies, handle creations container_names_by_service = compose.container_names_by_service if not args.services: args.services = container_names_by_service.keys() compose.assert_services(args.services) targets = [] for service in args.services: if service not in container_names_by_service: raise ValueError("unknown service: " + service) targets.extend(container_names_by_service[service]) if action in ["stop", "restart"]: targets = list(reversed(targets)) timeout_global = getattr(args, "timeout", None) tasks = [] for target in targets: podman_args = [] if action != "start": timeout = timeout_global if timeout is None: timeout_str = compose.container_by_name[target].get( "stop_grace_period", STOP_GRACE_PERIOD ) timeout = str_to_seconds(timeout_str) if timeout is not None: podman_args.extend(["-t", str(timeout)]) tasks.append(asyncio.create_task(compose.podman.run([], action, podman_args + [target]))) await asyncio.gather(*tasks) @cmd_run(podman_compose, "start", "start specific services") async def compose_start(compose: PodmanCompose, args: argparse.Namespace) -> None: await transfer_service_status(compose, args, "start") if args.wait: await wait_for_container_running_healthy(compose, args) @cmd_run(podman_compose, "stop", "stop specific services") async def compose_stop(compose: PodmanCompose, args: argparse.Namespace) -> None: await transfer_service_status(compose, args, "stop") @cmd_run(podman_compose, "restart", "restart specific services") async def compose_restart(compose: PodmanCompose, args: argparse.Namespace) -> None: await transfer_service_status(compose, args, "restart") @cmd_run(podman_compose, "logs", "show logs from services") async def compose_logs( compose: PodmanCompose, args: argparse.Namespace, log_formatter: str | None = None ) -> None: container_names_by_service = compose.container_names_by_service if not args.services and not args.latest: args.services = container_names_by_service.keys() compose.assert_services(args.services) podman_args = [] if args.follow: podman_args.append("-f") if args.latest: podman_args.append("-l") if args.names: podman_args.append("-n") if not args.no_color: podman_args.append("--color") if args.since: podman_args.extend(["--since", args.since]) # the default value is to print all logs which is in podman = 0 and not # needed to be passed if args.tail and args.tail != "all": podman_args.extend(["--tail", args.tail]) if args.timestamps: podman_args.append("-t") if args.until: podman_args.extend(["--until", args.until]) max_service_length = 0 tasks = [] max_service_length = max(len(service) for service in args.services) for i, service in enumerate(args.services): # Add colored service prefix to output by piping output through sed if args.no_log_prefix: log_formatter = None else: color_idx = i % len(compose.console_colors) if args.no_color: # monochrome output color = '\x1b[0m' else: color = compose.console_colors[color_idx] space_suffix = " " * (max_service_length - len(service) + 1) log_formatter = f"{color}[{service}]{space_suffix}|\x1b[0m" podman_args_with_target = podman_args + container_names_by_service[service] tasks.append( asyncio.create_task( compose.podman.run([], "logs", podman_args_with_target, log_formatter=log_formatter) ) ) await asyncio.gather(*tasks) @cmd_run(podman_compose, "config", "displays the compose file") async def compose_config(compose: PodmanCompose, args: argparse.Namespace) -> None: if args.services: for service in compose.services: if not args.quiet: print(service) return if not args.quiet: print(compose.merged_yaml) @cmd_run(podman_compose, "port", "Prints the public port for a port binding.") async def compose_port(compose: PodmanCompose, args: argparse.Namespace) -> None: compose.assert_services(args.service) containers = compose.container_names_by_service[args.service] output = await compose.podman.output([], "inspect", [containers[args.index - 1]]) inspect_json = json.loads(output.decode("utf-8")) private_port = str(args.private_port) + "/" + args.protocol host_port = inspect_json[0]["NetworkSettings"]["Ports"][private_port][0]["HostPort"] print(host_port) @cmd_run(podman_compose, "pause", "Pause all running containers") async def compose_pause(compose: PodmanCompose, args: argparse.Namespace) -> None: container_names_by_service = compose.container_names_by_service if not args.services: args.services = container_names_by_service.keys() targets = [] for service in args.services: targets.extend(container_names_by_service[service]) await compose.podman.run([], "pause", targets) @cmd_run(podman_compose, "unpause", "Unpause all running containers") async def compose_unpause(compose: PodmanCompose, args: argparse.Namespace) -> None: container_names_by_service = compose.container_names_by_service if not args.services: args.services = container_names_by_service.keys() targets = [] for service in args.services: targets.extend(container_names_by_service[service]) await compose.podman.run([], "unpause", targets) @cmd_run(podman_compose, "kill", "Kill one or more running containers with a specific signal") async def compose_kill(compose: PodmanCompose, args: argparse.Namespace) -> None: # to ensure that the user did not execute the command by mistake if not args.services and not args.all: log.fatal( "Error: you must provide at least one service name or use (--all) to kill all services" ) sys.exit() container_names_by_service = compose.container_names_by_service podman_args = [] if args.signal: podman_args.extend(["--signal", args.signal]) if args.all is True: services = container_names_by_service.keys() targets = [] for service in services: targets.extend(container_names_by_service[service]) for target in targets: podman_args.append(target) await compose.podman.run([], "kill", podman_args) elif args.services: targets = [] for service in args.services: targets.extend(container_names_by_service[service]) for target in targets: podman_args.append(target) await compose.podman.run([], "kill", podman_args) @cmd_run( podman_compose, "stats", "Display percentage of CPU, memory, network I/O, block I/O and PIDs for services.", ) async def compose_stats(compose: PodmanCompose, args: argparse.Namespace) -> None: container_names_by_service = compose.container_names_by_service if not args.services: args.services = container_names_by_service.keys() targets = [] podman_args = [] if args.interval: podman_args.extend(["--interval", args.interval]) if args.format: podman_args.extend(["--format", args.format]) if args.no_reset: podman_args.append("--no-reset") if args.no_stream: podman_args.append("--no-stream") for service in args.services: targets.extend(container_names_by_service[service]) for target in targets: podman_args.append(target) try: await compose.podman.run([], "stats", podman_args) except KeyboardInterrupt: pass @cmd_run(podman_compose, "images", "List images used by the created containers") async def compose_images(compose: PodmanCompose, args: argparse.Namespace) -> None: img_containers = [cnt for cnt in compose.containers if "image" in cnt] data = [] if args.quiet is True: for img in img_containers: name = img["name"] output = await compose.podman.output([], "images", ["--quiet", img["image"]]) data.append(output.decode("utf-8").split()) else: data.append(["CONTAINER", "REPOSITORY", "TAG", "IMAGE ID", "SIZE", ""]) for img in img_containers: name = img["name"] output = await compose.podman.output( [], "images", [ "--format", "table " + name + " {{.Repository}} {{.Tag}} {{.ID}} {{.Size}}", "-n", img["image"], ], ) data.append(output.decode("utf-8").split()) # Determine the maximum length of each column column_widths = [max(map(len, column)) for column in zip(*data)] # Print each row for row in data: # Format each cell using the maximum column width formatted_row = [cell.ljust(width) for cell, width in zip(row, column_widths)] formatted_row[-2:] = ["".join(formatted_row[-2:]).strip()] print("\t".join(formatted_row)) ################### # command arguments parsing ################### @cmd_parse(podman_compose, "version") def compose_version_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "-f", "--format", choices=["pretty", "json"], default="pretty", help="Format the output", ) parser.add_argument( "--short", action="store_true", help="Shows only Podman Compose's version number", ) @cmd_parse(podman_compose, "up") def compose_up_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "-d", "--detach", action="store_true", help="Detached mode: Run container in the background, print new container name. \ Incompatible with --abort-on-container-exit and --abort-on-container-failure.", ) parser.add_argument("--no-color", action="store_true", help="Produce monochrome output.") parser.add_argument( "--quiet-pull", action="store_true", help="Pull without printing progress information.", ) parser.add_argument("--no-deps", action="store_true", help="Don't start linked services.") parser.add_argument( "--force-recreate", action="store_true", help="Recreate containers even if their configuration and image haven't changed.", ) parser.add_argument( "--always-recreate-deps", action="store_true", help="Recreate dependent containers. Incompatible with --no-recreate.", ) parser.add_argument( "--no-recreate", action="store_true", help="If containers already exist, don't recreate them. Incompatible with --force-recreate " "and -V.", ) parser.add_argument( "--no-build", action="store_true", help="Don't build an image, even if it's missing.", ) parser.add_argument( "--no-start", action="store_true", help="Don't start the services after creating them.", ) parser.add_argument( "--build", action="store_true", help="Build images before starting containers." ) parser.add_argument( "--abort-on-container-exit", action="store_true", help="Stops all containers if any container was stopped. Incompatible with -d and " "--abort-on-container-failure.", ) parser.add_argument( "--abort-on-container-failure", action="store_true", help="Stops all containers if any container stops with a non-zero exit code. Incompatible " "with -d and --abort-on-container-exit.", ) parser.add_argument( "-t", "--timeout", type=int, default=None, help="Use this timeout in seconds for container shutdown when attached or when containers " "are already running. (default: 10)", ) parser.add_argument( "-V", "--renew-anon-volumes", action="store_true", help="Recreate anonymous volumes instead of retrieving data from the previous containers.", ) parser.add_argument( "--remove-orphans", action="store_true", help="Remove containers for services not defined in the Compose file.", ) # `--scale` argument needs to store as single value and not append, # as multiple scale values could be confusing. parser.add_argument( "--scale", metavar="SERVICE=NUM", help="Scale SERVICE to NUM instances. " "Overrides the `scale` setting in the Compose file if present.", ) parser.add_argument( "--exit-code-from", metavar="SERVICE", type=str, default=None, help="Return the exit code of the selected service container. " "Implies --abort-on-container-exit.", ) @cmd_parse(podman_compose, "down") def compose_down_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "-v", "--volumes", action="store_true", default=False, help="Remove named volumes declared in the `volumes` section of the Compose file and " "anonymous volumes attached to containers.", ) parser.add_argument( "--remove-orphans", action="store_true", help="Remove containers for services not defined in the Compose file.", ) parser.add_argument( "--rmi", type=str, nargs="?", const="all", choices=["local", "all"], help="Remove images used by services. `local` remove only images that don't have a " "custom tag. (`local` or `all`)", ) @cmd_parse(podman_compose, "run") def compose_run_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--build", action="store_true", help="Build images before starting containers." ) parser.add_argument( "-d", "--detach", action="store_true", help="Detached mode: Run container in the background, print new container name.", ) parser.add_argument("--name", type=str, default=None, help="Assign a name to the container") parser.add_argument( "--entrypoint", type=str, default=None, help="Override the entrypoint of the image.", ) parser.add_argument( "-e", "--env", metavar="KEY=VAL", action="append", help="Set an environment variable (can be used multiple times)", ) parser.add_argument( "-l", "--label", metavar="KEY=VAL", action="append", help="Add or override a label (can be used multiple times)", ) parser.add_argument( "-u", "--user", type=str, default=None, help="Run as specified username or uid" ) parser.add_argument("--no-deps", action="store_true", help="Don't start linked services") parser.add_argument( "--rm", action="store_true", help="Remove container after run. Ignored in detached mode.", ) parser.add_argument( "-p", "--publish", action="append", help="Publish a container's port(s) to the host (can be used multiple times)", ) parser.add_argument( "--service-ports", action="store_true", help="Run command with the service's ports enabled and mapped to the host.", ) parser.add_argument( "-v", "--volume", action="append", help="Bind mount a volume (can be used multiple times)", ) parser.add_argument( "-T", action="store_true", help="Disable pseudo-tty allocation. By default `podman-compose run` allocates a TTY.", ) parser.add_argument( "-w", "--workdir", type=str, default=None, help="Working directory inside the container", ) parser.add_argument("service", metavar="service", nargs=None, help="service name") parser.add_argument( "cnt_command", metavar="command", nargs=argparse.REMAINDER, help="command and its arguments", ) @cmd_parse(podman_compose, "exec") def compose_exec_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "-d", "--detach", action="store_true", help="Detached mode: Run container in the background, print new container name.", ) parser.add_argument( "--privileged", action="store_true", default=False, help="Give the process extended Linux capabilities inside the container", ) parser.add_argument( "-u", "--user", type=str, default=None, help="Run as specified username or uid" ) parser.add_argument( "-T", action="store_true", help="Disable pseudo-tty allocation. By default `podman-compose run` allocates a TTY.", ) parser.add_argument( "--index", type=int, default=1, help="Index of the container if there are multiple instances of a service", ) parser.add_argument( "-e", "--env", metavar="KEY=VAL", action="append", help="Set an environment variable (can be used multiple times)", ) parser.add_argument( "-w", "--workdir", type=str, default=None, help="Working directory inside the container", ) parser.add_argument("service", metavar="service", nargs=None, help="service name") parser.add_argument( "cnt_command", metavar="command", nargs=argparse.REMAINDER, help="command and its arguments", ) @cmd_parse(podman_compose, ["down", "stop", "restart"]) def compose_parse_timeout(parser: argparse.ArgumentParser) -> None: parser.add_argument( "-t", "--timeout", help="Specify a shutdown timeout in seconds. ", type=int, default=None, ) @cmd_parse(podman_compose, ["logs"]) def compose_logs_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "-f", "--follow", action="store_true", help="Follow log output. The default is false", ) parser.add_argument( "-l", "--latest", action="store_true", help="Act on the latest container podman is aware of", ) parser.add_argument( "-n", "--names", action="store_true", help="Output the container name in the log", ) parser.add_argument("--no-color", action="store_true", help="Produce monochrome output") parser.add_argument( "--no-log-prefix", action="store_true", help="Don't print prefix with container identifier in logs", ) parser.add_argument("--since", help="Show logs since TIMESTAMP", type=str, default=None) parser.add_argument("-t", "--timestamps", action="store_true", help="Show timestamps.") parser.add_argument( "--tail", help="Number of lines to show from the end of the logs for each container.", type=str, default="all", ) parser.add_argument("--until", help="Show logs until TIMESTAMP", type=str, default=None) parser.add_argument( "services", metavar="services", nargs="*", default=None, help="service names" ) @cmd_parse(podman_compose, "systemd") def compose_systemd_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "-a", "--action", choices=["register", "unregister", "create-unit", "list", "ls"], default="register", help="create systemd unit file or register compose stack to it", ) @cmd_parse(podman_compose, "pull") def compose_pull_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--force-local", action="store_true", default=False, help="Also pull unprefixed images for services which have a build section", ) parser.add_argument("services", metavar="services", nargs="*", help="services to pull") @cmd_parse(podman_compose, "push") def compose_push_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--ignore-push-failures", action="store_true", help="Push what it can and ignores images with push failures. (not implemented)", ) parser.add_argument("services", metavar="services", nargs="*", help="services to push") @cmd_parse(podman_compose, "ps") def compose_ps_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument("-q", "--quiet", help="Only display container IDs", action="store_true") class PullPolicyAction(argparse.Action): def __call__( self, parser: argparse.ArgumentParser, namespace: argparse.Namespace, values: str | Sequence[str] | None, option_string: str | None = None, ) -> None: if option_string == "--pull-always": if values in (None, "true"): namespace.pull = "always" return namespace.pull = "newer" if values is None else values @cmd_parse(podman_compose, ["build", "up"]) def compose_build_up_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--pull", help="Pull image policy (always|missing|never|newer)." " Set to 'newer' if specify --pull without a value." " default (pull_policy in compose file).", action=PullPolicyAction, nargs="?", choices=["always", "missing", "never", "newer"], ) parser.add_argument( "--pull-always", help="Deprecated, use --pull=always instead", action=PullPolicyAction, nargs="?", choices=["true", "false"], ) parser.add_argument( "--build-arg", metavar="key=val", action="append", default=[], help="Set build-time variables for services.", ) parser.add_argument( "--no-cache", help="Do not use cache when building the image.", action="store_true", ) @cmd_parse(podman_compose, ["build", "up", "down", "start", "stop", "restart"]) def compose_build_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "services", metavar="services", nargs="*", default=None, help="affected services", ) @cmd_parse(podman_compose, ["up", "start"]) def compose_up_start_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--wait", action="store_true", help="Wait for services to be running|healthy. Implies detached mode.", ) parser.add_argument( "--wait-timeout", type=int, default=None, help="Maximum duration in seconds to wait for the project to be running|healthy", ) @cmd_parse(podman_compose, "config") def compose_config_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--no-normalize", help="Don't normalize compose model.", action="store_true" ) parser.add_argument( "--services", help="Print the service names, one per line.", action="store_true" ) parser.add_argument( "-q", "--quiet", help="Do not print config, only parse.", action="store_true", ) @cmd_parse(podman_compose, "port") def compose_port_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "--index", type=int, default=1, help="index of the container if there are multiple instances of a service", ) parser.add_argument( "--protocol", choices=["tcp", "udp"], default="tcp", help="tcp or udp", ) parser.add_argument("service", metavar="service", nargs=None, help="service name") parser.add_argument( "private_port", metavar="private_port", nargs=None, type=int, help="private port", ) @cmd_parse(podman_compose, ["pause", "unpause"]) def compose_pause_unpause_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "services", metavar="services", nargs="*", default=None, help="service names" ) @cmd_parse(podman_compose, ["kill"]) def compose_kill_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "services", metavar="services", nargs="*", default=None, help="service names" ) parser.add_argument( "-s", "--signal", type=str, help="Signal to send to the container (default 'KILL')", ) parser.add_argument( "-a", "--all", help="Signal all running containers", action="store_true", ) @cmd_parse(podman_compose, "images") def compose_images_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument("-q", "--quiet", help="Only display images IDs", action="store_true") @cmd_parse(podman_compose, ["stats"]) def compose_stats_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "services", metavar="services", nargs="*", default=None, help="service names" ) parser.add_argument( "-i", "--interval", type=int, help="Time in seconds between stats reports (default 5)", ) parser.add_argument( "--no-reset", help="Disable resetting the screen between intervals", action="store_true", ) parser.add_argument( "--no-stream", help="Disable streaming stats and only pull the first result", action="store_true", ) @cmd_parse(podman_compose, ["ps", "stats"]) def compose_format_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "-f", "--format", type=str, help="Pretty-print container statistics to JSON or using a Go template", ) async def async_main() -> None: await podman_compose.run() def main() -> None: try: asyncio.run(async_main()) except PodmanComposeError as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) if __name__ == "__main__": main() podman-compose-1.6.0/pyproject.toml000066400000000000000000000057041521011662300173560ustar00rootroot00000000000000[build-system] build-backend = "setuptools.build_meta" requires = ["setuptools"] [project] name = "podman-compose" authors = [ { email = "alsadi@gmail.com", name = "Muayyad Alsadi" }, ] description = "A script to run docker-compose.yml using podman" dependencies = [ "python-dotenv", "pyyaml", ] requires-python = ">=3.9" classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Topic :: Software Development :: Build Tools", ] keywords = [ "podman", "podman-compose", ] license = "GPL-2.0-only" dynamic = ["version", "readme"] [project.urls] homepage = "https://github.com/containers/podman-compose" [project.optional-dependencies] devel = [ "coverage", "parameterized", "pre-commit", "ruff", ] [project.scripts] podman-compose = "podman_compose:main" [tool.setuptools] py-modules = ["podman_compose"] [tool.setuptools.dynamic] readme = {file = ["README.md"], content-type = "text/markdown"} version = {attr = "podman_compose.__version__"} [tool.ruff] line-length = 100 target-version = "py38" [tool.ruff.lint] select = ["W", "E", "F", "I", "U"] ignore = [ ] [tool.ruff.lint.isort] force-single-line = true [tool.ruff.format] preview = true # needed for quote-style quote-style = "preserve" [tool.towncrier] package = "podman_compose" package_dir = "master" directory = "newsfragments" filename = "docs/Changelog-new.md" template = "scripts/Changelog-template.jinja" title_format = "Version {version} ({project_date})" [[tool.towncrier.section]] path = "" [[tool.towncrier.type]] directory = "feature" name = "Features" showcontent = true [[tool.towncrier.type]] directory = "change" name = "Changes" showcontent = true [[tool.towncrier.type]] directory = "bugfix" name = "Bug fixes" showcontent = true [[tool.towncrier.type]] directory = "doc" name = "Improved Documentation" showcontent = true [[tool.towncrier.type]] directory = "removal" name = "Deprecations and Removals" showcontent = true [[tool.towncrier.type]] directory = "misc" name = "Misc" showcontent = true [tool.mypy] python_version = "3.9" namespace_packages = true explicit_package_bases = true pretty = true warn_redundant_casts = true disallow_untyped_calls = false disallow_untyped_defs = true no_implicit_optional = true mypy_path = "$MYPY_CONFIG_FILE_DIR" exclude = "build" [[tool.mypy.overrides]] module = [ "parameterized.*", "apscheduler.*", ] ignore_missing_imports = true podman-compose-1.6.0/requirements.txt000066400000000000000000000003511521011662300177170ustar00rootroot00000000000000# The order of packages is significant, because pip processes them in the order # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. pyyaml python-dotenv podman-compose-1.6.0/scripts/000077500000000000000000000000001521011662300161235ustar00rootroot00000000000000podman-compose-1.6.0/scripts/Changelog-template.jinja000066400000000000000000000012171521011662300226410ustar00rootroot00000000000000{% for section, _ in sections|dictsort(by='key') %} {% set underline = "-" %} {% if section %} {{section}} {{ underline * section|length }}{% set underline = "~" %} {% endif %} {% if sections[section] %} {% for category, val in definitions|dictsort if category in sections[section]%} {{ definitions[category]['name'] }} {{ underline * definitions[category]['name']|length }} {% for text, values in sections[section][category]|dictsort(by='value') %} - {{ text }} {% endfor %} {% if sections[section][category]|length == 0 %} No significant changes. {% else %} {% endif %} {% endfor %} {% else %} No significant changes. {% endif %} {% endfor %} podman-compose-1.6.0/scripts/clean_up.sh000077500000000000000000000003641521011662300202530ustar00rootroot00000000000000#!/usr/bin/env bash find . -name "*.pyc" -delete find . -name "__pycache__" -delete find . -name "*.orig" -delete rm -rf .cache/ rm -rf build/ rm -rf builddocs/ rm -rf dist/ rm -rf deb_dist/ rm src/podman_compose.egg-info -rf rm builddocs.zip podman-compose-1.6.0/scripts/download_and_build_podman-compose.sh000066400000000000000000000005541521011662300252740ustar00rootroot00000000000000#!/bin/sh # Delete repository dir rm -rf podman-compose-src # Clone repository git clone https://github.com/containers/podman-compose podman-compose-src # Generate binary sh podman-compose-src/scripts/generate_binary_using_dockerfile.sh # Move binary outside repo's dir mv podman-compose-src/podman-compose . # Delete repository dir rm -rf podman-compose-src podman-compose-1.6.0/scripts/generate_binary_using_dockerfile.sh000066400000000000000000000034601521011662300252140ustar00rootroot00000000000000#!/bin/sh # Find an available container tool (docker or podman) find_container_tool() { if command -v docker > /dev/null 2>&1; then echo "sudo docker" elif command -v podman > /dev/null 2>&1; then echo "podman" else echo "Error: Neither docker nor podman is available." >&2 exit 1 fi } # Determine which container tool to use CONTAINER_TOOL=$(find_container_tool) # Locate the directory containing dockerfile (root) PROJECT_ROOT_DIR="$(cd "$(dirname "$0")" && pwd)/.." # Check SELinux status and set appropriate mount option check_selinux() { if command -v getenforce > /dev/null 2>&1; then SELINUX_STATUS=$(getenforce) if [ "$SELINUX_STATUS" = "Enforcing" ] || [ "$SELINUX_STATUS" = "Permissive" ]; then echo ":z" else echo "" fi elif [ -f /sys/fs/selinux/enforce ]; then if [ "$(cat /sys/fs/selinux/enforce)" = "1" ]; then echo ":z" else echo "" fi else echo "" fi } # Get the SELinux option for volume mounts if SELinux is enforcing or permissive SELINUX=$(check_selinux) # Build binary $CONTAINER_TOOL image rm build-podman-compose if expr "$CONTAINER_TOOL" : '.*docker.*' >/dev/null; then $CONTAINER_TOOL build -t build-podman-compose "$PROJECT_ROOT_DIR" $CONTAINER_TOOL run --name build-podman-compose build-podman-compose $CONTAINER_TOOL cp build-podman-compose:/result/podman-compose "$PROJECT_ROOT_DIR/podman-compose" $CONTAINER_TOOL container stop build-podman-compose $CONTAINER_TOOL container rm -f build-podman-compose else $CONTAINER_TOOL build -v "$PROJECT_ROOT_DIR:/result$SELINUX" -t build-podman-compose "$PROJECT_ROOT_DIR" fi $CONTAINER_TOOL image rm python:3.11-slim $CONTAINER_TOOL image rm build-podman-compose podman-compose-1.6.0/scripts/make_release.sh000077500000000000000000000005431521011662300211010ustar00rootroot00000000000000#!/bin/bash set -e if [ $# -ne 1 ]; then echo "Usage: make_release.sh VERSION" exit 1 fi VERSION=$1 sed "s/__version__ = .*/__version__ = \"$VERSION\"/g" -i podman_compose.py git add podman_compose.py git commit -m "Release $VERSION" git tag "v$VERSION" -m "v$VERSION" -s git push ssh://github.com/containers/podman-compose main "v$VERSION" podman-compose-1.6.0/scripts/make_release_notes.sh000077500000000000000000000004401521011662300223050ustar00rootroot00000000000000#!/bin/bash set -e if [ $# -ne 1 ]; then echo "Usage: make_release_notes.sh VERSION" exit 1 fi VERSION=$1 towncrier build --version "$VERSION" --yes git mv "docs/Changelog-new.md" "docs/Changelog-$VERSION.md" git add "newsfragments/" git commit -m "Release notes for $VERSION" podman-compose-1.6.0/scripts/make_release_upload.sh000077500000000000000000000001451521011662300224430ustar00rootroot00000000000000#!/usr/bin/env bash ./scripts/uninstall.sh ./scripts/clean_up.sh pyproject-build twine upload dist/* podman-compose-1.6.0/scripts/uninstall.sh000077500000000000000000000001131521011662300204660ustar00rootroot00000000000000#!/usr/bin/env bash pip3 uninstall podman-compose -y ./scripts/clean_up.sh podman-compose-1.6.0/setup.cfg000066400000000000000000000002061521011662300162530ustar00rootroot00000000000000[flake8] # The GitHub editor is 127 chars wide max-line-length=127 # These are not being followed yet ignore=E222,E231,E272,E713,W503 podman-compose-1.6.0/test-requirements.txt000066400000000000000000000021461521011662300207000ustar00rootroot00000000000000-e . coverage==7.4.3 cryptography==46.0.7 parameterized==0.9.0 pytest==8.0.2 tox==4.13.0 mypy==1.15.0 ruff==0.14.8 pylint==3.1.0 types-PyYAML==6.0.12.20250402 types-requests==2.32.0.20250328 types-setuptools==80.7.0.20250516 APScheduler==3.11.2 # NOTE: when upgrading the above packages, also upgrade .pre-commit-config.yaml # The packages below are transitive dependencies of the packages above and are included here # to make testing reproducible. # To refresh, create a new virtualenv and do: # pip install -r requirements.txt -r test-requirements.txt # pip freeze > test-requirements.txt # and edit test-requirements.txt to add this comment astroid==3.1.0 cachetools==5.3.3 certifi==2025.11.12 cffi==2.0.0 chardet==5.2.0 charset-normalizer==3.4.4 colorama==0.4.6 dill==0.3.8 distlib==0.3.8 filelock==3.13.1 idna==3.15 iniconfig==2.0.0 isort==5.13.2 mccabe==0.7.0 mypy_extensions==1.1.0 packaging==23.2 platformdirs==4.2.0 pluggy==1.4.0 pycparser==2.23 pyproject-api==1.6.1 python-dotenv==1.0.1 PyYAML==6.0.1 requests==2.32.5 tomlkit==0.12.4 typing_extensions==4.13.2 tzlocal==5.3.1 urllib3==2.6.3 virtualenv==20.26.6 podman-compose-1.6.0/tests/000077500000000000000000000000001521011662300155765ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/000077500000000000000000000000001521011662300201215ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/__init__.py000066400000000000000000000004111521011662300222260ustar00rootroot00000000000000import os import subprocess def create_base_test_image() -> None: subprocess.check_call( ['podman', 'build', '-t', 'nopush/podman-compose-test', '.'], cwd=os.path.join(os.path.dirname(__file__), "base_image"), ) create_base_test_image() podman-compose-1.6.0/tests/integration/abort/000077500000000000000000000000001521011662300212305ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/abort/__init__.py000066400000000000000000000000001521011662300233270ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/abort/docker-compose-fail-first.yaml000066400000000000000000000006161521011662300270670ustar00rootroot00000000000000version: "3" services: sh1: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 1; exit 1"] sh2: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 2; exit 0"] sh3: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 3; exit 0"] podman-compose-1.6.0/tests/integration/abort/docker-compose-fail-none.yaml000066400000000000000000000006161521011662300266770ustar00rootroot00000000000000version: "3" services: sh1: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 1; exit 0"] sh2: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 2; exit 0"] sh3: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 3; exit 0"] podman-compose-1.6.0/tests/integration/abort/docker-compose-fail-second.yaml000066400000000000000000000006161521011662300272130ustar00rootroot00000000000000version: "3" services: sh1: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 1; exit 0"] sh2: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 2; exit 1"] sh3: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 3; exit 0"] podman-compose-1.6.0/tests/integration/abort/docker-compose-fail-simultaneous.yaml000066400000000000000000000006161521011662300304700ustar00rootroot00000000000000version: "3" services: sh1: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 1; exit 1"] sh2: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 1; exit 0"] sh3: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 2; exit 0"] podman-compose-1.6.0/tests/integration/abort/test_podman_compose_abort.py000066400000000000000000000026621521011662300270410ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from parameterized import parameterized from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(failure_order: str) -> str: return os.path.join(test_path(), "abort", f"docker-compose-fail-{failure_order}.yaml") class TestComposeAbort(unittest.TestCase, RunSubprocessMixin): @parameterized.expand([ ("exit", "first", 0), ("failure", "first", 1), ("exit", "second", 0), ("failure", "second", 1), ("exit", "simultaneous", 0), ("failure", "simultaneous", 1), ("exit", "none", 0), ("failure", "none", 0), ]) def test_abort(self, abort_type: str, failure_order: str, expected_exit_code: int) -> None: try: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(failure_order), "up", f"--abort-on-container-{abort_type}", ], expected_exit_code, ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(failure_order), "down", ]) podman-compose-1.6.0/tests/integration/additional_contexts/000077500000000000000000000000001521011662300241605ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/additional_contexts/README.md000066400000000000000000000003101521011662300254310ustar00rootroot00000000000000# Test podman-compose with build.additional_contexts ``` podman-compose build podman-compose up podman-compose down ``` The expected output is ``` [dict] | Data for dict [list] | Data for list ``` podman-compose-1.6.0/tests/integration/additional_contexts/__init__.py000066400000000000000000000000011521011662300262600ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/additional_contexts/data_for_dict/000077500000000000000000000000001521011662300267425ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/additional_contexts/data_for_dict/data.txt000066400000000000000000000000161521011662300304110ustar00rootroot00000000000000Data for dict podman-compose-1.6.0/tests/integration/additional_contexts/data_for_list/000077500000000000000000000000001521011662300267725ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/additional_contexts/data_for_list/data.txt000066400000000000000000000000161521011662300304410ustar00rootroot00000000000000Data for list podman-compose-1.6.0/tests/integration/additional_contexts/project/000077500000000000000000000000001521011662300256265ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/additional_contexts/project/Dockerfile000066400000000000000000000001371521011662300276210ustar00rootroot00000000000000FROM busybox COPY --from=data data.txt /data/data.txt CMD ["busybox", "cat", "/data/data.txt"] podman-compose-1.6.0/tests/integration/additional_contexts/project/docker-compose.yml000066400000000000000000000003261521011662300312640ustar00rootroot00000000000000version: "3.7" services: dict: build: context: . additional_contexts: data: ../data_for_dict list: build: context: . additional_contexts: - data=../data_for_list test_podman_compose_additional_contexts.py000066400000000000000000000024771521011662300346460ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/additional_contexts# SPDX-License-Identifier: GPL-2.0 """Test how additional contexts are passed to podman.""" import os import subprocess import unittest from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: """ "Returns the path to the compose file used for this test module""" return os.path.join(test_path(), "additional_contexts", "project") class TestComposeBuildAdditionalContexts(unittest.TestCase): def test_build_additional_context(self) -> None: """podman build should receive additional contexts as --build-context See additional_context/project/docker-compose.yaml for context paths """ cmd = ( "coverage", "run", podman_compose_path(), "--dry-run", "--verbose", "-f", os.path.join(compose_yaml_path(), "docker-compose.yml"), "build", ) p = subprocess.run( cmd, stdout=subprocess.PIPE, check=False, stderr=subprocess.STDOUT, text=True, ) self.assertEqual(p.returncode, 0) self.assertIn("--build-context=data=../data_for_dict", p.stdout) self.assertIn("--build-context=data=../data_for_list", p.stdout) podman-compose-1.6.0/tests/integration/additional_contexts_services/000077500000000000000000000000001521011662300260635ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/additional_contexts_services/__init__.py000066400000000000000000000000011521011662300301630ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/additional_contexts_services/docker-compose.yml000066400000000000000000000022151521011662300315200ustar00rootroot00000000000000# A complicated build-time dependency graph: # service_a -> service_c, service_d # service_b -> service_a, service_d, service_e # # The images will be built in this order: # Step 1: service_c, service_d, service_e # Step 2: service_a # Step 3: service_b services: service_a: build: dockerfile_inline: | FROM busybox COPY --from=basic_c /flag_c / COPY --from=basic_d /flag_d / RUN touch /flag_a additional_contexts: basic_c: service:service_c basic_d: service:service_d service_b: build: dockerfile_inline: | FROM basic_a COPY --from=basic_e /flag_e / RUN sh -c "[ -f /flag_a ] && [ -f /flag_c ] && [ -f /flag_d ] && [ -f /flag_e ]" additional_contexts: basic_a: service:service_a basic_d: service:service_d basic_e: service:service_e service_c: build: dockerfile_inline: | FROM busybox RUN touch /flag_c service_d: build: dockerfile_inline: | FROM busybox RUN touch /flag_d service_e: build: dockerfile_inline: | FROM busybox RUN touch /flag_e test_services_in_additional_build_contexts.py000066400000000000000000000017531521011662300372320ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/additional_contexts_services# SPDX-License-Identifier: GPL-2.0 """Test how services can be used in the additional build contexts.""" import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: """ "Returns the path to the compose file used for this test module""" return os.path.join(test_path(), "additional_contexts_services", "docker-compose.yml") class TestComposeBuildAdditionalContextsServices(unittest.TestCase, RunSubprocessMixin): def test_build_additional_contexts_services(self) -> None: # Podman should automatically build the images, resolving the service dependencies. # The build will fail if the order is incorrect or something is unresolved. self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "build", ]) podman-compose-1.6.0/tests/integration/base_image/000077500000000000000000000000001521011662300221755ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/base_image/Dockerfile000066400000000000000000000002201521011662300241610ustar00rootroot00000000000000FROM docker.io/library/debian:bookworm-slim RUN apt-get update \ && apt-get install -y \ dumb-init \ busybox \ wget podman-compose-1.6.0/tests/integration/build/000077500000000000000000000000001521011662300212205ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/build/__init__.py000066400000000000000000000000011521011662300233200ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/build/context/000077500000000000000000000000001521011662300227045ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/build/context/Dockerfile000066400000000000000000000002271521011662300246770ustar00rootroot00000000000000FROM busybox RUN mkdir -p /var/www/html/ && date -Iseconds > /var/www/html/index.txt CMD ["busybox", "httpd", "-f", "-p", "80", "-h", "/var/www/html"] podman-compose-1.6.0/tests/integration/build/context/Dockerfile-alt000066400000000000000000000004661521011662300254620ustar00rootroot00000000000000FROM busybox ARG buildno=1 ARG httpd_port=80 ARG other_variable=not_set ENV httpd_port ${httpd_port} ENV other_variable ${other_variable} RUN mkdir -p /var/www/html/ && \ echo "ALT buildno=$buildno port=$httpd_port `date -Iseconds`" > /var/www/html/index.txt CMD httpd -f -p "$httpd_port" -h /var/www/html podman-compose-1.6.0/tests/integration/build/docker-compose.yml000066400000000000000000000010131521011662300246500ustar00rootroot00000000000000version: "3" services: web1: build: ./context image: my-busybox-httpd ports: - 8080:80 web2: build: context: ./context dockerfile: Dockerfile-alt labels: mykey: myval args: buildno: 2 httpd_port: 8000 image: my-busybox-httpd2 ports: - 8000:8000 test_build_arg_argument: build: context: ./context dockerfile: Dockerfile-alt image: my-busybox-httpd2 command: env podman-compose-1.6.0/tests/integration/build/git_url_context/000077500000000000000000000000001521011662300244315ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/build/git_url_context/__init__.py000066400000000000000000000000011521011662300265310ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/build/git_url_context/docker-compose.yml000066400000000000000000000004541521011662300300710ustar00rootroot00000000000000version: "3" services: test_context: build: context: https://github.com/mokibit/test-git-url-as-context.git image: test-git-url-as-context test_context_inline: build: https://github.com/mokibit/test-git-url-as-context.git image: test-git-url-as-context-inline test_podman_compose_build_git_url_context.py000066400000000000000000000036011521011662300354360ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/build/git_url_context# SPDX-License-Identifier: GPL-2.0 import os import unittest from parameterized import parameterized from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(): """ "Returns the path to the compose file used for this test module""" base_path = os.path.join(test_path(), "build/git_url_context") return os.path.join(base_path, "docker-compose.yml") class TestComposeBuildGitUrlAsContext(unittest.TestCase, RunSubprocessMixin): @parameterized.expand([ ("git_url_context_test_context_1", "data_1.txt", b'test1\r\n'), ("git_url_context_test_context_1", "data_2.txt", b'test2\r\n'), ("git_url_context_test_context_inline_1", "data_1.txt", b'test1\r\n'), ("git_url_context_test_context_inline_1", "data_2.txt", b'test2\r\n'), ]) def test_build_git_url_as_context(self, container_name, file_name, output): # test if container can access specific files from git repository when git url is used as # a build context try: out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "up", "-d", ]) out, _ = self.run_subprocess_assert_returncode([ "podman", "exec", "-ti", f"{container_name}", "sh", "-c", f"cat {file_name}", ]) self.assertEqual(out, output) finally: out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/build/test_podman_compose_build.py000066400000000000000000000041351521011662300270160ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest import requests from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(): """ "Returns the path to the compose file used for this test module""" base_path = os.path.join(test_path(), "build") return os.path.join(base_path, "docker-compose.yml") class TestComposeBuild(unittest.TestCase, RunSubprocessMixin): def test_build(self): try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "build", "--no-cache", ]) self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "up", "-d", ]) request = requests.get('http://localhost:8080/index.txt') self.assertEqual(request.status_code, 200) alt_request_success = False try: # FIXME: suspicious behaviour, too often ends up in error alt_request = requests.get('http://localhost:8000/index.txt') self.assertEqual(alt_request.status_code, 200) self.assertIn("ALT buildno=2 port=8000 ", alt_request.text) alt_request_success = True except requests.exceptions.ConnectionError: pass if alt_request_success: output, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", "my-busybox-httpd2", ]) self.assertIn("httpd_port=8000", str(output)) self.assertIn("buildno=2", str(output)) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) podman-compose-1.6.0/tests/integration/build_fail/000077500000000000000000000000001521011662300222135ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/build_fail/__init__.py000066400000000000000000000000011521011662300243130ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/build_fail/context/000077500000000000000000000000001521011662300236775ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/build_fail/context/Dockerfile000066400000000000000000000000701521011662300256660ustar00rootroot00000000000000FROM busybox RUN this_command_does_not_exist CMD ["sh"] podman-compose-1.6.0/tests/integration/build_fail/context_no_file/000077500000000000000000000000001521011662300253725ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/build_fail/context_no_file/NotDockerfile000066400000000000000000000000051521011662300300400ustar00rootroot00000000000000test podman-compose-1.6.0/tests/integration/build_fail/docker-compose.yml000066400000000000000000000004701521011662300256510ustar00rootroot00000000000000version: "3" services: test: build: ./context image: build-fail-img test_no_dockerfile: build: context: ./context_no_file image: busybox test_no_custom_dockerfile: build: context: ./context_no_file dockerfile: Dockerfile-alt image: busybox podman-compose-1.6.0/tests/integration/build_fail/test_podman_compose_build_fail.py000066400000000000000000000044531521011662300310070ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(): """Returns the path to the compose file used for this test module""" base_path = os.path.join(test_path(), "build_fail") return os.path.join(base_path, "docker-compose.yml") class TestComposeBuildFail(unittest.TestCase, RunSubprocessMixin): def test_build_fail(self): output, error = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "build", "test", ], expected_returncode=127, ) self.assertIn("RUN this_command_does_not_exist", str(output)) self.assertIn("this_command_does_not_exist: not found", str(error)) self.assertIn("while running runtime: exit status 127", str(error)) def test_dockerfile_does_not_exist(self): out, error = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "build", "test_no_dockerfile", ], expected_returncode=1, ) error = error.decode('utf-8') result = '\n'.join(error.splitlines()[-1:]) expected_path = os.path.join(os.path.dirname(__file__), "context_no_file") expected = f'OSError: Dockerfile not found in {expected_path}' self.assertEqual(expected, result) def test_custom_dockerfile_does_not_exist(self): out, error = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "build", "test_no_custom_dockerfile", ], expected_returncode=1, ) error = error.decode('utf-8') result = '\n'.join(error.splitlines()[-1:]) expected_path = os.path.join(os.path.dirname(__file__), "context_no_file/Dockerfile-alt") expected = f'OSError: Dockerfile not found in {expected_path}' self.assertEqual(expected, result) podman-compose-1.6.0/tests/integration/build_labels/000077500000000000000000000000001521011662300225425ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/build_labels/__init__.py000066400000000000000000000000001521011662300246410ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/build_labels/context/000077500000000000000000000000001521011662300242265ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/build_labels/context/Dockerfile000066400000000000000000000000151521011662300262140ustar00rootroot00000000000000FROM busybox podman-compose-1.6.0/tests/integration/build_labels/docker-compose.yml000066400000000000000000000011711521011662300261770ustar00rootroot00000000000000version: "3" services: test_build_labels_map: build: context: ./context dockerfile: Dockerfile labels: com.example.description: "Accounting webapp" com.example.department: "Finance" com.example.label-with-empty-value: "" image: my-busybox-build-labels-map command: env test_build_labels_array: build: context: ./context dockerfile: Dockerfile labels: - "com.example.description=Accounting webapp" - "com.example.department=Finance" - "com.example.label-with-empty-value" image: my-busybox-build-labels-array command: env podman-compose-1.6.0/tests/integration/build_labels/test_build_labels.py000066400000000000000000000037351521011662300266040ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import json import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path class TestBuildLabels(unittest.TestCase, RunSubprocessMixin): def test_build_labels(self): """The build context can contain labels which should be added to the resulting image. They can be either an array or a map. """ compose_path = os.path.join(test_path(), "build_labels/docker-compose.yml") try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "build", "test_build_labels_map", "test_build_labels_array", ]) expected_labels = { "com.example.department": "Finance", "com.example.description": "Accounting webapp", "com.example.label-with-empty-value": "", } out, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", "my-busybox-build-labels-map", "my-busybox-build-labels-array", ]) images = json.loads(out) self.assertEqual(len(images), 2) labels_map = images[0].get("Config", {}).get("Labels", {}) labels_array = images[1].get("Config", {}).get("Labels", {}) for k, v in expected_labels.items(): self.assertIn(k, labels_map) self.assertEqual(labels_map[k], v) self.assertIn(k, labels_array) self.assertEqual(labels_array[k], v) finally: self.run_subprocess_assert_returncode([ "podman", "rmi", "my-busybox-build-labels-map", "my-busybox-build-labels-array", ]) podman-compose-1.6.0/tests/integration/build_secrets/000077500000000000000000000000001521011662300227505ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/build_secrets/Dockerfile000066400000000000000000000004341521011662300247430ustar00rootroot00000000000000FROM busybox RUN --mount=type=secret,required=true,id=build_secret \ ls -l /run/secrets/ && cat /run/secrets/build_secret RUN --mount=type=secret,required=true,id=build_secret,target=/tmp/secret \ ls -l /run/secrets/ /tmp/ && cat /tmp/secret CMD [ 'echo', 'nothing here' ] podman-compose-1.6.0/tests/integration/build_secrets/__init__.py000066400000000000000000000000011521011662300250500ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/build_secrets/docker-compose.yaml000066400000000000000000000014651521011662300265540ustar00rootroot00000000000000version: "3.8" services: test: image: test secrets: - run_secret # implicitly mount to /run/secrets/run_secret - source: run_secret target: /tmp/run_secret2 # explicit mount point build: context: . secrets: - build_secret_file # can be mounted in Dockerfile with "RUN --mount=type=secret,id=build_secret_file" - source: build_secret_file target: build_secret_file2 # rename to build_secret_file2 - build_secret_env # environment-sourced build secret - source: build_secret_env target: build_secret_env2 # environment-sourced with custom target secrets: build_secret_file: file: ./my_secret build_secret_env: environment: MY_SECRET_VAR run_secret: file: ./my_secret podman-compose-1.6.0/tests/integration/build_secrets/docker-compose.yaml.invalid000066400000000000000000000006701521011662300301760ustar00rootroot00000000000000version: "3.8" services: test: image: test build: context: . secrets: # invalid target argument # # According to https://github.com/compose-spec/compose-spec/blob/master/build.md, target is # supposed to be the "name of a *file* to be mounted in /run/secrets/". Not a path. - source: build_secret target: /build_secret secrets: build_secret: file: ./my_secret podman-compose-1.6.0/tests/integration/build_secrets/my_secret000066400000000000000000000000361521011662300246640ustar00rootroot00000000000000important-secret-is-important podman-compose-1.6.0/tests/integration/build_secrets/test_podman_compose_build_secrets.py000066400000000000000000000062621521011662300323010ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 """Test how secrets in files are passed to podman.""" import os import subprocess import unittest from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(): """ "Returns the path to the compose file used for this test module""" return os.path.join(test_path(), "build_secrets") class TestComposeBuildSecrets(unittest.TestCase): def test_run_secret(self): """podman run should receive file secrets as --volume See build_secrets/docker-compose.yaml for secret names and mount points (aka targets) """ cmd = ( "coverage", "run", podman_compose_path(), "--dry-run", "--verbose", "-f", os.path.join(compose_yaml_path(), "docker-compose.yaml"), "run", "test", ) p = subprocess.run( cmd, stdout=subprocess.PIPE, check=False, stderr=subprocess.STDOUT, text=True ) self.assertEqual(p.returncode, 0) secret_path = os.path.join(compose_yaml_path(), "my_secret") self.assertIn(f"--volume {secret_path}:/run/secrets/run_secret:ro,rprivate,rbind", p.stdout) self.assertIn(f"--volume {secret_path}:/tmp/run_secret2:ro,rprivate,rbind", p.stdout) def test_build_secret(self): """podman build should receive secrets as --secret, so that they can be used inside the Dockerfile in "RUN --mount=type=secret ..." commands. """ cmd = ( "coverage", "run", podman_compose_path(), "--dry-run", "--verbose", "-f", os.path.join(compose_yaml_path(), "docker-compose.yaml"), "build", ) p = subprocess.run( cmd, stdout=subprocess.PIPE, check=False, stderr=subprocess.STDOUT, text=True ) self.assertEqual(p.returncode, 0) secret_path = os.path.join(compose_yaml_path(), "my_secret") # file-sourced secrets self.assertIn(f"--secret id=build_secret_file,src={secret_path}", p.stdout) self.assertIn(f"--secret id=build_secret_file2,src={secret_path}", p.stdout) # environment-sourced secrets self.assertIn("--secret id=build_secret_env,env=MY_SECRET_VAR", p.stdout) self.assertIn("--secret id=build_secret_env2,env=MY_SECRET_VAR", p.stdout) def test_invalid_build_secret(self): """build secrets in docker-compose file can only have a target argument without directory component """ cmd = ( "coverage", "run", podman_compose_path(), "--dry-run", "--verbose", "-f", os.path.join(compose_yaml_path(), "docker-compose.yaml.invalid"), "build", ) p = subprocess.run( cmd, stdout=subprocess.PIPE, check=False, stderr=subprocess.STDOUT, text=True ) self.assertNotEqual(p.returncode, 0) self.assertIn( 'ValueError: ERROR: Build secret "build_secret" has invalid target "/build_secret"', p.stdout, ) podman-compose-1.6.0/tests/integration/build_ssh/000077500000000000000000000000001521011662300220755ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/build_ssh/__init__.py000066400000000000000000000000011521011662300241750ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/build_ssh/context/000077500000000000000000000000001521011662300235615ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/build_ssh/context/Dockerfile000066400000000000000000000006041521011662300255530ustar00rootroot00000000000000# Base image FROM alpine:latest # Install OpenSSH client RUN apk add openssh # Test the SSH agents during the build RUN echo -n "default: " >> /result.log RUN --mount=type=ssh ssh-add -L >> /result.log RUN echo -n "id1: " >> /result.log RUN --mount=type=ssh,id=id1 ssh-add -L >> /result.log RUN echo -n "id2: " >> /result.log RUN --mount=type=ssh,id=id2 ssh-add -L >> /result.log podman-compose-1.6.0/tests/integration/build_ssh/docker-compose.yml000066400000000000000000000010541521011662300255320ustar00rootroot00000000000000version: "3" services: test_build_ssh_map: build: context: ./context dockerfile: Dockerfile ssh: default: id1: "./id_ed25519_dummy" id2: "./agent_dummy.sock" image: my-alpine-build-ssh-map command: - cat - /result.log test_build_ssh_array: build: context: ./context dockerfile: Dockerfile ssh: - default - "id1=./id_ed25519_dummy" - "id2=./agent_dummy.sock" image: my-alpine-build-ssh-array command: - cat - /result.log podman-compose-1.6.0/tests/integration/build_ssh/id_ed25519_dummy000066400000000000000000000006331521011662300247070ustar00rootroot00000000000000-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW QyNTUxOQAAACBWELzfWvraCAeo0rOM2OxTGqWZx7fNBCglK/1oS8FLpgAAAJhzHuERcx7h EQAAAAtzc2gtZWQyNTUxOQAAACBWELzfWvraCAeo0rOM2OxTGqWZx7fNBCglK/1oS8FLpg AAAEAEIrYvY3jJ2IvAnUa5jIrVe8UG+7G7PzWzZqqBQykZllYQvN9a+toIB6jSs4zY7FMa pZnHt80EKCUr/WhLwUumAAAADnJpbmdvQGJuZHRib3gyAQIDBAUGBw== -----END OPENSSH PRIVATE KEY----- podman-compose-1.6.0/tests/integration/build_ssh/test_build_ssh.py000066400000000000000000000216301521011662300254640ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import socket import struct import threading import unittest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path expected_lines = [ "default: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFYQvN9a+toIB6jSs4zY7FMapZnHt80EKCUr/WhLwUum", "id1: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFYQvN9a+toIB6jSs4zY7FMapZnHt80EKCUr/WhLwUum", "id2: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFYQvN9a+toIB6jSs4zY7FMapZnHt80EKCUr/WhLwUum", ] class TestBuildSsh(unittest.TestCase, RunSubprocessMixin): def test_build_ssh(self): """The build context can contain the ssh authentications that the image builder should use during image build. They can be either an array or a map. """ compose_path = os.path.join(test_path(), "build_ssh/docker-compose.yml") sock_path = os.path.join(test_path(), "build_ssh/agent_dummy.sock") private_key_file = os.path.join(test_path(), "build_ssh/id_ed25519_dummy") agent = MockSSHAgent(private_key_file) try: # Set SSH_AUTH_SOCK because `default` expects it os.environ['SSH_AUTH_SOCK'] = sock_path # Start a mock SSH agent server agent.start_agent(sock_path) self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "build", "test_build_ssh_map", "test_build_ssh_array", ]) for test_image in [ "test_build_ssh_map", "test_build_ssh_array", ]: out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "run", "--rm", test_image, ]) out = out.decode('utf-8') # Check if all lines are contained in the output self.assertTrue( all(line in out for line in expected_lines), f"Incorrect output for image {test_image}", ) finally: # Now we send the stop command to gracefully shut down the server agent.stop_agent() if os.path.exists(sock_path): os.remove(sock_path) self.run_subprocess_assert_returncode([ "podman", "rmi", "my-alpine-build-ssh-map", "my-alpine-build-ssh-array", ]) # SSH agent message types SSH_AGENTC_REQUEST_IDENTITIES = 11 SSH_AGENT_IDENTITIES_ANSWER = 12 SSH_AGENT_FAILURE = 5 STOP_REQUEST = 0xFF class MockSSHAgent: def __init__(self, private_key_path): self.sock_path = None self.server_sock = None self.running = threading.Event() self.keys = [self._load_ed25519_private_key(private_key_path)] self.agent_thread = None # Thread to run the agent def _load_ed25519_private_key(self, private_key_path): """Load ED25519 private key from an OpenSSH private key file.""" with open(private_key_path, 'rb') as key_file: private_key = serialization.load_ssh_private_key(key_file.read(), password=None) # Ensure it's an Ed25519 key if not isinstance(private_key, Ed25519PrivateKey): raise ValueError("Invalid key type, expected ED25519 private key.") # Get the public key corresponding to the private key public_key = private_key.public_key() # Serialize the public key to the OpenSSH format public_key_blob = public_key.public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw ) # SSH key type "ssh-ed25519" key_type = b"ssh-ed25519" # Build the key blob (public key part for the agent) key_blob_full = ( struct.pack(">I", len(key_type)) + key_type # Key type length + type + struct.pack(">I", len(public_key_blob)) + public_key_blob # Public key length + key blob ) # Comment (empty) comment = "" return ("ssh-ed25519", key_blob_full, comment) def start_agent(self, sock_path): """Start the mock SSH agent and create a Unix domain socket.""" self.sock_path = sock_path if os.path.exists(self.sock_path): os.remove(self.sock_path) self.server_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.server_sock.bind(self.sock_path) self.server_sock.listen(5) os.environ['SSH_AUTH_SOCK'] = self.sock_path self.running.set() # Set the running event # Start a thread to accept client connections self.agent_thread = threading.Thread(target=self._accept_connections, daemon=True) self.agent_thread.start() def _accept_connections(self): """Accept and handle incoming connections.""" while self.running.is_set(): try: client_sock, _ = self.server_sock.accept() self._handle_client(client_sock) except Exception as e: print(f"Error accepting connection: {e}") def _handle_client(self, client_sock): """Handle a single client request (like ssh-add).""" try: # Read the message length (first 4 bytes) length_message = client_sock.recv(4) if not length_message: raise "no length message received" msg_len = struct.unpack(">I", length_message)[0] request_message = client_sock.recv(msg_len) # Check for STOP_REQUEST if request_message[0] == STOP_REQUEST: client_sock.close() self.running.clear() # Stop accepting connections return # Check for SSH_AGENTC_REQUEST_IDENTITIES if request_message[0] == SSH_AGENTC_REQUEST_IDENTITIES: response = self._mock_list_keys_response() client_sock.sendall(response) else: print("Message not recognized") # Send failure if the message type is not recognized response = struct.pack(">I", 1) + struct.pack(">B", SSH_AGENT_FAILURE) client_sock.sendall(response) except OSError: print("Client socket error.") pass # You can handle specific errors here if needed finally: client_sock.close() # Ensure the client socket is closed def _mock_list_keys_response(self): """Create a mock response for ssh-add -l, listing keys.""" # Start building the response response = struct.pack(">B", SSH_AGENT_IDENTITIES_ANSWER) # Message type # Number of keys response += struct.pack(">I", len(self.keys)) # For each key, append key blob and comment for key_type, key_blob, comment in self.keys: # Key blob length and content response += struct.pack(">I", len(key_blob)) + key_blob # Comment length and content comment_encoded = comment.encode() response += struct.pack(">I", len(comment_encoded)) + comment_encoded # Prefix the entire response with the total message length response = struct.pack(">I", len(response)) + response return response def stop_agent(self): """Stop the mock SSH agent.""" if self.running.is_set(): # First check if the agent is running # Create a temporary connection to send the stop command with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_sock: client_sock.connect(self.sock_path) # Connect to the server stop_command = struct.pack( ">B", STOP_REQUEST ) # Pack the stop command as a single byte # Send the message length first message_length = struct.pack(">I", len(stop_command)) client_sock.sendall(message_length) # Send the length first client_sock.sendall(stop_command) # Send the stop command self.running.clear() # Stop accepting new connections # Wait for the agent thread to finish if self.agent_thread: self.agent_thread.join() # Wait for the thread to finish self.agent_thread = None # Reset thread reference # Remove the socket file only after the server socket is closed if self.server_sock: # Check if the server socket exists self.server_sock.close() # Close the server socket os.remove(self.sock_path) podman-compose-1.6.0/tests/integration/command_config/000077500000000000000000000000001521011662300230645ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/command_config/__init__.py000066400000000000000000000000011521011662300251640ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/command_config/docker-compose.yml000066400000000000000000000001711521011662300265200ustar00rootroot00000000000000version: '3' services: test: image: nopush/podman-compose-test environment: - VAR1=var1 - VAR2=var2podman-compose-1.6.0/tests/integration/command_config/test_podman_compose_config_command.py000066400000000000000000000017411521011662300325260ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: """ "Returns the path to the compose file used for this test module""" base_path = os.path.join(test_path(), "commands_fail_exit_code") return os.path.join(base_path, "docker-compose.yml") class TestConfigCommand(unittest.TestCase, RunSubprocessMixin): def test_config_quiet(self) -> None: """ Tests podman-compose config command with the --quiet flag. """ config_cmd = [ "coverage", "run", podman_compose_path(), "-f", compose_yaml_path(), "config", "--quiet", ] out, _ = self.run_subprocess_assert_returncode(config_cmd) self.assertEqual(out.decode("utf-8"), "") podman-compose-1.6.0/tests/integration/commands_fail_exit_code/000077500000000000000000000000001521011662300247405ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/commands_fail_exit_code/__init__.py000066400000000000000000000000001521011662300270370ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/commands_fail_exit_code/bad/000077500000000000000000000000001521011662300254665ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/commands_fail_exit_code/bad/Dockerfile000066400000000000000000000000301521011662300274510ustar00rootroot00000000000000FROM busybox RUN false podman-compose-1.6.0/tests/integration/commands_fail_exit_code/docker-compose.yml000066400000000000000000000003201521011662300303700ustar00rootroot00000000000000version: "3" services: bad: build: context: bad good: build: context: good test_pull_good: image: nopush/podman-compose-test test_pull_bad: image: localhost/does-not-exist podman-compose-1.6.0/tests/integration/commands_fail_exit_code/good/000077500000000000000000000000001521011662300256705ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/commands_fail_exit_code/good/Dockerfile000066400000000000000000000001711521011662300276610ustar00rootroot00000000000000FROM busybox # ensure that this build finishes second so that it has a chance to overwrite the return code RUN sleep 0.5 test_podman_compose_commands_fail_exit_code.py000066400000000000000000000067341521011662300362060ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/commands_fail_exit_code# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: """ "Returns the path to the compose file used for this test module""" base_path = os.path.join(test_path(), "commands_fail_exit_code") return os.path.join(base_path, "docker-compose.yml") class TestComposeCommandsFailExitCodes(unittest.TestCase, RunSubprocessMixin): def test_build_command_fail(self) -> None: output, error = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "build", # prevent the successful build from being cached to ensure it runs long enough "--no-cache", ], expected_returncode=1, ) self.assertIn("RUN false", str(output)) self.assertIn("while running runtime: exit status 1", str(error)) def test_push_command_fail(self) -> None: # test that push command is able to return other than "0" return code # "push" command fails due to several steps missing before running it (logging, tagging) try: output, error = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "push", "good", ], expected_returncode=125, ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) def test_pull_command_fail(self) -> None: # test that pull command is able to return other than "0" return code # "pull" command fails due to missing images or networking errors # failure in pulling any one of the images ends up in error code for "pull" command try: output, error = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "pull", "test_pull_bad", "test_pull_good", ], expected_returncode=125, ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) def test_run_command_fail(self) -> None: # test that run command is able to return other than "0" return code try: output, error = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "run", "bad", ], expected_returncode=125, ) self.assertIn("RUN false", str(output)) self.assertIn("while running runtime: exit status 1", str(error)) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) podman-compose-1.6.0/tests/integration/compose_down_behavior/000077500000000000000000000000001521011662300244745ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/compose_down_behavior/__init__.py000066400000000000000000000000001521011662300265730ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/compose_down_behavior/docker-compose_default.yaml000066400000000000000000000006071521011662300320010ustar00rootroot00000000000000services: app: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] depends_on: - db db: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] no_deps: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] podman-compose-1.6.0/tests/integration/compose_down_behavior/test_compose_down_behavior.py000066400000000000000000000044601521011662300324640ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from parameterized import parameterized from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(scenario: str) -> str: return os.path.join( os.path.join(test_path(), "compose_down_behavior"), f"docker-compose_{scenario}.yaml" ) class TestComposeDownBehavior(unittest.TestCase, RunSubprocessMixin): @parameterized.expand([ ("default", ["down"], set()), ( "default", ["down", "app"], { "compose_down_behavior_db_1", "compose_down_behavior_no_deps_1", }, ), ( "default", ["down", "db"], { "compose_down_behavior_no_deps_1", }, ), ]) def test_compose_down( self, scenario: str, command_args: list[str], expect_containers: set[str] ) -> None: try: self.run_subprocess_assert_returncode( [podman_compose_path(), "-f", compose_yaml_path(scenario), "up", "-d"], ) self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(scenario), *command_args, ], ) out, _ = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(scenario), "ps", "--format", '{{ .Names }}', ], ) actual_containers = set() for line in out.decode('utf-8').strip().split('\n'): name = line.strip() if name: actual_containers.add(name) self.assertEqual(actual_containers, expect_containers) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(scenario), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/compose_up_behavior/000077500000000000000000000000001521011662300241515ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/compose_up_behavior/Dockerfile000066400000000000000000000000231521011662300261360ustar00rootroot00000000000000FROM alpine:latest podman-compose-1.6.0/tests/integration/compose_up_behavior/__init__.py000066400000000000000000000000001521011662300262500ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/compose_up_behavior/docker-compose_build_localhost_image.yaml000066400000000000000000000001531521011662300343370ustar00rootroot00000000000000services: test: build: context: . dockerfile: Dockerfile image: localhost/not-exists podman-compose-1.6.0/tests/integration/compose_up_behavior/docker-compose_default_pull_policy.yaml000066400000000000000000000001011521011662300340560ustar00rootroot00000000000000services: test: image: alpine:latest command: ["true"] docker-compose_non_existent_localhost_image.yaml000066400000000000000000000001551521011662300357000ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/compose_up_behaviorservices: test: build: context: . dockerfile: Dockerfile image: localhost/test-image:1 podman-compose-1.6.0/tests/integration/compose_up_behavior/docker-compose_override_missing.yaml000066400000000000000000000003001521011662300333700ustar00rootroot00000000000000services: test: image: alpine:latest command: ["true"] # default pull_policy is 'missing' test-override: image: alpine:latest command: ["true"] pull_policy: always podman-compose-1.6.0/tests/integration/compose_up_behavior/docker-compose_service_change_app.yaml000066400000000000000000000006641521011662300336420ustar00rootroot00000000000000services: app: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] environment: - SERVICE_CHANGE=true depends_on: - db db: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] no_deps: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] podman-compose-1.6.0/tests/integration/compose_up_behavior/docker-compose_service_change_base.yaml000066400000000000000000000006071521011662300337710ustar00rootroot00000000000000services: app: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] depends_on: - db db: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] no_deps: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] podman-compose-1.6.0/tests/integration/compose_up_behavior/docker-compose_service_change_db.yaml000066400000000000000000000006641521011662300334470ustar00rootroot00000000000000services: app: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] depends_on: - db db: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] environment: - SERVICE_CHANGE=true no_deps: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] podman-compose-1.6.0/tests/integration/compose_up_behavior/test_compose_up_behavior.py000066400000000000000000000314151521011662300316160ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import json import os import unittest from typing import Any from packaging import version from parameterized import parameterized from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import get_podman_version from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(scenario: str) -> str: return os.path.join( os.path.join(test_path(), "compose_up_behavior"), f"docker-compose_{scenario}.yaml" ) class TestComposeUpBehavior(unittest.TestCase, RunSubprocessMixin): def get_existing_containers(self, scenario: str) -> dict[str, Any]: out, _ = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(scenario), "ps", "--format", 'json', ], ) containers = json.loads(out) return { c.get("Names")[0]: { "name": c.get("Names")[0], "id": c.get("Id"), "service_name": c.get("Labels", {}).get("io.podman.compose.service", ""), "config_hash": c.get("Labels", {}).get("io.podman.compose.config-hash", ""), "exited": c.get("Exited"), } for c in containers } @parameterized.expand([ ( "service_change_app", "service_change_base", ["up"], {"app"}, ), ( "service_change_app", "service_change_base", ["up", "app"], {"app"}, ), ( "service_change_app", "service_change_base", ["up", "db"], set(), ), ( "service_change_db", "service_change_base", ["up"], {"db", "app"}, ), ( "service_change_db", "service_change_base", ["up", "app"], {"db", "app"}, ), ( "service_change_db", "service_change_base", ["up", "db"], {"db", "app"}, ), ]) def test_recreate_on_config_changed( self, change_to: str, running_scenario: str, command_args: list[str], expect_recreated_services: set[str], ) -> None: try: self.run_subprocess_assert_returncode( [podman_compose_path(), "-f", compose_yaml_path(running_scenario), "up", "-d"], ) original_containers = self.get_existing_containers(running_scenario) out, err = self.run_subprocess_assert_returncode( [ podman_compose_path(), "--verbose", "-f", compose_yaml_path(change_to), *command_args, "-d", ], ) new_containers = self.get_existing_containers(change_to) recreated_services = { c.get("service_name") for c in original_containers.values() if new_containers.get(c.get("name"), {}).get("id") != c.get("id") } self.assertEqual( recreated_services, expect_recreated_services, msg=f"Expected services to be recreated: {expect_recreated_services}, " f"but got: {recreated_services}, containers: " f"[{original_containers}, {new_containers}]", ) self.assertTrue( all([c.get("exited") is False for c in new_containers.values()]), msg="Not all containers are running after up command", ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(change_to), "down", "-t", "0", ]) @unittest.skipIf( get_podman_version() < version.parse("5.6.0"), "The image pull policy feature was only added as of Podman 5.6.0.", ) def test_pull_only_if_image_missing(self) -> None: """Verify image is pulled because default pull policy is --missing""" compose_file = compose_yaml_path("default_pull_policy") image = "docker.io/library/alpine:latest" try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_file, "up", "-d", ]) # Remove image while container is still up self.run_subprocess_assert_returncode([ "podman", "rmi", "-f", image, ]) # up container one more time, it sees missing images and pulls them again _, error = self.run_subprocess_assert_returncode([ podman_compose_path(), "--verbose", "-f", compose_file, "up", "-d", ]) # podman pull command is explicitly run before container teardown and now is # visible in --verbose output. After container teardown, podman create is called # and already has needed images available self.assertIn(b"podman pull --policy missing alpine:latest", error) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_file, "ps", ]) self.assertIn(b"compose_up_behavior_test_1", output) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_file, "down", "-t", "0", ]) self.run_subprocess_assert_returncode([ "podman", "rmi", "-f", image, ]) @unittest.skipIf( get_podman_version() < version.parse("5.6.0"), "The image pull policy feature was only added as of Podman 5.6.0.", ) def test_pull_default_policy_overrides_lower_priority_policy(self) -> None: """Verify pull policy flag with higher priority overrides the default pull policy --missing""" compose_file = compose_yaml_path("override_missing") image = "docker.io/library/alpine:latest" try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_file, "up", "-d", ]) # Remove image while container is still up self.run_subprocess_assert_returncode([ "podman", "rmi", "-f", image, ]) # up container one more time _, error = self.run_subprocess_assert_returncode([ podman_compose_path(), "--verbose", "-f", compose_file, "up", "-d", ]) # default pull-policy is --missing, but it has been overridden by policy of # higher priority --always self.assertIn(b"podman pull --policy always alpine:latest", error) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_file, "down", "-t", "0", ]) self.run_subprocess_assert_returncode([ "podman", "rmi", "-f", image, ]) @unittest.skipIf( get_podman_version() < version.parse("5.6.0"), "The image pull policy feature was only added as of Podman 5.6.0.", ) def test_localhost_image_not_pulled(self) -> None: """Verify existing localhost/ images are used locally without pulling""" compose_file = compose_yaml_path("non_existent_localhost_image") image = "localhost/test-image:1" try: # pre-create image locally by tagging a minimal base image, # this simulates the image already existing locally self.run_subprocess_assert_returncode(["podman", "pull", "alpine:latest"]) self.run_subprocess_assert_returncode(["podman", "tag", "alpine:latest", image]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_file, "up", "-d", ]) # Remove image while container is still up self.run_subprocess_assert_returncode([ "podman", "rmi", "-f", image, ]) # since the compose file has both 'build' and 'image', podman-compose # should use the existing image (or rebuild if necessary), but it should # never attempt to pull localhost/ images _, error = self.run_subprocess_assert_returncode([ podman_compose_path(), "--verbose", "-f", compose_file, "up", "-d", ]) self.assertNotIn(b"Trying to pull", error) # Confirm container is actually started with localhost/ image output, _ = self.run_subprocess_assert_returncode([ "podman", "ps", "-a", "--filter", f"ancestor={image}", "--format", "{{.Image}}", ]) self.assertIn(b"localhost/test-image:1", output) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_file, "down", ]) self.run_subprocess_assert_returncode([ "podman", "rmi", "-f", image, ]) @unittest.skipIf( get_podman_version() < version.parse("5.6.0"), "The image pull policy feature was only added as of Podman 5.6.0.", ) def test_localhost_image_built_if_does_not_exist(self) -> None: """Verify non-existent localhost/ images are built instead of pulling""" compose_file = compose_yaml_path("build_localhost_image") image = "localhost/not-exists" # check image does not exist self.run_subprocess_assert_returncode( [ "podman", "image", "exists", image, ], 1, ) try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_file, "up", "-d", ]) # Remove image while container is still up self.run_subprocess_assert_returncode([ "podman", "rmi", "-f", image, ]) # image was not pulled, it was built output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "--verbose", "-f", compose_file, "up", "-d", ]) self.assertNotIn(b"Trying to pull", output) # After up command, image now exists (was built) output, _ = self.run_subprocess_assert_returncode([ "podman", "images", "--filter", f"reference={image}", "--format", "{{.Repository}}:{{.Tag}}", ]) self.assertIn(image.encode(), output) # Container uses the locally built image output, _ = self.run_subprocess_assert_returncode([ "podman", "ps", "-a", "--filter", f"ancestor={image}", "--format", "{{.Image}}", ]) self.assertIn(image.encode(), output) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_file, "down", ]) self.run_subprocess_assert_returncode( ["podman", "rmi", "-f", image], ) podman-compose-1.6.0/tests/integration/default_net_behavior/000077500000000000000000000000001521011662300242725ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/default_net_behavior/__init__.py000066400000000000000000000000011521011662300263720ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/default_net_behavior/docker-compose_no_nets.yaml000066400000000000000000000001131521011662300316100ustar00rootroot00000000000000services: web: image: busybox command: httpd -f -p 8123 -h /tmp/ podman-compose-1.6.0/tests/integration/default_net_behavior/docker-compose_no_nets_compat.yaml000066400000000000000000000001721521011662300331600ustar00rootroot00000000000000services: web: image: busybox command: httpd -f -p 8123 -h /tmp/ x-podman: default_net_behavior_compat: true podman-compose-1.6.0/tests/integration/default_net_behavior/docker-compose_one_net.yaml000066400000000000000000000001411521011662300315730ustar00rootroot00000000000000services: web: image: busybox command: httpd -f -p 8123 -h /tmp/ networks: net0: {} podman-compose-1.6.0/tests/integration/default_net_behavior/docker-compose_one_net_compat.yaml000066400000000000000000000002201521011662300331340ustar00rootroot00000000000000services: web: image: busybox command: httpd -f -p 8123 -h /tmp/ networks: net0: {} x-podman: default_net_behavior_compat: true podman-compose-1.6.0/tests/integration/default_net_behavior/docker-compose_two_nets.yaml000066400000000000000000000001541521011662300320120ustar00rootroot00000000000000services: web: image: busybox command: httpd -f -p 8123 -h /tmp/ networks: net0: {} net1: {} podman-compose-1.6.0/tests/integration/default_net_behavior/docker-compose_two_nets_compat.yaml000066400000000000000000000002331521011662300333530ustar00rootroot00000000000000services: web: image: busybox command: httpd -f -p 8123 -h /tmp/ networks: net0: {} net1: {} x-podman: default_net_behavior_compat: true podman-compose-1.6.0/tests/integration/default_net_behavior/docker-compose_with_default.yaml000066400000000000000000000001721521011662300326270ustar00rootroot00000000000000services: web: image: busybox command: httpd -f -p 8123 -h /tmp/ networks: net0: {} net1: {} default: {} podman-compose-1.6.0/tests/integration/default_net_behavior/docker-compose_with_default_compat.yaml000066400000000000000000000002511521011662300341700ustar00rootroot00000000000000services: web: image: busybox command: httpd -f -p 8123 -h /tmp/ networks: net0: {} net1: {} default: {} x-podman: default_net_behavior_compat: true test_podman_compose_default_net_behavior.py000066400000000000000000000044061521011662300350640ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/default_net_behavior# SPDX-License-Identifier: GPL-2.0 import os import unittest from parameterized import parameterized from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(scenario: str) -> str: return os.path.join( os.path.join(test_path(), "default_net_behavior"), f"docker-compose_{scenario}.yaml" ) class TestComposeDefaultNetBehavior(unittest.TestCase, RunSubprocessMixin): @parameterized.expand([ ('no_nets', 'default_net_behavior_default'), ('one_net', 'default_net_behavior_net0'), ('two_nets', 'podman'), ('with_default', 'default_net_behavior_default'), ('no_nets_compat', 'default_net_behavior_default'), ('one_net_compat', 'default_net_behavior_default'), ('two_nets_compat', 'default_net_behavior_default'), ('with_default_compat', 'default_net_behavior_default'), ]) def test_nethost(self, scenario: str, default_net: str) -> None: try: self.run_subprocess_assert_returncode( [podman_compose_path(), "-f", compose_yaml_path(scenario), "up", "-d"], ) container_id_out, _ = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(scenario), "ps", "--format", '{{.ID}}', ], ) container_id = container_id_out.decode('utf-8').split('\n')[0] output, _ = self.run_subprocess_assert_returncode( [ "podman", "inspect", container_id, "--format", "{{range $key, $value := .NetworkSettings.Networks }}{{ $key }}\n{{ end }}", ], ) self.assertEqual(output.decode('utf-8').strip(), default_net) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(scenario), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/deps/000077500000000000000000000000001521011662300210545ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/deps/__init__.py000066400000000000000000000000011521011662300231540ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/deps/docker-compose-conditional-fails.yaml000066400000000000000000000011731521011662300302510ustar00rootroot00000000000000version: "3.7" services: web: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"] tmpfs: - /run - /tmp healthcheck: test: ["CMD", "/bin/false"] interval: 10s # Time between health checks timeout: 1s # Time to wait for a response retries: 1 # Number of consecutive failures before marking as unhealthy sleep: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 3600"] depends_on: web: condition: service_healthy tmpfs: - /run - /tmp podman-compose-1.6.0/tests/integration/deps/docker-compose-conditional-healthy.yaml000066400000000000000000000013651521011662300306140ustar00rootroot00000000000000version: "3.7" services: web: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"] tmpfs: - /run - /tmp healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8000/hosts"] start_period: 10s # initialization time for containers that need time to bootstrap interval: 10s # Time between health checks timeout: 5s # Time to wait for a response retries: 3 # Number of consecutive failures before marking as unhealthy sleep: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 3600"] depends_on: web: condition: service_healthy tmpfs: - /run - /tmp podman-compose-1.6.0/tests/integration/deps/docker-compose-conditional-succeeds.yaml000066400000000000000000000012341521011662300307470ustar00rootroot00000000000000version: "3.7" services: web: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"] tmpfs: - /run - /tmp healthcheck: test: ["CMD", "wget", "-qO-", "http://localhost:8000/hosts"] interval: 30s # Time between health checks timeout: 5s # Time to wait for a response retries: 3 # Number of consecutive failures before marking as unhealthy sleep: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 3600"] depends_on: web: condition: service_healthy tmpfs: - /run - /tmp podman-compose-1.6.0/tests/integration/deps/docker-compose.yaml000066400000000000000000000011341521011662300246510ustar00rootroot00000000000000version: "3.7" services: web: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"] tmpfs: - /run - /tmp sleep: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 3600"] depends_on: - "web" tmpfs: - /run - /tmp sleep2: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 3600"] depends_on: - sleep tmpfs: - /run - /tmp podman-compose-1.6.0/tests/integration/deps/test_podman_compose_deps.py000066400000000000000000000232411521011662300265050ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from packaging import version from tests.integration.test_utils import PodmanAwareRunSubprocessMixin from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import get_podman_version from tests.integration.test_utils import is_systemd_available from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(suffix: str = "") -> str: return os.path.join(os.path.join(test_path(), "deps"), f"docker-compose{suffix}.yaml") class TestComposeBaseDeps(unittest.TestCase, RunSubprocessMixin): @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") def test_deps(self) -> None: try: output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "run", "--rm", "sleep", "/bin/sh", "-c", "wget -O - http://web:8000/hosts", ]) self.assertIn(b"HTTP request sent, awaiting response... 200 OK", output) self.assertIn(b"deps_web_1", output) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) def test_run_nodeps(self) -> None: try: output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "run", "--rm", "--no-deps", "sleep", "/bin/sh", "-c", "wget -O - http://web:8000/hosts || echo Failed to connect", ]) self.assertNotIn(b"HTTP request sent, awaiting response... 200 OK", output) self.assertNotIn(b"deps_web_1", output) self.assertIn(b"Failed to connect", output) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) def test_up_nodeps(self) -> None: try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "up", "--no-deps", "--detach", "sleep", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "ps", ]) self.assertNotIn(b"deps_web_1", output) self.assertIn(b"deps_sleep_1", output) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") def test_podman_compose_run(self) -> None: """ This will test depends_on as well """ run_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "deps", "docker-compose.yaml"), "run", "--rm", "sleep", "/bin/sh", "-c", "wget -q -O - http://web:8000/hosts", ] out, _ = self.run_subprocess_assert_returncode(run_cmd) self.assertIn(b"127.0.0.1\tlocalhost", out) # Run it again to make sure we can run it twice. I saw an issue where a second run, with # the container left up, would fail run_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "deps", "docker-compose.yaml"), "run", "--rm", "sleep", "/bin/sh", "-c", "wget -q -O - http://web:8000/hosts", ] out, _ = self.run_subprocess_assert_returncode(run_cmd) self.assertIn(b"127.0.0.1\tlocalhost", out) # This leaves a container running. Not sure it's intended, but it matches docker-compose down_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "deps", "docker-compose.yaml"), "down", ] self.run_subprocess_assert_returncode(down_cmd) class TestComposeConditionalDeps(unittest.TestCase, RunSubprocessMixin): @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") def test_deps_succeeds(self) -> None: suffix = "-conditional-succeeds" try: output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(suffix), "run", "--rm", "sleep", "/bin/sh", "-c", "wget -O - http://web:8000/hosts", ]) self.assertIn(b"HTTP request sent, awaiting response... 200 OK", output) self.assertIn(b"deps_web_1", output) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(suffix), "down", ]) def test_deps_fails(self) -> None: suffix = "-conditional-fails" try: output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(suffix), "ps", ]) self.assertNotIn(b"HTTP request sent, awaiting response... 200 OK", output) self.assertNotIn(b"deps_web_1", output) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(suffix), "down", ]) class TestComposeConditionalDepsHealthy(unittest.TestCase, PodmanAwareRunSubprocessMixin): def setUp(self) -> None: self.podman_version = self.retrieve_podman_version() @unittest.skipIf( get_podman_version() > version.parse("4.4.0"), "Breaks as of podman-4.9.5 and podman-5.4.2." ) def test_up_deps_healthy(self) -> None: suffix = "-conditional-healthy" try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(suffix), "up", "sleep", "--detach", ]) # Since the command `podman wait --condition=healthy` is invalid prior to 4.6.0, # we only validate healthy status for podman 4.6.0+, which won't be tested in the # CI pipeline of the podman-compose project where podman 4.3.1 is employed. podman_ver_major, podman_ver_minor, podman_ver_patch = self.podman_version if podman_ver_major >= 4 and podman_ver_minor >= 6 and podman_ver_patch >= 0: self.run_subprocess_assert_returncode([ "podman", "wait", "--condition=running", "deps_web_1", "deps_sleep_1", ]) # check both web and sleep are running output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.StartedAt}}", ]) # extract container id of web decoded_out = output.decode('utf-8') lines = decoded_out.split("\n") web_lines = [line for line in lines if "web" in line] self.assertTrue(web_lines) self.assertEqual(1, len(web_lines)) web_cnt_id, web_cnt_name, web_cnt_status, web_cnt_started = web_lines[0].split("\t") self.assertNotEqual("", web_cnt_id) self.assertEqual("deps_web_1", web_cnt_name) sleep_lines = [line for line in lines if "sleep" in line] self.assertTrue(sleep_lines) self.assertEqual(1, len(sleep_lines)) sleep_cnt_id, sleep_cnt_name, _, sleep_cnt_started = sleep_lines[0].split("\t") self.assertNotEqual("", sleep_cnt_id) self.assertEqual("deps_sleep_1", sleep_cnt_name) # When test case is executed inside container like github actions, the absence of # systemd prevents health check from working properly, resulting in failure to # transit to healthy state. As a result, we only assert the `healthy` state where # systemd is functioning. if ( is_systemd_available() and podman_ver_major >= 4 and podman_ver_minor >= 6 and podman_ver_patch >= 0 ): self.assertIn("healthy", web_cnt_status) self.assertGreaterEqual(int(sleep_cnt_started), int(web_cnt_started)) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(suffix), "down", ]) podman-compose-1.6.0/tests/integration/env_file_tests/000077500000000000000000000000001521011662300231325ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/env_file_tests/.env000066400000000000000000000001611521011662300237210ustar00rootroot00000000000000ZZVAR1='This value is overwritten by env_file_tests/.env' ZZVAR3='This value is loaded from env_file_tests/.env' podman-compose-1.6.0/tests/integration/env_file_tests/.gitignore000066400000000000000000000002361521011662300251230ustar00rootroot00000000000000# This overrides the repository root .gitignore (ignoring all .env). # The .env files in this directory are important for the test cases. !.env !project/.env podman-compose-1.6.0/tests/integration/env_file_tests/__init__.py000066400000000000000000000000011521011662300252320ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/env_file_tests/env-files/000077500000000000000000000000001521011662300250225ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/env_file_tests/env-files/project-1.env000066400000000000000000000001101521011662300273300ustar00rootroot00000000000000ZZVAR1=podman-rocks-123 ZZVAR2=podman-rocks-124 ZZVAR3=podman-rocks-125 podman-compose-1.6.0/tests/integration/env_file_tests/env-files/project-2.env000066400000000000000000000000601521011662300273350ustar00rootroot00000000000000ZZVAR1=podman-rocks-223 ZZVAR2=podman-rocks-224 podman-compose-1.6.0/tests/integration/env_file_tests/project/000077500000000000000000000000001521011662300246005ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/env_file_tests/project/.env000066400000000000000000000002021521011662300253630ustar00rootroot00000000000000ZZVAR1='This value is loaded but should be overwritten' ZZVAR2='This value is loaded from .env in project/ directory' ZZVAR3=TEST podman-compose-1.6.0/tests/integration/env_file_tests/project/container-compose.env-file-flat.yaml000066400000000000000000000002651521011662300335440ustar00rootroot00000000000000services: app: image: busybox command: ["/bin/busybox", "sh", "-c", "env | grep ZZ"] tmpfs: - /run - /tmp env_file: - ../env-files/project-1.env container-compose.env-file-obj-optional-exists.yaml000066400000000000000000000004171521011662300364500ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/env_file_tests/projectservices: app: image: busybox command: ["/bin/busybox", "sh", "-c", "env | grep ZZ"] tmpfs: - /run - /tmp env_file: - path: ../env-files/project-1.env - path: ../env-files/project-2.env # this file exists required: false container-compose.env-file-obj-optional-missing.yaml000066400000000000000000000004231521011662300365770ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/env_file_tests/projectservices: app: image: busybox command: ["/bin/busybox", "sh", "-c", "env | grep ZZ"] tmpfs: - /run - /tmp env_file: - path: ../env-files/project-1.env - path: ../env-files/project-3.env # this file is missing required: false podman-compose-1.6.0/tests/integration/env_file_tests/project/container-compose.env-file-obj.yaml000066400000000000000000000002731521011662300333670ustar00rootroot00000000000000services: app: image: busybox command: ["/bin/busybox", "sh", "-c", "env | grep ZZ"] tmpfs: - /run - /tmp env_file: - path: ../env-files/project-1.env container-compose.load-.env-in-project.yaml000066400000000000000000000003271521011662300346660ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/env_file_tests/projectservices: app: image: busybox command: ["/bin/busybox", "sh", "-c", "env | grep ZZ"] tmpfs: - /run - /tmp environment: ZZVAR1: $ZZVAR1 ZZVAR2: $ZZVAR2 ZZVAR3: $ZZVAR3 podman-compose-1.6.0/tests/integration/env_file_tests/project/container-compose.short_syntax.yaml000066400000000000000000000006271521011662300336620ustar00rootroot00000000000000services: app: image: nopush/podman-compose-test command: ["/bin/busybox", "sh", "-c", "env | grep ZZVAR3"] # 'env_file:' section is not used, so .env file is searched in the same directory as compose.yml # file environment: # this is short syntax: podman-compose takes only this variable value from '.env' file and # sends it to container environment - ZZVAR3 podman-compose-1.6.0/tests/integration/env_file_tests/project/container-compose.yaml000066400000000000000000000002531521011662300311110ustar00rootroot00000000000000services: app: image: busybox command: ["/bin/busybox", "sh", "-c", "env | grep ZZ"] tmpfs: - /run - /tmp environment: ZZVAR1: $ZZVAR1 podman-compose-1.6.0/tests/integration/env_file_tests/test_podman_compose_env_file.py000066400000000000000000000255451521011662300314300ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_base_path() -> str: return os.path.join(test_path(), "env_file_tests") class TestComposeEnvFile(unittest.TestCase, RunSubprocessMixin): def test_path_env_file_inline(self) -> None: # Test taking env variable value directly from env-file when its path is inline path base_path = compose_base_path() path_compose_file = os.path.join(base_path, "project/container-compose.yaml") try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "--env-file", os.path.join(base_path, "env-files/project-1.env"), "up", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "logs", "--no-log-prefix", "--no-color", ]) # takes only value ZZVAR1 as container-compose.yaml file requires self.assertEqual(output, b"ZZVAR1=podman-rocks-123\n") finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "down", ]) def test_path_env_file_flat_in_compose_file(self) -> None: # Test taking env variable value from env-file/project-1.env which was declared in # compose file's env_file base_path = compose_base_path() path_compose_file = os.path.join(base_path, "project/container-compose.env-file-flat.yaml") try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "up", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "logs", "--no-log-prefix", "--no-color", ]) # takes all values with a substring ZZ as container-compose.env-file-flat.yaml # file requires self.assertEqual( output, b"ZZVAR1=podman-rocks-123\nZZVAR2=podman-rocks-124\nZZVAR3=podman-rocks-125\n", ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "down", ]) def test_path_env_file_obj_in_compose_file(self) -> None: # take variable value from env-file project-1.env which was declared in compose # file's env_file by -path: ... base_path = compose_base_path() path_compose_file = os.path.join(base_path, "project/container-compose.env-file-obj.yaml") try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "up", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "logs", "--no-log-prefix", "--no-color", ]) # takes all values with a substring ZZ as container-compose.env-file-obj.yaml # file requires self.assertEqual( output, b"ZZVAR1=podman-rocks-123\nZZVAR2=podman-rocks-124\nZZVAR3=podman-rocks-125\n", ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "down", ]) def test_exists_optional_env_file_path_in_compose_file(self) -> None: # test taking env variable values from several env-files when one of them is optional # and exists base_path = compose_base_path() path_compose_file = os.path.join( base_path, "project/container-compose.env-file-obj-optional-exists.yaml" ) try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "up", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "logs", "--no-log-prefix", "--no-color", ]) # FIXME: gives a weird output, needs to be double checked self.assertEqual( output, b"ZZVAR1=podman-rocks-223\nZZVAR2=podman-rocks-224\nZZVAR3=podman-rocks-125\n", ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "down", ]) def test_missing_optional_env_file_path_in_compose_file(self) -> None: # test taking env variable values from several env-files when one of them is optional and # is missing (silently skip it) base_path = compose_base_path() path_compose_file = os.path.join( base_path, "project/container-compose.env-file-obj-optional-missing.yaml" ) try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "up", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "logs", "--no-log-prefix", "--no-color", ]) # takes all values with a substring ZZ as container-compose.env-file-obj-optional.yaml # file requires self.assertEqual( output, b"ZZVAR1=podman-rocks-123\nZZVAR2=podman-rocks-124\nZZVAR3=podman-rocks-125\n", ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "down", ]) def test_var_value_inline_overrides_env_file_path_inline(self) -> None: # Test overriding env value when value is declared in inline command base_path = compose_base_path() path_compose_file = os.path.join(base_path, "project/container-compose.yaml") try: self.run_subprocess_assert_returncode([ "env", "ZZVAR1=podman-rocks-321", podman_compose_path(), "-f", path_compose_file, "--env-file", os.path.join(base_path, "env-files/project-1.env"), "up", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "logs", "--no-log-prefix", "--no-color", ]) # takes only value ZZVAR1 as container-compose.yaml file requires self.assertEqual(output, b"ZZVAR1=podman-rocks-321\n") finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "down", ]) def test_taking_env_variables_from_env_files_from_different_directories(self) -> None: # FIXME: It is not clear what this test actually tests, but from README.md it looks like: # Test overriding env values by directory env-files-tests/.env file values # and only take value from project/.env, when it does not exist in env-files-tests/.env base_path = compose_base_path() path_compose_file = os.path.join( base_path, "project/container-compose.load-.env-in-project.yaml" ) try: # looks like 'run' command does not actually create a container, so output_logs can not # be used for test comparison output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "run", "--rm", "app", ]) # takes all values with a substring ZZ as container-compose.load-.env-in-project.yaml # file requires # first line is random ID so is ignored in asserting lines = output.decode('utf-8').split('\n')[1:] self.assertEqual( lines, [ 'ZZVAR1=This value is loaded but should be overwritten\r', 'ZZVAR2=This value is loaded from .env in project/ directory\r', 'ZZVAR3=TEST\r', '', ], ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", path_compose_file, "down", ]) def test_env_var_value_accessed_in_compose_file_short_syntax(self) -> None: # Test that compose file can access the environment variable set in .env file using # short syntax, that is: only the name of environment variable is used in "environment:" in # compose.yml file and its value is picked up directly from .env file # long syntax of environment variables interpolation is tested in # tests/integration/interpolation base_path = compose_base_path() compose_file_path = os.path.join(base_path, "project/container-compose.short_syntax.yaml") try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_file_path, "up", "-d", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_file_path, "logs", "--no-log-prefix", "--no-color", ]) # ZZVAR3 was set in .env file self.assertEqual(output, b"ZZVAR3=TEST\n") finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_file_path, "down", ]) podman-compose-1.6.0/tests/integration/env_tests/000077500000000000000000000000001521011662300221335ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/env_tests/__init__.py000066400000000000000000000000011521011662300242330ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/env_tests/container-compose.yml000066400000000000000000000005261521011662300263060ustar00rootroot00000000000000version: "3" name: my-project-name${ZZVAR1:-} services: env-test: image: busybox command: sh -c "export | grep ZZ" environment: ZZVAR1: myval1 ZZVAR2: 2-$ZZVAR1 ZZVAR3: 3-$ZZVAR2 project-name-test: image: busybox command: sh -c "echo $$PNAME" environment: PNAME: ${COMPOSE_PROJECT_NAME} podman-compose-1.6.0/tests/integration/env_tests/test_podman_compose_env.py000066400000000000000000000070671521011662300274310ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "env_tests"), "container-compose.yml") class TestComposeEnv(unittest.TestCase, RunSubprocessMixin): """Test that inline environment variable overrides environment variable from compose file.""" def test_env(self) -> None: try: output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "run", "-l", "monkey", "-e", "ZZVAR1=myval2", "env-test", ]) self.assertIn("ZZVAR1='myval2'", str(output)) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) """ Tests interpolation of COMPOSE_PROJECT_NAME in the podman-compose config, which is different from external environment variables because COMPOSE_PROJECT_NAME is a predefined environment variable generated from the `name` value in the top-level of the compose.yaml. See also - https://docs.docker.com/reference/compose-file/interpolation/ - https://docs.docker.com/reference/compose-file/version-and-name/#name-top-level-element - https://docs.docker.com/compose/how-tos/environment-variables/envvars/ - https://github.com/compose-spec/compose-spec/blob/main/04-version-and-name.md """ def test_project_name(self) -> None: try: output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "run", "project-name-test", ]) self.assertIn("my-project-name", str(output)) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) def test_project_name_override(self) -> None: try: output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "run", "-e", "COMPOSE_PROJECT_NAME=project-name-override", "project-name-test", ]) self.assertIn("project-name-override", str(output)) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) def test_project_name_interpolation(self) -> None: try: output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "run", "-e", "ZZVAR1=-myval2", "project-name-test", ]) self.assertIn("my-project-name-myval2", str(output)) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) podman-compose-1.6.0/tests/integration/exit_from/000077500000000000000000000000001521011662300221155ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/exit_from/__init__.py000066400000000000000000000000011521011662300242150ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/exit_from/docker-compose.yaml000066400000000000000000000005501521011662300257130ustar00rootroot00000000000000version: "3" services: sh1: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 1; exit 1"] tmpfs: - /run - /tmp sh2: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 1; exit 2"] tmpfs: - /run - /tmp podman-compose-1.6.0/tests/integration/exit_from/test_podman_compose_exit_from.py000066400000000000000000000036421521011662300306120ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "exit_from"), "docker-compose.yaml") class TestComposeExitFrom(unittest.TestCase, RunSubprocessMixin): def test_exit_code_sh1(self) -> None: try: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "up", "--exit-code-from=sh1", ], 1, ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) def test_exit_code_sh2(self) -> None: try: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "up", "--exit-code-from=sh2", ], 2, ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) def test_podman_compose_exit_from(self) -> None: up_cmd = [ "coverage", "run", podman_compose_path(), "-f", compose_yaml_path(), "up", ] self.run_subprocess_assert_returncode(up_cmd + ["--exit-code-from", "sh1"], 1) self.run_subprocess_assert_returncode(up_cmd + ["--exit-code-from", "sh2"], 2) podman-compose-1.6.0/tests/integration/extends/000077500000000000000000000000001521011662300215735ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/extends/__init__.py000066400000000000000000000000011521011662300236730ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/extends/docker-compose.yaml000066400000000000000000000011511521011662300253670ustar00rootroot00000000000000version: "3" services: echo: image: busybox command: ["/bin/busybox", "echo", "Zero"] ports: - '1234:1234' environment: - FOO=original - BAR=original # volumes: # - ./original:/foo # - ./original:/bar echo1: extends: service: echo command: ["/bin/busybox", "echo", "One"] ports: - '12345:12345' # volumes: # - ./local:/bar # - ./local:/baz env1: extends: service: echo command: ["/bin/busybox", "env"] environment: - BAR=local - BAZ=local podman-compose-1.6.0/tests/integration/extends/test_podman_compose_extends.py000066400000000000000000000071571521011662300277530ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "extends"), "docker-compose.yaml") class TestComposeExteds(unittest.TestCase, RunSubprocessMixin): def test_extends_service_launch_echo(self) -> None: try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "up", "echo", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "logs", "--no-log-prefix", "--no-color", "echo", ]) self.assertEqual(output, b"Zero\n") finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) def test_extends_service_launch_echo1(self) -> None: try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "up", "echo1", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "logs", "--no-log-prefix", "--no-color", "echo1", ]) self.assertEqual(output, b"One\n") finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) def test_extends_service_launch_env1(self) -> None: try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "up", "env1", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "logs", "--no-log-prefix", "--no-color", "env1", ]) lines = output.decode('utf-8').split('\n') # Test selected env variables to improve robustness lines = sorted([ line for line in lines if line.startswith("BAR") or line.startswith("BAZ") or line.startswith("FOO") or line.startswith("HOME") or line.startswith("PATH") or line.startswith("container") ]) self.assertEqual( lines, [ 'BAR=local', 'BAZ=local', 'FOO=original', 'HOME=/root', 'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', 'container=podman', ], ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) podman-compose-1.6.0/tests/integration/extends_w_empty_service/000077500000000000000000000000001521011662300250575ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/extends_w_empty_service/__init__.py000066400000000000000000000000011521011662300271570ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/extends_w_empty_service/common-services.yml000066400000000000000000000001621521011662300307120ustar00rootroot00000000000000services: webapp_default: webapp_special: image: nopush/podman-compose-test volumes: - "/data" podman-compose-1.6.0/tests/integration/extends_w_empty_service/docker-compose.yml000066400000000000000000000003021521011662300305070ustar00rootroot00000000000000version: "3" services: web: image: nopush/podman-compose-test extends: file: common-services.yml service: webapp_default environment: - DEBUG=1 cpu_shares: 5 test_podman_compose_extends_w_empty_service.py000066400000000000000000000037621521011662300364420ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/extends_w_empty_service# SPDX-License-Identifier: GPL-2.0 import os import unittest from pathlib import Path from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "extends_w_empty_service"), "docker-compose.yml") class TestComposeExtendsWithEmptyService(unittest.TestCase, RunSubprocessMixin): def test_extends_w_empty_service(self) -> None: try: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "up", ], ) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "ps", ]) self.assertIn("extends_w_empty_service_web_1", str(output)) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) def test_podman_compose_extends_w_empty_service(self) -> None: """ Test that podman-compose can execute podman-compose -f up with extended File which includes an empty service. (e.g. if the file is used as placeholder for more complex configurations.) """ main_path = Path(__file__).parent.parent.parent.parent command_up = [ "python3", str(main_path.joinpath("podman_compose.py")), "-f", str( main_path.joinpath( "tests", "integration", "extends_w_empty_service", "docker-compose.yml" ) ), "up", "-d", ] self.run_subprocess_assert_returncode(command_up) podman-compose-1.6.0/tests/integration/extends_w_file/000077500000000000000000000000001521011662300231205ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/extends_w_file/Dockerfile000066400000000000000000000000501521011662300251050ustar00rootroot00000000000000FROM nopush/podman-compose-test as base podman-compose-1.6.0/tests/integration/extends_w_file/__init__.py000066400000000000000000000000011521011662300252200ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/extends_w_file/common-services.yml000066400000000000000000000001101521011662300267440ustar00rootroot00000000000000webapp: build: . ports: - "8000:8000" volumes: - "/data" podman-compose-1.6.0/tests/integration/extends_w_file/docker-compose.yml000066400000000000000000000003131521011662300265520ustar00rootroot00000000000000version: "3" services: web: extends: file: common-services.yml service: webapp environment: - DEBUG=1 cpu_shares: 5 important_web: extends: web cpu_shares: 10 podman-compose-1.6.0/tests/integration/extends_w_file/test_podman_compose_extends_w_file.py000066400000000000000000000024501521011662300326140ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "extends_w_file"), "docker-compose.yml") class TestComposeExtendsWithFile(unittest.TestCase, RunSubprocessMixin): def test_extends_w_file(self) -> None: # when file is Dockerfile for building the image try: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "up", ], ) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "ps", ]) self.assertIn("extends_w_file_web_1", str(output)) self.assertIn("extends_w_file_important_web_1", str(output)) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) podman-compose-1.6.0/tests/integration/extends_w_file_subdir/000077500000000000000000000000001521011662300244705ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/extends_w_file_subdir/__init__.py000066400000000000000000000000011521011662300265700ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/extends_w_file_subdir/docker-compose.yml000066400000000000000000000002041521011662300301210ustar00rootroot00000000000000version: "3" services: web: extends: file: sub/docker-compose.yml service: webapp environment: - DEBUG=1podman-compose-1.6.0/tests/integration/extends_w_file_subdir/sub/000077500000000000000000000000001521011662300252615ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/extends_w_file_subdir/sub/docker-compose.yml000066400000000000000000000003101521011662300307100ustar00rootroot00000000000000version: "3" services: webapp: build: context: docker/example dockerfile: Dockerfile image: localhost/subdir_test:me ports: - "8000:8000" volumes: - "/data" podman-compose-1.6.0/tests/integration/extends_w_file_subdir/sub/docker/000077500000000000000000000000001521011662300265305ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/extends_w_file_subdir/sub/docker/example/000077500000000000000000000000001521011662300301635ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/extends_w_file_subdir/sub/docker/example/Dockerfile000066400000000000000000000000501521011662300321500ustar00rootroot00000000000000FROM nopush/podman-compose-test as base test_podman_compose_extends_w_file_subdir.py000066400000000000000000000063661521011662300354670ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/extends_w_file_subdir# SPDX-License-Identifier: GPL-2.0 import os import unittest from pathlib import Path from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "extends_w_file_subdir"), "docker-compose.yml") class TestComposeExtendsWithFileSubdir(unittest.TestCase, RunSubprocessMixin): def test_extends_w_file_subdir(self) -> None: # when file is Dockerfile for building the image try: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "up", ], ) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "ps", ]) self.assertIn("extends_w_file_subdir_web_1", str(output)) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) def test_podman_compose_extends_w_file_subdir(self) -> None: """ Test that podman-compose can execute podman-compose -f up with extended File which includes a build context :return: """ main_path = Path(__file__).parent.parent.parent.parent command_up = [ "coverage", "run", str(main_path.joinpath("podman_compose.py")), "-f", str( main_path.joinpath( "tests", "integration", "extends_w_file_subdir", "docker-compose.yml" ) ), "up", "-d", ] command_check_container = [ "coverage", "run", str(main_path.joinpath("podman_compose.py")), "-f", str( main_path.joinpath( "tests", "integration", "extends_w_file_subdir", "docker-compose.yml" ) ), "ps", "--format", '{{.Image}}', ] self.run_subprocess_assert_returncode(command_up) # check container was created and exists out, _ = self.run_subprocess_assert_returncode(command_check_container) self.assertEqual(out, b'localhost/subdir_test:me\n') # cleanup test image(tags) self.run_subprocess_assert_returncode([ str(main_path.joinpath("podman_compose.py")), "-f", str( main_path.joinpath( "tests", "integration", "extends_w_file_subdir", "docker-compose.yml" ) ), "down", ]) self.run_subprocess_assert_returncode([ "podman", "rmi", "--force", "localhost/subdir_test:me", ]) # check container did not exist anymore out, _ = self.run_subprocess_assert_returncode(command_check_container) self.assertEqual(out, b'') podman-compose-1.6.0/tests/integration/extra_hosts/000077500000000000000000000000001521011662300224645ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/extra_hosts/__init__.py000066400000000000000000000000001521011662300245630ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/extra_hosts/context/000077500000000000000000000000001521011662300241505ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/extra_hosts/context/Dockerfile000066400000000000000000000001021521011662300261330ustar00rootroot00000000000000FROM nopush/podman-compose-test RUN getent hosts example.internalpodman-compose-1.6.0/tests/integration/extra_hosts/docker-compose.yml000066400000000000000000000002461521011662300261230ustar00rootroot00000000000000version: "3" services: web1: build: context: ./context dockerfile: Dockerfile extra_hosts: - "example.internal:127.0.0.1" podman-compose-1.6.0/tests/integration/extra_hosts/test_podman_compose_extra_hosts.py000066400000000000000000000023001521011662300315160ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: """Returns the path to the compose file used for this test module""" base_path = os.path.join(test_path(), "extra_hosts") return os.path.join(base_path, "docker-compose.yml") class TestComposeExtraHosts(unittest.TestCase, RunSubprocessMixin): def test_extra_hosts(self) -> None: try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "build", "--no-cache", ]) self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "up", "-d", ]) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) podman-compose-1.6.0/tests/integration/filesystem/000077500000000000000000000000001521011662300223055ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/filesystem/__init__.py000066400000000000000000000000011521011662300244050ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/filesystem/compose_symlink/000077500000000000000000000000001521011662300255205ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/filesystem/compose_symlink/docker-compose.yml000077700000000000000000000000001521011662300412522../compose_symlink_dest/docker-compose.ymlustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/filesystem/compose_symlink/file000066400000000000000000000000251521011662300263570ustar00rootroot00000000000000data_compose_symlink podman-compose-1.6.0/tests/integration/filesystem/compose_symlink_dest/000077500000000000000000000000001521011662300265375ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/filesystem/compose_symlink_dest/docker-compose.yml000066400000000000000000000002351521011662300321740ustar00rootroot00000000000000version: "3" services: container1: image: nopush/podman-compose-test command: ["/bin/busybox", "cat", "/file"] volumes: - "./file:/file" podman-compose-1.6.0/tests/integration/filesystem/compose_symlink_dest/file000066400000000000000000000000321521011662300273740ustar00rootroot00000000000000data_compose_symlink_dest podman-compose-1.6.0/tests/integration/filesystem/test_podman_compose_filesystem.py000066400000000000000000000025471521011662300311750ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path class TestFilesystem(unittest.TestCase, RunSubprocessMixin): def test_compose_symlink(self) -> None: """The context of podman-compose.yml should come from the same directory as the file even if it is a symlink """ compose_path = os.path.join(test_path(), "filesystem/compose_symlink/docker-compose.yml") try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "up", "-d", "container1", ]) out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "logs", "--no-log-prefix", "--no-color", "container1", ]) self.assertEqual(out, b'data_compose_symlink\n') finally: out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "down", ]) podman-compose-1.6.0/tests/integration/healthcheck/000077500000000000000000000000001521011662300223645ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/healthcheck/__init__.py000066400000000000000000000000001521011662300244630ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/healthcheck/docker-compose.yml000066400000000000000000000004371521011662300260250ustar00rootroot00000000000000services: app: command: ["sleep", "infinity"] image: docker.io/library/alpine volumes: - ./health-trigger:/data healthcheck: test: ["CMD", "test", "-f", "/data/healthy"] interval: 1s timeout: 1s retries: 2 start_period: 0s podman-compose-1.6.0/tests/integration/healthcheck/test_podman_compose_healthcheck.py000066400000000000000000000103311521011662300313210ustar00rootroot00000000000000import json import os import time import unittest from datetime import timedelta from io import BytesIO from pathlib import Path from tests.integration.test_utils import EnsureHealthcheckRun from tests.integration.test_utils import ExecutionTime from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(test_path(), "healthcheck", "docker-compose.yml") class TestComposeHealthcheck(unittest.TestCase, RunSubprocessMixin): def _is_running(self, container_name: str) -> bool: output, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", container_name, ]) inspect = json.load(BytesIO(output)) self.assertEqual(len(inspect), 1) self.assertIn("State", inspect[0]) self.assertIn("Running", inspect[0]["State"]) return inspect[0]["State"]["Running"] def _get_health_status(self, container_name: str) -> str: output, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", container_name, ]) inspect = json.load(BytesIO(output)) self.assertEqual(len(inspect), 1) self.assertIn("State", inspect[0]) self.assertIn("Health", inspect[0]["State"]) self.assertIn("Status", inspect[0]["State"]["Health"]) return inspect[0]["State"]["Health"]["Status"] def _create_file_for_health_test(self, file_path: str) -> Path: path = Path(file_path) path.parent.mkdir(parents=True, exist_ok=True) path.touch(exist_ok=True) return path def _assert_status(self, expected: str, container_name: str = "healthcheck_app_1") -> str: status = "unknown" # Total test time is ~10s: grace period (~6s) + healthcheck duration (~4s) # Healthcheck timing: interval 1s + timeout 1s per attempt, with 2 retries for _ in range(20): status = self._get_health_status(container_name) if status == expected: return status time.sleep(0.5) return status # test if healthcheck is able to change health status: starting -> healthy -> unhealthy def test_healthcheck(self) -> None: try: with EnsureHealthcheckRun( runner=self, test_case=self, container_name="healthcheck_app_1" ): # the execution time of this command must be not more than 10 seconds # otherwise this test case makes no sense with ExecutionTime(max_execution_time=timedelta(seconds=10)): self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "up", "-d", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "ps", ]) self.assertIn(b"healthcheck_app_1", output) self.assertTrue(self._is_running("healthcheck_app_1")) health = self._get_health_status("healthcheck_app_1") self.assertEqual(health, "starting") # condition for a container to become 'healthy' file_path = os.path.join(test_path(), "healthcheck", "health-trigger", "healthy") path = self._create_file_for_health_test(file_path) health_status = self._assert_status("healthy") self.assertEqual(health_status, "healthy") # condition for a container to become 'unhealthy' path.unlink() health_status = self._assert_status("unhealthy") self.assertEqual(health_status, "unhealthy") finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/in_pod/000077500000000000000000000000001521011662300213715ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/in_pod/__init__.py000066400000000000000000000000011521011662300234710ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/in_pod/custom_x-podman_custom_name/000077500000000000000000000000001521011662300271005ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/in_pod/custom_x-podman_custom_name/docker-compose.yml000066400000000000000000000003031521011662300325310ustar00rootroot00000000000000version: "3" services: cont: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] x-podman: in_pod: custom_test_pod_name podman-compose-1.6.0/tests/integration/in_pod/custom_x-podman_false/000077500000000000000000000000001521011662300256605ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/in_pod/custom_x-podman_false/docker-compose.yml000066400000000000000000000003321521011662300313130ustar00rootroot00000000000000version: "3" services: cont: image: nopush/podman-compose-test userns_mode: keep-id:uid=1000 command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] x-podman: in_pod: false podman-compose-1.6.0/tests/integration/in_pod/custom_x-podman_not_exists/000077500000000000000000000000001521011662300267655ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/in_pod/custom_x-podman_not_exists/docker-compose.yml000066400000000000000000000002751521011662300324260ustar00rootroot00000000000000version: "3" services: cont: image: nopush/podman-compose-test userns_mode: keep-id:uid=1000 command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] podman-compose-1.6.0/tests/integration/in_pod/custom_x-podman_true/000077500000000000000000000000001521011662300255455ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/in_pod/custom_x-podman_true/docker-compose.yml000066400000000000000000000003311521011662300311770ustar00rootroot00000000000000version: "3" services: cont: image: nopush/podman-compose-test userns_mode: keep-id:uid=1000 command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] x-podman: in_pod: true podman-compose-1.6.0/tests/integration/in_pod/test_podman_compose_in_pod.py000066400000000000000000000540011521011662300273350ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from packaging import version from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import get_podman_version def base_path() -> str: """Returns the base path for the project""" return os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) def test_path() -> str: """Returns the path to the tests directory""" return os.path.join(base_path(), "tests/integration") def podman_compose_path() -> str: """Returns the path to the podman compose script""" return os.path.join(base_path(), "podman_compose.py") def is_root() -> bool: return os.geteuid() == 0 def failure_exitcode_when_rootful() -> int: if is_root(): return 125 return 0 # If a compose file has userns_mode set, setting in_pod to True, results in error. # Default in_pod setting is True, unless compose file provides otherwise. # Compose file provides custom in_pod option, which can be overridden by command line in_pod option. # Test all combinations of command line argument in_pod and compose file argument in_pod. class TestPodmanComposeInPod(unittest.TestCase, RunSubprocessMixin): # compose file provides x-podman in_pod=false @unittest.skipIf( get_podman_version() > version.parse("4.4.0"), "Breaks as of podman-4.9.5 and podman-5.4.2." ) def test_x_podman_in_pod_false_command_line_in_pod_not_exists(self) -> None: """ Test that podman-compose will not create a pod, when x-podman in_pod=false and command line does not provide this option """ command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_false", "docker-compose.yml", ), "up", "-d", ] down_cmd = [ "python3", podman_compose_path(), "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_false", "docker-compose.yml", ), "down", ] try: self.run_subprocess_assert_returncode(command_up, failure_exitcode_when_rootful()) finally: self.run_subprocess_assert_returncode(down_cmd) command_rm_pod = ["podman", "pod", "rm", "pod_custom_x-podman_false"] # throws an error, can not actually find this pod because it was not created self.run_subprocess_assert_returncode(command_rm_pod, expected_returncode=1) def test_x_podman_in_pod_false_command_line_in_pod_true(self) -> None: """ Test that podman-compose does not allow pod creating even with command line in_pod=True when --userns and --pod are set together: throws an error """ # FIXME: creates a pod anyway, although it should not command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "--in-pod=True", "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_false", "docker-compose.yml", ), "up", "-d", ] try: out, err = self.run_subprocess_assert_returncode(command_up, 125) self.assertEqual(b"Error: --userns and --pod cannot be set together" in err, True) finally: command_rm_pod = ["podman", "pod", "rm", "pod_custom_x-podman_false"] # should throw an error of not being able to find this pod (because it should not have # been created) and have expected_returncode=1 (see FIXME above) self.run_subprocess_assert_returncode(command_rm_pod) @unittest.skipIf( get_podman_version() > version.parse("4.4.0"), "Breaks as of podman-4.9.5 and podman-5.4.2." ) def test_x_podman_in_pod_false_command_line_in_pod_false(self) -> None: """ Test that podman-compose will not create a pod as command line sets in_pod=False """ command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "--in-pod=False", "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_false", "docker-compose.yml", ), "up", "-d", ] down_cmd = [ "python3", podman_compose_path(), "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_false", "docker-compose.yml", ), "down", ] try: self.run_subprocess_assert_returncode(command_up, failure_exitcode_when_rootful()) finally: self.run_subprocess_assert_returncode(down_cmd) command_rm_pod = ["podman", "pod", "rm", "pod_custom_x-podman_false"] # can not actually find this pod because it was not created self.run_subprocess_assert_returncode(command_rm_pod, 1) @unittest.skipIf( get_podman_version() > version.parse("4.4.0"), "Breaks as of podman-4.9.5 and podman-5.4.2." ) def test_x_podman_in_pod_false_command_line_in_pod_empty_string(self) -> None: """ Test that podman-compose will not create a pod, when x-podman in_pod=false and command line command line in_pod="" """ command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "--in-pod=", "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_false", "docker-compose.yml", ), "up", "-d", ] down_cmd = [ "python3", podman_compose_path(), "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_false", "docker-compose.yml", ), "down", ] try: self.run_subprocess_assert_returncode(command_up, failure_exitcode_when_rootful()) finally: self.run_subprocess_assert_returncode(down_cmd) command_rm_pod = ["podman", "pod", "rm", "pod_custom_x-podman_false"] # can not actually find this pod because it was not created self.run_subprocess_assert_returncode(command_rm_pod, 1) # compose file provides x-podman in_pod=true def test_x_podman_in_pod_true_command_line_in_pod_not_exists(self) -> None: """ Test that podman-compose does not allow pod creating when --userns and --pod are set together even when x-podman in_pod=true: throws an error """ # FIXME: creates a pod anyway, although it should not # Container is not created, so command 'down' is not needed command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_true", "docker-compose.yml", ), "up", "-d", ] try: out, err = self.run_subprocess_assert_returncode(command_up, 125) self.assertEqual(b"Error: --userns and --pod cannot be set together" in err, True) finally: command_rm_pod = ["podman", "pod", "rm", "pod_custom_x-podman_true"] # should throw an error of not being able to find this pod (it should not have been # created) and have expected_returncode=1 (see FIXME above) self.run_subprocess_assert_returncode(command_rm_pod) def test_x_podman_in_pod_true_command_line_in_pod_true(self) -> None: """ Test that podman-compose does not allow pod creating when --userns and --pod are set together even when x-podman in_pod=true and command line in_pod=True: throws an error """ # FIXME: creates a pod anyway, although it should not # Container is not created, so command 'down' is not needed command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "--in-pod=True", "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_true", "docker-compose.yml", ), "up", "-d", ] try: out, err = self.run_subprocess_assert_returncode(command_up, 125) self.assertEqual(b"Error: --userns and --pod cannot be set together" in err, True) finally: command_rm_pod = ["podman", "pod", "rm", "pod_custom_x-podman_true"] # should throw an error of not being able to find this pod (because it should not have # been created) and have expected_returncode=1 (see FIXME above) self.run_subprocess_assert_returncode(command_rm_pod) @unittest.skipIf( get_podman_version() > version.parse("4.4.0"), "Breaks as of podman-4.9.5 and podman-5.4.2." ) def test_x_podman_in_pod_true_command_line_in_pod_false(self) -> None: """ Test that podman-compose will not create a pod as command line sets in_pod=False """ command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "--in-pod=False", "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_true", "docker-compose.yml", ), "up", "-d", ] down_cmd = [ "python3", podman_compose_path(), "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_true", "docker-compose.yml", ), "down", ] try: self.run_subprocess_assert_returncode(command_up, failure_exitcode_when_rootful()) finally: self.run_subprocess_assert_returncode(down_cmd) command_rm_pod = ["podman", "pod", "rm", "pod_custom_x-podman_false"] # can not actually find this pod because it was not created self.run_subprocess_assert_returncode(command_rm_pod, 1) def test_x_podman_in_pod_true_command_line_in_pod_empty_string(self) -> None: """ Test that podman-compose does not allow pod creating when --userns and --pod are set together even when x-podman in_pod=true and command line in_pod="": throws an error """ # FIXME: creates a pod anyway, although it should not # Container is not created, so command 'down' is not needed command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "--in-pod=", "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_true", "docker-compose.yml", ), "up", "-d", ] try: out, err = self.run_subprocess_assert_returncode(command_up, 125) self.assertEqual(b"Error: --userns and --pod cannot be set together" in err, True) finally: command_rm_pod = ["podman", "pod", "rm", "pod_custom_x-podman_true"] # should throw an error of not being able to find this pod (because it should not have # been created) and have expected_returncode=1 (see FIXME above) self.run_subprocess_assert_returncode(command_rm_pod) # compose file does not provide x-podman in_pod def test_x_podman_in_pod_not_exists_command_line_in_pod_not_exists(self) -> None: """ Test that podman-compose does not allow pod creating when --userns and --pod are set together: throws an error """ # FIXME: creates a pod anyway, although it should not # Container is not created, so command 'down' is not needed command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_not_exists", "docker-compose.yml", ), "up", "-d", ] try: out, err = self.run_subprocess_assert_returncode(command_up, 125) self.assertEqual(b"Error: --userns and --pod cannot be set together" in err, True) finally: command_rm_pod = ["podman", "pod", "rm", "pod_custom_x-podman_not_exists"] # should throw an error of not being able to find this pod (it should not have been # created) and have expected_returncode=1 (see FIXME above) self.run_subprocess_assert_returncode(command_rm_pod) def test_x_podman_in_pod_not_exists_command_line_in_pod_true(self) -> None: """ Test that podman-compose does not allow pod creating when --userns and --pod are set together even when x-podman in_pod=true: throws an error """ # FIXME: creates a pod anyway, although it should not # Container was not created, so command 'down' is not needed command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "--in-pod=True", "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_not_exists", "docker-compose.yml", ), "up", "-d", ] try: out, err = self.run_subprocess_assert_returncode(command_up, 125) self.assertEqual(b"Error: --userns and --pod cannot be set together" in err, True) finally: command_rm_pod = ["podman", "pod", "rm", "pod_custom_x-podman_not_exists"] # should throw an error of not being able to find this pod (because it should not have # been created) and have expected_returncode=1 (see FIXME above) self.run_subprocess_assert_returncode(command_rm_pod) @unittest.skipIf( get_podman_version() > version.parse("4.4.0"), "Breaks as of podman-4.9.5 and podman-5.4.2." ) def test_x_podman_in_pod_not_exists_command_line_in_pod_false(self) -> None: """ Test that podman-compose will not create a pod as command line sets in_pod=False """ command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "--in-pod=False", "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_not_exists", "docker-compose.yml", ), "up", "-d", ] down_cmd = [ "python3", podman_compose_path(), "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_not_exists", "docker-compose.yml", ), "down", ] try: self.run_subprocess_assert_returncode(command_up, failure_exitcode_when_rootful()) finally: self.run_subprocess_assert_returncode(down_cmd) command_rm_pod = ["podman", "pod", "rm", "pod_custom_x-podman_not_exists"] # can not actually find this pod because it was not created self.run_subprocess_assert_returncode(command_rm_pod, 1) @unittest.skipIf( get_podman_version() > version.parse("4.4.0"), "Breaks as of podman-4.9.5 and podman-5.4.2." ) def test_x_podman_in_pod_not_exists_command_line_in_pod_not_exists_docker_compat(self) -> None: """ Test that podman-compose will not create a pod when docker compat is requested. """ command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_not_exists", "docker-compose.yml", ), "up", "-d", ] down_cmd = [ "python3", podman_compose_path(), "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_not_exists", "docker-compose.yml", ), "down", ] env = { "PODMAN_COMPOSE_DOCKER_COMPOSE_COMPAT": "1", } try: self.run_subprocess_assert_returncode( command_up, failure_exitcode_when_rootful(), env=env ) finally: self.run_subprocess_assert_returncode(down_cmd, env=env) command_rm_pod = ["podman", "pod", "rm", "pod_custom_x-podman_not_exists"] # can not actually find this pod because it was not created self.run_subprocess_assert_returncode(command_rm_pod, 1) @unittest.skipIf( get_podman_version() > version.parse("4.4.0"), "Breaks as of podman-4.9.5 and podman-5.4.2." ) def test_x_podman_in_pod_not_exists_command_line_in_pod_not_exists_env_var(self) -> None: """ Test that podman-compose will not create a pod when env var is set. """ command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_not_exists", "docker-compose.yml", ), "up", "-d", ] down_cmd = [ "python3", podman_compose_path(), "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_not_exists", "docker-compose.yml", ), "down", ] env = { "PODMAN_COMPOSE_IN_POD": "0", } try: self.run_subprocess_assert_returncode( command_up, failure_exitcode_when_rootful(), env=env ) finally: self.run_subprocess_assert_returncode(down_cmd, env=env) command_rm_pod = ["podman", "pod", "rm", "pod_custom_x-podman_not_exists"] # can not actually find this pod because it was not created self.run_subprocess_assert_returncode(command_rm_pod, 1) def test_x_podman_in_pod_custom_name(self) -> None: """ Test that podman-compose will create a pod with a custom name """ command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_custom_name", "docker-compose.yml", ), "up", "--no-start", ] try: self.run_subprocess_assert_returncode(command_up) finally: command_rm_pod = ["podman", "pod", "rm", "custom_test_pod_name"] self.run_subprocess_assert_returncode(command_rm_pod) def test_x_podman_in_pod_not_exists_command_line_in_pod_empty_string(self) -> None: """ Test that podman-compose does not allow pod creating when --userns and --pod are set together: throws an error """ # FIXME: creates a pod anyway, although it should not # Container was not created, so command 'down' is not needed command_up = [ "python3", os.path.join(base_path(), "podman_compose.py"), "--in-pod=", "-f", os.path.join( base_path(), "tests", "integration", "in_pod", "custom_x-podman_not_exists", "docker-compose.yml", ), "up", "-d", ] try: out, err = self.run_subprocess_assert_returncode(command_up, 125) self.assertEqual(b"Error: --userns and --pod cannot be set together" in err, True) finally: command_rm_pod = ["podman", "pod", "rm", "pod_custom_x-podman_not_exists"] # should throw an error of not being able to find this pod (because it should not have # been created) and have expected_returncode=1 (see FIXME above) self.run_subprocess_assert_returncode(command_rm_pod) podman-compose-1.6.0/tests/integration/include/000077500000000000000000000000001521011662300215445ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/include/__init__.py000066400000000000000000000000011521011662300236440ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/include/docker-compose.base.yaml000066400000000000000000000002331521011662300262510ustar00rootroot00000000000000version: '3.6' services: web: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", ".", "-p", "8003"] podman-compose-1.6.0/tests/integration/include/docker-compose.extend.yaml000066400000000000000000000002331521011662300266260ustar00rootroot00000000000000version: '3.6' services: web2: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", ".", "-p", "8004"] podman-compose-1.6.0/tests/integration/include/docker-compose.yaml000066400000000000000000000001251521011662300253400ustar00rootroot00000000000000version: '3.6' include: - docker-compose.base.yaml - docker-compose.extend.yaml podman-compose-1.6.0/tests/integration/include/test_podman_compose_include.py000066400000000000000000000044711521011662300276710ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import unittest from pathlib import Path from packaging import version from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import get_podman_version class TestPodmanComposeInclude(unittest.TestCase, RunSubprocessMixin): @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") def test_podman_compose_include(self) -> None: """ Test that podman-compose can execute podman-compose -f up with include :return: """ main_path = Path(__file__).parent.parent.parent.parent command_up = [ "coverage", "run", str(main_path.joinpath("podman_compose.py")), "-f", str(main_path.joinpath("tests", "integration", "include", "docker-compose.yaml")), "up", "-d", ] command_check_container = [ "podman", "ps", "-a", "--filter", "label=io.podman.compose.project=include", "--format", '"{{.Image}}"', ] command_container_id = [ "podman", "ps", "-a", "--filter", "label=io.podman.compose.project=include", "--format", '"{{.ID}}"', ] command_down = ["podman", "rm", "--force"] self.run_subprocess_assert_returncode(command_up) out, _ = self.run_subprocess_assert_returncode(command_check_container) expected_output = b'"localhost/nopush/podman-compose-test:latest"\n' * 2 self.assertEqual(out, expected_output) # Get container ID to remove it out, _ = self.run_subprocess_assert_returncode(command_container_id) self.assertNotEqual(out, b"") container_ids = out.decode().strip().split("\n") container_ids = [container_id.replace('"', "") for container_id in container_ids] command_down.extend(container_ids) out, _ = self.run_subprocess_assert_returncode(command_down) # cleanup test image(tags) self.assertNotEqual(out, b"") # check container did not exist anymore out, _ = self.run_subprocess_assert_returncode(command_check_container) self.assertEqual(out, b"") podman-compose-1.6.0/tests/integration/interpolation/000077500000000000000000000000001521011662300230105ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/interpolation/.env000066400000000000000000000001031521011662300235730ustar00rootroot00000000000000DOT_ENV_VARIABLE=This value is from the .env file TEST_LABELS=TEST podman-compose-1.6.0/tests/integration/interpolation/__init__.py000066400000000000000000000000011521011662300251100ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/interpolation/docker-compose-colon-question-error.yml000066400000000000000000000003521521011662300325510ustar00rootroot00000000000000version: "3.7" services: variables: image: busybox command: ["/bin/busybox", "sh", "-c", "export | grep EXAMPLE"] environment: EXAMPLE_COLON_QUESTION_ERROR: ${NOT_A_VARIABLE:?Missing variable} podman-compose-1.6.0/tests/integration/interpolation/docker-compose-question-error.yml000066400000000000000000000003431521011662300314410ustar00rootroot00000000000000version: "3.7" services: variables: image: busybox command: ["/bin/busybox", "sh", "-c", "export | grep EXAMPLE"] environment: EXAMPLE_QUESTION_ERROR: ${NOT_A_VARIABLE?Missing variable} podman-compose-1.6.0/tests/integration/interpolation/docker-compose.yml000066400000000000000000000014051521011662300264450ustar00rootroot00000000000000version: "3.7" services: variables: image: busybox command: ["/bin/busybox", "sh", "-c", "export | grep EXAMPLE"] environment: EXAMPLE_VARIABLE: "Host user: $EXAMPLE_VARIABLE_USER" EXAMPLE_BRACES: "Host user: ${EXAMPLE_VARIABLE_USER}" EXAMPLE_COLON_DASH_DEFAULT: ${NOT_A_VARIABLE:-My default} EXAMPLE_DASH_DEFAULT: ${NOT_A_VARIABLE-My other default} EXAMPLE_DOT_ENV: $DOT_ENV_VARIABLE EXAMPLE_LITERAL: This is a $$literal EXAMPLE_EMPTY: $NOT_A_VARIABLE labels_test: image: busybox labels: - "$TEST_LABELS=test_labels" - test.${TEST_LABELS}=${TEST_LABELS} - "${TEST_LABELS}.test2=test2(`${TEST_LABELS}`)" podman-compose-1.6.0/tests/integration/interpolation/test_podman_compose_interpolation.py000066400000000000000000000043171521011662300324000ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import json import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "interpolation"), "docker-compose.yml") class TestComposeInterpolation(unittest.TestCase, RunSubprocessMixin): def test_interpolation(self) -> None: try: self.run_subprocess_assert_returncode([ "env", "EXAMPLE_VARIABLE_USER=test_user", podman_compose_path(), "-f", compose_yaml_path(), "up", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "logs", ]) self.assertIn("EXAMPLE_VARIABLE='Host user: test_user'", str(output)) self.assertIn("EXAMPLE_BRACES='Host user: test_user'", str(output)) self.assertIn("EXAMPLE_COLON_DASH_DEFAULT='My default'", str(output)) self.assertIn("EXAMPLE_DASH_DEFAULT='My other default'", str(output)) self.assertIn("EXAMPLE_DOT_ENV='This value is from the .env file'", str(output)) self.assertIn("EXAMPLE_EMPTY=''", str(output)) self.assertIn("EXAMPLE_LITERAL='This is a $literal'", str(output)) output, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", "interpolation_labels_test_1", ]) inspect_output = json.loads(output) labels_dict = inspect_output[0].get("Config", {}).get("Labels", {}) self.assertIn(('TEST', 'test_labels'), labels_dict.items()) self.assertIn(('TEST.test2', 'test2(`TEST`)'), labels_dict.items()) self.assertIn(('test.TEST', 'TEST'), labels_dict.items()) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) podman-compose-1.6.0/tests/integration/ipam_default/000077500000000000000000000000001521011662300225535ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/ipam_default/__init__.py000066400000000000000000000000011521011662300246530ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/ipam_default/docker-compose.yaml000066400000000000000000000004061521011662300263510ustar00rootroot00000000000000version: '3' # --ipam-driver must not be passed when driver is "default" networks: ipam_test_default: ipam: driver: default config: - subnet: 172.19.0.0/24 services: testipam: image: busybox command: ["echo", "ipamtest"] podman-compose-1.6.0/tests/integration/ipam_default/test_podman_compose_ipam_default.py000066400000000000000000000040171521011662300317030ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import json import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "ipam_default"), "docker-compose.yaml") class TestComposeIpamDefault(unittest.TestCase, RunSubprocessMixin): def test_ipam_default(self) -> None: try: self.run_subprocess_assert_returncode( [podman_compose_path(), "-f", compose_yaml_path(), "up", "-d"], ) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "logs", ]) # when container is created, its command echoes 'ipamtest' # BUG: figure out why echo is called twice self.assertIn("ipamtest", str(output)) output, _ = self.run_subprocess_assert_returncode( [ "podman", "inspect", "ipam_default_testipam_1", ], ) network_info = json.loads(output.decode('utf-8'))[0] network_name = next(iter(network_info["NetworkSettings"]["Networks"].keys())) output, _ = self.run_subprocess_assert_returncode([ "podman", "network", "inspect", f"{network_name}", ]) network_info = json.loads(output.decode('utf-8'))[0] # bridge is the default network driver self.assertEqual(network_info['driver'], "bridge") self.assertEqual(network_info['ipam_options'], {'driver': 'host-local'}) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) podman-compose-1.6.0/tests/integration/lifetime/000077500000000000000000000000001521011662300217175ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/lifetime/__init__.py000066400000000000000000000000001521011662300240160ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/lifetime/test_lifetime.py000066400000000000000000000106011521011662300251240ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import time import unittest from packaging import version from parameterized import parameterized from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import get_podman_version from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path class TestLifetime(unittest.TestCase, RunSubprocessMixin): def test_up_single_container(self) -> None: """Podman compose up should be able to start containers one after another""" compose_path = os.path.join(test_path(), "lifetime/up_single_container/docker-compose.yml") try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "up", "-d", "container1", ]) self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "up", "-d", "container2", ]) out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "logs", "--no-log-prefix", "--no-color", "container1", ]) self.assertEqual(out, b"test1\n") out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "logs", "--no-log-prefix", "--no-color", "container2", ]) self.assertEqual(out, b"test2\n") finally: out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "down", ]) @parameterized.expand([ ("no_ports", "up_single_container_many_times"), ("with_ports", "up_single_container_many_times_with_ports"), ]) @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") def test_up_single_container_many_times(self, name: str, subdir: str) -> None: """Podman compose up should be able to start a container many times after it finishes running. """ compose_path = os.path.join(test_path(), f"lifetime/{subdir}/docker-compose.yml") try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "up", "-d", "container1", ]) self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "up", "-d", "container2", ]) out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "logs", "--no-log-prefix", "--no-color", "container1", ]) self.assertEqual(out, b"test1\n") # "restart: always" keeps restarting container until its removal out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "logs", "--no-log-prefix", "--no-color", "container2", ]) if not out.startswith(b"test2\ntest2"): time.sleep(1) out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "logs", "--no-log-prefix", "--no-color", "container2", ]) self.assertTrue(out.startswith(b"test2\ntest2")) finally: out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/lifetime/up_single_container/000077500000000000000000000000001521011662300257465ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/lifetime/up_single_container/docker-compose.yml000066400000000000000000000003751521011662300314100ustar00rootroot00000000000000version: "3" services: container1: image: nopush/podman-compose-test command: ["/bin/bash", "-c", "echo test1; sleep infinity"] container2: image: nopush/podman-compose-test command: ["/bin/bash", "-c", "echo test2; sleep infinity"] podman-compose-1.6.0/tests/integration/lifetime/up_single_container_many_times/000077500000000000000000000000001521011662300301735ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/lifetime/up_single_container_many_times/docker-compose.yml000066400000000000000000000004011521011662300336230ustar00rootroot00000000000000version: "3" services: container1: image: nopush/podman-compose-test command: ["/bin/bash", "-c", "echo test1; sleep infinity"] container2: image: nopush/podman-compose-test restart: always command: ["/bin/bash", "-c", "echo test2"] podman-compose-1.6.0/tests/integration/lifetime/up_single_container_many_times_with_ports/000077500000000000000000000000001521011662300324555ustar00rootroot00000000000000docker-compose.yml000066400000000000000000000004571521011662300360410ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/lifetime/up_single_container_many_times_with_portsversion: "3" services: container1: image: nopush/podman-compose-test ports: "9001:9001" command: ["/bin/bash", "-c", "echo test1; sleep infinity"] container2: image: nopush/podman-compose-test restart: always ports: "9002:9002" command: ["/bin/bash", "-c", "echo test2"] podman-compose-1.6.0/tests/integration/logs/000077500000000000000000000000001521011662300210655ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/logs/docker-compose.yml000066400000000000000000000003101521011662300245140ustar00rootroot00000000000000version: "3" services: test1: image: busybox command: ["echo", "test1"] test2: image: busybox command: ["echo", "test2"] test3: image: busybox command: ["echo", "test3"] podman-compose-1.6.0/tests/integration/logs/test_podman_compose_logs.py000066400000000000000000000073551521011662300265370ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from parameterized import parameterized from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path class TestLogs(unittest.TestCase, RunSubprocessMixin): @parameterized.expand([ ( "all_services_no_flag", [], [ b'', b'\x1b[1;32m[test1] |\x1b[0m \x1b[37mtest1\x1b[0m', b'\x1b[1;33m[test2] |\x1b[0m \x1b[37mtest2\x1b[0m', b'\x1b[1;34m[test3] |\x1b[0m \x1b[37mtest3\x1b[0m', ], ), ( "all_services_flag_no_color", ["--no-color"], [ b'', b'\x1b[0m[test1] |\x1b[0m test1', b'\x1b[0m[test2] |\x1b[0m test2', b'\x1b[0m[test3] |\x1b[0m test3', ], ), ( "all_services_flag_no_log_prefix", ["--no-log-prefix"], [b'', b'\x1b[37mtest1\x1b[0m', b'\x1b[37mtest2\x1b[0m', b'\x1b[37mtest3\x1b[0m'], ), ( "all_services_flag_no_color_no_log_prefix", ["--no-color", "--no-log-prefix"], [b'', b'test1', b'test2', b'test3'], ), ( "one_service_no_flag", ["test1"], [b'', b'\x1b[1;32m[test1] |\x1b[0m \x1b[37mtest1\x1b[0m'], ), ( "one_service_flag_no_color", ["test1", "--no-color"], [b'', b'\x1b[0m[test1] |\x1b[0m test1'], ), ( "one_service_flag_no_log_prefix", ["test1", "--no-log-prefix"], [b'', b'\x1b[37mtest1\x1b[0m'], ), ( "one_service_flag_no_color_no_log_prefix", ["test1", "--no-color", "--no-log-prefix"], [b'', b'test1'], ), ( "two_services_no_flag", ["test2", "test3"], [ b'', b'\x1b[1;32m[test2] |\x1b[0m \x1b[37mtest2\x1b[0m', b'\x1b[1;33m[test3] |\x1b[0m \x1b[37mtest3\x1b[0m', ], ), ( "two_services_flag_no_color", ["test2", "test3", "--no-color"], [b'', b'\x1b[0m[test2] |\x1b[0m test2', b'\x1b[0m[test3] |\x1b[0m test3'], ), ( "two_services_flag_no_log_prefix", ["test2", "test3", "--no-log-prefix"], [b'', b'\x1b[37mtest2\x1b[0m', b'\x1b[37mtest3\x1b[0m'], ), ( "two_services_flag_no_color_no_log_prefix", ["test2", "test3", "--no-color", "--no-log-prefix"], [b'', b'test2', b'test3'], ), ]) def test_logs( self, test_name: str, additional_args: list[str], expected_log: list[bytes] ) -> None: compose_path = os.path.join(test_path(), "logs/docker-compose.yml") try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "up", "-d", ]) command_args = [ podman_compose_path(), "-f", compose_path, "logs", ] command_args.extend(additional_args) out, _ = self.run_subprocess_assert_returncode(command_args) lines = out.split(b'\n') lines.sort() self.assertEqual(lines, expected_log) finally: out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "down", ]) podman-compose-1.6.0/tests/integration/longlog/000077500000000000000000000000001521011662300215625ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/longlog/docker-compose-multibyte.yml000066400000000000000000000002131521011662300272270ustar00rootroot00000000000000services: s1: image: docker.io/python:3.11-slim command: env LANG=ja_JP.utf8 python -c'print("あ" * 10000000 + " end", end="")' podman-compose-1.6.0/tests/integration/longlog/test_long_log.py000066400000000000000000000027161521011662300250010ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest import uuid from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: base_path = os.path.join(test_path(), "longlog") return os.path.join(base_path, "docker-compose-multibyte.yml") class TestLongLog(unittest.TestCase, RunSubprocessMixin): def test_print_until_end_of_multibyte_output(self) -> None: # adding uuid because hangup containers can interfere next test runs project_id = f"longlog_{uuid.uuid4().hex[:8]}" try: out, err = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-p", project_id, "-f", compose_yaml_path(), "up", ], 0, timeout=6.0, ) self.assertEqual(b"", err) outstring = out.decode("utf-8") self.assertTrue( outstring.endswith(" end\n"), f"ending with {outstring[-20:].encode('utf-8')!r}" ) finally: self.run_subprocess([ podman_compose_path(), "-p", project_id, "-f", compose_yaml_path(), "down", ]) podman-compose-1.6.0/tests/integration/merge/000077500000000000000000000000001521011662300212205ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/__init__.py000066400000000000000000000000011521011662300233200ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/000077500000000000000000000000001521011662300261015ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/__init__.py000066400000000000000000000000011521011662300302010ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/override_tag_attribute/000077500000000000000000000000001521011662300326365ustar00rootroot00000000000000__init__.py000066400000000000000000000000011521011662300346570ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/override_tag_attribute docker-compose.override_attribute.yaml000066400000000000000000000004441521011662300422600ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/override_tag_attributeversion: "3" services: app: image: busybox command: ["/bin/busybox", "echo", "One"] ports: !override - "8111:81" depends_on: !override - other_db other_db: image: busybox command: ["/bin/busybox", "sleep", "10"] docker-compose.yaml000066400000000000000000000002631521011662300363560ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/override_tag_attributeversion: "3" services: app: image: busybox command: ["/bin/busybox", "echo", "Zero"] ports: - "8080:80" depends_on: - db test_podman_compose_override_tag_attribute.py000066400000000000000000000047551521011662300440230ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/override_tag_attribute# SPDX-License-Identifier: GPL-2.0 import json import os import unittest from packaging import version from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import get_podman_version from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join( test_path(), "merge/reset_and_override_tags/override_tag_attribute/docker-compose.yaml", ) class TestComposeOverrideTagAttribute(unittest.TestCase, RunSubprocessMixin): # test if a service attribute from docker-compose.yaml file is overridden @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") def test_override_tag_attribute(self) -> None: override_file = os.path.join( test_path(), "merge/reset_and_override_tags/override_tag_attribute/docker-compose.override_attribute.yaml", ) try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "-f", override_file, "up", ]) # merge rules are still applied output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "-f", override_file, "logs", "--no-log-prefix", "--no-color", ]) self.assertEqual(output, b"One\n") # only app service attribute "ports" was overridden output, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", "override_tag_attribute_app_1", ]) container_info = json.loads(output.decode('utf-8'))[0] self.assertEqual( container_info['NetworkSettings']["Ports"], {"81/tcp": [{"HostIp": "", "HostPort": "8111"}]}, ) # depends_on: !override testing: if this test works, depends_on is correctly overridden. # Otherwise, the test would break, since "db" dependency service is not provided finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/override_tag_service/000077500000000000000000000000001521011662300322735ustar00rootroot00000000000000__init__.py000066400000000000000000000000011521011662300343140ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/override_tag_service docker-compose.override_service.yaml000066400000000000000000000002311521011662300413440ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/override_tag_serviceversion: "3" services: app: !override image: busybox command: ["/bin/busybox", "echo", "One"] ports: - "8111:81" docker-compose.yaml000066400000000000000000000002201521011662300360040ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/override_tag_serviceversion: "3" services: app: image: busybox command: ["/bin/busybox", "echo", "Zero"] ports: - "8080:80" test_podman_compose_override_tag_service.py000066400000000000000000000045351521011662300431110ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/override_tag_service# SPDX-License-Identifier: GPL-2.0 import json import os import unittest from packaging import version from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import get_podman_version from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join( test_path(), "merge/reset_and_override_tags/override_tag_service/docker-compose.yaml", ) class TestComposeOverrideTagService(unittest.TestCase, RunSubprocessMixin): # test if whole service from docker-compose.yaml file is overridden in another file @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") def test_override_tag_service(self) -> None: override_file = os.path.join( test_path(), "merge/reset_and_override_tags/override_tag_service/docker-compose.override_service.yaml", ) try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "-f", override_file, "up", ]) # Whole app service was overridden in the docker-compose.override_tag_service.yaml file. # Command and port is overridden accordingly. output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "-f", override_file, "logs", "--no-log-prefix", "--no-color", ]) self.assertEqual(output, b"One\n") output, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", "override_tag_service_app_1", ]) container_info = json.loads(output.decode('utf-8'))[0] self.assertEqual( container_info['NetworkSettings']["Ports"], {"81/tcp": [{"HostIp": "", "HostPort": "8111"}]}, ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_attribute/000077500000000000000000000000001521011662300321415ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_attribute/__init__.py000066400000000000000000000000011521011662300342410ustar00rootroot00000000000000 docker-compose.reset_attribute.yaml000066400000000000000000000001621521011662300410630ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_attributeversion: "3" services: app: image: busybox command: !reset {} depends_on: !reset null docker-compose.yaml000066400000000000000000000002161521011662300356570ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_attributeversion: "3" services: app: image: busybox command: ["/bin/busybox", "echo", "Zero"] depends_on: - db test_podman_compose_reset_tag_attribute.py000066400000000000000000000041711521011662300426210ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_attribute# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join( test_path(), "merge/reset_and_override_tags/reset_tag_attribute/docker-compose.yaml", ) class TestComposeResetTagAttribute(unittest.TestCase, RunSubprocessMixin): # test if the attribute of the service is correctly reset def test_reset_tag_attribute(self) -> None: reset_file = os.path.join( test_path(), "merge/reset_and_override_tags/reset_tag_attribute/docker-compose.reset_attribute.yaml", ) try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "-f", reset_file, "up", ]) # the service still exists, but its command attribute was reset in # docker-compose.reset_tag_attribute.yaml file and is now empty output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "-f", reset_file, "ps", ]) self.assertIn(b"reset_tag_attribute_app_1", output) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "-f", reset_file, "logs", ]) self.assertEqual(output, b"") # depends_on: !reset null testing: if this test works, depends_on is correctly reset. # Otherwise the test would break, since "db" dependency service is not provided finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_attribute_w_extends/000077500000000000000000000000001521011662300342215ustar00rootroot00000000000000__init__.py000066400000000000000000000000001521011662300362410ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_attribute_w_extendscommon-services.yml000066400000000000000000000001071521011662300377740ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_attribute_w_extendswebapp: image: nopush/podman-compose-test ports: - "8000:8000" docker-compose.yml000066400000000000000000000001661521011662300376020ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_attribute_w_extendsversion: "3" services: web: extends: file: common-services.yml service: webapp ports: !reset [] test_podman_compose_extends_w_file.py000066400000000000000000000035431521011662300436420ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_attribute_w_extends# SPDX-License-Identifier: GPL-2.0 import json import os import unittest from io import BytesIO from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join( os.path.join( test_path(), "merge", "reset_and_override_tags", "reset_tag_attribute_w_extends" ), "docker-compose.yml", ) class TestComposeResetTagAttributeWithExtends(unittest.TestCase, RunSubprocessMixin): def test_reset_w_extends(self) -> None: # when file is Dockerfile for building the image try: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "up", ], ) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "ps", ]) self.assertIn("reset_tag_attribute_w_extends_web_1", str(output)) output, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", "reset_tag_attribute_w_extends_web_1", ]) inspect = json.load(BytesIO(output)) self.assertEqual(len(inspect), 1) self.assertIn("NetworkSettings", inspect[0]) self.assertIn("Ports", inspect[0]["NetworkSettings"]) self.assertEqual(len(inspect[0]["NetworkSettings"]["Ports"]), 0) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_service/000077500000000000000000000000001521011662300315765ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_service/__init__.py000066400000000000000000000000011521011662300336760ustar00rootroot00000000000000 docker-compose.reset_service.yaml000066400000000000000000000001711521011662300401550ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_serviceversion: "3" services: app: !reset app2: image: busybox command: ["/bin/busybox", "echo", "One"] docker-compose.yaml000066400000000000000000000001511521011662300353120ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_serviceversion: "3" services: app: image: busybox command: ["/bin/busybox", "echo", "Zero"] test_podman_compose_reset_tag_service.py000066400000000000000000000040741521011662300417150ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/reset_and_override_tags/reset_tag_service# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join( test_path(), "merge/reset_and_override_tags/reset_tag_service/docker-compose.yaml" ) class TestComposeResetTagService(unittest.TestCase, RunSubprocessMixin): # test if whole service from docker-compose.yaml file is reset def test_reset_tag_service(self) -> None: reset_file = os.path.join( test_path(), "merge/reset_and_override_tags/reset_tag_service/docker-compose.reset_service.yaml", ) try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "-f", reset_file, "up", ]) # app service was fully reset in docker-compose.reset_tag_service.yaml file, therefore # does not exist. A new service was created instead. output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "-f", reset_file, "ps", ]) self.assertNotIn(b"reset_tag_service_app_1", output) self.assertIn(b"reset_tag_service_app2_1", output) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "-f", reset_file, "logs", "--no-log-prefix", "--no-color", ]) self.assertEqual(output, b"One\n") finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", ]) podman-compose-1.6.0/tests/integration/merge/volumes_merge/000077500000000000000000000000001521011662300240715ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/merge/volumes_merge/__init__.py000066400000000000000000000000011521011662300261710ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/merge/volumes_merge/docker-compose.override.yaml000066400000000000000000000003041521011662300315020ustar00rootroot00000000000000version: "3" services: web: volumes: - ./override.txt:/var/www/html/index.html:ro,z - ./override.txt:/var/www/html/index2.html:z - ./override.txt:/var/www/html/index3.html podman-compose-1.6.0/tests/integration/merge/volumes_merge/docker-compose.yaml000066400000000000000000000004761521011662300276760ustar00rootroot00000000000000version: "3" services: web: image: busybox command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8080"] ports: - 8080:8080 volumes: - ./index.txt:/var/www/html/index.html:ro,z - ./index.txt:/var/www/html/index2.html - ./index.txt:/var/www/html/index3.html:ro podman-compose-1.6.0/tests/integration/merge/volumes_merge/index.txt000066400000000000000000000000421521011662300257350ustar00rootroot00000000000000The file from docker-compose.yaml podman-compose-1.6.0/tests/integration/merge/volumes_merge/override.txt000066400000000000000000000000531521011662300264470ustar00rootroot00000000000000The file from docker-compose.override.yaml podman-compose-1.6.0/tests/integration/merge/volumes_merge/test_podman_compose_volumes_merge.py000066400000000000000000000055461521011662300334500ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import json import os import unittest from packaging import version from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import get_podman_version from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(compose_name: str) -> str: """ "Returns the path to the compose file used for this test module""" base_path = os.path.join(test_path(), "merge/volumes_merge/") return os.path.join(base_path, compose_name) class TestComposeVolumesMerge(unittest.TestCase, RunSubprocessMixin): @unittest.skipIf(get_podman_version() >= version.parse("5.7.0"), "Breaks as of podman-5.7.1.") def test_volumes_merge(self) -> None: # test if additional compose file overrides host path and access mode of a volume try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path("docker-compose.yaml"), "-f", compose_yaml_path("docker-compose.override.yaml"), "up", "-d", ]) out, _ = self.run_subprocess_assert_returncode([ "podman", "exec", "-ti", "volumes_merge_web_1", "cat", "/var/www/html/index.html", "/var/www/html/index2.html", "/var/www/html/index3.html", ]) self.assertEqual( out, b"The file from docker-compose.override.yaml\r\n" b"The file from docker-compose.override.yaml\r\n" b"The file from docker-compose.override.yaml\r\n", ) out, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", "volumes_merge_web_1", ]) volumes_info = json.loads(out.decode('utf-8'))[0] binds_info = volumes_info["HostConfig"]["Binds"] binds_info.sort() file_path = os.path.join(test_path(), "merge/volumes_merge/override.txt") expected = [ f'{file_path}:/var/www/html/index.html:ro,rprivate,rbind', f'{file_path}:/var/www/html/index2.html:rw,rprivate,rbind', f'{file_path}:/var/www/html/index3.html:rw,rprivate,rbind', ] self.assertEqual(binds_info, expected) finally: out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path("docker-compose.yaml"), "-f", compose_yaml_path("docker-compose.override.yaml"), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/multicompose/000077500000000000000000000000001521011662300226415ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/multicompose/__init__.py000066400000000000000000000000011521011662300247410ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/multicompose/d1/000077500000000000000000000000001521011662300231455ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/multicompose/d1/1.env000066400000000000000000000000161521011662300240140ustar00rootroot00000000000000var1=d1/1.env podman-compose-1.6.0/tests/integration/multicompose/d1/12.env000066400000000000000000000000201521011662300240710ustar00rootroot00000000000000var12=d1/12.env podman-compose-1.6.0/tests/integration/multicompose/d1/2.env000066400000000000000000000000161521011662300240150ustar00rootroot00000000000000var2=d1/2.env podman-compose-1.6.0/tests/integration/multicompose/d1/docker-compose.yml000066400000000000000000000003661521011662300266070ustar00rootroot00000000000000version: '3' services: web1: image: busybox command: busybox httpd -h /var/www/html/ -f -p 8001 volumes: - ./1.env:/var/www/html/index.txt:z env_file: ./1.env labels: l1: v1 environment: - mykey1=myval1 podman-compose-1.6.0/tests/integration/multicompose/d2/000077500000000000000000000000001521011662300231465ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/multicompose/d2/12.env000066400000000000000000000000201521011662300240720ustar00rootroot00000000000000var12=d2/12.env podman-compose-1.6.0/tests/integration/multicompose/d2/2.env000066400000000000000000000000161521011662300240160ustar00rootroot00000000000000var2=d2/2.env podman-compose-1.6.0/tests/integration/multicompose/d2/docker-compose.yml000066400000000000000000000005221521011662300266020ustar00rootroot00000000000000version: '3' services: web1: image: busybox env_file: ./12.env labels: - l1=v2 - l2=v2 environment: mykey1: myval2 mykey2: myval2 web2: image: busybox command: busybox httpd -h /var/www/html/ -f -p 8002 volumes: - ./2.env:/var/www/html/index.txt:z env_file: ./2.env podman-compose-1.6.0/tests/integration/multicompose/test_podman_compose_multicompose.py000066400000000000000000000066341521011662300320660ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "multicompose"), "docker-compose.yml") class TestComposeMulticompose(unittest.TestCase, RunSubprocessMixin): def test_multicompose(self) -> None: try: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", os.path.join( os.path.join(test_path(), "multicompose"), "d1/docker-compose.yml" ), "-f", os.path.join( os.path.join(test_path(), "multicompose"), "d2/docker-compose.yml" ), "up", "-d", ], ) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", os.path.join(os.path.join(test_path(), "multicompose"), "d1/docker-compose.yml"), "-f", os.path.join(os.path.join(test_path(), "multicompose"), "d2/docker-compose.yml"), "ps", ]) self.assertIn(b"d1_web1_1", output) self.assertIn(b"d1_web2_1", output) output, _ = self.run_subprocess_assert_returncode([ "podman", "exec", "-ti", "d1_web1_1", "sh", "-c", "set", ]) # checks if `env_file` was appended, not replaced # (which means that we normalize to array before merge) self.assertIn(b"var12='d1/12.env'", output) output, _ = self.run_subprocess_assert_returncode([ "podman", "exec", "-ti", "d1_web2_1", "sh", "-c", "set", ]) # checks if paths inside `d2/docker-compose.yml` directory are relative to `d1` self.assertIn(b"var2='d1/2.env'", output) output, _ = self.run_subprocess_assert_returncode([ "podman", "exec", "-ti", "d1_web1_1", "sh", "-c", "cat /var/www/html/index.txt", ]) self.assertIn(b"var1=d1/1.env", output) # check if project base directory and project name is d1 output, _ = self.run_subprocess_assert_returncode([ "podman", "exec", "-ti", "d1_web2_1", "sh", "-c", "cat /var/www/html/index.txt", ]) self.assertIn(b"var2=d1/2.env", output) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", os.path.join(os.path.join(test_path(), "multicompose"), "d1/docker-compose.yml"), "-f", os.path.join(os.path.join(test_path(), "multicompose"), "d2/docker-compose.yml"), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/name_separator_compat/000077500000000000000000000000001521011662300244645ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/name_separator_compat/__init__.py000066400000000000000000000000011521011662300265640ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/name_separator_compat/docker-compose_compat.yaml000066400000000000000000000001641521011662300316260ustar00rootroot00000000000000services: web: image: busybox command: httpd -f -p 8123 -h /tmp/ x-podman: name_separator_compat: true podman-compose-1.6.0/tests/integration/name_separator_compat/docker-compose_default.yaml000066400000000000000000000001131521011662300317610ustar00rootroot00000000000000services: web: image: busybox command: httpd -f -p 8123 -h /tmp/ test_podman_compose_name_separator_compat.py000066400000000000000000000040251521011662300354450ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/name_separator_compat# SPDX-License-Identifier: GPL-2.0 import os import unittest from parameterized import parameterized from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path class TestComposeNameSeparatorCompat(unittest.TestCase, RunSubprocessMixin): @parameterized.expand([ ('default', {}, '_'), ('default', {'PODMAN_COMPOSE_NAME_SEPARATOR_COMPAT': '1'}, '-'), ('default', {'PODMAN_COMPOSE_DOCKER_COMPOSE_COMPAT': '1'}, '-'), ('compat', {}, '-'), ('compat', {'PODMAN_COMPOSE_NAME_SEPARATOR_COMPAT': '1'}, '-'), ('compat', {'PODMAN_COMPOSE_NAME_SEPARATOR_COMPAT': '0'}, '_'), ]) def test_container_name(self, file: str, env: dict[str, str], expected_sep: str) -> None: compose_yaml_path = os.path.join( test_path(), "name_separator_compat", f"docker-compose_{file}.yaml" ) try: self.run_subprocess_assert_returncode( [podman_compose_path(), "-f", compose_yaml_path, "up", "-d"], env=env, ) container_name_out, _ = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path, "ps", "--format", '{{.Names}}', ], env=env, ) container_name = container_name_out.decode('utf-8').strip() expected_container_name = f'name_separator_compat{expected_sep}web{expected_sep}1' self.assertEqual(container_name, expected_container_name) finally: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path, "down", "-t", "0", ], env=env, ) podman-compose-1.6.0/tests/integration/nethost/000077500000000000000000000000001521011662300216055ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/nethost/__init__.py000066400000000000000000000000011521011662300237050ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/nethost/docker-compose.yaml000066400000000000000000000001571521011662300254060ustar00rootroot00000000000000version: '3' services: web: image: busybox command: httpd -f -p 8123 -h /tmp/ network_mode: host podman-compose-1.6.0/tests/integration/nethost/test_podman_compose_nethost.py000066400000000000000000000036201521011662300277660ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest import requests from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "nethost"), "docker-compose.yaml") class TestComposeNethost(unittest.TestCase, RunSubprocessMixin): # check if container listens for http requests and sends response back # as network_mode: host allows to connect to container easily def test_nethost(self) -> None: try: self.run_subprocess_assert_returncode( [podman_compose_path(), "-f", compose_yaml_path(), "up", "-d"], ) container_id_out, _ = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "ps", "--format", '{{.ID}}', ], ) container_id = container_id_out.decode('utf-8').split('\n')[0] output, _ = self.run_subprocess_assert_returncode( [ "podman", "exec", "-it", container_id, "sh", "-c", "echo test_123 >> /tmp/test.txt", ], ) response = requests.get('http://localhost:8123/test.txt') self.assertEqual(response.ok, True) self.assertEqual(response.text, "test_123\n") finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/nets_test1/000077500000000000000000000000001521011662300222125ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/nets_test1/__init__.py000066400000000000000000000000011521011662300243120ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/nets_test1/docker-compose.yml000066400000000000000000000010601521011662300256440ustar00rootroot00000000000000version: "3" services: web1: image: busybox hostname: web1 command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] working_dir: /var/www/html ports: - 8001:8001 volumes: - ./test1.txt:/var/www/html/index.txt:ro,z web2: image: busybox hostname: web2 command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] working_dir: /var/www/html ports: - 8002:8001 volumes: - ./test2.txt:/var/www/html/index.txt:ro,z podman-compose-1.6.0/tests/integration/nets_test1/test1.txt000066400000000000000000000000061521011662300240070ustar00rootroot00000000000000test1 podman-compose-1.6.0/tests/integration/nets_test1/test2.txt000066400000000000000000000000061521011662300240100ustar00rootroot00000000000000test2 podman-compose-1.6.0/tests/integration/nets_test1/test_podman_compose_nets_test1.py000066400000000000000000000073011521011662300310000ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import json import os import unittest import requests from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "nets_test1"), "docker-compose.yml") class TestComposeNetsTest1(unittest.TestCase, RunSubprocessMixin): # test if port mapping works as expected def test_nets_test1(self) -> None: try: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "up", "-d", ], ) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "ps", ]) self.assertIn(b"nets_test1_web1_1", output) self.assertIn(b"nets_test1_web2_1", output) response = requests.get('http://localhost:8001/index.txt') self.assertTrue(response.ok) self.assertEqual(response.text, "test1\n") response = requests.get('http://localhost:8002/index.txt') self.assertTrue(response.ok) self.assertEqual(response.text, "test2\n") # inspect 1st container output, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", "nets_test1_web1_1", ]) container_info = json.loads(output.decode('utf-8'))[0] # check if network got default name self.assertEqual( list(container_info["NetworkSettings"]["Networks"].keys())[0], "nets_test1_default" ) # check if Host port is the same as provided by the service port self.assertIsNotNone(container_info['NetworkSettings']["Ports"].get("8001/tcp", None)) self.assertGreater(len(container_info['NetworkSettings']["Ports"]["8001/tcp"]), 0) self.assertIsNotNone( container_info['NetworkSettings']["Ports"]["8001/tcp"][0].get("HostPort", None) ) self.assertEqual( container_info['NetworkSettings']["Ports"]["8001/tcp"][0]["HostPort"], "8001" ) self.assertEqual(container_info["Config"]["Hostname"], "web1") # inspect 2nd container output, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", "nets_test1_web2_1", ]) container_info = json.loads(output.decode('utf-8'))[0] self.assertEqual( list(container_info["NetworkSettings"]["Networks"].keys())[0], "nets_test1_default" ) self.assertIsNotNone(container_info['NetworkSettings']["Ports"].get("8001/tcp", None)) self.assertGreater(len(container_info['NetworkSettings']["Ports"]["8001/tcp"]), 0) self.assertIsNotNone( container_info['NetworkSettings']["Ports"]["8001/tcp"][0].get("HostPort", None) ) self.assertEqual( container_info['NetworkSettings']["Ports"]["8001/tcp"][0]["HostPort"], "8002" ) self.assertEqual(container_info["Config"]["Hostname"], "web2") finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/nets_test2/000077500000000000000000000000001521011662300222135ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/nets_test2/__init__.py000066400000000000000000000000011521011662300243130ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/nets_test2/docker-compose.yml000066400000000000000000000011071521011662300256470ustar00rootroot00000000000000version: "3" networks: mystack: services: web1: image: busybox hostname: web1 command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] working_dir: /var/www/html ports: - 8001:8001 volumes: - ./test1.txt:/var/www/html/index.txt:ro,z web2: image: busybox hostname: web2 command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] working_dir: /var/www/html ports: - 8002:8001 volumes: - ./test2.txt:/var/www/html/index.txt:ro,z podman-compose-1.6.0/tests/integration/nets_test2/test1.txt000066400000000000000000000000061521011662300240100ustar00rootroot00000000000000test1 podman-compose-1.6.0/tests/integration/nets_test2/test2.txt000066400000000000000000000000061521011662300240110ustar00rootroot00000000000000test2 podman-compose-1.6.0/tests/integration/nets_test2/test_podman_compose_nets_test2.py000066400000000000000000000074041521011662300310060ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import json import os import unittest import requests from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "nets_test2"), "docker-compose.yml") class TestComposeNetsTest2(unittest.TestCase, RunSubprocessMixin): # test if port mapping works as expected with networks top-level element def test_nets_test2(self) -> None: try: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "up", "-d", ], ) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "ps", ]) self.assertIn(b"nets_test2_web1_1", output) self.assertIn(b"nets_test2_web2_1", output) response = requests.get('http://localhost:8001/index.txt') self.assertTrue(response.ok) self.assertEqual(response.text, "test1\n") response = requests.get('http://localhost:8002/index.txt') self.assertTrue(response.ok) self.assertEqual(response.text, "test2\n") # inspect 1st container output, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", "nets_test2_web1_1", ]) container_info = json.loads(output.decode('utf-8'))[0] # check if network got specific name from networks top-level element self.assertEqual( list(container_info["NetworkSettings"]["Networks"].keys())[0], "nets_test2_mystack" ) # check if Host port is the same as prodvided by the service port self.assertIsNotNone(container_info['NetworkSettings']["Ports"].get("8001/tcp", None)) self.assertGreater(len(container_info['NetworkSettings']["Ports"]["8001/tcp"]), 0) self.assertIsNotNone( container_info['NetworkSettings']["Ports"]["8001/tcp"][0].get("HostPort", None) ) self.assertEqual( container_info['NetworkSettings']["Ports"]["8001/tcp"][0]["HostPort"], "8001" ) self.assertEqual(container_info["Config"]["Hostname"], "web1") # inspect 2nd container output, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", "nets_test2_web2_1", ]) container_info = json.loads(output.decode('utf-8'))[0] self.assertEqual( list(container_info["NetworkSettings"]["Networks"].keys())[0], "nets_test2_mystack" ) self.assertIsNotNone(container_info['NetworkSettings']["Ports"].get("8001/tcp", None)) self.assertGreater(len(container_info['NetworkSettings']["Ports"]["8001/tcp"]), 0) self.assertIsNotNone( container_info['NetworkSettings']["Ports"]["8001/tcp"][0].get("HostPort", None) ) self.assertEqual( container_info['NetworkSettings']["Ports"]["8001/tcp"][0]["HostPort"], "8002" ) self.assertEqual(container_info["Config"]["Hostname"], "web2") finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/nets_test3/000077500000000000000000000000001521011662300222145ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/nets_test3/__init__.py000066400000000000000000000000001521011662300243130ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/nets_test3/docker-compose.yml000066400000000000000000000020611521011662300256500ustar00rootroot00000000000000version: "3" networks: net1: net2: services: web1: image: busybox #container_name: web1 hostname: web1 command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] working_dir: /var/www/html networks: - net1 ports: - 8001:8001 volumes: - ./test1.txt:/var/www/html/index.txt:ro,z web2: image: busybox #container_name: web2 hostname: web2 command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] working_dir: /var/www/html networks: - net1 - net2 ports: - 8002:8001 volumes: - ./test2.txt:/var/www/html/index.txt:ro,z web3: image: busybox command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] working_dir: /var/www/html networks: net1: aliases: - alias11 - alias12 net2: aliases: - alias21 volumes: - ./test3.txt:/var/www/html/index.txt:ro,z podman-compose-1.6.0/tests/integration/nets_test3/test1.txt000066400000000000000000000000061521011662300240110ustar00rootroot00000000000000test1 podman-compose-1.6.0/tests/integration/nets_test3/test2.txt000066400000000000000000000000061521011662300240120ustar00rootroot00000000000000test2 podman-compose-1.6.0/tests/integration/nets_test3/test3.txt000066400000000000000000000000061521011662300240130ustar00rootroot00000000000000test3 podman-compose-1.6.0/tests/integration/nets_test3/test_podman_compose_nets_test3.py000066400000000000000000000050731521011662300310100ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from packaging import version from parameterized import parameterized from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import get_podman_version from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "nets_test3"), "docker-compose.yml") @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") class TestComposeNetsTest3(unittest.TestCase, RunSubprocessMixin): # test if services can access the networks of other services using their respective aliases @parameterized.expand([ ("nets_test3_web2_1", "web3", b"test3", 0), ("nets_test3_web2_1", "alias11", b"test3", 0), ("nets_test3_web2_1", "alias12", b"test3", 0), ("nets_test3_web2_1", "alias21", b"test3", 0), ("nets_test3_web1_1", "web3", b"test3", 0), ("nets_test3_web1_1", "alias11", b"test3", 0), ("nets_test3_web1_1", "alias12", b"test3", 0), # connection fails as web1 service does not know net2 and its aliases ("nets_test3_web1_1", "alias21", b"", 1), ]) def test_nets_test3( self, container_name: str, network_alias_name: str, expected_text: bytes, expected_returncode: int, ) -> None: try: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "up", "-d", ], ) # check connection from different services to network aliases of web3 service cmd = [ "podman", "exec", "-it", f"{container_name}", "/bin/busybox", "wget", "-O", "-", "-o", "/dev/null", f"http://{network_alias_name}:8001/index.txt", ] out, _, returncode = self.run_subprocess(cmd) self.assertEqual(expected_returncode, returncode) self.assertEqual(expected_text, out.strip()) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/nets_test_ip/000077500000000000000000000000001521011662300226215ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/nets_test_ip/__init__.py000066400000000000000000000000001521011662300247200ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/nets_test_ip/docker-compose.yml000066400000000000000000000034521521011662300262620ustar00rootroot00000000000000version: "3" networks: shared-network: driver: bridge ipam: config: - subnet: "172.19.1.0/24" internal-network: driver: bridge ipam: config: - subnet: "172.19.2.0/24" services: web1: image: busybox hostname: web1 command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] working_dir: /var/www/html networks: shared-network: ipv4_address: "172.19.1.10" x-podman.mac_address: "02:01:01:00:01:01" internal-network: ipv4_address: "172.19.2.10" mac_address: "02:01:01:00:02:01" volumes: - ./test1.txt:/var/www/html/index.txt:ro,z web2: image: busybox hostname: web2 command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] working_dir: /var/www/html mac_address: "02:01:01:00:02:02" networks: internal-network: ipv4_address: "172.19.2.11" volumes: - ./test2.txt:/var/www/html/index.txt:ro,z web3: image: busybox hostname: web2 command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] working_dir: /var/www/html networks: internal-network: volumes: - ./test3.txt:/var/www/html/index.txt:ro,z web4: image: busybox hostname: web2 command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] working_dir: /var/www/html networks: internal-network: shared-network: ipv4_address: "172.19.1.13" volumes: - ./test4.txt:/var/www/html/index.txt:ro,z podman-compose-1.6.0/tests/integration/nets_test_ip/test1.txt000066400000000000000000000000061521011662300244160ustar00rootroot00000000000000test1 podman-compose-1.6.0/tests/integration/nets_test_ip/test2.txt000066400000000000000000000000061521011662300244170ustar00rootroot00000000000000test2 podman-compose-1.6.0/tests/integration/nets_test_ip/test3.txt000066400000000000000000000000061521011662300244200ustar00rootroot00000000000000test3 podman-compose-1.6.0/tests/integration/nets_test_ip/test4.txt000066400000000000000000000000061521011662300244210ustar00rootroot00000000000000test4 podman-compose-1.6.0/tests/integration/nets_test_ip/test_podman_compose_nets_test_ip.py000066400000000000000000000050461521011662300320220ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "nets_test_ip"), "docker-compose.yml") class TestComposeNetsTestIp(unittest.TestCase, RunSubprocessMixin): # test if services retain custom ipv4_address and mac_address matching the subnet provided # in networks top-level element def test_nets_test_ip(self) -> None: try: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "up", "-d", ], ) expected_results = [ ( "web1", b"inet 172.19.1.10/24 ", b"link/ether 02:01:01:00:01:01 ", b"inet 172.19.2.10/24 ", b"link/ether 02:01:01:00:02:01 ", b"", ), ("web2", b"", b"", b"inet 172.19.2.11/24 ", b"", b"link/ether 02:01:01:00:02:02 "), ("web3", b"", b"", b"inet 172.19.2.", b"", b""), ("web4", b"inet 172.19.1.13/24 ", b"", b"inet 172.19.2.", b"", b""), ] for ( service_name, shared_network_ip, shared_network_mac_address, internal_network_ip, internal_network_mac_address, mac_address, ) in expected_results: output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "exec", service_name, "ip", "a", ]) self.assertIn(shared_network_ip, output) self.assertIn(shared_network_mac_address, output) self.assertIn(internal_network_ip, output) self.assertIn(internal_network_mac_address, output) self.assertIn(mac_address, output) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/network/000077500000000000000000000000001521011662300216125ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/network/__init__.py000066400000000000000000000000011521011662300237120ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/network/docker-compose.yml000066400000000000000000000011071521011662300252460ustar00rootroot00000000000000version: "3" networks: mystack: services: web1: image: busybox hostname: web1 command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] working_dir: /var/www/html ports: - 8001:8001 volumes: - ./test1.txt:/var/www/html/index.txt:ro,z web2: image: busybox hostname: web2 command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] working_dir: /var/www/html ports: - 8002:8001 volumes: - ./test2.txt:/var/www/html/index.txt:ro,z podman-compose-1.6.0/tests/integration/network/test1.txt000066400000000000000000000000061521011662300234070ustar00rootroot00000000000000test1 podman-compose-1.6.0/tests/integration/network/test2.txt000066400000000000000000000000061521011662300234100ustar00rootroot00000000000000test2 podman-compose-1.6.0/tests/integration/network/test_podman_compose_network.py000066400000000000000000000107671521011662300300120ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 """ test_podman_compose_networks.py Tests the podman networking parameters """ # pylint: disable=redefined-outer-name import os import unittest from typing import Generator from packaging import version from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import get_podman_version from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path class TestPodmanComposeNetwork(RunSubprocessMixin, unittest.TestCase): @staticmethod def compose_file() -> str: """Returns the path to the compose file used for this test module""" return os.path.join(test_path(), "nets_test_ip", "docker-compose.yml") def teardown(self) -> Generator[None, None, None]: """ Ensures that the services within the "profile compose file" are removed between each test case. """ # run the test case yield down_cmd = [ "coverage", "run", podman_compose_path(), "-f", self.compose_file(), "kill", "-a", ] self.run_subprocess(down_cmd) @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") def test_networks(self) -> None: up_cmd = [ "coverage", "run", podman_compose_path(), "-f", self.compose_file(), "up", "-d", "--force-recreate", ] self.run_subprocess_assert_returncode(up_cmd) check_cmd = [ podman_compose_path(), "-f", self.compose_file(), "ps", "--format", '"{{.Names}}"', ] out, _ = self.run_subprocess_assert_returncode(check_cmd) self.assertIn(b"nets_test_ip_web1_1", out) self.assertIn(b"nets_test_ip_web2_1", out) expected_wget = { "172.19.1.10": "test1", "172.19.2.10": "test1", "172.19.2.11": "test2", "web3": "test3", "172.19.1.13": "test4", } for service in ("web1", "web2"): for ip, expect in expected_wget.items(): wget_cmd = [ podman_compose_path(), "-f", self.compose_file(), "exec", service, "wget", "-q", "-O-", f"http://{ip}:8001/index.txt", ] out, _ = self.run_subprocess_assert_returncode(wget_cmd) self.assertEqual(f"{expect}\r\n", out.decode('utf-8')) expected_macip = { "web1": { "eth0": ["172.19.1.10", "02:01:01:00:01:01"], "eth1": ["172.19.2.10", "02:01:01:00:02:01"], }, "web2": {"eth0": ["172.19.2.11", "02:01:01:00:02:02"]}, } for service, interfaces in expected_macip.items(): ip_cmd = [ podman_compose_path(), "-f", self.compose_file(), "exec", service, "ip", "addr", "show", ] out, _ = self.run_subprocess_assert_returncode(ip_cmd) for interface, values in interfaces.items(): ip, mac = values self.assertIn(f"ether {mac}", out.decode('utf-8')) self.assertIn(f"inet {ip}/", out.decode('utf-8')) def test_down_with_network(self) -> None: try: self.run_subprocess_assert_returncode([ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "network", "docker-compose.yml"), "up", "-d", ]) output, _, _ = self.run_subprocess(["podman", "network", "ls"]) self.assertIn("network_mystack", output.decode()) finally: self.run_subprocess_assert_returncode([ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "network", "docker-compose.yml"), "down", ]) output, _, _ = self.run_subprocess(["podman", "network", "ls"]) self.assertNotIn("network_mystack", output.decode()) podman-compose-1.6.0/tests/integration/network_interface_name/000077500000000000000000000000001521011662300246325ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/network_interface_name/__init__.py000066400000000000000000000000011521011662300267320ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/network_interface_name/docker-compose.yml000066400000000000000000000003441521011662300302700ustar00rootroot00000000000000version: "3" networks: mystack: services: web: image: busybox command: ["/bin/busybox", "httpd", "-f", "-h", ".", "-p", "8004"] networks: mystack: x-podman.interface_name: customName0 test_podman_compose_network_interface_name.py000066400000000000000000000030751521011662300357650ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/network_interface_name# SPDX-License-Identifier: GPL-2.0 # pylint: disable=redefined-outer-name import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path class TestPodmanComposeNetworkInterfaceName(RunSubprocessMixin, unittest.TestCase): def compose_file(self) -> str: return os.path.join(test_path(), "network_interface_name", "docker-compose.yml") def up(self) -> None: up_cmd = [ "coverage", "run", podman_compose_path(), "-f", self.compose_file(), "up", "-d", "--force-recreate", ] self.run_subprocess_assert_returncode(up_cmd) def down(self) -> None: down_cmd = [ "coverage", "run", podman_compose_path(), "-f", self.compose_file(), "kill", "-a", ] self.run_subprocess(down_cmd) def test_interface_name(self) -> None: try: self.up() interfaces_cmd = [ podman_compose_path(), "-f", self.compose_file(), "exec", "web", "ls", "/sys/class/net", "--color=never", ] out, _ = self.run_subprocess_assert_returncode(interfaces_cmd) self.assertEqual("customName0 lo\r\n", out.decode()) finally: self.down() podman-compose-1.6.0/tests/integration/network_scoped_aliases/000077500000000000000000000000001521011662300246505ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/network_scoped_aliases/__init__.py000066400000000000000000000000011521011662300267500ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/network_scoped_aliases/docker-compose.yaml000066400000000000000000000013121521011662300304430ustar00rootroot00000000000000--- networks: net0: ipam: config: - subnet: "172.19.3.0/24" net1: ipam: config: - subnet: "172.19.4.0/24" services: web1: image: busybox command: ["/bin/busybox", "httpd", "-f", "-h", "/tmp", "-p", "8001"] networks: net0: ipv4_address: "172.19.3.11" aliases: - secure-web net1: ipv4_address: "172.19.4.11" aliases: - insecure-web utils-net0: image: busybox command: ["/bin/busybox", "httpd", "-f", "-h", "/tmp", "-p", "8001"] networks: - net0 utils-net1: image: busybox command: ["/bin/busybox", "httpd", "-f", "-h", "/tmp", "-p", "8001"] networks: - net1 test_podman_compose_network_scoped_aliases.py000066400000000000000000000052261521011662300360210ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/network_scoped_aliases# SPDX-License-Identifier: GPL-2.0 # pylint: disable=redefined-outer-name import os import unittest from packaging import version from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import get_podman_version from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path class TestPodmanComposeNetworkScopedAliases(RunSubprocessMixin, unittest.TestCase): @staticmethod def compose_file() -> str: """Returns the path to the compose file used for this test module""" return os.path.join(test_path(), "network_scoped_aliases", "docker-compose.yaml") @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") def test_network_scoped_aliases(self) -> None: try: self.up() self.verify() finally: self.down() def up(self) -> None: up_cmd = [ "coverage", "run", podman_compose_path(), "-f", self.compose_file(), "up", "-d", "--force-recreate", ] self.run_subprocess_assert_returncode(up_cmd) def down(self) -> None: down_cmd = [ "coverage", "run", podman_compose_path(), "-f", self.compose_file(), "kill", "-a", ] self.run_subprocess(down_cmd) def verify(self) -> None: expected_results = [ ("utils-net0", "web1", ["172.19.3.11"]), ("utils-net0", "secure-web", ["172.19.3.11"]), ("utils-net0", "insecure-web", []), ("utils-net1", "web1", ["172.19.4.11"]), ("utils-net1", "secure-web", []), ("utils-net1", "insecure-web", ["172.19.4.11"]), ] for utils, service, expected_result in expected_results: cmd = [ podman_compose_path(), "-f", self.compose_file(), "exec", utils, "nslookup", service, ] out, _, _ = self.run_subprocess(cmd) addresses = self.parse_dnslookup(out.decode()) self.assertEqual(addresses, expected_result) def parse_dnslookup(self, output: str) -> list[str]: lines = output.splitlines() addresses = [] for line in lines: if line.startswith("Address"): addr = line.split(":", 1)[1].strip() if ":" not in addr: addresses.append(addr) return list(sorted(set(addresses))) podman-compose-1.6.0/tests/integration/no_recreate/000077500000000000000000000000001521011662300224075ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/no_recreate/__init__.py000066400000000000000000000000001521011662300245060ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/no_recreate/compose1.yaml000066400000000000000000000001521521011662300250170ustar00rootroot00000000000000services: web: image: busybox command: ["/bin/busybox", "httpd", "-f", "-h", ".", "-p", "8004"] podman-compose-1.6.0/tests/integration/no_recreate/compose2.yaml000066400000000000000000000001521521011662300250200ustar00rootroot00000000000000services: web: image: busybox command: ["/bin/busybox", "httpd", "-f", "-h", ".", "-p", "8005"] podman-compose-1.6.0/tests/integration/no_recreate/test_podman_compose_no_recreate.py000066400000000000000000000046311521011662300313750ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(version: str = '1') -> str: return os.path.join(os.path.join(test_path(), "no_recreate"), f"compose{version}.yaml") class TestComposeNoRecreate(unittest.TestCase, RunSubprocessMixin): def test_no_recreate(self) -> None: def up(args: list[str] = [], version: str = '1') -> None: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(version), "up", "-t", "0", "-d", *args, ], ) def get_container_id() -> bytes: return self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "ps", "--format", '{{.ID}}', ])[0] try: up() container_id = get_container_id() self.assertGreater(len(container_id), 0) # Default behavior - up with same compose file should not recreate the container up() self.assertEqual(get_container_id(), container_id) # Default behavior - up with modified compose file should recreate the container up(version='2') self.assertNotEqual(get_container_id(), container_id) container_id = get_container_id() # Using --no-recreate should not recreate the container # even if the compose file is modified up(["--no-recreate"], version='1') self.assertEqual(get_container_id(), container_id) # Using --force-recreate should recreate the container # even if the compose file is not modified up(["--force-recreate"], version='1') self.assertNotEqual(get_container_id(), container_id) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/no_services/000077500000000000000000000000001521011662300224405ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/no_services/__init__.py000066400000000000000000000000001521011662300245370ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/no_services/docker-compose-empty-services.yaml000066400000000000000000000000121521011662300312040ustar00rootroot00000000000000services: podman-compose-1.6.0/tests/integration/no_services/docker-compose-networks-no-services.yaml000066400000000000000000000001661521011662300323460ustar00rootroot00000000000000version: '3' networks: shared-network: driver: bridge ipam: config: - subnet: 172.19.0.0/24 podman-compose-1.6.0/tests/integration/no_services/test_podman_compose_no_services.py000066400000000000000000000044531521011662300314610ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def networks_no_services_compose() -> str: return compose_yaml_path("docker-compose-networks-no-services.yaml") def empty_services_compose() -> str: return compose_yaml_path("docker-compose-empty-services.yaml") def compose_yaml_path(filename: str) -> str: return os.path.join(os.path.join(test_path(), "no_services"), filename) class TestComposeNoServices(unittest.TestCase, RunSubprocessMixin): # test if a network was created, but not the services def test_no_services(self) -> None: try: _, err = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", networks_no_services_compose(), "up", "-d", ], ) self.assertIn(b'WARNING:__main__:WARNING: unused networks: shared-network\n', err) container_id, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", networks_no_services_compose(), "ps", "--format", '{{.ID}}', ]) self.assertEqual(container_id, b"") finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", networks_no_services_compose(), "down", "-t", "0", ]) def test_empty_services_no_stacktrace(self) -> None: try: _, err = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", empty_services_compose(), "up", "-d", ]) self.assertNotIn(b"\nTraceback (most recent call last):\n", err) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", empty_services_compose(), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/parse-error/000077500000000000000000000000001521011662300223625ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/parse-error/docker-compose-error.yml000066400000000000000000000000711521011662300271440ustar00rootroot00000000000000version: "3" services:foo web1: image: busybox podman-compose-1.6.0/tests/integration/parse-error/docker-compose.yml000066400000000000000000000000661521011662300260210ustar00rootroot00000000000000version: "3" services: web1: image: busybox podman-compose-1.6.0/tests/integration/parse-error/test_podman_compose_parse_error.py000066400000000000000000000026711521011662300314070ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def bad_compose_yaml_path() -> str: base_path = os.path.join(test_path(), "parse-error") return os.path.join(base_path, "docker-compose-error.yml") def good_compose_yaml_path() -> str: base_path = os.path.join(test_path(), "parse-error") return os.path.join(base_path, "docker-compose.yml") class TestComposeBuildParseError(unittest.TestCase, RunSubprocessMixin): def test_no_error(self) -> None: _, err = self.run_subprocess_assert_returncode( [podman_compose_path(), "-f", good_compose_yaml_path(), "config"], 0 ) self.assertEqual(b"", err) def test_simple_parse_error(self) -> None: _, err = self.run_subprocess_assert_returncode( [podman_compose_path(), "-f", bad_compose_yaml_path(), "config"], 1 ) self.assertIn(b"could not find expected ':'", err) self.assertNotIn(b"\nTraceback (most recent call last):\n", err) def test_verbose_parse_error_contains_stack_trace(self) -> None: _, err = self.run_subprocess_assert_returncode( [podman_compose_path(), "--verbose", "-f", bad_compose_yaml_path(), "config"], 1 ) self.assertIn(b"\nTraceback (most recent call last):\n", err) podman-compose-1.6.0/tests/integration/pid/000077500000000000000000000000001521011662300206755ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/pid/README.txt000066400000000000000000000011061521011662300223710ustar00rootroot00000000000000This is the output of command "up -d" with corresponding "pid/docker-compose.yml" file: WARN[0000] freezer not supported: openat2 /sys/fs/cgroup/machine.slice/libpod-SHA.scope/cgroup.freeze: no such file or directory WARN[0000] lstat /sys/fs/cgroup/machine.slice/libpod-SHA.scope: no such file or directory pid_serv_1 Command output corresponds to a closed (but not fixed) issue in "containers/podman": https://github.com/containers/podman/issues/11784 The command was tested on: podman-compose version 1.3.0 podman version 4.3.1 Operating System: Debian GNU/Linux 12 (bookworm) podman-compose-1.6.0/tests/integration/pid/docker-compose.yml000066400000000000000000000001341521011662300243300ustar00rootroot00000000000000version: "3" services: serv: image: busybox pid: host command: sh -c "ps all" podman-compose-1.6.0/tests/integration/pod_args/000077500000000000000000000000001521011662300217175ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/pod_args/__init__.py000066400000000000000000000000011521011662300240170ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/pod_args/custom_pod_args_empty/000077500000000000000000000000001521011662300263255ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/pod_args/custom_pod_args_empty/docker-compose.yml000066400000000000000000000002621521011662300317620ustar00rootroot00000000000000version: "3" services: cont: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] x-podman: pod_args: [] podman-compose-1.6.0/tests/integration/pod_args/custom_pod_args_set/000077500000000000000000000000001521011662300257625ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/pod_args/custom_pod_args_set/docker-compose.yml000066400000000000000000000003311521011662300314140ustar00rootroot00000000000000version: "3" services: cont: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] x-podman: pod_args: ["--infra=false", "--share=", "--cpus=2"] podman-compose-1.6.0/tests/integration/pod_args/custom_pod_args_unset/000077500000000000000000000000001521011662300263255ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/pod_args/custom_pod_args_unset/docker-compose.yml000066400000000000000000000002271521011662300317630ustar00rootroot00000000000000version: "3" services: cont: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] podman-compose-1.6.0/tests/integration/pod_args/test_podman_compose_pod_args.py000066400000000000000000000144551521011662300302220ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import json import os import unittest from packaging import version from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import get_podman_version def base_path() -> str: """Returns the base path for the project""" return os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) def test_path() -> str: """Returns the path to the tests directory""" return os.path.join(base_path(), "tests/integration") def podman_compose_path() -> str: """Returns the path to the podman compose script""" return os.path.join(base_path(), "podman_compose.py") class TestPodmanComposePodArgs(unittest.TestCase, RunSubprocessMixin): def load_pod_info(self, pod_name: str) -> dict: output, _ = self.run_subprocess_assert_returncode([ "podman", "pod", "inspect", pod_name, ]) pod_info = json.loads(output.decode('utf-8')) # Podman 5.0 changed pod inspect to always output a list. # Check type to support both old and new version. if isinstance(pod_info, list): return pod_info[0] return pod_info def run_pod_args_test(self, config: str, args: list, expected: list) -> None: """ Helper to run podman up with a docker-compose.yml config, additional (--pod-args) arguments and compare the CreateCommand of the resulting pod with an expected value """ pod_name = "pod_" + config command_up = ( [ "python3", os.path.join(base_path(), "podman_compose.py"), "-f", os.path.join( base_path(), "tests", "integration", "pod_args", config, "docker-compose.yml", ), ] + args + [ "up", "--no-start", ] ) try: self.run_subprocess_assert_returncode(command_up) pod_info = self.load_pod_info(pod_name) self.assertEqual( pod_info['CreateCommand'], ["podman", "pod", "create", "--name=" + pod_name] + expected, ) finally: command_rm_pod = ["podman", "pod", "rm", pod_name] self.run_subprocess_assert_returncode(command_rm_pod) def test_x_podman_pod_args_unset_unset(self) -> None: """ Test that podman-compose will use the default pod-args when unset in both docker-compose.yml and command line """ self.run_pod_args_test( "custom_pod_args_unset", [], ["--infra=false", "--share="], ) @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") def test_x_podman_pod_args_unset_empty(self) -> None: """ Test that podman-compose will use empty pod-args when unset in docker-compose.yml and passing an empty value on the command line """ self.run_pod_args_test( "custom_pod_args_unset", ["--pod-args="], [], ) @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") def test_x_podman_pod_args_unset_set(self) -> None: """ Test that podman-compose will use the passed pod-args when unset in docker-compose.yml and passing a non-empty value on the command line """ self.run_pod_args_test( "custom_pod_args_unset", ["--pod-args=--infra=false --share= --cpus=1"], ["--infra=false", "--share=", "--cpus=1"], ) @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") def test_x_podman_pod_args_empty_unset(self) -> None: """ Test that podman-compose will use empty pod-args when set to an empty value in docker-compose.yml and unset on the command line """ self.run_pod_args_test( "custom_pod_args_empty", [], [], ) @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") def test_x_podman_pod_args_empty_empty(self) -> None: """ Test that podman-compose will use empty pod-args when set to an empty value in both docker-compose.yml and command line """ self.run_pod_args_test( "custom_pod_args_empty", ["--pod-args="], [], ) def test_x_podman_pod_args_empty_set(self) -> None: """ Test that podman-compose will use the passed pod-args when set to an empty value in docker-compose.yml and passing a non-empty value on the command line """ self.run_pod_args_test( "custom_pod_args_empty", ["--pod-args=--infra=false --share= --cpus=1"], ["--infra=false", "--share=", "--cpus=1"], ) def test_x_podman_pod_args_set_unset(self) -> None: """ Test that podman-compose will use the set pod-args when set to a non-empty value in docker-compose.yml and unset on the command line """ self.run_pod_args_test( "custom_pod_args_set", [], ["--infra=false", "--share=", "--cpus=2"], ) @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") def test_x_podman_pod_args_set_empty(self) -> None: """ Test that podman-compose will use empty pod-args when set to a non-empty value in docker-compose.yml and passing an empty value on the command line """ self.run_pod_args_test( "custom_pod_args_set", ["--pod-args="], [], ) def test_x_podman_pod_args_set_set(self) -> None: """ Test that podman-compose will use the passed pod-args when set to a non-empty value in both docker-compose.yml and command line """ self.run_pod_args_test( "custom_pod_args_set", ["--pod-args=--infra=false --share= --cpus=1"], ["--infra=false", "--share=", "--cpus=1"], ) podman-compose-1.6.0/tests/integration/ports/000077500000000000000000000000001521011662300212705ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/ports/__init__.py000066400000000000000000000000011521011662300233700ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/ports/docker-compose.yml000066400000000000000000000012051521011662300247230ustar00rootroot00000000000000version: "3" services: web1: image: nopush/podman-compose-test hostname: web1 command: ["dumb-init", "sleep", "infinity"] ports: - 8000:8000 - 8001 web2: image: nopush/podman-compose-test hostname: web2 command: ["dumb-init", "sleep", "infinity"] ports: - 8002:8002 - target: 8003 host_ip: 127.0.0.1 published: 8003 protocol: udp - target: 8004 host_ip: 127.0.0.1 published: 8004 protocol: tcp - target: 8005 published: 8005 - target: 8006 protocol: udp - target: 8007 host_ip: 127.0.0.1 podman-compose-1.6.0/tests/integration/ports/test_podman_compose_ports.py000066400000000000000000000050171521011662300271360ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 """ test_podman_compose_ports.py Tests the podman compose port command used to show the host port. """ import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path class TestPodmanCompose(unittest.TestCase, RunSubprocessMixin): def test_up_with_ports(self) -> None: up_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "ports", "docker-compose.yml"), "up", "-d", "--force-recreate", ] down_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "ports", "docker-compose.yml"), "down", ] port_cmd = [ podman_compose_path(), "-f", os.path.join(test_path(), "ports", "docker-compose.yml"), "port", ] udp_arg = ["--protocol", "udp"] tcp_arg = ["--protocol", "tcp"] try: self.run_subprocess_assert_returncode(up_cmd) port = self.run_subprocess_assert_returncode(port_cmd + ["web1", "8000"]) self.assertEqual(port[0].decode().strip(), "8000") port = self.run_subprocess_assert_returncode(port_cmd + ["web1", "8001"]) self.assertNotEqual(port[0].decode().strip(), "8001") port = self.run_subprocess_assert_returncode(port_cmd + ["web2", "8002"]) self.assertEqual(port[0].decode().strip(), "8002") port = self.run_subprocess_assert_returncode(port_cmd + udp_arg + ["web2", "8003"]) self.assertEqual(port[0].decode().strip(), "8003") port = self.run_subprocess_assert_returncode(port_cmd + ["web2", "8004"]) self.assertEqual(port[0].decode().strip(), "8004") port = self.run_subprocess_assert_returncode(port_cmd + tcp_arg + ["web2", "8005"]) self.assertEqual(port[0].decode().strip(), "8005") port = self.run_subprocess_assert_returncode(port_cmd + udp_arg + ["web2", "8006"]) self.assertNotEqual(port[0].decode().strip(), "8006") port = self.run_subprocess_assert_returncode(port_cmd + ["web2", "8007"]) self.assertNotEqual(port[0].decode().strip(), "8007") finally: self.run_subprocess_assert_returncode(down_cmd) podman-compose-1.6.0/tests/integration/profile/000077500000000000000000000000001521011662300215615ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/profile/__init__.py000066400000000000000000000000011521011662300236610ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/profile/docker-compose.yml000066400000000000000000000012261521011662300252170ustar00rootroot00000000000000version: "3" services: default-service: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"] tmpfs: - /run - /tmp service-1: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"] tmpfs: - /run - /tmp profiles: - profile-1 service-2: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"] tmpfs: - /run - /tmp profiles: - profile-2 podman-compose-1.6.0/tests/integration/profile/test_podman_compose_config.py000066400000000000000000000053541521011662300275310ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 """ test_podman_compose_config.py Tests the podman-compose config command which is used to return defined compose services. """ # pylint: disable=redefined-outer-name import os import unittest from parameterized import parameterized from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def profile_compose_file() -> str: """ "Returns the path to the `profile` compose file used for this test module""" return os.path.join(test_path(), "profile", "docker-compose.yml") class TestComposeConfig(unittest.TestCase, RunSubprocessMixin): def test_config_no_profiles(self) -> None: """ Tests podman-compose config command without profile enablement. """ config_cmd = [ "coverage", "run", podman_compose_path(), "-f", profile_compose_file(), "config", ] out, _ = self.run_subprocess_assert_returncode(config_cmd) string_output = out.decode("utf-8") self.assertIn("default-service", string_output) self.assertNotIn("service-1", string_output) self.assertNotIn("service-2", string_output) @parameterized.expand( [ ( ["--profile", "profile-1", "config"], {"default-service": True, "service-1": True, "service-2": False}, ), ( ["--profile", "profile-2", "config"], {"default-service": True, "service-1": False, "service-2": True}, ), ( ["--profile", "profile-1", "--profile", "profile-2", "config"], {"default-service": True, "service-1": True, "service-2": True}, ), ], ) def test_config_profiles(self, profiles: list, expected_services: dict) -> None: """ Tests podman-compose :param profiles: The enabled profiles for the parameterized test. :param expected_services: Dictionary used to model the expected "enabled" services in the profile. Key = service name, Value = True if the service is enabled, otherwise False. """ config_cmd = ["coverage", "run", podman_compose_path(), "-f", profile_compose_file()] config_cmd.extend(profiles) out, _ = self.run_subprocess_assert_returncode(config_cmd) actual_output = out.decode("utf-8") self.assertEqual(len(expected_services), 3) actual_services = {} for service, _ in expected_services.items(): actual_services[service] = service in actual_output self.assertEqual(expected_services, actual_services) podman-compose-1.6.0/tests/integration/profile/test_podman_compose_up_down.py000066400000000000000000000111241521011662300277270ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 """ test_podman_compose_up_down.py Tests the podman compose up and down commands used to create and remove services. """ # pylint: disable=redefined-outer-name import os import unittest from typing import List from parameterized import parameterized from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def profile_compose_file() -> str: """ "Returns the path to the `profile` compose file used for this test module""" return os.path.join(test_path(), "profile", "docker-compose.yml") class TestUpDown(unittest.TestCase, RunSubprocessMixin): def tearDown(self) -> None: """ Ensures that the services within the "profile compose file" are removed between each test case. """ # run the test case down_cmd = [ "coverage", "run", podman_compose_path(), "--profile", "profile-1", "--profile", "profile-2", "-f", profile_compose_file(), "down", ] self.run_subprocess(down_cmd) @parameterized.expand( [ ( ["--profile", "profile-1", "up", "-d"], {"default-service": True, "service-1": True, "service-2": False}, ), ( ["--profile", "profile-2", "up", "-d"], {"default-service": True, "service-1": False, "service-2": True}, ), ( ["--profile", "profile-1", "--profile", "profile-2", "up", "-d"], {"default-service": True, "service-1": True, "service-2": True}, ), ], ) def test_up(self, profiles: List[str], expected_services: dict) -> None: up_cmd = [ "coverage", "run", podman_compose_path(), "-f", profile_compose_file(), ] up_cmd.extend(profiles) self.run_subprocess_assert_returncode(up_cmd) check_cmd = [ "podman", "container", "ps", "--format", '"{{.Names}}"', ] out, _ = self.run_subprocess_assert_returncode(check_cmd) self.assertEqual(len(expected_services), 3) actual_output = out.decode("utf-8") actual_services = {} for service, _ in expected_services.items(): actual_services[service] = service in actual_output self.assertEqual(expected_services, actual_services) @parameterized.expand( [ ( ["--profile", "profile-1"], "profile-2", {"default-service": True, "service-1": True, "service-2": True}, ), ( [], "profile-1,profile-2", {"default-service": True, "service-1": True, "service-2": True}, ), ( [], "profile-1, profile-2", {"default-service": True, "service-1": True, "service-2": True}, ), ( [], "", {"default-service": True, "service-1": False, "service-2": False}, ), ( [], ",", {"default-service": True, "service-1": False, "service-2": False}, ), ], ) def test_up_with_compose_profiles_env( self, profiles: List[str], compose_profiles: str, expected_services: dict ) -> None: """ Tests the `up` command when the `COMPOSE_PROFILES` environment variable is set. """ up_cmd = [ "coverage", "run", podman_compose_path(), "-f", profile_compose_file(), ] up_cmd.extend(profiles) up_cmd.extend(["up", "-d"]) env = os.environ.copy() env["COMPOSE_PROFILES"] = compose_profiles self.run_subprocess_assert_returncode(up_cmd, env=env) check_cmd = [ "podman", "container", "ps", "--format", '"{{.Names}}"', ] out, _ = self.run_subprocess_assert_returncode(check_cmd) self.assertEqual(len(expected_services), 3) actual_output = out.decode("utf-8") actual_services = {} for service, _ in expected_services.items(): actual_services[service] = service in actual_output self.assertEqual(expected_services, actual_services) podman-compose-1.6.0/tests/integration/seccomp/000077500000000000000000000000001521011662300215525ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/seccomp/__init__.py000066400000000000000000000000001521011662300236510ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/seccomp/default.json000066400000000000000000000002031521011662300240640ustar00rootroot00000000000000{ "defaultAction": "SCMP_ACT_ALLOW", "syscalls": [ { "name": "mkdir", "action": "SCMP_ACT_ERRNO", "args": [] } ] } podman-compose-1.6.0/tests/integration/seccomp/docker-compose.yml000066400000000000000000000003751521011662300252140ustar00rootroot00000000000000version: "3" services: web1: image: busybox command: sh -c "mkdir /tmp_test" security_opt: # Currently only absolute path works, like this: # - seccomp:/.../tests/integration/seccomp/default.json - seccomp:./default.json podman-compose-1.6.0/tests/integration/seccomp/test_podman_compose_seccomp.py000066400000000000000000000026021521011662300276770ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "seccomp"), "docker-compose.yml") class TestComposeSeccomp(unittest.TestCase, RunSubprocessMixin): @unittest.skip( "Skip till security_opt seccomp from 'docker-compose.yml' will be able to accept a " "relative path of 'default.json' file. Now test works as expected but only with the " "absolute path." ) # test if seccomp uses custom seccomp profile file 'default.json' where command mkdir is not # allowed def test_seccomp(self) -> None: try: output, _, return_code = self.run_subprocess( [podman_compose_path(), "-f", compose_yaml_path(), "run", "--rm", "web1"], ) self.assertEqual(return_code, 1) self.assertIn( b"mkdir: can't create directory '/tmp_test': Operation not permitted", output ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/secrets/000077500000000000000000000000001521011662300215715ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/secrets/__init__.py000066400000000000000000000000001521011662300236700ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/secrets/docker-compose.yaml000066400000000000000000000025631521011662300253750ustar00rootroot00000000000000version: "3.8" services: test: image: busybox command: - /tmp/print_secrets.sh tmpfs: - /run - /tmp volumes: - ./print_secrets.sh:/tmp/print_secrets.sh:z secrets: - podman_compose_test_secret # Custom name reference for mounted external secret is not supported #- podman_compose_test_secret_2 - source: podman_compose_test_secret_3 # warning about unsupported "target" field target: podman_compose_test_secret_3 uid: '103' gid: '103' mode: 400 - file_secret - source: file_secret target: custom_name - source: file_secret target: /etc/custom_location - source: file_secret # warning about unsupported "uid", "gid", "mode" fields target: unused_params_warning uid: '103' gid: '103' mode: 400 - source: podman_compose_test_secret target: ENV_SECRET type: env secrets: podman_compose_test_secret: external: true # Custom name reference for mounted external secret is not supported #podman_compose_test_secret_2: #external: true #name: podman_compose_test_secret_custom_name podman_compose_test_secret_3: external: true name: podman_compose_test_secret_3 file_secret: file: ./my_secret podman-compose-1.6.0/tests/integration/secrets/my_secret000066400000000000000000000000361521011662300235050ustar00rootroot00000000000000important-secret-is-important podman-compose-1.6.0/tests/integration/secrets/print_secrets.sh000077500000000000000000000001201521011662300250050ustar00rootroot00000000000000#!/bin/sh grep . /run/secrets/* grep . /etc/custom_location echo "$ENV_SECRET" podman-compose-1.6.0/tests/integration/secrets/test_podman_compose_secrets.py000066400000000000000000000055471521011662300277500ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from subprocess import PIPE from subprocess import Popen from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path() -> str: return os.path.join(os.path.join(test_path(), "secrets"), "docker-compose.yaml") class TestComposeNoSecrets(unittest.TestCase, RunSubprocessMixin): created_secrets = [ "podman_compose_test_secret", "podman_compose_test_secret_2", "podman_compose_test_secret_3", "podman_compose_test_secret_custom_name", ] def setUp(self) -> None: for secret in self.created_secrets: p = Popen(["podman", "secret", "create", secret, "-"], stdin=PIPE) p.communicate(secret.encode('utf-8')) def tearDown(self) -> None: for secret in self.created_secrets: self.run_subprocess_assert_returncode([ "podman", "secret", "rm", f"{secret}", ]) # test if secrets are saved and available in respective files of a container def test_secrets(self) -> None: try: _, error, _ = self.run_subprocess( [ podman_compose_path(), "-f", compose_yaml_path(), "up", "test", ], ) self.assertNotIn( b'That is un-supported and a no-op and is ignored.', error, ) self.assertIn( b'WARNING: Service test uses secret unused_params_warning with uid, ' + b'gid, or mode. These fields are not supported by this implementation ' + b'of the Compose file', error, ) output, _ = self.run_subprocess_assert_returncode(["podman", "logs", "secrets_test_1"]) expected_output = ( b'/run/secrets/custom_name:important-secret-is-important\n' + b'/run/secrets/file_secret:important-secret-is-important\n' + b'/run/secrets/podman_compose_test_secret:podman_compose_test_secret\n' + b'/run/secrets/podman_compose_test_secret_3:podman_compose_test_secret_3\n' + b'/run/secrets/unused_params_warning:important-secret-is-important\n' + b'important-secret-is-important\n' + b'podman_compose_test_secret\n' ) self.assertEqual(expected_output, output) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/selinux/000077500000000000000000000000001521011662300216105ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/selinux/__init__.py000066400000000000000000000000001521011662300237070ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/selinux/docker-compose.yml000066400000000000000000000006401521011662300252450ustar00rootroot00000000000000version: "3" services: container1: image: busybox command: ["busybox", "sleep", "infinity"] volumes: - type: bind source: ./host_test_text.txt target: /test_text.txt bind: selinux: z container2: image: busybox command: ["busybox", "sleep", "infinity"] volumes: - type: bind source: ./host_test_text.txt target: /test_text.txt podman-compose-1.6.0/tests/integration/selinux/host_test_text.txt000066400000000000000000000001011521011662300254210ustar00rootroot00000000000000# There must be a source file in the host for volumes type: bind podman-compose-1.6.0/tests/integration/selinux/test_podman_compose_selinux.py000066400000000000000000000044531521011662300300010ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import json import os import subprocess import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path class TestPodmanCompose(unittest.TestCase, RunSubprocessMixin): def test_selinux(self) -> None: # test if when using volumes type:bind with selinux:z option, container ackquires a # respective host:source:z mapping in CreateCommand list compose_path = os.path.join(test_path(), "selinux", "docker-compose.yml") try: # change working directory to where docker-compose.yml file is so that containers can # directly access host source file for mounting from that working directory subprocess.run( [ podman_compose_path(), "-f", compose_path, "up", "-d", "container1", "container2", ], cwd=os.path.join(test_path(), 'selinux'), ) out, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", "selinux_container1_1", ]) inspect_out = json.loads(out) create_command_list = inspect_out[0].get("Config", []).get("CreateCommand", []) host_path = os.path.join(test_path(), "selinux", "host_test_text.txt") self.assertIn(f'{host_path}:/test_text.txt:z', create_command_list) out, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", "selinux_container2_1", ]) inspect_out = json.loads(out) create_command_list = inspect_out[0].get("Config", []).get("CreateCommand", []) host_path = os.path.join(test_path(), "selinux", "host_test_text.txt") self.assertIn(f'{host_path}:/test_text.txt', create_command_list) finally: out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/service_scale/000077500000000000000000000000001521011662300227305ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/service_scale/__init__.py000066400000000000000000000000001521011662300250270ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/service_scale/scaleup_cli/000077500000000000000000000000001521011662300252135ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/service_scale/scaleup_cli/docker-compose.yml000066400000000000000000000001461521011662300306510ustar00rootroot00000000000000name: podman-compose services: service1: image: docker.io/library/busybox:latest tty: true podman-compose-1.6.0/tests/integration/service_scale/scaleup_deploy_replicas_parameter/000077500000000000000000000000001521011662300316625ustar00rootroot00000000000000docker-compose.yml000066400000000000000000000002331521011662300352360ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/service_scale/scaleup_deploy_replicas_parametername: podman-compose services: service1: image: docker.io/library/busybox:latest tty: true deploy: mode: replicated replicas: 3 podman-compose-1.6.0/tests/integration/service_scale/scaleup_scale_parameter/000077500000000000000000000000001521011662300275735ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/service_scale/scaleup_scale_parameter/docker-compose.yml000066400000000000000000000001631521011662300332300ustar00rootroot00000000000000name: podman-compose services: service1: image: docker.io/library/busybox:latest tty: true scale: 2 podman-compose-1.6.0/tests/integration/service_scale/test_podman_compose_scale.py000066400000000000000000000114501521011662300305140ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from packaging import version from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import get_podman_version from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(test_ref_folder: str) -> str: return os.path.join(test_path(), "service_scale", test_ref_folder, "docker-compose.yml") @unittest.skipIf(get_podman_version() >= version.parse("5.0.0"), "Breaks as of podman-5.4.2.") class TestComposeScale(unittest.TestCase, RunSubprocessMixin): # scale-up using `scale` parameter in docker-compose.yml def test_scaleup_scale_parameter(self) -> None: try: output, _, return_code = self.run_subprocess([ podman_compose_path(), "-f", compose_yaml_path("scaleup_scale_parameter"), "up", "-d", ]) self.assertEqual(return_code, 0) output, _, return_code = self.run_subprocess([ podman_compose_path(), "-f", compose_yaml_path("scaleup_scale_parameter"), "ps", "-q", ]) self.assertEqual(len(output.splitlines()), 2) finally: self.run_subprocess_assert_returncode([ "podman", "rm", "--force", "-t", "0", "podman-compose_service1_1", "podman-compose_service1_2", ]) self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path("scaleup_scale_parameter"), "down", "-t", "0", ]) # scale-up using `deploy => replicas` parameter in docker-compose.yml def test_scaleup_deploy_replicas_parameter(self) -> None: try: output, _, return_code = self.run_subprocess([ podman_compose_path(), "-f", compose_yaml_path('scaleup_deploy_replicas_parameter'), "up", "-d", ]) self.assertEqual(return_code, 0) output, _, return_code = self.run_subprocess([ podman_compose_path(), "-f", compose_yaml_path("scaleup_deploy_replicas_parameter"), "ps", "-q", ]) self.assertEqual(len(output.splitlines()), 3) finally: self.run_subprocess_assert_returncode([ "podman", "rm", "--force", "-t", "0", "podman-compose_service1_1", "podman-compose_service1_2", "podman-compose_service1_3", ]) self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path('scaleup_deploy_replicas_parameter'), "down", "-t", "0", ]) # scale-up using `--scale =` argument in CLI def test_scaleup_cli(self) -> None: try: output, _, return_code = self.run_subprocess([ podman_compose_path(), "-f", compose_yaml_path('scaleup_cli'), "up", "-d", ]) self.assertEqual(return_code, 0) output, _, return_code = self.run_subprocess([ podman_compose_path(), "-f", compose_yaml_path('scaleup_cli'), "up", "-d", "--scale", "service1=4", ]) self.assertEqual(return_code, 0) output, _, return_code = self.run_subprocess([ podman_compose_path(), "-f", compose_yaml_path('scaleup_cli'), "ps", "-q", ]) self.assertEqual(len(output.splitlines()), 4) finally: self.run_subprocess_assert_returncode([ "podman", "rm", "--force", "-t", "0", "podman-compose_service1_1", "podman-compose_service1_2", "podman-compose_service1_3", "podman-compose_service1_4", ]) self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path('scaleup_cli'), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/short/000077500000000000000000000000001521011662300212605ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/short/data/000077500000000000000000000000001521011662300221715ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/short/data/redis/000077500000000000000000000000001521011662300232775ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/short/data/redis/.keep000066400000000000000000000000001521011662300242120ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/short/data/web/000077500000000000000000000000001521011662300227465ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/short/data/web/.keep000066400000000000000000000000001521011662300236610ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/short/docker-compose.yaml000066400000000000000000000022031521011662300250530ustar00rootroot00000000000000version: "3" services: redis: image: redis:alpine command: ["redis-server", "--appendonly yes", "--notify-keyspace-events", "Ex"] volumes: - ./data/redis:/data:z tmpfs: /run1 ports: - "6379" environment: - SECRET_KEY=aabbcc - ENV_IS_SET web: image: busybox command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8000"] working_dir: /var/www/html volumes: - /var/www/html tmpfs: - /run - /tmp web1: image: busybox command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] working_dir: /var/www/html volumes: - ./data/web:/var/www/html:ro,z web2: image: busybox command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8002"] working_dir: /var/www/html volumes: - ~/Downloads/www:/var/www/html:ro,z web3: image: busybox command: ["/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8003"] working_dir: /var/www/html volumes: - /var/www/html:/var/www/html:ro,z podman-compose-1.6.0/tests/integration/test_utils.py000066400000000000000000000170501521011662300226750ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import re import subprocess import time import unittest from datetime import datetime from datetime import timedelta from pathlib import Path from typing import Any from typing import Optional from apscheduler.job import Job from apscheduler.jobstores.base import JobLookupError from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.interval import IntervalTrigger from packaging import version from tzlocal import get_localzone _podman_version = None def get_podman_version() -> version.Version: """ Return the *packaging.version.Version* object for the podman binary found in PATH (cached after the first call). Raise RuntimeError if podman is missing or gives unexpected output. """ global _podman_version if _podman_version is None: try: out = subprocess.check_output( ["podman", "--version"], text=True, stderr=subprocess.STDOUT ) # ‘podman version 4.5.0’ → take last token raw = out.strip().split()[-1] _podman_version = version.parse(raw) except Exception as exc: raise RuntimeError("cannot determine podman version") from exc return _podman_version def base_path() -> Path: """Returns the base path for the project""" return Path(__file__).parent.parent.parent def test_path() -> str: """Returns the path to the tests directory""" return os.path.join(base_path(), "tests/integration") def podman_compose_path() -> str: """Returns the path to the podman compose script""" return os.path.join(base_path(), "podman_compose.py") def is_systemd_available() -> bool: try: with open("/proc/1/comm", encoding="utf-8") as fh: return fh.read().strip() == "systemd" except FileNotFoundError: return False class RunSubprocessMixin: def is_debug_enabled(self) -> bool: return "TESTS_DEBUG" in os.environ def run_subprocess( self, args: list[str], env: dict[str, str] = {}, timeout: Optional[float] = None, ) -> tuple[bytes, bytes, int]: begin = time.time() if self.is_debug_enabled(): print("TEST_CALL", args) proc = subprocess.Popen( args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=os.environ | env, ) out, err = proc.communicate(timeout=timeout) if self.is_debug_enabled(): print("TEST_CALL completed", time.time() - begin) print("STDOUT:", out.decode('utf-8')) print("STDERR:", err.decode('utf-8')) return out, err, proc.returncode def run_subprocess_assert_returncode( self, args: list[str], expected_returncode: int = 0, env: dict[str, str] = {}, timeout: Optional[float] = None, ) -> tuple[bytes, bytes]: out, err, returncode = self.run_subprocess(args, env=env, timeout=timeout) decoded_out = out.decode('utf-8') decoded_err = err.decode('utf-8') self.assertEqual( # type: ignore[attr-defined] returncode, expected_returncode, f"Invalid return code of process {returncode} != {expected_returncode}\n" f"stdout: {decoded_out}\nstderr: {decoded_err}\n", ) return out, err class PodmanAwareRunSubprocessMixin(RunSubprocessMixin): def retrieve_podman_version(self) -> tuple[int, int, int]: out, _ = self.run_subprocess_assert_returncode(["podman", "--version"]) matcher = re.match(r"\D*(\d+)\.(\d+)\.(\d+)", out.decode('utf-8')) if matcher: major = int(matcher.group(1)) minor = int(matcher.group(2)) patch = int(matcher.group(3)) return (major, minor, patch) raise RuntimeError("Unable to retrieve podman version") # WORKAROUND for https://github.com/containers/podman/issues/28192 # the healthchecks of Podman rely on systemd timers # in the test environment (GH actions -> container -> Podman) no systemd is available # therefore, this class ensures that a periodical healthcheck is run by calling # podman healthcheck run # with the help of APScheduler # # if systemd is available, this class does nothing class EnsureHealthcheckRun: def __init__( self, runner: RunSubprocessMixin, test_case: unittest.TestCase, container_name: str ) -> None: self.__runner: RunSubprocessMixin = runner self.__scheduler: Optional[BackgroundScheduler] = None self.__healthcheck_job: Optional[Job] = None self.__container_name: str = container_name # check if systemd is not available if not is_systemd_available(): # initialize APScheduler self.__scheduler = BackgroundScheduler() self.__scheduler.start() # add a clean up to the test case (in case the test fails) test_case.addCleanup(self._cleanup_scheduler) def _cleanup_scheduler(self) -> None: # stop and remove the healtcheck job if self.__scheduler is not None: try: if self.__healthcheck_job is not None: self.__scheduler.remove_job(self.__healthcheck_job.id) else: self.__scheduler.remove_all_jobs() except JobLookupError: # failover self.__scheduler.remove_all_jobs() # shutdown the scheduler self.__scheduler.shutdown(wait=True) # prevent this method from being executed twice: # after exiting the context manager and during clean up of the test case self.__scheduler = None def __enter__(self) -> None: # start the healthcheck job if self.__scheduler is not None: self.__healthcheck_job = self.__scheduler.add_job( func=self.__runner.run_subprocess, # run health checking only for the provided container kwargs={"args": ["podman", "healthcheck", "run", f"{self.__container_name}"]}, trigger=IntervalTrigger( # trigger the healthcheck every 3 seconds (like defined in docker-compose.yml) seconds=3, # run first healthcheck after 3 seconds (initial interval) start_date=datetime.now(get_localzone()) + timedelta(seconds=3), # stop after 21 seconds = 3s (interval) * 6 (retries) + 3s (initial interval) end_date=datetime.now(get_localzone()) + timedelta(seconds=21), ), ) def __exit__(self, *_: Any) -> None: # clean up after exiting the context manager self._cleanup_scheduler() class ExecutionTime: def __init__( self, min_execution_time: Optional[timedelta] = None, max_execution_time: Optional[timedelta] = None, ) -> None: self.__min_execution_time = min_execution_time self.__max_execution_time = max_execution_time self.__start_time: Optional[datetime] = None def __enter__(self) -> None: self.__start_time = datetime.now() def __exit__(self, *_: Any) -> None: # should normally not happen if self.__start_time is None: return execution_time = datetime.now() - self.__start_time if self.__min_execution_time: assert execution_time >= self.__min_execution_time if self.__max_execution_time: assert execution_time <= self.__max_execution_time podman-compose-1.6.0/tests/integration/uidmaps/000077500000000000000000000000001521011662300215635ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/uidmaps/__init__.py000066400000000000000000000000001521011662300236620ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/uidmaps/docker-compose.yml000066400000000000000000000003631521011662300252220ustar00rootroot00000000000000version: "3.7" services: touch: image: busybox command: 'touch /mnt/test' volumes: - ./:/mnt user: 999:999 x-podman.uidmaps: - "0:1:1" - "999:0:1" x-podman.gidmaps: - "0:1:1" - "999:0:1" podman-compose-1.6.0/tests/integration/uidmaps/test_podman_compose_uidmaps.py000066400000000000000000000024331521011662300277230ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import json import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path class TestPodmanCompose(unittest.TestCase, RunSubprocessMixin): def test_uidmaps(self) -> None: compose_path = os.path.join(test_path(), "uidmaps", "docker-compose.yml") try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "up", "-d", ]) out, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", "uidmaps_touch_1", ]) inspect_out = json.loads(out) host_config_map = inspect_out[0].get("HostConfig", {}).get("IDMappings", {}) self.assertEqual(['0:1:1', '999:0:1'], host_config_map['UidMap']) self.assertEqual(['0:1:1', '999:0:1'], host_config_map['GidMap']) finally: out, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "down", ]) podman-compose-1.6.0/tests/integration/ulimit/000077500000000000000000000000001521011662300214245ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/ulimit/Dockerfile000066400000000000000000000000561521011662300234170ustar00rootroot00000000000000FROM busybox COPY ./ulimit.sh /bin/ulimit.sh podman-compose-1.6.0/tests/integration/ulimit/__init__.py000066400000000000000000000000011521011662300235240ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/ulimit/docker-compose.yaml000066400000000000000000000011311521011662300252160ustar00rootroot00000000000000version: "3" services: ulimit1: image: ulimit_test command: ["ulimit.sh" ] ulimits: nofile=1001 build: context: ./ dockerfile: Dockerfile ulimit2: image: ulimit_test command: ["ulimit.sh" ] ulimits: - nproc=1002:2002 - nofile=1002 build: context: ./ dockerfile: Dockerfile ulimit3: image: ulimit_test command: [ "ulimit.sh" ] ulimits: nofile: 1003 nproc: soft: 1003 hard: 2003 build: context: ./ dockerfile: Dockerfile podman-compose-1.6.0/tests/integration/ulimit/test_podman_compose_ulimit.py000066400000000000000000000042751521011662300274330ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path class TestUlimit(unittest.TestCase, RunSubprocessMixin): def test_ulimit(self) -> None: compose_path = os.path.join(test_path(), "ulimit/docker-compose.yaml") try: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "up", "-d", ]) out, _ = self.run_subprocess_assert_returncode([ "podman", "logs", "ulimit_ulimit1_1", ]) split_output = out.strip(b"\n").split(b"\n") # trow away system specific default ulimit values output_part = [ el for el in split_output if not el.startswith(b"soft process") and not el.startswith(b"hard process") ] self.assertEqual( output_part, [ b"soft nofile limit 1001", b"hard nofile limit 1001", ], ) out, _ = self.run_subprocess_assert_returncode([ "podman", "logs", "ulimit_ulimit2_1", ]) self.assertEqual( out, b"soft process limit 1002\nhard process limit 2002\nsoft nofile limit 1002\n" b"hard nofile limit 1002\n", ) out, _ = self.run_subprocess_assert_returncode([ "podman", "logs", "ulimit_ulimit3_1", ]) self.assertEqual( out, b"soft process limit 1003\nhard process limit 2003\nsoft nofile limit 1003\n" b"hard nofile limit 1003\n", ) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_path, "down", ]) podman-compose-1.6.0/tests/integration/ulimit/ulimit.sh000077500000000000000000000002651521011662300232710ustar00rootroot00000000000000#!/bin/sh echo -n "soft process limit " ulimit -S -u echo -n "hard process limit " ulimit -H -u echo -n "soft nofile limit " ulimit -S -n echo -n "hard nofile limit " ulimit -H -n podman-compose-1.6.0/tests/integration/ulimit_build/000077500000000000000000000000001521011662300226035ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/ulimit_build/Dockerfile000066400000000000000000000001021521011662300245660ustar00rootroot00000000000000FROM busybox COPY ./ulimit.sh /bin/ulimit.sh RUN /bin/ulimit.sh podman-compose-1.6.0/tests/integration/ulimit_build/__init__.py000066400000000000000000000000011521011662300247030ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/ulimit_build/docker-compose.yaml000066400000000000000000000007641521011662300264100ustar00rootroot00000000000000version: "3" services: ulimit1: image: ulimit_build_test build: context: ./ dockerfile: Dockerfile ulimits: nofile=1001 ulimit2: image: ulimit_build_test build: context: ./ dockerfile: Dockerfile ulimits: - nproc=1002:2002 - nofile=1002 ulimit3: image: ulimit_build_test build: context: ./ dockerfile: Dockerfile ulimits: nofile: 1003 nproc: soft: 1003 hard: 2003 podman-compose-1.6.0/tests/integration/ulimit_build/test_podman_compose_build_ulimits.py000066400000000000000000000060541521011662300321510ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 """Test how ulimits are applied in podman-compose build.""" import os import subprocess import unittest from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(): """ "Returns the path to the compose file used for this test module""" return os.path.join(test_path(), "ulimit_build") class TestComposeBuildUlimits(unittest.TestCase): def test_build_ulimits_ulimit1(self): """podman build should receive and apply limits when building service ulimit1""" cmd = ( "coverage", "run", podman_compose_path(), "--verbose", "-f", os.path.join(compose_yaml_path(), "docker-compose.yaml"), "build", "--no-cache", "ulimit1", ) p = subprocess.run( cmd, stdout=subprocess.PIPE, check=False, stderr=subprocess.STDOUT, text=True ) self.assertEqual(p.returncode, 0) self.assertIn("--ulimit nofile=1001", p.stdout) self.assertIn("soft nofile limit: 1001", p.stdout) self.assertIn("hard nofile limit: 1001", p.stdout) def test_build_ulimits_ulimit2(self): """podman build should receive and apply limits when building service ulimit2""" cmd = ( "coverage", "run", podman_compose_path(), "--verbose", "-f", os.path.join(compose_yaml_path(), "docker-compose.yaml"), "build", "--no-cache", "ulimit2", ) p = subprocess.run( cmd, stdout=subprocess.PIPE, check=False, stderr=subprocess.STDOUT, text=True ) self.assertEqual(p.returncode, 0) self.assertIn("--ulimit nofile=1002", p.stdout) self.assertIn("--ulimit nproc=1002:2002", p.stdout) self.assertIn("soft process limit: 1002", p.stdout) self.assertIn("hard process limit: 2002", p.stdout) self.assertIn("soft nofile limit: 1002", p.stdout) self.assertIn("hard nofile limit: 1002", p.stdout) def test_build_ulimits_ulimit3(self): """podman build should receive and apply limits when building service ulimit3""" cmd = ( "coverage", "run", podman_compose_path(), "--verbose", "-f", os.path.join(compose_yaml_path(), "docker-compose.yaml"), "build", "--no-cache", "ulimit3", ) p = subprocess.run( cmd, stdout=subprocess.PIPE, check=False, stderr=subprocess.STDOUT, text=True ) self.assertEqual(p.returncode, 0) self.assertIn("--ulimit nofile=1003", p.stdout) self.assertIn("--ulimit nproc=1003:2003", p.stdout) self.assertIn("soft process limit: 1003", p.stdout) self.assertIn("hard process limit: 2003", p.stdout) self.assertIn("soft nofile limit: 1003", p.stdout) self.assertIn("hard nofile limit: 1003", p.stdout) podman-compose-1.6.0/tests/integration/ulimit_build/ulimit.sh000077500000000000000000000002651521011662300244500ustar00rootroot00000000000000#!/bin/sh echo "soft process limit:" $(ulimit -S -u) echo "hard process limit:" $(ulimit -H -u) echo "soft nofile limit:" $(ulimit -S -n) echo "hard nofile limit:" $(ulimit -H -n) podman-compose-1.6.0/tests/integration/up_down/000077500000000000000000000000001521011662300215745ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/up_down/Dockerfile000066400000000000000000000002431521011662300235650ustar00rootroot00000000000000FROM docker.io/library/debian:bookworm-slim RUN apt-get update \ && apt-get install -y \ dumb-init \ && apt-get autoremove RUN mkdir -p /mnt/test/ podman-compose-1.6.0/tests/integration/up_down/__init__.py000066400000000000000000000000011521011662300236740ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/up_down/docker-compose-orphans.yml000066400000000000000000000003401521011662300266760ustar00rootroot00000000000000version: "3" volumes: web1_vol: web2_vol: services: web1: image: podman-compose-up-down-test build: . hostname: web1 command: ["dumb-init", "sleep", "infinity"] volumes: - web1_vol:/mnt/test/ podman-compose-1.6.0/tests/integration/up_down/docker-compose.yml000066400000000000000000000005711521011662300252340ustar00rootroot00000000000000version: "3" volumes: web1_vol: web2_vol: services: web1: image: podman-compose-up-down-test build: . hostname: web1 command: ["dumb-init", "sleep", "infinity"] volumes: - web1_vol:/mnt/test/ web2: image: docker.io/library/debian:up-down-test hostname: web2 command: ["sleep", "infinity"] volumes: - web2_vol:/mnt/test/ podman-compose-1.6.0/tests/integration/up_down/test_podman_compose_up_down.py000066400000000000000000000431661521011662300277550ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 """ test_podman_compose_up_down.py Tests the podman compose up and down commands used to create and remove services. """ import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path class TestPodmanCompose(unittest.TestCase, RunSubprocessMixin): up_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "up_down", "docker-compose.yml"), "up", "-d", "--force-recreate", ] def setUp(self) -> None: """ Retag the debian image before each test to not mess with the other integration tests when testing the `--rmi` argument """ tag_cmd = [ "podman", "tag", "docker.io/library/debian:bookworm-slim", "docker.io/library/debian:up-down-test", ] self.run_subprocess_assert_returncode(tag_cmd) @classmethod def tearDownClass(cls) -> None: """ Ensures that the images that were created for this tests will be removed """ rmi_cmd = [ "podman", "rmi", "--force", "--ignore", "podman-compose-up-down-test", "docker.io/library/debian:up-down-test", ] cls().run_subprocess_assert_returncode(rmi_cmd) def test_down(self) -> None: down_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "up_down", "docker-compose.yml"), "down", "--timeout", "0", ] try: self.run_subprocess_assert_returncode(self.up_cmd) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web1_1", ]) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web2_1", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web1_vol", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web2_vol", ]) finally: self.run_subprocess_assert_returncode(down_cmd) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web1_1"], 1 ) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web2_1"], 1 ) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web1_vol"]) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web2_vol"]) self.run_subprocess_assert_returncode([ "podman", "image", "exists", "podman-compose-up-down-test", ]) self.run_subprocess_assert_returncode([ "podman", "image", "exists", "docker.io/library/debian:up-down-test", ]) def test_down_with_volumes(self) -> None: down_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "up_down", "docker-compose.yml"), "down", "--volumes", "--timeout", "0", ] try: self.run_subprocess_assert_returncode(self.up_cmd) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web1_1", ]) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web2_1", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web1_vol", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web2_vol", ]) finally: self.run_subprocess_assert_returncode(down_cmd) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web1_1"], 1 ) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web2_1"], 1 ) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web1_vol"], 1) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web2_vol"], 1) self.run_subprocess_assert_returncode([ "podman", "image", "exists", "podman-compose-up-down-test", ]) self.run_subprocess_assert_returncode([ "podman", "image", "exists", "docker.io/library/debian:up-down-test", ]) def test_down_without_orphans(self) -> None: down_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "up_down", "docker-compose-orphans.yml"), "down", "--volumes", "--timeout", "0", ] try: self.run_subprocess_assert_returncode(self.up_cmd) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web1_1", ]) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web2_1", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web1_vol", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web2_vol", ]) finally: self.run_subprocess_assert_returncode(down_cmd) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web1_1"], 1 ) self.run_subprocess_assert_returncode(["podman", "container", "exists", "up_down_web2_1"]) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web1_vol"], 1) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web2_vol"]) self.run_subprocess_assert_returncode([ "podman", "image", "exists", "podman-compose-up-down-test", ]) self.run_subprocess_assert_returncode([ "podman", "image", "exists", "docker.io/library/debian:up-down-test", ]) # Cleanup orphaned container down_all_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "up_down", "docker-compose.yml"), "down", "--volumes", "--timeout", "0", ] self.run_subprocess_assert_returncode(down_all_cmd) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web2_1"], 1 ) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web2_vol"], 1) def test_down_with_orphans(self) -> None: down_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "up_down", "docker-compose-orphans.yml"), "down", "--volumes", "--remove-orphans", "--timeout", "0", ] try: self.run_subprocess_assert_returncode(self.up_cmd) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web1_1", ]) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web2_1", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web1_vol", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web2_vol", ]) finally: self.run_subprocess_assert_returncode(down_cmd) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web1_1"], 1 ) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web2_1"], 1 ) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web1_vol"], 1) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web2_vol"], 1) self.run_subprocess_assert_returncode([ "podman", "image", "exists", "podman-compose-up-down-test", ]) self.run_subprocess_assert_returncode([ "podman", "image", "exists", "docker.io/library/debian:up-down-test", ]) def test_down_with_images_default(self) -> None: down_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "up_down", "docker-compose.yml"), "down", "--rmi", "--timeout", "0", ] try: self.run_subprocess_assert_returncode(self.up_cmd) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web1_1", ]) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web2_1", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web1_vol", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web2_vol", ]) finally: self.run_subprocess_assert_returncode(down_cmd) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web1_1"], 1 ) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web2_1"], 1 ) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web1_vol"]) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web2_vol"]) self.run_subprocess_assert_returncode( ["podman", "image", "exists", "podman-compose-up-down-test"], 1 ) self.run_subprocess_assert_returncode( ["podman", "image", "exists", "docker.io/library/debian:up-down-test"], 1 ) def test_down_with_images_all(self) -> None: down_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "up_down", "docker-compose.yml"), "down", "--rmi", "all", "--timeout", "0", ] try: self.run_subprocess_assert_returncode(self.up_cmd) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web1_1", ]) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web2_1", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web1_vol", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web2_vol", ]) finally: self.run_subprocess_assert_returncode(down_cmd) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web1_1"], 1 ) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web2_1"], 1 ) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web1_vol"]) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web2_vol"]) self.run_subprocess_assert_returncode( ["podman", "image", "exists", "podman-compose-up-down-test"], 1 ) self.run_subprocess_assert_returncode( ["podman", "image", "exists", "docker.io/library/debian:up-down-test"], 1 ) def test_down_with_images_all_and_orphans(self) -> None: down_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "up_down", "docker-compose-orphans.yml"), "down", "--volumes", "--remove-orphans", "--rmi", "all", "--timeout", "0", ] try: self.run_subprocess_assert_returncode(self.up_cmd) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web1_1", ]) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web2_1", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web1_vol", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web2_vol", ]) finally: self.run_subprocess_assert_returncode(down_cmd) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web1_1"], 1 ) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web2_1"], 1 ) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web1_vol"], 1) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web2_vol"], 1) self.run_subprocess_assert_returncode( ["podman", "image", "exists", "podman-compose-up-down-test"], 1 ) self.run_subprocess_assert_returncode( ["podman", "image", "exists", "docker.io/library/debian:up-down-test"], 1 ) def test_down_with_images_local(self) -> None: down_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "up_down", "docker-compose.yml"), "down", "--rmi", "local", "--timeout", "0", ] try: self.run_subprocess_assert_returncode(self.up_cmd) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web1_1", ]) self.run_subprocess_assert_returncode([ "podman", "container", "exists", "up_down_web2_1", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web1_vol", ]) self.run_subprocess_assert_returncode([ "podman", "volume", "exists", "up_down_web2_vol", ]) finally: self.run_subprocess_assert_returncode(down_cmd) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web1_1"], 1 ) self.run_subprocess_assert_returncode( ["podman", "container", "exists", "up_down_web2_1"], 1 ) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web1_vol"]) self.run_subprocess_assert_returncode(["podman", "volume", "exists", "up_down_web2_vol"]) self.run_subprocess_assert_returncode( ["podman", "image", "exists", "podman-compose-up-down-test"], 1 ) self.run_subprocess_assert_returncode([ "podman", "image", "exists", "docker.io/library/debian:up-down-test", ]) podman-compose-1.6.0/tests/integration/vol/000077500000000000000000000000001521011662300207215ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/vol/README.md000066400000000000000000000002231521011662300221750ustar00rootroot00000000000000# To test, create the two external volumes ``` podman volume create my-app-data podman volume create actual-name-of-volume podman-compose up ``` podman-compose-1.6.0/tests/integration/vol/__init__.py000066400000000000000000000000011521011662300230210ustar00rootroot00000000000000 podman-compose-1.6.0/tests/integration/vol/docker-compose.yaml000066400000000000000000000024621521011662300245230ustar00rootroot00000000000000version: "3" services: web: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8000"] working_dir: /var/www/html restart: always volumes: - /var/www/html tmpfs: - /run - /tmp web1: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8001"] restart: unless-stopped working_dir: /var/www/html volumes: - myvol1:/var/www/html:ro,z web2: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8002"] working_dir: /var/www/html volumes: - myvol2:/var/www/html:ro web3: image: nopush/podman-compose-test command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", "/var/www/html", "-p", "8003"] working_dir: /var/www/html volumes: - myvol2:/var/www/html - data:/var/www/html_data - data2:/var/www/html_data2 - data3:/var/www/html_data3 volumes: myvol1: myvol2: labels: mylabel: myval data: name: my-app-data external: true data2: external: name: actual-name-of-volume data3: name: my-app-data3 podman-compose-1.6.0/tests/integration/vol/long_syntax/000077500000000000000000000000001521011662300232665ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/vol/long_syntax/__init__.py000066400000000000000000000000001521011662300253650ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/vol/long_syntax/docker-compose.yml000066400000000000000000000014501521011662300267230ustar00rootroot00000000000000version: "3" services: create_host_path_default_true: # default is to always create image: nopush/podman-compose-test command: ["/bin/busybox", "mkdir", "cont/test_dir/new_dir"] volumes: - type: bind source: ./test_dir target: /cont/test_dir create_host_path_true: image: nopush/podman-compose-test command: ["/bin/busybox", "mkdir", "cont/test_dir/new_dir"] volumes: - type: bind source: ./test_dir target: /cont/test_dir bind: create_host_path: true create_host_path_false: image: nopush/podman-compose-test command: ["/bin/busybox", "mkdir", "cont/test_dir/new_dir"] volumes: - type: bind source: ./test_dir target: /cont/test_dir bind: create_host_path: false podman-compose-1.6.0/tests/integration/vol/long_syntax/test_podman_compose_vol_long_syntax.py000066400000000000000000000060211521011662300332060ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import shutil import unittest from parameterized import parameterized from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(test_ref_folder: str) -> str: return os.path.join(test_path(), "vol", test_ref_folder, "docker-compose.yml") class TestComposeVolLongSyntax(unittest.TestCase, RunSubprocessMixin): @parameterized.expand([ (True, "create_host_path_default_true"), (True, "create_host_path_true"), (True, "create_host_path_false"), (False, "create_host_path_default_true"), (False, "create_host_path_true"), ]) def test_source_host_dir(self, source_dir_exists: bool, service_name: str) -> None: project_dir = os.path.join(test_path(), "vol", "long_syntax") if source_dir_exists: # create host source directory for volume os.mkdir(os.path.join(project_dir, "test_dir")) else: # make sure there is no such directory self.assertFalse(os.path.isdir(os.path.join(project_dir, "test_dir"))) try: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path("long_syntax"), "up", "-d", f"{service_name}", ], 0, ) # command of the service creates a new directory on mounted directory 'test_dir' in # the container. Check if host directory now has the same directory. It represents a # successful mount. # If source host directory does not exist, it is created self.assertTrue(os.path.isdir(os.path.join(project_dir, "test_dir/new_dir"))) finally: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path("long_syntax"), "down", "-t", "0", ]) shutil.rmtree(os.path.join(project_dir, "test_dir")) def test_no_host_source_dir_create_host_path_false(self) -> None: try: _, error = self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path("long_syntax"), "up", "-d", "create_host_path_false", ], 1, ) self.assertIn( b"invalid mount config for type 'bind': bind source path does not exist:", error ) finally: self.run_subprocess([ podman_compose_path(), "-f", compose_yaml_path("long_syntax"), "down", "-t", "0", ]) podman-compose-1.6.0/tests/integration/vol/short_syntax/000077500000000000000000000000001521011662300234665ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/vol/short_syntax/__init__.py000066400000000000000000000000001521011662300255650ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/vol/short_syntax/docker-compose.yml000066400000000000000000000010131521011662300271160ustar00rootroot00000000000000version: "3" services: test1: image: nopush/podman-compose-test #["/bin/busybox", "sh", "-c", "env | grep ZZVAR3"] command: ["/bin/busybox", "mkdir", "cont/test_dir/new_dir"] volumes: - ./test_dir:/cont/test_dir test2: image: nopush/podman-compose-test command: ["/bin/busybox", "mkdir", "cont/test_dir/new_dir"] volumes: - ./test_dir:/cont/test_dir test3: image: nopush/podman-compose-test command: ["echo", "test3"] volumes: - /tmp/symlink-test/link:/mnt podman-compose-1.6.0/tests/integration/vol/short_syntax/test_podman_compose_vol_short_syntax.py000066400000000000000000000064451521011662300336200ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import json import os import shutil import unittest from parameterized import parameterized from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path def compose_yaml_path(test_ref_folder: str) -> str: return os.path.join(test_path(), "vol", test_ref_folder, "docker-compose.yml") class TestComposeVolShortSyntax(unittest.TestCase, RunSubprocessMixin): @parameterized.expand([ (True, "test1"), (False, "test2"), ]) def test_source_host_dir(self, source_host_dir_exists: bool, service_name: str) -> None: project_dir = os.path.join(test_path(), "vol", "short_syntax") # create host source directory for volume if source_host_dir_exists: os.mkdir(os.path.join(project_dir, "test_dir")) try: self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path("short_syntax"), "up", "-d", f"{service_name}", ], 0, ) # command of the service creates a new directory on mounted directory 'test_dir' in # container. Check if host directory now has the same directory. It represents a # successful mount. # On service test2 source host directory is created as it did not exist self.assertTrue(os.path.isdir(os.path.join(project_dir, "test_dir/new_dir"))) finally: self.run_subprocess([ podman_compose_path(), "-f", compose_yaml_path("short_syntax"), "down", "-t", "0", ]) shutil.rmtree(os.path.join(project_dir, "test_dir")) def test_volume_source_is_symlink(self) -> None: try: base = "/tmp/symlink-test" real_dir = os.path.join(base, "real") link_dir = os.path.join(base, "link") os.makedirs(real_dir, exist_ok=True) os.symlink(real_dir, link_dir) self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path("short_syntax"), "up", "-d", ], 0, ) out, _ = self.run_subprocess_assert_returncode( [ "podman", "inspect", "short_syntax_test3_1", ], 0, ) container_info = json.loads(out) bind_string = container_info[0].get("HostConfig", {}).get("Binds")[0] # podman-compose should not dereference the symlink directory self.assertEqual(bind_string.split(":", 1)[0], link_dir) finally: self.run_subprocess([ podman_compose_path(), "-f", compose_yaml_path("short_syntax"), "down", "-t", "0", ]) shutil.rmtree(base, ignore_errors=True) podman-compose-1.6.0/tests/integration/vol/test_podman_compose_vol.py000066400000000000000000000032431521011662300262170ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 """ test_podman_compose_vol.py Tests the podman compose up and down commands used to create and remove services with volumes. """ # pylint: disable=redefined-outer-name import os import unittest from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path class TestPodmanCompose(unittest.TestCase, RunSubprocessMixin): def test_down_with_vols(self) -> None: up_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "vol", "docker-compose.yaml"), "up", "-d", ] down_cmd = [ "coverage", "run", podman_compose_path(), "-f", os.path.join(test_path(), "vol", "docker-compose.yaml"), "down", "--volumes", ] try: self.run_subprocess_assert_returncode(["podman", "volume", "create", "my-app-data"]) self.run_subprocess_assert_returncode([ "podman", "volume", "create", "actual-name-of-volume", ]) self.run_subprocess_assert_returncode(up_cmd) self.run_subprocess(["podman", "inspect", "volume", ""]) finally: out, _, return_code = self.run_subprocess(down_cmd) self.run_subprocess(["podman", "volume", "rm", "my-app-data"]) self.run_subprocess(["podman", "volume", "rm", "actual-name-of-volume"]) self.assertEqual(return_code, 0) podman-compose-1.6.0/tests/integration/wait/000077500000000000000000000000001521011662300210655ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/wait/Dockerfile000066400000000000000000000001421521011662300230540ustar00rootroot00000000000000FROM nopush/podman-compose-test COPY entrypoint.sh /entrypoint.sh ENTRYPOINT [ "/entrypoint.sh" ] podman-compose-1.6.0/tests/integration/wait/__init__.py000066400000000000000000000000001521011662300231640ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/wait/docker-compose.yml000066400000000000000000000004171521011662300245240ustar00rootroot00000000000000services: app_health: image: podman-compose-wait-test build: . healthcheck: test: "test -f /healthy" interval: 3s retries: 6 app: image: podman-compose-wait-test build: . entrypoint: ["dumb-init", "sleep", "infinity"] podman-compose-1.6.0/tests/integration/wait/entrypoint.sh000077500000000000000000000001151521011662300236340ustar00rootroot00000000000000#!/bin/sh sleep 10 touch /healthy # sleep forever dumb-init sleep infinity podman-compose-1.6.0/tests/integration/wait/test_podman_compose_wait.py000066400000000000000000000215171521011662300265330ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import json import os import unittest from datetime import timedelta from io import BytesIO from typing import Final from packaging import version from tests.integration.test_utils import EnsureHealthcheckRun from tests.integration.test_utils import ExecutionTime from tests.integration.test_utils import RunSubprocessMixin from tests.integration.test_utils import get_podman_version from tests.integration.test_utils import podman_compose_path from tests.integration.test_utils import test_path EXECUTION_TIMEOUT: Final[float] = 30 def compose_yaml_path() -> str: return os.path.join(test_path(), "wait", "docker-compose.yml") @unittest.skipIf( get_podman_version() < version.parse("4.6.0"), "Wait feature not supported below 4.6.0." ) class TestComposeWait(unittest.TestCase, RunSubprocessMixin): def setUp(self) -> None: # build the test image before starting the tests # this is not possible in setUpClass method, because the run_subprocess_* methods are not # available as classmethods self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "build", ]) def _get_health_status(self, container_name: str) -> str: output, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", container_name, ]) inspect = json.load(BytesIO(output)) self.assertEqual(len(inspect), 1) self.assertIn("State", inspect[0]) self.assertIn("Health", inspect[0]["State"]) self.assertIn("Status", inspect[0]["State"]["Health"]) return inspect[0]["State"]["Health"]["Status"] def _is_running(self, container_name: str) -> bool: output, _ = self.run_subprocess_assert_returncode([ "podman", "inspect", container_name, ]) inspect = json.load(BytesIO(output)) self.assertEqual(len(inspect), 1) self.assertIn("State", inspect[0]) self.assertIn("Running", inspect[0]["State"]) return inspect[0]["State"]["Running"] def _compose_down(self) -> None: self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "down", "-t", "0", ]) def test_without_wait(self) -> None: try: with EnsureHealthcheckRun( runner=self, test_case=self, container_name="wait_app_health_1" ): # the execution time of this command must be not more then 10 seconds # otherwise this test case makes no sense with ExecutionTime(max_execution_time=timedelta(seconds=10)): self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "up", "-d", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "ps", ]) self.assertIn(b"wait_app_health_1", output) self.assertIn(b"wait_app_1", output) self.assertTrue(self._is_running("wait_app_1")) health = self._get_health_status("wait_app_health_1") self.assertEqual(health, "starting") finally: self._compose_down() def test_wait(self) -> None: try: with EnsureHealthcheckRun( runner=self, test_case=self, container_name="wait_app_health_1" ): # the execution time of this command must be at least 10 seconds, # because of the sleep command in entrypoint.sh with ExecutionTime(min_execution_time=timedelta(seconds=10)): self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "up", "-d", "--wait", ], timeout=EXECUTION_TIMEOUT, ) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "ps", ]) self.assertIn(b"wait_app_health_1", output) self.assertIn(b"wait_app_1", output) self.assertTrue(self._is_running("wait_app_1")) health = self._get_health_status("wait_app_health_1") self.assertEqual(health, "healthy") finally: self._compose_down() def test_wait_with_timeout(self) -> None: try: with EnsureHealthcheckRun( runner=self, test_case=self, container_name="wait_app_health_1" ): # the execution time of this command must be between 5 and 10 seconds with ExecutionTime( min_execution_time=timedelta(seconds=5), max_execution_time=timedelta(seconds=10), ): self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "up", "-d", "--wait", "--wait-timeout", "5", ], timeout=EXECUTION_TIMEOUT, ) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "ps", ]) self.assertIn(b"wait_app_health_1", output) self.assertIn(b"wait_app_1", output) self.assertTrue(self._is_running("wait_app_1")) health = self._get_health_status("wait_app_health_1") self.assertEqual(health, "starting") finally: self._compose_down() def test_wait_with_start(self) -> None: try: with EnsureHealthcheckRun( runner=self, test_case=self, container_name="wait_app_health_1" ): # the execution time of this command must be not more then 10 seconds # otherwise this test case makes no sense with ExecutionTime(max_execution_time=timedelta(seconds=10)): # podman-compose create does not exist # therefore bring the containers up and kill them immediately again self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "up", "-d", ]) self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "kill", "--all", ]) output, _ = self.run_subprocess_assert_returncode([ podman_compose_path(), "-f", compose_yaml_path(), "ps", ]) self.assertIn(b"wait_app_health_1", output) self.assertIn(b"wait_app_1", output) self.assertFalse(self._is_running("wait_app_health_1")) self.assertFalse(self._is_running("wait_app_1")) # the execution time of this command must be at least 10 seconds, # because of the sleep command in entrypoint.sh with ExecutionTime(min_execution_time=timedelta(seconds=10)): self.run_subprocess_assert_returncode( [ podman_compose_path(), "-f", compose_yaml_path(), "start", "--wait", ], timeout=EXECUTION_TIMEOUT, ) self.assertTrue(self._is_running("wait_app_1")) health = self._get_health_status("wait_app_health_1") self.assertEqual(health, "healthy") finally: self._compose_down() podman-compose-1.6.0/tests/integration/yamlmagic/000077500000000000000000000000001521011662300220645ustar00rootroot00000000000000podman-compose-1.6.0/tests/integration/yamlmagic/docker-compose.yml000066400000000000000000000007771521011662300255340ustar00rootroot00000000000000version: '3.6' x-deploy-base: &deploy-base restart_policy: delay: 2s x-common: &common network: host deploy: <<: *deploy-base networks: hostnet: {} networks: hostnet: external: true name: host volumes: node-red_data: services: node-red: <<: *common image: busybox command: busybox httpd -h /data -f -p 8080 deploy: <<: *deploy-base resources: limits: cpus: '0.5' memory: 32M volumes: - node-red_data:/data podman-compose-1.6.0/tests/unit/000077500000000000000000000000001521011662300165555ustar00rootroot00000000000000podman-compose-1.6.0/tests/unit/__init__.py000066400000000000000000000000001521011662300206540ustar00rootroot00000000000000podman-compose-1.6.0/tests/unit/test_build_deps.py000066400000000000000000000061741521011662300223100ustar00rootroot00000000000000import unittest from podman_compose import PodmanCompose # Test the build-time resolution of dependencies created by the service:service in # the `build.additional_contexts` section. class TestResolveContextDependencies(unittest.TestCase): def setUp(self): self.compose = PodmanCompose() # project_name is used in format_name if image is not present self.compose.project_name = "test_project" def test_resolve_context_dependencies_simple(self): services = { "service_a": {"image": "image_a"}, "service_b": {"build": {"additional_contexts": ["img=service:service_a"]}}, } self.compose._resolve_context_dependencies(services) build_deps = services["service_b"]["build"].get("build_deps") self.assertEqual(build_deps, ["service_a"]) contexts = services["service_b"]["build"]["additional_contexts"] # The dependency uses the specified image name from the service self.assertTrue(any("img=docker://image_a" in c for c in contexts)) def test_resolve_context_dependencies_no_image_in_target(self): services = { "service_a": {}, # No image "service_b": {"build": {"additional_contexts": ["img=service:service_a"]}}, } self.compose._resolve_context_dependencies(services) contexts = services["service_b"]["build"]["additional_contexts"] # Default name format: localhost/project_service expected_image = "docker://localhost/test_project_service_a" self.assertTrue(any(expected_image in c for c in contexts)) def test_circular_dependencies(self): services = {"service_a": {"build": {"additional_contexts": ["ctx=service:service_a"]}}} with self.assertRaises(ValueError): self.compose._resolve_context_dependencies(services) services = { "service_a": {"build": {"additional_contexts": ["ctx=service:service_b"]}}, "service_b": {"build": {"additional_contexts": ["ctx=service:service_c"]}}, "service_c": {"build": {"additional_contexts": ["ctx=service:service_a"]}}, } with self.assertRaises(ValueError): self.compose._resolve_context_dependencies(services) def test_diamond_dependency_valid(self): # A depends on B and C. B depends on D. C depends on D. # This is a perfectly cromulent arrangement services = { "service_a": { "build": { "additional_contexts": ["ctx1=service:service_b", "ctx2=service:service_c"] } }, "service_b": {"build": {"additional_contexts": ["ctx=service:service_d"]}}, "service_c": {"build": {"additional_contexts": ["ctx=service:service_d"]}}, "service_d": {"image": "image_d"}, } self.compose._resolve_context_dependencies(services) self.assertEqual(["service_b", "service_c"], services["service_a"]["build"]["build_deps"]) self.assertEqual(["service_d"], services["service_b"]["build"]["build_deps"]) self.assertEqual(["service_d"], services["service_c"]["build"]["build_deps"]) podman-compose-1.6.0/tests/unit/test_can_merge_build.py000066400000000000000000000203211521011662300232630ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 from __future__ import annotations import argparse import copy import os import unittest import yaml from parameterized import parameterized from podman_compose import PodmanCompose class TestCanMergeBuild(unittest.TestCase): @parameterized.expand([ ({}, {}, {}), ({}, {"test": "test"}, {"test": "test"}), ({"test": "test"}, {}, {"test": "test"}), ({"test": "test-1"}, {"test": "test-2"}, {"test": "test-2"}), ({}, {"build": "."}, {"build": {"context": "."}}), ({"build": "."}, {}, {"build": {"context": "."}}), ({"build": "./dir-1"}, {"build": "./dir-2"}, {"build": {"context": "./dir-2"}}), ({}, {"build": {"context": "./dir-1"}}, {"build": {"context": "./dir-1"}}), ({"build": {"context": "./dir-1"}}, {}, {"build": {"context": "./dir-1"}}), ( {"build": {"context": "./dir-1"}}, {"build": {"context": "./dir-2"}}, {"build": {"context": "./dir-2"}}, ), ( {}, {"build": {"dockerfile": "dockerfile-1"}}, {"build": {"dockerfile": "dockerfile-1"}}, ), ( {"build": {"dockerfile": "dockerfile-1"}}, {}, {"build": {"dockerfile": "dockerfile-1"}}, ), ( {"build": {"dockerfile": "./dockerfile-1"}}, {"build": {"dockerfile": "./dockerfile-2"}}, {"build": {"dockerfile": "./dockerfile-2"}}, ), ( {"build": {"dockerfile": "./dockerfile-1"}}, {"build": {"context": "./dir-2"}}, {"build": {"dockerfile": "./dockerfile-1", "context": "./dir-2"}}, ), ( {"build": {"dockerfile": "./dockerfile-1", "context": "./dir-1"}}, {"build": {"dockerfile": "./dockerfile-2", "context": "./dir-2"}}, {"build": {"dockerfile": "./dockerfile-2", "context": "./dir-2"}}, ), ( {"build": {"dockerfile": "./dockerfile-1"}}, {"build": {"dockerfile": "./dockerfile-2", "args": ["ENV1=1"]}}, {"build": {"dockerfile": "./dockerfile-2", "args": ["ENV1=1"]}}, ), ( {"build": {"dockerfile": "./dockerfile-2", "args": ["ENV1=1"]}}, {"build": {"dockerfile": "./dockerfile-1"}}, {"build": {"dockerfile": "./dockerfile-1", "args": ["ENV1=1"]}}, ), ( {"build": {"dockerfile": "./dockerfile-2", "args": ["ENV1=1"]}}, {"build": {"dockerfile": "./dockerfile-1", "args": ["ENV2=2"]}}, {"build": {"dockerfile": "./dockerfile-1", "args": ["ENV1=1", "ENV2=2"]}}, ), ( {"build": {"dockerfile": "./dockerfile-1", "args": {"ENV1": "1"}}}, {"build": {"dockerfile": "./dockerfile-2", "args": {"ENV2": "2"}}}, {"build": {"dockerfile": "./dockerfile-2", "args": ["ENV1=1", "ENV2=2"]}}, ), ( {"build": {"dockerfile": "./dockerfile-1", "args": ["ENV1=1"]}}, {"build": {"dockerfile": "./dockerfile-2", "args": {"ENV2": "2"}}}, {"build": {"dockerfile": "./dockerfile-2", "args": ["ENV1=1", "ENV2=2"]}}, ), ]) def test_parse_compose_file_when_multiple_composes(self, input, override, expected): compose_test_1 = {"services": {"test-service": input}} compose_test_2 = {"services": {"test-service": override}} dump_yaml(compose_test_1, "test-compose-1.yaml") dump_yaml(compose_test_2, "test-compose-2.yaml") podman_compose = PodmanCompose() set_args(podman_compose, ["test-compose-1.yaml", "test-compose-2.yaml"]) podman_compose._parse_compose_file() # pylint: disable=protected-access actual_compose = {} if podman_compose.services: actual_compose = podman_compose.original_configuration( podman_compose.services["test-service"] ) self.assertEqual(actual_compose, expected) def test_parse_with_map_merge_into_none(self): compose_test_1 = {"volumes": {"vol_a": None}} compose_test_2 = { "volumes": { "vol_a": { "driver_opts": {"type": "none", "device": "/dev/some", "o": "bind"}, } } } expected = {"driver_opts": {"device": "/dev/some", "o": "bind", "type": "none"}} dump_yaml(compose_test_1, "test-compose-1.yaml") dump_yaml(compose_test_2, "test-compose-2.yaml") podman_compose = PodmanCompose() set_args(podman_compose, ["test-compose-1.yaml", "test-compose-2.yaml"]) podman_compose._parse_compose_file() # pylint: disable=protected-access actual_compose = podman_compose.vols["vol_a"] self.assertEqual(actual_compose, expected) # $$$ is a placeholder for either command or entrypoint @parameterized.expand([ ({}, {"$$$": []}, {"$$$": []}), ({"$$$": []}, {}, {"$$$": []}), ({"$$$": []}, {"$$$": "sh-2"}, {"$$$": ["sh-2"]}), ({"$$$": "sh-2"}, {"$$$": []}, {"$$$": []}), ({}, {"$$$": "sh"}, {"$$$": ["sh"]}), ({"$$$": "sh"}, {}, {"$$$": ["sh"]}), ({"$$$": "sh-1"}, {"$$$": "sh-2"}, {"$$$": ["sh-2"]}), ({"$$$": ["sh-1"]}, {"$$$": "sh-2"}, {"$$$": ["sh-2"]}), ({"$$$": "sh-1"}, {"$$$": ["sh-2"]}, {"$$$": ["sh-2"]}), ({"$$$": "sh-1"}, {"$$$": ["sh-2", "sh-3"]}, {"$$$": ["sh-2", "sh-3"]}), ({"$$$": ["sh-1"]}, {"$$$": ["sh-2", "sh-3"]}, {"$$$": ["sh-2", "sh-3"]}), ({"$$$": ["sh-1", "sh-2"]}, {"$$$": ["sh-3", "sh-4"]}, {"$$$": ["sh-3", "sh-4"]}), ({}, {"$$$": ["sh-3", "sh 4"]}, {"$$$": ["sh-3", "sh 4"]}), ({"$$$": "sleep infinity"}, {"$$$": "sh"}, {"$$$": ["sh"]}), ({"$$$": "sh"}, {"$$$": "sleep infinity"}, {"$$$": ["sleep", "infinity"]}), ( {}, {"$$$": "bash -c 'sleep infinity'"}, {"$$$": ["bash", "-c", "sleep infinity"]}, ), ]) def test_parse_compose_file_when_multiple_composes_keys_command_entrypoint( self, base_template, override_template, expected_template ): for key in ['command', 'entrypoint']: base, override, expected = template_to_expression( base_template, override_template, expected_template, key ) compose_test_1 = {"services": {"test-service": base}} compose_test_2 = {"services": {"test-service": override}} dump_yaml(compose_test_1, "test-compose-1.yaml") dump_yaml(compose_test_2, "test-compose-2.yaml") podman_compose = PodmanCompose() set_args(podman_compose, ["test-compose-1.yaml", "test-compose-2.yaml"]) podman_compose._parse_compose_file() # pylint: disable=protected-access actual = {} if podman_compose.services: actual = podman_compose.original_configuration( podman_compose.services["test-service"] ) self.assertEqual(actual, expected) def set_args(podman_compose: PodmanCompose, file_names: list[str]) -> None: podman_compose.global_args = argparse.Namespace() podman_compose.global_args.file = file_names podman_compose.global_args.project_name = None podman_compose.global_args.env_file = None podman_compose.global_args.profile = [] podman_compose.global_args.in_pod = "1" podman_compose.global_args.pod_args = None podman_compose.global_args.no_normalize = True def dump_yaml(compose: dict, name: str) -> None: with open(name, "w", encoding="utf-8") as outfile: yaml.safe_dump(compose, outfile, default_flow_style=False) def template_to_expression(base, override, expected, key): base_copy = copy.deepcopy(base) override_copy = copy.deepcopy(override) expected_copy = copy.deepcopy(expected) expected_copy[key] = expected_copy.pop("$$$") if "$$$" in base: base_copy[key] = base_copy.pop("$$$") if "$$$" in override: override_copy[key] = override_copy.pop("$$$") return base_copy, override_copy, expected_copy def test_clean_test_yamls() -> None: test_files = ["test-compose-1.yaml", "test-compose-2.yaml"] for file in test_files: if os.path.exists(file): os.remove(file) podman-compose-1.6.0/tests/unit/test_compose_exec_args.py000066400000000000000000000022151521011662300236530ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import argparse import unittest from podman_compose import compose_exec_args class TestComposeExecArgs(unittest.TestCase): def test_minimal(self) -> None: cnt = get_minimal_container() args = get_minimal_args() result = compose_exec_args(cnt, "container_name", args) expected = ["--interactive", "--tty", "container_name"] self.assertEqual(result, expected) def test_additional_env_value_equals(self) -> None: cnt = get_minimal_container() args = get_minimal_args() args.env = ["key=valuepart1=valuepart2"] result = compose_exec_args(cnt, "container_name", args) expected = [ "--interactive", "--tty", "--env", "key=valuepart1=valuepart2", "container_name", ] self.assertEqual(result, expected) def get_minimal_container() -> dict: return {} def get_minimal_args() -> argparse.Namespace: return argparse.Namespace( T=None, cnt_command=None, env=None, privileged=None, user=None, workdir=None, ) podman-compose-1.6.0/tests/unit/test_compose_run_log_format.py000066400000000000000000000072241521011662300247350ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 # pylint: disable=protected-access import io import unittest from typing import Union from podman_compose import Podman class DummyReader: def __init__(self, data: Union[list[bytes], None] = None): self.data = data or [] async def readuntil(self, _: str) -> bytes: return self.data.pop(0) def at_eof(self) -> bool: return len(self.data) == 0 class TestComposeRunLogFormat(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.p = get_minimal_podman() self.buffer = io.StringIO() self.unicode_sample = b'\xe3\x81\x82\xe3\x81\x84\n' # result of 'あい\n'.encode('utf-8') async def test_single_line_single_chunk(self) -> None: reader = DummyReader([b'hello, world\n']) await self.p._format_stream(reader, self.buffer, 'LL:') # type: ignore[arg-type] self.assertEqual(self.buffer.getvalue(), 'LL: hello, world\n') async def test_empty(self) -> None: reader = DummyReader([]) await self.p._format_stream(reader, self.buffer, 'LL:') # type: ignore[arg-type] self.assertEqual(self.buffer.getvalue(), '') async def test_empty2(self) -> None: reader = DummyReader([b'']) await self.p._format_stream(reader, self.buffer, 'LL:') # type: ignore[arg-type] self.assertEqual(self.buffer.getvalue(), '') async def test_empty_line(self) -> None: reader = DummyReader([b'\n']) await self.p._format_stream(reader, self.buffer, 'LL:') # type: ignore[arg-type] self.assertEqual(self.buffer.getvalue(), 'LL: \n') async def test_line_split(self) -> None: reader = DummyReader([b'hello,', b' world\n']) await self.p._format_stream(reader, self.buffer, 'LL:') # type: ignore[arg-type] self.assertEqual(self.buffer.getvalue(), 'LL: hello, world\n') async def test_two_lines_in_one_chunk(self) -> None: reader = DummyReader([b'hello\nbye\n']) await self.p._format_stream(reader, self.buffer, 'LL:') # type: ignore[arg-type] self.assertEqual(self.buffer.getvalue(), 'LL: hello\nLL: bye\n') async def test_double_blank(self) -> None: reader = DummyReader([b'hello\n\n\nbye\n']) await self.p._format_stream(reader, self.buffer, 'LL:') # type: ignore[arg-type] self.assertEqual(self.buffer.getvalue(), 'LL: hello\nLL: \nLL: \nLL: bye\n') async def test_no_new_line_at_end(self) -> None: reader = DummyReader([b'hello\nbye']) await self.p._format_stream(reader, self.buffer, 'LL:') # type: ignore[arg-type] self.assertEqual(self.buffer.getvalue(), 'LL: hello\nLL: bye\n') async def test_split_multibyte(self) -> None: string = self.unicode_sample mid = 4 reader = DummyReader([string[:mid], string[mid:]]) await self.p._format_stream(reader, self.buffer, 'LL:') # type: ignore[arg-type] self.assertEqual(self.buffer.getvalue(), 'LL: あい\n') async def test_incomplete_multibyte_at_end(self) -> None: string = self.unicode_sample mid = 4 reader = DummyReader([string[:mid]]) await self.p._format_stream(reader, self.buffer, 'LL:') # type: ignore[arg-type] self.assertEqual(self.buffer.getvalue(), 'LL: あ\n') async def test_incomplete_multibyte_at_beginning(self) -> None: string = self.unicode_sample mid = 4 reader = DummyReader([string[mid:]]) with self.assertRaises(UnicodeDecodeError): await self.p._format_stream(reader, self.buffer, 'LL:') # type: ignore[arg-type] def get_minimal_podman() -> Podman: return Podman(None) # type: ignore[arg-type] podman-compose-1.6.0/tests/unit/test_compose_run_update_container_from_args.py000066400000000000000000000040461521011662300301660ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import argparse import unittest from podman_compose import PodmanCompose from podman_compose import compose_run_update_container_from_args class TestComposeRunUpdateContainerFromArgs(unittest.TestCase): def test_minimal(self) -> None: cnt = get_minimal_container() compose = get_minimal_compose() args = get_minimal_args() compose_run_update_container_from_args(compose, cnt, args) expected_cnt = {"name": "default_name", "tty": True} self.assertEqual(cnt, expected_cnt) def test_additional_env_value_equals(self) -> None: cnt = get_minimal_container() compose = get_minimal_compose() args = get_minimal_args() args.env = ["key=valuepart1=valuepart2"] compose_run_update_container_from_args(compose, cnt, args) expected_cnt = { "environment": { "key": "valuepart1=valuepart2", }, "name": "default_name", "tty": True, } self.assertEqual(cnt, expected_cnt) def test_publish_ports(self) -> None: cnt = get_minimal_container() compose = get_minimal_compose() args = get_minimal_args() args.publish = ["1111", "2222:2222"] compose_run_update_container_from_args(compose, cnt, args) expected_cnt = { "name": "default_name", "ports": ["1111", "2222:2222"], "tty": True, } self.assertEqual(cnt, expected_cnt) def get_minimal_container() -> dict: return {} def get_minimal_compose() -> PodmanCompose: compose = PodmanCompose() compose.project_name = "test_project" return compose def get_minimal_args() -> argparse.Namespace: return argparse.Namespace( T=None, cnt_command=None, entrypoint=None, env=None, name="default_name", rm=None, service="test_service", publish=None, service_ports=None, user=None, volume=None, workdir=None, ) podman-compose-1.6.0/tests/unit/test_container_to_args.py000066400000000000000000001067571521011662300237060ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import shutil import subprocess import unittest from typing import Any from unittest import mock from parameterized import parameterized from podman_compose import PodmanCompose from podman_compose import PodmanComposeError from podman_compose import container_to_args def create_compose_mock(project_name: str = "test_project_name") -> PodmanCompose: compose = mock.Mock() compose.project_name = project_name compose.dirname = "test_dirname" compose.container_names_by_service.get = mock.Mock(return_value=None) compose.prefer_volume_over_mount = False compose.default_net = None compose.networks = {} compose.x_podman = {} compose.join_name_parts = mock.Mock(side_effect=lambda *args: '_'.join(args)) compose.format_name = mock.Mock(side_effect=lambda *args: '_'.join([project_name, *args])) async def podman_output(*args: Any, **kwargs: Any) -> None: pass compose.podman.output = mock.Mock(side_effect=podman_output) return compose def get_minimal_container() -> dict[str, Any]: return { "name": "project_name_service_name1", "service_name": "service_name", "image": "busybox", } def get_test_file_path(rel_path: str) -> str: repo_root = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) return os.path.realpath(os.path.join(repo_root, rel_path)) class TestContainerToArgs(unittest.IsolatedAsyncioTestCase): async def test_external_network_missing_raises_user_friendly_error(self) -> None: c = create_compose_mock() c.default_net = "external_net" c.networks = { "external_net": { "external": True, "name": "missing-external-network", } } async def podman_output(*args: Any, **kwargs: Any) -> None: if args[2] == ["exists", "missing-external-network"]: raise subprocess.CalledProcessError(1, "podman network exists") setattr(c.podman, "output", mock.Mock(side_effect=podman_output)) cnt = get_minimal_container() with self.assertRaisesRegex( PodmanComposeError, r"External network \[missing-external-network\] does not exist\.", ): await container_to_args(c, cnt) async def test_minimal(self) -> None: c = create_compose_mock() cnt = get_minimal_container() args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "busybox", ], ) async def test_runtime(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["runtime"] = "runsc" args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--runtime", "runsc", "busybox", ], ) async def test_sysctl_list(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["sysctls"] = [ "net.core.somaxconn=1024", "net.ipv4.tcp_syncookies=0", ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--sysctl", "net.core.somaxconn=1024", "--sysctl", "net.ipv4.tcp_syncookies=0", "busybox", ], ) async def test_sysctl_map(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["sysctls"] = { "net.core.somaxconn": 1024, "net.ipv4.tcp_syncookies": 0, } args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--sysctl", "net.core.somaxconn=1024", "--sysctl", "net.ipv4.tcp_syncookies=0", "busybox", ], ) async def test_sysctl_wrong_type(self) -> None: c = create_compose_mock() cnt = get_minimal_container() # check whether wrong types are correctly rejected for wrong_type in [True, 0, 0.0, "wrong", ()]: with self.assertRaises(TypeError): cnt["sysctls"] = wrong_type await container_to_args(c, cnt) async def test_pid(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["pid"] = "host" args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--pid", "host", "busybox", ], ) async def test_http_proxy(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["http_proxy"] = False args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--http-proxy=false", "--network=bridge:alias=service_name", "busybox", ], ) async def test_uidmaps_extension_old_path(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt['x-podman'] = {'uidmaps': ['1000:1000:1']} with self.assertRaises(ValueError): await container_to_args(c, cnt) async def test_uidmaps_extension(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt['x-podman.uidmaps'] = ['1000:1000:1', '1001:1001:2'] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", '--uidmap', '1000:1000:1', '--uidmap', '1001:1001:2', "busybox", ], ) async def test_gidmaps_extension(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt['x-podman.gidmaps'] = ['1000:1000:1', '1001:1001:2'] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", '--gidmap', '1000:1000:1', '--gidmap', '1001:1001:2', "busybox", ], ) async def test_rootfs_extension(self) -> None: c = create_compose_mock() cnt = get_minimal_container() del cnt["image"] cnt["x-podman.rootfs"] = "/path/to/rootfs" args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--rootfs", "/path/to/rootfs", ], ) async def test_no_hosts_extension(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["x-podman.no_hosts"] = True args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--no-hosts", "busybox", ], ) @parameterized.expand([ # short syntax: only take this specific environment variable value from .env file ("use_env_var_from_default_env_file_short_syntax", ["ZZVAR1"], "ZZVAR1=TEST1"), # long syntax: environment variable value from .env file is taken through variable # interpolation # only the value required in 'environment:' compose file is sent to containers # environment ("use_env_var_from_default_env_file_long_syntax", ["ZZVAR1=TEST1"], "ZZVAR1=TEST1"), # "environment:" section in compose file overrides environment variable value from .env file ( "use_env_var_from_default_env_file_override_value", ["ZZVAR1=NEW_TEST1"], "ZZVAR1=NEW_TEST1", ), ]) async def test_env_file(self, test_name: str, cnt_env: list, expected_var: str) -> None: c = create_compose_mock() # environment variables were set in .env file c.environ = {"ZZVAR1": "TEST1", "ZZVAR2": "TEST2"} cnt = get_minimal_container() cnt["environment"] = cnt_env args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "-e", f"{expected_var}", "--network=bridge:alias=service_name", "busybox", ], ) async def test_env_file_str(self) -> None: c = create_compose_mock() cnt = get_minimal_container() env_file = get_test_file_path('tests/integration/env_file_tests/env-files/project-1.env') cnt['env_file'] = env_file args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "-e", "ZZVAR1=podman-rocks-123", "-e", "ZZVAR2=podman-rocks-124", "-e", "ZZVAR3=podman-rocks-125", "--network=bridge:alias=service_name", "busybox", ], ) async def test_env_file_str_not_exists(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt['env_file'] = 'notexists' with self.assertRaises(ValueError): await container_to_args(c, cnt) async def test_env_file_str_array_one_path(self) -> None: c = create_compose_mock() cnt = get_minimal_container() env_file = get_test_file_path('tests/integration/env_file_tests/env-files/project-1.env') cnt['env_file'] = [env_file] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "-e", "ZZVAR1=podman-rocks-123", "-e", "ZZVAR2=podman-rocks-124", "-e", "ZZVAR3=podman-rocks-125", "--network=bridge:alias=service_name", "busybox", ], ) async def test_env_file_str_array_two_paths(self) -> None: c = create_compose_mock() cnt = get_minimal_container() env_file = get_test_file_path('tests/integration/env_file_tests/env-files/project-1.env') env_file_2 = get_test_file_path('tests/integration/env_file_tests/env-files/project-2.env') cnt['env_file'] = [env_file, env_file_2] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "-e", "ZZVAR1=podman-rocks-123", "-e", "ZZVAR2=podman-rocks-124", "-e", "ZZVAR3=podman-rocks-125", "-e", "ZZVAR1=podman-rocks-223", "-e", "ZZVAR2=podman-rocks-224", "--network=bridge:alias=service_name", "busybox", ], ) async def test_env_file_obj_required(self) -> None: c = create_compose_mock() cnt = get_minimal_container() env_file = get_test_file_path('tests/integration/env_file_tests/env-files/project-1.env') cnt['env_file'] = {'path': env_file, 'required': True} args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "-e", "ZZVAR1=podman-rocks-123", "-e", "ZZVAR2=podman-rocks-124", "-e", "ZZVAR3=podman-rocks-125", "--network=bridge:alias=service_name", "busybox", ], ) async def test_env_file_obj_required_non_existent_path(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt['env_file'] = {'path': 'not-exists', 'required': True} with self.assertRaises(ValueError): await container_to_args(c, cnt) async def test_env_file_obj_optional(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt['env_file'] = {'path': 'not-exists', 'required': False} args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "busybox", ], ) async def test_gpu_count_all(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["command"] = ["nvidia-smi"] cnt["deploy"] = {"resources": {"reservations": {"devices": [{}]}}} cnt["deploy"]["resources"]["reservations"]["devices"][0] = { "driver": "nvidia", "count": "all", "capabilities": ["gpu"], } args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--device", "nvidia.com/gpu=all", "--security-opt=label=disable", "busybox", "nvidia-smi", ], ) async def test_gpu_count_specific(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["command"] = ["nvidia-smi"] cnt["deploy"] = { "resources": { "reservations": { "devices": [ { "driver": "nvidia", "count": 2, "capabilities": ["gpu"], } ] } } } args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--device", "nvidia.com/gpu=0", "--device", "nvidia.com/gpu=1", "--security-opt=label=disable", "busybox", "nvidia-smi", ], ) async def test_gpu_device_ids_all(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["command"] = ["nvidia-smi"] cnt["deploy"] = { "resources": { "reservations": { "devices": [ { "driver": "nvidia", "device_ids": "all", "capabilities": ["gpu"], } ] } } } args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--device", "nvidia.com/gpu=all", "--security-opt=label=disable", "busybox", "nvidia-smi", ], ) async def test_gpu_device_ids_specific(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["command"] = ["nvidia-smi"] cnt["deploy"] = { "resources": { "reservations": { "devices": [ { "driver": "nvidia", "device_ids": [1, 3], "capabilities": ["gpu"], } ] } } } args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--device", "nvidia.com/gpu=1", "--device", "nvidia.com/gpu=3", "--security-opt=label=disable", "busybox", "nvidia-smi", ], ) @parameterized.expand([ ( False, "z", [ "--mount", f"type=bind,source={get_test_file_path('test_dirname/foo')},destination=/mnt,z", ], ), ( False, "Z", [ "--mount", f"type=bind,source={get_test_file_path('test_dirname/foo')},destination=/mnt,Z", ], ), (True, "z", ["-v", f"{get_test_file_path('test_dirname/foo')}:/mnt:z"]), (True, "Z", ["-v", f"{get_test_file_path('test_dirname/foo')}:/mnt:Z"]), ]) async def test_selinux_volume( self, prefer_volume: bool, selinux_type: str, expected_additional_args: list ) -> None: c = create_compose_mock() c.prefer_volume_over_mount = prefer_volume cnt = get_minimal_container() # This is supposed to happen during `_parse_compose_file` # but that is probably getting skipped during testing cnt["_service"] = cnt["service_name"] cnt["volumes"] = [ { "type": "bind", "source": "./foo", "target": "/mnt", "bind": { "selinux": selinux_type, }, } ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", *expected_additional_args, "--network=bridge:alias=service_name", "busybox", ], ) async def test_volumes_glob_mount_source(self) -> None: c = create_compose_mock() cnt = get_minimal_container() # This is supposed to happen during `_parse_compose_file` # but that is probably getting skipped during testing cnt["_service"] = cnt["service_name"] cnt["volumes"] = [ { "type": "glob", "source": f"{get_test_file_path('test_dirname/foo')}/*.ext", "target": "/mnt", } ] expected_additional_args = [ "--mount", ( "type=glob,source=" f"{get_test_file_path('test_dirname/foo') + '/*.ext'}," "destination=/mnt" ), ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", *expected_additional_args, "--network=bridge:alias=service_name", "busybox", ], ) @parameterized.expand([ ( "absolute_path", get_test_file_path('test_dirname/foo'), [ "--mount", f"type=bind,source={get_test_file_path('test_dirname/foo')},destination=/mnt", ], ), ( "relative_path", './foo', [ "--mount", f"type=bind,source={get_test_file_path('test_dirname/foo')},destination=/mnt", ], ), ( "home_dir", '~/test_dirname/foo', [ "--mount", f"type=bind,source={os.path.expanduser('~/test_dirname/foo')},destination=/mnt", ], ), ]) async def test_volumes_bind_mount_source( self, test_name: str, mount_source: str, expected_additional_args: list ) -> None: c = create_compose_mock() cnt = get_minimal_container() # This is supposed to happen during `_parse_compose_file` # but that is probably getting skipped during testing cnt["_service"] = cnt["service_name"] cnt["volumes"] = [ { "type": "bind", "source": f"{mount_source}", "target": "/mnt", } ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", *expected_additional_args, "--network=bridge:alias=service_name", "busybox", ], ) @parameterized.expand([ ( "without_subpath", {}, "type=image,source=example:latest,destination=/mnt/example", ), ( "with_subpath", {"image": {"subpath": "path/to/image/folder"}}, "type=image,source=example:latest,destination=/mnt/example,subpath=path/to/image/folder", ), ]) async def test_volumes_image_mount( self, test_name: str, image_opts: dict, expected_mount_arg: str ) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["_service"] = cnt["service_name"] cnt["volumes"] = [ { "type": "image", "source": "example:latest", "target": "/mnt/example", **image_opts, }, ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--mount", expected_mount_arg, "--network=bridge:alias=service_name", "busybox", ], ) @parameterized.expand([ ( "without_subpath", {}, "type=volume,source=volname,destination=/mnt/example", ), ( "with_subpath", {"volume": {"subpath": "path/to/image/folder"}}, "type=volume,source=volname,destination=/mnt/example,subpath=path/to/image/folder", ), ]) async def test_volumes_mount( self, test_name: str, volume_opts: dict, expected_mount_arg: str ) -> None: c = create_compose_mock() c.vols = {"volname": {"name": "volname"}} cnt = get_minimal_container() cnt["_service"] = cnt["service_name"] cnt["volumes"] = [ { "type": "volume", "source": "volname", "target": "/mnt/example", **volume_opts, }, ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--mount", expected_mount_arg, "--network=bridge:alias=service_name", "busybox", ], ) @parameterized.expand([ ( "create_host_path_set_to_true", {"bind": {"create_host_path": True}}, ), ( "create_host_path_default_true", {}, ), ]) async def test_volumes_bind_mount_create_source_dir(self, test_name: str, bind: dict) -> None: # creates a missing source dir c = create_compose_mock() c.prefer_volume_over_mount = True cnt = get_minimal_container() cnt["_service"] = cnt["service_name"] volume_info = { "type": "bind", "source": "./not_exists/foo", "target": "/mnt", } volume_info.update(bind) cnt["volumes"] = [ volume_info, ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "-v", f"{get_test_file_path('./test_dirname/not_exists/foo')}:/mnt", "--network=bridge:alias=service_name", "busybox", ], ) dir_path = get_test_file_path('./test_dirname/not_exists/foo') shutil.rmtree(dir_path) # throws an error as the source path does not exist and its creation was suppressed with the # create_host_path = False option async def test_volumes_bind_mount_source_does_not_exist(self) -> None: c = create_compose_mock() c.prefer_volume_over_mount = True cnt = get_minimal_container() cnt["_service"] = cnt["service_name"] cnt["volumes"] = [ { "type": "bind", "source": "./not_exists/foo", "target": "/mnt", "bind": {"create_host_path": False}, } ] with self.assertRaises(ValueError): await container_to_args(c, cnt) @parameterized.expand([ ("not_compat", False, "test_project_name", "test_project_name_network1"), ("compat_no_dash", True, "test_project_name", "test_project_name_network1"), ("compat_dash", True, "test_project-name", "test_projectname_network1"), ]) async def test_network_default_name( self, name: str, is_compat: bool, project_name: str, expected_network_name: str ) -> None: c = create_compose_mock(project_name) c.x_podman = {PodmanCompose.XPodmanSettingKey.DEFAULT_NET_NAME_COMPAT: is_compat} c.networks = {'network1': {}} cnt = get_minimal_container() cnt['networks'] = ['network1'] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", f"--network={expected_network_name}:alias=service_name", "busybox", ], ) async def test_device(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt['devices'] = ['/dev/ttyS0'] cnt['device_cgroup_rules'] = ['c 100:200 rwm'] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--device", "/dev/ttyS0", "--device-cgroup-rule", "c 100:200 rwm", "--network=bridge:alias=service_name", "busybox", ], ) async def test_cpuset(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["cpuset"] = "0-1" args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--cpuset-cpus", "0-1", "busybox", ], ) async def test_pids_limit_container_level(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["pids_limit"] = 100 args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--pids-limit", "100", "busybox", ], ) async def test_pids_limit_deploy_section(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["deploy"] = {"resources": {"limits": {"pids": 100}}} args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--pids-limit", "100", "busybox", ], ) async def test_pids_limit_both_same(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["pids_limit"] = 100 cnt["deploy"] = {"resources": {"limits": {"pids": 100}}} args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--pids-limit", "100", "busybox", ], ) async def test_pids_limit_both_different(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["pids_limit"] = 100 cnt["deploy"] = {"resources": {"limits": {"pids": 200}}} with self.assertRaises(ValueError): await container_to_args(c, cnt) async def test_healthcheck_string(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["healthcheck"] = { "test": "cmd arg1 arg2", } args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--health-cmd", '["CMD-SHELL", "cmd arg1 arg2"]', "busybox", ], ) async def test_healthcheck_cmd_args(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["healthcheck"] = { "test": ["CMD", "cmd", "arg1", "arg2"], } args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--health-cmd", '["cmd", "arg1", "arg2"]', "busybox", ], ) async def test_healthcheck_cmd_shell(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["healthcheck"] = { "test": ["CMD-SHELL", "cmd arg1 arg2"], } args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--health-cmd", '["cmd arg1 arg2"]', "busybox", ], ) async def test_healthcheck_disable(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["healthcheck"] = { "test": "cmd arg1 arg2", "disable": "true", } args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--no-healthcheck", "busybox", ], ) async def test_healthcheck_cmd_shell_error(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["healthcheck"] = { "test": ["CMD-SHELL", "cmd arg1", "arg2"], } with self.assertRaisesRegex(ValueError, "'CMD-SHELL' takes a single string after it"): await container_to_args(c, cnt) async def test_unknown_healthcheck_test_type(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["healthcheck"] = { "test": ["TEST", "arg1", "arg2"], } with self.assertRaises(ValueError) as error_msg: await container_to_args(c, cnt) expected = "unknown healthcheck test type [TEST], expecting NONE, CMD or CMD-SHELL." self.assertEqual(expected, str(error_msg.exception)) async def test_healthcheck_test_not_string_or_list(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["healthcheck"] = { "test": 2, } with self.assertRaisesRegex(ValueError, "'healthcheck.test' either a string or a list"): await container_to_args(c, cnt) async def test_healthcheck_not_a_dict_error(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["healthcheck"] = [] with self.assertRaisesRegex(ValueError, "'healthcheck' must be a key-value mapping"): await container_to_args(c, cnt) async def test_healthcheck_test_is_missing(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["healthcheck"] = { "interval": "1m", "retries": "3", } args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", '--health-interval', '1m', '--health-retries', '3', "busybox", ], ) async def test_healthcheck_options(self) -> None: c = create_compose_mock() cnt = get_minimal_container() cnt["healthcheck"] = { "test": ["CMD", "cmd", "arg1", "arg2"], "interval": "1m", "timeout": "10s", "retries": "3", "start_period": "5s", "start_interval": "6s", } args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--health-cmd", '["cmd", "arg1", "arg2"]', '--health-interval', '1m', '--health-timeout', '10s', '--health-start-period', '5s', '--health-startup-interval', '6s', '--health-retries', '3', "busybox", ], ) podman-compose-1.6.0/tests/unit/test_container_to_args_secrets.py000066400000000000000000000410031521011662300254140ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from parameterized import parameterized from podman_compose import container_to_args from tests.unit.test_container_to_args import create_compose_mock from tests.unit.test_container_to_args import get_minimal_container def repo_root() -> str: return os.path.dirname(os.path.dirname(os.path.dirname(__file__))) class TestContainerToArgsSecrets(unittest.IsolatedAsyncioTestCase): async def test_pass_secret_as_env_variable(self) -> None: c = create_compose_mock() c.declared_secrets = { "my_secret": {"external": "true"} # must have external or name value } cnt = get_minimal_container() cnt["secrets"] = [ { "source": "my_secret", "target": "ENV_SECRET", "type": "env", }, ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--secret", "my_secret,type=env,target=ENV_SECRET", "busybox", ], ) async def test_secret_as_env_external_true_has_no_name(self) -> None: c = create_compose_mock() c.declared_secrets = { "my_secret": { "name": "my_secret", # must have external or name value } } cnt = get_minimal_container() cnt["_service"] = "test-service" cnt["secrets"] = [ { "source": "my_secret", "target": "ENV_SECRET", "type": "env", } ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--secret", "my_secret,type=env,target=ENV_SECRET", "busybox", ], ) async def test_pass_secret_as_env_variable_no_external(self) -> None: c = create_compose_mock() c.declared_secrets = { "my_secret": {} # must have external or name value } cnt = get_minimal_container() cnt["_service"] = "test-service" cnt["secrets"] = [ { "source": "my_secret", "target": "ENV_SECRET", "type": "env", } ] with self.assertRaises(ValueError) as context: await container_to_args(c, cnt) self.assertIn('ERROR: unparsable secret: ', str(context.exception)) @parameterized.expand([ ( "secret_no_name", {"my_secret": "my_secret_name", "external": "true"}, {}, # must have a name ), ( "no_secret_name_in_declared_secrets", {}, # must have a name { "source": "my_secret_name", }, ), ( "secret_name_does_not_match_declared_secrets_name", { "wrong_name": "my_secret_name", }, { "source": "name", # secret name must match the one in declared_secrets }, ), ( "secret_name_empty_string", {"": "my_secret_name"}, { "source": "", # can not be empty string }, ), ]) async def test_secret_name( self, test_name: str, declared_secrets: dict, add_to_minimal_container: dict ) -> None: c = create_compose_mock() c.declared_secrets = declared_secrets cnt = get_minimal_container() cnt["_service"] = "test-service" cnt["secrets"] = [add_to_minimal_container] with self.assertRaises(ValueError) as context: await container_to_args(c, cnt) self.assertIn('ERROR: undeclared secret: ', str(context.exception)) async def test_secret_string_no_external_name_in_declared_secrets(self) -> None: c = create_compose_mock() c.declared_secrets = {"my_secret_name": {"external": "true"}} cnt = get_minimal_container() cnt["_service"] = "test-service" cnt["secrets"] = [ "my_secret_name", ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--secret", "my_secret_name", "busybox", ], ) async def test_secret_string_options_external_name_in_declared_secrets(self) -> None: c = create_compose_mock() c.declared_secrets = { "my_secret_name": { "external": "true", "name": "my_secret_name", } } cnt = get_minimal_container() cnt["_service"] = "test-service" cnt["secrets"] = [ { "source": "my_secret_name", "target": "my_secret_name", "uid": "103", "gid": "103", "mode": "400", } ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--secret", "my_secret_name,uid=103,gid=103,mode=400,target=my_secret_name", "busybox", ], ) async def test_secret_string_external_name_in_declared_secrets_does_not_match_secret( self, ) -> None: c = create_compose_mock() c.declared_secrets = { "my_secret_name": { "external": "true", "name": "wrong_secret_name", } } cnt = get_minimal_container() cnt["_service"] = "test-service" cnt["secrets"] = [ "my_secret_name", ] with self.assertRaises(ValueError) as context: await container_to_args(c, cnt) self.assertIn('ERROR: Custom name/target reference ', str(context.exception)) async def test_secret_target_does_not_match_secret_name_secret_type_mount(self) -> None: c = create_compose_mock() c.declared_secrets = { "my_secret_name": { "external": "true", } } cnt = get_minimal_container() cnt["_service"] = "test-service" cnt["secrets"] = [ { "source": "my_secret_name", "target": "/tmp/custom_path", "type": "mount", } ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--secret", "my_secret_name,type=mount,target=/tmp/custom_path", "busybox", ], ) async def test_secret_target_does_not_match_secret_name_secret_type_env(self) -> None: c = create_compose_mock() c.declared_secrets = { "my_secret_name": { "external": "true", } } cnt = get_minimal_container() cnt["_service"] = "test-service" cnt["secrets"] = [ {"source": "my_secret_name", "target": "does_not_equal_secret_name", "type": "env"} ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--secret", "my_secret_name,type=env,target=does_not_equal_secret_name", "busybox", ], ) async def test_secret_target_matches_secret_name_secret_type_not_env(self) -> None: c = create_compose_mock() c.declared_secrets = { "my_secret_name": { "external": "true", } } cnt = get_minimal_container() cnt["_service"] = "test-service" cnt["secrets"] = [{"source": "my_secret_name", "target": "my_secret_name", "type": "mount"}] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--secret", "my_secret_name,type=mount,target=my_secret_name", "busybox", ], ) @parameterized.expand([ ( "no_secret_target", { "file_secret": { "file": "./my_secret", } }, "file_secret", repo_root() + "/test_dirname/my_secret:/run/secrets/file_secret:ro,rprivate,rbind", ), ( "relabel", {"file_secret": {"file": "./my_secret", "x-podman.relabel": "Z"}}, "file_secret", repo_root() + "/test_dirname/my_secret:/run/secrets/file_secret:ro,rprivate,rbind,Z", ), ( "relabel", {"file_secret": {"file": "./my_secret", "x-podman.relabel": "z"}}, "file_secret", repo_root() + "/test_dirname/my_secret:/run/secrets/file_secret:ro,rprivate,rbind,z", ), ( "custom_target_name", { "file_secret": { "file": "./my_secret", } }, { "source": "file_secret", "target": "custom_name", }, repo_root() + "/test_dirname/my_secret:/run/secrets/custom_name:ro,rprivate,rbind", ), ( "no_custom_target_name", { "file_secret": { "file": "./my_secret", } }, { "source": "file_secret", }, repo_root() + "/test_dirname/my_secret:/run/secrets/file_secret:ro,rprivate,rbind", ), ( "custom_location", { "file_secret": { "file": "./my_secret", } }, { "source": "file_secret", "target": "/etc/custom_location", }, repo_root() + "/test_dirname/my_secret:/etc/custom_location:ro,rprivate,rbind", ), ]) async def test_file_secret( self, test_name: str, declared_secrets: dict, add_to_minimal_container: dict, expected_volume_ref: str, ) -> None: c = create_compose_mock() c.declared_secrets = declared_secrets cnt = get_minimal_container() cnt["_service"] = "test-service" cnt["secrets"] = [add_to_minimal_container] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--volume", expected_volume_ref, "busybox", ], ) async def test_file_secret_unused_params_warning(self) -> None: c = create_compose_mock() c.declared_secrets = { "file_secret": { "file": "./my_secret", } } cnt = get_minimal_container() cnt["_service"] = "test-service" cnt["secrets"] = [ { "source": "file_secret", "target": "unused_params_warning", "uid": "103", "gid": "103", "mode": "400", } ] with self.assertLogs() as cm: args = await container_to_args(c, cnt) self.assertEqual(len(cm.output), 1) self.assertIn('with uid, gid, or mode.', cm.output[0]) self.assertIn('unused_params_warning', cm.output[0]) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--volume", repo_root() + "/test_dirname/my_secret:/run/secrets/unused_params_warning:ro,rprivate,rbind", "busybox", ], ) async def test_environment_secret_run_raises(self) -> None: c = create_compose_mock() c.declared_secrets = {'my_secret': {'environment': 'MY_VAR'}} cnt = get_minimal_container() cnt['_service'] = 'test-service' cnt['secrets'] = ['my_secret'] with self.assertRaises(ValueError) as context: await container_to_args(c, cnt) self.assertIn('not supported for runtime secrets', str(context.exception)) async def test_external_secret_with_target_path_uid_gid_mode(self) -> None: """Reproduce the reported bug: external secret with custom target path, uid, gid, mode.""" c = create_compose_mock() c.declared_secrets = {"ssh-private-key": {"external": True}} cnt = get_minimal_container() cnt["_service"] = "alpine_secret" cnt["secrets"] = [ { "source": "ssh-private-key", "target": "/tmp/private_key", "mode": "700", "uid": "600", "gid": "600", } ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--secret", "ssh-private-key,uid=600,gid=600,mode=700,target=/tmp/private_key", "busybox", ], ) async def test_external_secret_with_mount_type_and_target(self) -> None: """External secret with explicit type=mount and a target path.""" c = create_compose_mock() c.declared_secrets = {"my_secret": {"external": True}} cnt = get_minimal_container() cnt["_service"] = "test-service" cnt["secrets"] = [ { "source": "my_secret", "target": "/etc/secrets/my_secret", "type": "mount", "uid": "1000", "gid": "1000", "mode": "400", } ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--secret", "my_secret,uid=1000,gid=1000,mode=400,type=mount,target=/etc/secrets/my_secret", "busybox", ], ) async def test_external_secret_no_target(self) -> None: """External secret without target should not include target in args.""" c = create_compose_mock() c.declared_secrets = {"my_secret": {"external": True}} cnt = get_minimal_container() cnt["_service"] = "test-service" cnt["secrets"] = [ { "source": "my_secret", } ] args = await container_to_args(c, cnt) self.assertEqual( args, [ "--name=project_name_service_name1", "-d", "--network=bridge:alias=service_name", "--secret", "my_secret", "busybox", ], ) async def test_external_secret_custom_name_mismatch_still_raises(self) -> None: """External secret with mismatched 'name' should still raise ValueError.""" c = create_compose_mock() c.declared_secrets = { "my_secret": { "external": True, "name": "different_name", } } cnt = get_minimal_container() cnt["_service"] = "test-service" cnt["secrets"] = ["my_secret"] with self.assertRaises(ValueError) as context: await container_to_args(c, cnt) self.assertIn('ERROR: Custom name/target reference ', str(context.exception)) podman-compose-1.6.0/tests/unit/test_container_to_build_args.py000066400000000000000000000266441521011662300250610ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import os import unittest from unittest import mock from podman_compose import container_to_build_args def create_compose_mock(project_name='test_project_name'): compose = mock.Mock() compose.project_name = project_name compose.dirname = 'test_dirname' compose.container_names_by_service.get = mock.Mock(return_value=None) compose.prefer_volume_over_mount = False compose.default_net = None compose.networks = {} compose.x_podman = {} return compose def get_minimal_container(): return { 'name': 'project_name_service_name1', 'service_name': 'service_name', 'image': 'new-image', 'build': {}, } def get_minimal_args(): args = mock.Mock() args.build_arg = [] args.pull = None return args class TestContainerToBuildArgs(unittest.TestCase): def test_minimal(self): c = create_compose_mock() cnt = get_minimal_container() args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'Containerfile', '-t', 'new-image', '--no-cache', '.', ], ) def test_platform(self): c = create_compose_mock() cnt = get_minimal_container() cnt['platform'] = 'linux/amd64' args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'Containerfile', '-t', 'new-image', '--platform', 'linux/amd64', '--no-cache', '.', ], ) def test_tags(self): c = create_compose_mock() cnt = get_minimal_container() cnt['build']['tags'] = ['some-tag1', 'some-tag2:2'] args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'Containerfile', '-t', 'new-image', '-t', 'some-tag1', '-t', 'some-tag2:2', '--no-cache', '.', ], ) def test_labels(self): c = create_compose_mock() cnt = get_minimal_container() cnt['build']['labels'] = ['some-label1', 'some-label2.2'] args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'Containerfile', '-t', 'new-image', '--label', 'some-label1', '--label', 'some-label2.2', '--no-cache', '.', ], ) def test_caches(self): c = create_compose_mock() cnt = get_minimal_container() cnt['build']['cache_from'] = ['registry/image1', 'registry/image2'] cnt['build']['cache_to'] = ['registry/image3', 'registry/image4'] args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'Containerfile', '-t', 'new-image', '--no-cache', '--cache-from', 'registry/image1', '--cache-from', 'registry/image2', '--cache-to', 'registry/image3', '--cache-to', 'registry/image4', '.', ], ) def test_dockerfile_inline(self): c = create_compose_mock() cnt = get_minimal_container() cnt['build']['dockerfile_inline'] = "FROM busybox\nRUN echo 'hello world'" args = get_minimal_args() cleanup_callbacks = [] args = container_to_build_args( c, cnt, args, lambda path: True, cleanup_callbacks=cleanup_callbacks ) temp_dockerfile = args[args.index("-f") + 1] self.assertTrue(os.path.exists(temp_dockerfile)) with open(temp_dockerfile) as file: contents = file.read() self.assertEqual(contents, "FROM busybox\n" + "RUN echo 'hello world'") for c in cleanup_callbacks: c() self.assertFalse(os.path.exists(temp_dockerfile)) def test_context_git_url(self): c = create_compose_mock() cnt = get_minimal_container() cnt['build']['context'] = "https://github.com/test_repo.git" args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: False) self.assertEqual( args, [ '-t', 'new-image', '--no-cache', 'https://github.com/test_repo.git', ], ) def test_context_invalid_git_url_git_is_not_prefix(self): c = create_compose_mock() cnt = get_minimal_container() cnt['build']['context'] = "not_prefix://github.com/test_repo" args = get_minimal_args() with self.assertRaises(OSError): container_to_build_args(c, cnt, args, lambda path: False) def test_build_ssh_absolute_path(self): c = create_compose_mock() cnt = get_minimal_container() cnt['build']['ssh'] = ["id1=/test1"] args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'Containerfile', '-t', 'new-image', '--ssh', 'id1=/test1', '--no-cache', '.', ], ) def test_build_ssh_relative_path(self): c = create_compose_mock() cnt = get_minimal_container() cnt['build']['ssh'] = ["id1=id1/test1"] args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'Containerfile', '-t', 'new-image', '--ssh', 'id1=test_dirname/id1/test1', '--no-cache', '.', ], ) def test_build_ssh_working_dir(self): c = create_compose_mock() cnt = get_minimal_container() cnt['build']['ssh'] = ["id1=./test1"] args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'Containerfile', '-t', 'new-image', '--ssh', 'id1=test_dirname/./test1', '--no-cache', '.', ], ) @mock.patch.dict(os.environ, {"HOME": "/home/user"}, clear=True) def test_build_ssh_path_home_dir(self): c = create_compose_mock() cnt = get_minimal_container() cnt['build']['ssh'] = ["id1=~/test1"] args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'Containerfile', '-t', 'new-image', '--ssh', 'id1=/home/user/test1', '--no-cache', '.', ], ) def test_build_ssh_map(self): c = create_compose_mock() cnt = get_minimal_container() cnt['build']['ssh'] = {"id1": "test1", "id2": "test2"} args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'Containerfile', '-t', 'new-image', '--ssh', 'id1=test_dirname/test1', '--ssh', 'id2=test_dirname/test2', '--no-cache', '.', ], ) def test_build_ssh_array(self): c = create_compose_mock() cnt = get_minimal_container() cnt['build']['ssh'] = ['id1=test1', 'id2=test2'] args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'Containerfile', '-t', 'new-image', '--ssh', 'id1=test_dirname/test1', '--ssh', 'id2=test_dirname/test2', '--no-cache', '.', ], ) def test_pull_always(self): c = create_compose_mock() cnt = get_minimal_container() args = get_minimal_args() args.pull = 'always' args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'Containerfile', '-t', 'new-image', '--no-cache', '--pull=always', '.', ], ) def test_containerfile_in_context(self): c = create_compose_mock() cnt = get_minimal_container() cnt['build']['context'] = "./subdir" args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'subdir/Containerfile', '-t', 'new-image', '--no-cache', './subdir', ], ) def test_build_environment_secret_no_target(self): c = create_compose_mock() c.declared_secrets = {'my_secret': {'environment': 'MY_VAR'}} cnt = get_minimal_container() cnt['build']['secrets'] = ['my_secret'] args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'Containerfile', '-t', 'new-image', '--secret', 'id=my_secret,env=MY_VAR', '--no-cache', '.', ], ) def test_build_environment_secret_with_target(self): c = create_compose_mock() c.declared_secrets = {'my_secret': {'environment': 'MY_VAR'}} cnt = get_minimal_container() cnt['build']['secrets'] = [{'source': 'my_secret', 'target': 'custom_id'}] args = get_minimal_args() args = container_to_build_args(c, cnt, args, lambda path: True) self.assertEqual( args, [ '-f', 'Containerfile', '-t', 'new-image', '--secret', 'id=custom_id,env=MY_VAR', '--no-cache', '.', ], ) podman-compose-1.6.0/tests/unit/test_depends_on.py000066400000000000000000000030251521011662300223040ustar00rootroot00000000000000import unittest from typing import Any from parameterized import parameterized from podman_compose import flat_deps class TestDependsOn(unittest.TestCase): @parameterized.expand([ ( { "service_a": {}, "service_b": {"depends_on": {"service_a": {"condition": "healthy"}}}, "service_c": {"depends_on": {"service_b": {"condition": "healthy"}}}, }, # dependencies { "service_a": set(), "service_b": set(["service_a"]), "service_c": set(["service_a", "service_b"]), }, # dependents { "service_a": set(["service_b", "service_c"]), "service_b": set(["service_c"]), "service_c": set(), }, ), ]) def test_flat_deps( self, services: dict[str, Any], deps: dict[str, set[str]], dependents: dict[str, set[str]], ) -> None: flat_deps(services) self.assertEqual( { name: set([x.name for x in value.get("_deps", set())]) for name, value in services.items() }, deps, msg="Dependencies do not match", ) self.assertEqual( { name: set([x.name for x in value.get("_dependents", set())]) for name, value in services.items() }, dependents, msg="Dependents do not match", ) podman-compose-1.6.0/tests/unit/test_get_net_args.py000066400000000000000000000264021521011662300226330ustar00rootroot00000000000000import unittest from parameterized import parameterized from podman_compose import PodmanCompose from podman_compose import get_net_args from tests.unit.test_container_to_args import create_compose_mock PROJECT_NAME = "test_project_name" SERVICE_NAME = "service_name" CONTAINER_NAME = f"{PROJECT_NAME}_{SERVICE_NAME}_1" def get_networked_compose(num_networks: int = 1) -> PodmanCompose: compose = create_compose_mock(PROJECT_NAME) for network in range(num_networks): compose.networks[f"net{network}"] = { "driver": "bridge", "ipam": { "config": [ {"subnet": f"192.168.{network}.0/24"}, {"subnet": f"fd00:{network}::/64"}, ] }, "enable_ipv6": True, } if num_networks == 1: compose.default_net = "net0" return compose def get_minimal_container() -> dict: return { "name": CONTAINER_NAME, "service_name": SERVICE_NAME, "image": "busybox", } class TestGetNetArgs(unittest.TestCase): def test_minimal(self) -> None: compose = get_networked_compose() container = get_minimal_container() self.assertEqual( get_net_args(compose, container), [f"--network={PROJECT_NAME}_net0:alias={SERVICE_NAME}"], ) def test_default_net_is_None(self) -> None: compose = get_networked_compose() container = get_minimal_container() mac_address = "11:22:33:44:55:66" container["mac_address"] = mac_address compose.default_net = None self.assertEqual( get_net_args(compose, container), [f"--network=bridge:alias={SERVICE_NAME},mac={mac_address}"], ) def test_one_net(self) -> None: compose = get_networked_compose() container = get_minimal_container() container["networks"] = {"net0": {}} self.assertEqual( get_net_args(compose, container), [f"--network={PROJECT_NAME}_net0:alias={SERVICE_NAME}"], ) def test_alias(self) -> None: compose = get_networked_compose() container = get_minimal_container() container["networks"] = {"net0": {}} container["_aliases"] = ["alias1", "alias2"] self.assertEqual( get_net_args(compose, container), [f"--network={PROJECT_NAME}_net0:alias={SERVICE_NAME},alias=alias1,alias=alias2"], ) def test_aliases_on_network_scope(self) -> None: compose = get_networked_compose() container = get_minimal_container() container["networks"] = {"net0": {"aliases": ["alias1"]}} self.assertEqual( get_net_args(compose, container), [f"--network={PROJECT_NAME}_net0:alias={SERVICE_NAME},alias=alias1"], ) def test_one_ipv4(self) -> None: ip = "192.168.0.42" compose = get_networked_compose() container = get_minimal_container() container["networks"] = {"net0": {"ipv4_address": ip}} self.assertEqual( get_net_args(compose, container), [f"--network={PROJECT_NAME}_net0:ip={ip},alias={SERVICE_NAME}"], ) def test_one_ipv6(self) -> None: ipv6_address = "fd00:0::42" compose = get_networked_compose() container = get_minimal_container() container["networks"] = {"net0": {"ipv6_address": ipv6_address}} self.assertEqual( get_net_args(compose, container), [f"--network={PROJECT_NAME}_net0:ip6={ipv6_address},alias={SERVICE_NAME}"], ) def test_one_mac(self) -> None: mac = "00:11:22:33:44:55" compose = get_networked_compose() container = get_minimal_container() container["networks"] = {"net0": {}} container["mac_address"] = mac self.assertEqual( get_net_args(compose, container), [f"--network={PROJECT_NAME}_net0:mac={mac},alias={SERVICE_NAME}"], ) def test_one_mac_two_nets(self) -> None: mac = "00:11:22:33:44:55" compose = get_networked_compose(num_networks=6) container = get_minimal_container() container["networks"] = {"net0": {}, "net1": {}} container["mac_address"] = mac self.assertEqual( get_net_args(compose, container), [ f"--network={PROJECT_NAME}_net0:mac={mac},alias={SERVICE_NAME}", f"--network={PROJECT_NAME}_net1:alias={SERVICE_NAME}", ], ) @parameterized.expand([ "mac_address", "x-podman.mac_address", ]) def test_mac_on_network(self, mac_attr: str) -> None: mac = "00:11:22:33:44:55" compose = get_networked_compose() container = get_minimal_container() container["networks"] = {"net0": {mac_attr: mac}} self.assertEqual( get_net_args(compose, container), [f"--network={PROJECT_NAME}_net0:mac={mac},alias={SERVICE_NAME}"], ) def test_two_nets_as_dict(self) -> None: compose = get_networked_compose(num_networks=2) container = get_minimal_container() container["networks"] = {"net0": {}, "net1": {}} self.assertEqual( get_net_args(compose, container), [ f"--network={PROJECT_NAME}_net0:alias={SERVICE_NAME}", f"--network={PROJECT_NAME}_net1:alias={SERVICE_NAME}", ], ) def test_two_nets_as_list(self) -> None: compose = get_networked_compose(num_networks=2) container = get_minimal_container() container["networks"] = ["net0", "net1"] self.assertEqual( get_net_args(compose, container), [ f"--network={PROJECT_NAME}_net0:alias={SERVICE_NAME}", f"--network={PROJECT_NAME}_net1:alias={SERVICE_NAME}", ], ) def test_two_ipv4(self) -> None: ip0 = "192.168.0.42" ip1 = "192.168.1.42" compose = get_networked_compose(num_networks=2) container = get_minimal_container() container["networks"] = {"net0": {"ipv4_address": ip0}, "net1": {"ipv4_address": ip1}} self.assertEqual( get_net_args(compose, container), [ f"--network={PROJECT_NAME}_net0:ip={ip0},alias={SERVICE_NAME}", f"--network={PROJECT_NAME}_net1:ip={ip1},alias={SERVICE_NAME}", ], ) def test_two_ipv6(self) -> None: ip0 = "fd00:0::42" ip1 = "fd00:1::42" compose = get_networked_compose(num_networks=2) container = get_minimal_container() container["networks"] = {"net0": {"ipv6_address": ip0}, "net1": {"ipv6_address": ip1}} self.assertEqual( get_net_args(compose, container), [ f"--network={PROJECT_NAME}_net0:ip6={ip0},alias={SERVICE_NAME}", f"--network={PROJECT_NAME}_net1:ip6={ip1},alias={SERVICE_NAME}", ], ) # custom extension; not supported by docker-compose def test_two_mac(self) -> None: mac0 = "00:00:00:00:00:01" mac1 = "00:00:00:00:00:02" compose = get_networked_compose(num_networks=2) container = get_minimal_container() container["networks"] = { "net0": {"x-podman.mac_address": mac0}, "net1": {"x-podman.mac_address": mac1}, } self.assertEqual( get_net_args(compose, container), [ f"--network={PROJECT_NAME}_net0:mac={mac0},alias={SERVICE_NAME}", f"--network={PROJECT_NAME}_net1:mac={mac1},alias={SERVICE_NAME}", ], ) def test_mixed_mac(self) -> None: ip4_0 = "192.168.0.42" ip4_1 = "192.168.1.42" ip4_2 = "192.168.2.42" mac_0 = "00:00:00:00:00:01" mac_1 = "00:00:00:00:00:02" compose = get_networked_compose(num_networks=3) container = get_minimal_container() container["networks"] = { "net0": {"ipv4_address": ip4_0}, "net1": {"ipv4_address": ip4_1, "x-podman.mac_address": mac_0}, "net2": {"ipv4_address": ip4_2}, } container["mac_address"] = mac_1 expected_exception = ( r"specifying mac_address on both container and network level is not supported" ) self.assertRaisesRegex(RuntimeError, expected_exception, get_net_args, compose, container) def test_mixed_config(self) -> None: ip4_0 = "192.168.0.42" ip4_1 = "192.168.1.42" ip6_0 = "fd00:0::42" ip6_2 = "fd00:2::42" mac = "00:11:22:33:44:55" compose = get_networked_compose(num_networks=4) container = get_minimal_container() container["networks"] = { "net0": {"ipv4_address": ip4_0, "ipv6_address": ip6_0}, "net1": {"ipv4_address": ip4_1}, "net2": {"ipv6_address": ip6_2}, "net3": {}, } container["mac_address"] = mac self.assertEqual( get_net_args(compose, container), [ f"--network={PROJECT_NAME}_net0:ip={ip4_0},ip6={ip6_0},mac={mac},alias={SERVICE_NAME}", f"--network={PROJECT_NAME}_net1:ip={ip4_1},alias={SERVICE_NAME}", f"--network={PROJECT_NAME}_net2:ip6={ip6_2},alias={SERVICE_NAME}", f"--network={PROJECT_NAME}_net3:alias={SERVICE_NAME}", ], ) @parameterized.expand([ ("bridge", [f"--network=bridge:alias={SERVICE_NAME},mac=11:22:33:44:55:66"]), ( "bridge:ip=10.88.0.3", [f"--network=bridge:ip=10.88.0.3,alias={SERVICE_NAME},mac=11:22:33:44:55:66"], ), ("host", ["--network=host"]), ("none", ["--network=none"]), ("slirp4netns", ["--network=slirp4netns"]), ("slirp4netns:cidr=10.42.0.0/24", ["--network=slirp4netns:cidr=10.42.0.0/24"]), ("private", ["--network=private"]), ("pasta", ["--network=pasta"]), ("pasta:--ipv4-only,-a,10.0.2.0", ["--network=pasta:--ipv4-only,-a,10.0.2.0"]), ("ns:my_namespace", ["--network=ns:my_namespace"]), ("container:my_container", ["--network=container:my_container"]), ]) def test_network_modes(self, network_mode: str, expected_args: list) -> None: compose = get_networked_compose() container = get_minimal_container() container["network_mode"] = network_mode mac_address = "11:22:33:44:55:66" container["network_mode"] = network_mode container["mac_address"] = mac_address self.assertEqual(get_net_args(compose, container), expected_args) def test_network_mode_invalid(self) -> None: compose = get_networked_compose() container = get_minimal_container() container["network_mode"] = "invalid_mode" with self.assertRaises(SystemExit): get_net_args(compose, container) def test_network__mode_service(self) -> None: compose = get_networked_compose() compose.container_names_by_service = { "service_1": ["container_1"], "service_2": ["container_2"], } container = get_minimal_container() container["network_mode"] = "service:service_2" self.assertEqual(get_net_args(compose, container), ["--network=container:container_2"]) podman-compose-1.6.0/tests/unit/test_get_network_create_args.py000066400000000000000000000212541521011662300250610ustar00rootroot00000000000000import unittest from typing import Any from podman_compose import get_network_create_args class TestGetNetworkCreateArgs(unittest.TestCase): def get_minimal_net_desc(self) -> dict[str, Any]: return { "labels": [], "internal": False, "driver": None, "driver_opts": {}, "ipam": {"config": []}, "enable_ipv6": False, } def test_minimal(self) -> None: net_desc = self.get_minimal_net_desc() proj_name = "test_project" net_name = "test_network" expected_args = [ "create", "--label", f"io.podman.compose.project={proj_name}", "--label", f"com.docker.compose.project={proj_name}", net_name, ] args = get_network_create_args(net_desc, proj_name, net_name) self.assertEqual(args, expected_args) def test_ipv6(self) -> None: net_desc = self.get_minimal_net_desc() net_desc["enable_ipv6"] = True proj_name = "test_project" net_name = "test_network" expected_args = [ "create", "--label", f"io.podman.compose.project={proj_name}", "--label", f"com.docker.compose.project={proj_name}", "--ipv6", net_name, ] args = get_network_create_args(net_desc, proj_name, net_name) self.assertEqual(args, expected_args) def test_bridge(self) -> None: net_desc = self.get_minimal_net_desc() net_desc["driver"] = "bridge" net_desc["driver_opts"] = {"opt1": "value1", "opt2": "value2"} proj_name = "test_project" net_name = "test_network" expected_args = [ "create", "--label", f"io.podman.compose.project={proj_name}", "--label", f"com.docker.compose.project={proj_name}", "--driver", "bridge", "--opt", "opt1=value1", "--opt", "opt2=value2", net_name, ] args = get_network_create_args(net_desc, proj_name, net_name) self.assertEqual(args, expected_args) def test_ipam_driver_default(self) -> None: net_desc = self.get_minimal_net_desc() net_desc["ipam"] = { "driver": "default", "config": [ { "subnet": "192.168.0.0/24", "ip_range": "192.168.0.2/24", "gateway": "192.168.0.1", } ], } proj_name = "test_project" net_name = "test_network" expected_args = [ "create", "--label", f"io.podman.compose.project={proj_name}", "--label", f"com.docker.compose.project={proj_name}", "--subnet", "192.168.0.0/24", "--ip-range", "192.168.0.2/24", "--gateway", "192.168.0.1", net_name, ] args = get_network_create_args(net_desc, proj_name, net_name) self.assertEqual(args, expected_args) def test_ipam_driver(self) -> None: net_desc = self.get_minimal_net_desc() net_desc["ipam"] = { "driver": "someipamdriver", "config": [ { "subnet": "192.168.0.0/24", "ip_range": "192.168.0.2/24", "gateway": "192.168.0.1", } ], } proj_name = "test_project" net_name = "test_network" expected_args = [ "create", "--label", f"io.podman.compose.project={proj_name}", "--label", f"com.docker.compose.project={proj_name}", "--ipam-driver", "someipamdriver", "--subnet", "192.168.0.0/24", "--ip-range", "192.168.0.2/24", "--gateway", "192.168.0.1", net_name, ] args = get_network_create_args(net_desc, proj_name, net_name) self.assertEqual(args, expected_args) def test_complete(self) -> None: net_desc = { "labels": ["label1", "label2"], "internal": True, "driver": "bridge", "driver_opts": {"opt1": "value1", "opt2": "value2"}, "ipam": { "driver": "someipamdriver", "config": [ { "subnet": "192.168.0.0/24", "ip_range": "192.168.0.2/24", "gateway": "192.168.0.1", } ], }, "enable_ipv6": True, } proj_name = "test_project" net_name = "test_network" expected_args = [ "create", "--label", f"io.podman.compose.project={proj_name}", "--label", f"com.docker.compose.project={proj_name}", "--label", "label1", "--label", "label2", "--internal", "--driver", "bridge", "--opt", "opt1=value1", "--opt", "opt2=value2", "--ipam-driver", "someipamdriver", "--ipv6", "--subnet", "192.168.0.0/24", "--ip-range", "192.168.0.2/24", "--gateway", "192.168.0.1", net_name, ] args = get_network_create_args(net_desc, proj_name, net_name) self.assertEqual(args, expected_args) def test_disable_dns(self) -> None: net_desc = self.get_minimal_net_desc() net_desc["x-podman.disable_dns"] = True proj_name = "test_project" net_name = "test_network" expected_args = [ "create", "--label", f"io.podman.compose.project={proj_name}", "--label", f"com.docker.compose.project={proj_name}", "--disable-dns", net_name, ] args = get_network_create_args(net_desc, proj_name, net_name) self.assertEqual(args, expected_args) def test_dns_string(self) -> None: net_desc = self.get_minimal_net_desc() net_desc["x-podman.dns"] = "192.168.1.2" proj_name = "test_project" net_name = "test_network" expected_args = [ "create", "--label", f"io.podman.compose.project={proj_name}", "--label", f"com.docker.compose.project={proj_name}", "--dns", "192.168.1.2", net_name, ] args = get_network_create_args(net_desc, proj_name, net_name) self.assertEqual(args, expected_args) def test_dns_list(self) -> None: net_desc = self.get_minimal_net_desc() net_desc["x-podman.dns"] = ["192.168.1.2", "192.168.1.3"] proj_name = "test_project" net_name = "test_network" expected_args = [ "create", "--label", f"io.podman.compose.project={proj_name}", "--label", f"com.docker.compose.project={proj_name}", "--dns", "192.168.1.2,192.168.1.3", net_name, ] args = get_network_create_args(net_desc, proj_name, net_name) self.assertEqual(args, expected_args) def test_routes_string(self) -> None: net_desc = self.get_minimal_net_desc() net_desc["x-podman.routes"] = "192.168.1.0/24" proj_name = "test_project" net_name = "test_network" expected_args = [ "create", "--label", f"io.podman.compose.project={proj_name}", "--label", f"com.docker.compose.project={proj_name}", "--route", "192.168.1.0/24", net_name, ] args = get_network_create_args(net_desc, proj_name, net_name) self.assertEqual(args, expected_args) def test_routes_list(self) -> None: net_desc = self.get_minimal_net_desc() net_desc["x-podman.routes"] = ["192.168.1.0/24", "192.168.2.0/24"] proj_name = "test_project" net_name = "test_network" expected_args = [ "create", "--label", f"io.podman.compose.project={proj_name}", "--label", f"com.docker.compose.project={proj_name}", "--route", "192.168.1.0/24", "--route", "192.168.2.0/24", net_name, ] args = get_network_create_args(net_desc, proj_name, net_name) self.assertEqual(args, expected_args) podman-compose-1.6.0/tests/unit/test_include.py000066400000000000000000000037131521011662300216150ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 # pylint: disable=protected-access from __future__ import annotations import argparse import os import unittest from typing import Any import yaml from podman_compose import PodmanCompose class TestIncludeDict(unittest.TestCase): def setUp(self) -> None: self.test_files: list[str] = [] def tearDown(self) -> None: for f in self.test_files: if os.path.exists(f): os.remove(f) def write_yaml(self, name: str, content: dict[str, Any]) -> None: with open(name, "w", encoding="utf-8") as f: yaml.safe_dump(content, f) self.test_files.append(name) def test_parse_compose_file_with_include_dict(self) -> None: # 1. Create a base compose file to be included include_content = {"services": {"base_web": {"image": "nginx:alpine"}}} self.write_yaml("test-compose-include.yaml", include_content) # 2. Create a main compose file with include detailed format (dictionary) main_content = { "version": "3.8", "include": [{"path": "./test-compose-include.yaml"}], "services": {"web": {"image": "alpine:latest"}}, } self.write_yaml("test-compose-main.yaml", main_content) podman_compose = PodmanCompose() podman_compose.global_args = argparse.Namespace() podman_compose.global_args.file = ["test-compose-main.yaml"] podman_compose.global_args.project_name = "test_project" podman_compose.global_args.env_file = None podman_compose.global_args.profile = [] podman_compose.global_args.in_pod = "false" podman_compose.global_args.pod_args = None podman_compose.global_args.no_normalize = True # It should run successfully without raising a TypeError podman_compose._parse_compose_file() self.assertIn("web", podman_compose.services) self.assertIn("base_web", podman_compose.services) podman-compose-1.6.0/tests/unit/test_is_context_git_url.py000066400000000000000000000101261521011662300240720ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import unittest from parameterized import parameterized from podman_compose import is_context_git_url class TestIsContextGitUrl(unittest.TestCase): @parameterized.expand([ ("with_url_fragment", "http://host.xz/path/to/repo.git#fragment", True), ("suffix_and_prefix", "git://host.xz/path/to/repo.git", True), ("empty_url_path", "http://#fragment", True), ("no_prefix", "http://host.xz/path/to/repo", True), ("wrong_prefix_git", "gitt://host.xz/path/to/repo", False), ("wrong_prefix_http", "htt://host.xz/path/to/repo.git", False), ("user_path_ending_with_git", "path/to/workdir.git", False), ("", "/path/to/workdir.git", False), ("", "/path/to:workdir.git", False), ("", "/path/to@workdir.git", False), ("", "~/path/to/workdir.git", False), # many of possible ways git url can look like ("", "http://example.com/my-project.git", True), ("", "file:///absolute/path/to/my-project.git", True), ("", "ssh:user@example.com:my-project", True), ("", "git@github.com:user/project.git", True), ("", "https://github.com/user/project.git", True), ("", "http://github.com/user/project.git", True), ("", "git@192.168.101.127:user/project.git", True), ("", "https://192.168.101.127/user/project.git", True), ("", "http://192.168.101.127/user/project.git", True), ("", "ssh://user@host.xz:port/path/to/repo.git/", True), ("", "ssh://user@host.xz/path/to/repo.git/", True), ("", "ssh://host.xz:port/path/to/repo.git/", True), ("", "ssh://host.xz/path/to/repo.git/", True), ("", "ssh://user@host.xz/path/to/repo.git/", True), ("", "ssh://host.xz/path/to/repo.git/", True), ("", "ssh://user@host.xz/~user/path/to/repo.git/", True), ("", "ssh://host.xz/~user/path/to/repo.git/", True), ("", "ssh://user@host.xz/~/path/to/repo.git", True), ("", "ssh://host.xz/~/path/to/repo.git", True), ("", "git://host.xz/path/to/repo.git/", True), ("", "git://host.xz/~user/path/to/repo.git/", True), ("", "http://host.xz/path/to/repo.git/", True), ("", "https://host.xz/path/to/repo.git/", True), ("", "git@custom-gitlab:my-group/myrepo.git", True), ("", "ssh://user@host.xz:port/path/to/repo.git/", True), ("", "ssh://user@host.xz/path/to/repo.git/", True), ("", "ssh://host.xz:port/path/to/repo.git/", True), ("", "ssh://host.xz/path/to/repo.git/", True), ("", "ssh://user@host.xz/path/to/repo.git/", True), ("", "ssh://host.xz/path/to/repo.git/", True), ("", "ssh://user@host.xz/~user/path/to/repo.git/", True), ("", "ssh://host.xz/~user/path/to/repo.git/", True), ("", "ssh://user@host.xz/~/path/to/repo.git", True), ("", "ssh://host.xz/~/path/to/repo.git", True), ("", "git://host.xz/path/to/repo.git/", True), ("", "git://host.xz/~user/path/to/repo.git/", True), ("", "http://host.xz/path/to/repo.git/", True), ("", "https://host.xz/path/to/repo.git/", True), ("", "ssh:user@example.com:my-project", True), ("", "user@host.xz:/path/to/repo.git/", True), ("", "host.xz:/path/to/repo.git/", True), ("", "host:path/to/repo.git", True), ("", "host:path/to/repo", True), ("", "user@host.xz:~user/path/to/repo.git/", True), ("", "host.xz:~user/path/to/repo.git/", True), ("", "user@host.xz:path/to/repo.git", True), ("", "user@path/to/repo", True), ("", "host.xz:path/to/repo.git", True), ("", "rsync://host.xz/path/to/repo.git/", True), ("", "file:///path/to/repo.git/", True), ("", "file://~/path/to/repo.git/", True), ("", "github.com:containers/podman-compose.git", True), ("", "github:containers/podman-compose.git", True), ("", "https://github.com/test_repo.git/git_not_suffix", True), ]) def test_is_path_git_url(self, test_name: str, path: str, result: bool) -> None: self.assertEqual(is_context_git_url(path), result) podman-compose-1.6.0/tests/unit/test_is_path_git_url.py000066400000000000000000000100141521011662300233360ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import unittest from parameterized import parameterized from podman_compose import is_context_git_url class TestIsPathGitUrl(unittest.TestCase): @parameterized.expand([ ("with_url_fragment", "http://host.xz/path/to/repo.git#fragment", True), ("suffix_and_prefix", "git://host.xz/path/to/repo.git", True), ("empty_url_path", "http://#fragment", True), ("no_prefix", "http://host.xz/path/to/repo", True), ("wrong_prefix_git", "gitt://host.xz/path/to/repo", False), ("wrong_prefix_http", "htt://host.xz/path/to/repo.git", False), ("user_path_ending_with_git", "path/to/workdir.git", False), ("", "/path/to/workdir.git", False), ("", "/path/to:workdir.git", False), ("", "/path/to@workdir.git", False), ("", "~/path/to/workdir.git", False), # many of possible ways git url can look like ("", "http://example.com/my-project.git", True), ("", "file:///absolute/path/to/my-project.git", True), ("", "ssh:user@example.com:my-project", True), ("", "git@github.com:user/project.git", True), ("", "https://github.com/user/project.git", True), ("", "http://github.com/user/project.git", True), ("", "git@192.168.101.127:user/project.git", True), ("", "https://192.168.101.127/user/project.git", True), ("", "http://192.168.101.127/user/project.git", True), ("", "ssh://user@host.xz:port/path/to/repo.git/", True), ("", "ssh://user@host.xz/path/to/repo.git/", True), ("", "ssh://host.xz:port/path/to/repo.git/", True), ("", "ssh://host.xz/path/to/repo.git/", True), ("", "ssh://user@host.xz/path/to/repo.git/", True), ("", "ssh://host.xz/path/to/repo.git/", True), ("", "ssh://user@host.xz/~user/path/to/repo.git/", True), ("", "ssh://host.xz/~user/path/to/repo.git/", True), ("", "ssh://user@host.xz/~/path/to/repo.git", True), ("", "ssh://host.xz/~/path/to/repo.git", True), ("", "git://host.xz/path/to/repo.git/", True), ("", "git://host.xz/~user/path/to/repo.git/", True), ("", "http://host.xz/path/to/repo.git/", True), ("", "https://host.xz/path/to/repo.git/", True), ("", "git@custom-gitlab:my-group/myrepo.git", True), ("", "ssh://user@host.xz:port/path/to/repo.git/", True), ("", "ssh://user@host.xz/path/to/repo.git/", True), ("", "ssh://host.xz:port/path/to/repo.git/", True), ("", "ssh://host.xz/path/to/repo.git/", True), ("", "ssh://user@host.xz/path/to/repo.git/", True), ("", "ssh://host.xz/path/to/repo.git/", True), ("", "ssh://user@host.xz/~user/path/to/repo.git/", True), ("", "ssh://host.xz/~user/path/to/repo.git/", True), ("", "ssh://user@host.xz/~/path/to/repo.git", True), ("", "ssh://host.xz/~/path/to/repo.git", True), ("", "git://host.xz/path/to/repo.git/", True), ("", "git://host.xz/~user/path/to/repo.git/", True), ("", "http://host.xz/path/to/repo.git/", True), ("", "https://host.xz/path/to/repo.git/", True), ("", "ssh:user@example.com:my-project", True), ("", "user@host.xz:/path/to/repo.git/", True), ("", "host.xz:/path/to/repo.git/", True), ("", "host:path/to/repo.git", True), ("", "host:path/to/repo", True), ("", "user@host.xz:~user/path/to/repo.git/", True), ("", "host.xz:~user/path/to/repo.git/", True), ("", "user@host.xz:path/to/repo.git", True), ("", "user@path/to/repo", True), ("", "host.xz:path/to/repo.git", True), ("", "rsync://host.xz/path/to/repo.git/", True), ("", "file:///path/to/repo.git/", True), ("", "file://~/path/to/repo.git/", True), ("", "github.com:containers/podman-compose.git", True), ("", "github:containers/podman-compose.git", True), ]) def test_is_path_git_url(self, test_name: str, path: str, result: bool) -> None: self.assertEqual(is_context_git_url(path), result) podman-compose-1.6.0/tests/unit/test_main.py000066400000000000000000000017631521011662300211210ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import contextlib import io import unittest from unittest import mock from podman_compose import PodmanComposeError from podman_compose import main class TestMain(unittest.TestCase): @mock.patch("podman_compose.asyncio.run") def test_main_shows_clean_error_for_podman_compose_error(self, mocked_run: mock.Mock) -> None: def fake_asyncio_run(coro: object) -> None: if hasattr(coro, "close"): coro.close() # prevent un-awaited coroutine warnings in this unit test raise PodmanComposeError("External network [missing-net] does not exist") mocked_run.side_effect = fake_asyncio_run stderr = io.StringIO() with contextlib.redirect_stderr(stderr), self.assertRaises(SystemExit) as ex: main() self.assertEqual(ex.exception.code, 1) self.assertEqual( stderr.getvalue().strip(), "Error: External network [missing-net] does not exist", ) podman-compose-1.6.0/tests/unit/test_normalize_depends_on.py000066400000000000000000000026441521011662300243720ustar00rootroot00000000000000from __future__ import annotations import copy import unittest from typing import Any from parameterized import parameterized from podman_compose import normalize_service class TestNormalizeServicesSimple(unittest.TestCase): @parameterized.expand([ ( {"depends_on": "my_service"}, {"depends_on": {"my_service": {"condition": "service_started"}}}, ), ( {"depends_on": ["my_service"]}, {"depends_on": {"my_service": {"condition": "service_started"}}}, ), ( {"depends_on": ["my_service1", "my_service2"]}, { "depends_on": { "my_service1": {"condition": "service_started"}, "my_service2": {"condition": "service_started"}, }, }, ), ( {"depends_on": {"my_service": {"condition": "service_started"}}}, {"depends_on": {"my_service": {"condition": "service_started"}}}, ), ( {"depends_on": {"my_service": {"condition": "service_healthy"}}}, {"depends_on": {"my_service": {"condition": "service_healthy"}}}, ), ]) def test_normalize_service_simple( self, test_case: dict[str, Any], expected: dict[str, Any] ) -> None: copy.deepcopy(test_case) result = normalize_service(test_case) self.assertEqual(result, expected) podman-compose-1.6.0/tests/unit/test_normalize_final_build.py000066400000000000000000000207761521011662300245320ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 # pylint: disable=protected-access from __future__ import annotations import argparse import os import unittest import yaml from parameterized import parameterized from podman_compose import PodmanCompose from podman_compose import normalize_final from podman_compose import normalize_service_final cwd = os.path.abspath(".") class TestNormalizeFinalBuild(unittest.TestCase): cases_simple_normalization = [ ({"image": "test-image"}, {"image": "test-image"}), ( {"build": "."}, { "build": {"context": cwd}, }, ), ( {"build": "../relative"}, { "build": { "context": os.path.normpath(os.path.join(cwd, "../relative")), }, }, ), ( {"build": "./relative"}, { "build": { "context": os.path.normpath(os.path.join(cwd, "./relative")), }, }, ), ( {"build": "/workspace/absolute"}, { "build": { "context": "/workspace/absolute", }, }, ), ( { "build": { "dockerfile": "Dockerfile", }, }, { "build": { "context": cwd, "dockerfile": "Dockerfile", }, }, ), ( { "build": { "context": ".", }, }, { "build": { "context": cwd, }, }, ), ( { "build": {"context": "../", "dockerfile": "test-dockerfile"}, }, { "build": { "context": os.path.normpath(os.path.join(cwd, "../")), "dockerfile": "test-dockerfile", }, }, ), ( { "build": {"context": ".", "dockerfile": "./dev/test-dockerfile"}, }, { "build": { "context": cwd, "dockerfile": "./dev/test-dockerfile", }, }, ), ] @parameterized.expand(cases_simple_normalization) def test_normalize_service_final_returns_absolute_path_in_context(self, input, expected): # Tests that [service.build] is normalized after merges project_dir = cwd self.assertEqual(normalize_service_final(input, project_dir), expected) @parameterized.expand(cases_simple_normalization) def test_normalize_returns_absolute_path_in_context(self, input, expected): project_dir = cwd compose_test = {"services": {"test-service": input}} compose_expected = {"services": {"test-service": expected}} self.assertEqual(normalize_final(compose_test, project_dir), compose_expected) @parameterized.expand(cases_simple_normalization) def test_parse_compose_file_when_single_compose(self, input, expected): compose_test = {"services": {"test-service": input}} dump_yaml(compose_test, "test-compose.yaml") podman_compose = PodmanCompose() set_args(podman_compose, ["test-compose.yaml"], no_normalize=None) podman_compose._parse_compose_file() actual_compose = {} if podman_compose.services: actual_compose = podman_compose.original_configuration( podman_compose.services["test-service"] ) self.assertEqual(actual_compose, expected) @parameterized.expand([ ( {}, {"build": "."}, {"build": {"context": cwd}}, ), ( {"build": "."}, {}, {"build": {"context": cwd}}, ), ( {"build": "/workspace/absolute"}, {"build": "./relative"}, { "build": { "context": os.path.normpath(os.path.join(cwd, "./relative")), } }, ), ( {"build": "./relative"}, {"build": "/workspace/absolute"}, {"build": {"context": "/workspace/absolute"}}, ), ( {"build": "./relative"}, {"build": "/workspace/absolute"}, {"build": {"context": "/workspace/absolute"}}, ), ( {"build": {"dockerfile": "test-dockerfile"}}, {}, {"build": {"context": cwd, "dockerfile": "test-dockerfile"}}, ), ( {}, {"build": {"dockerfile": "test-dockerfile"}}, {"build": {"context": cwd, "dockerfile": "test-dockerfile"}}, ), ( {}, {"build": {"dockerfile": "test-dockerfile"}}, {"build": {"context": cwd, "dockerfile": "test-dockerfile"}}, ), ( {"build": {"dockerfile": "test-dockerfile-1"}}, {"build": {"dockerfile": "test-dockerfile-2"}}, {"build": {"context": cwd, "dockerfile": "test-dockerfile-2"}}, ), ( {"build": "/workspace/absolute"}, {"build": {"dockerfile": "test-dockerfile"}}, {"build": {"context": "/workspace/absolute", "dockerfile": "test-dockerfile"}}, ), ( {"build": {"dockerfile": "test-dockerfile"}}, {"build": "/workspace/absolute"}, {"build": {"context": "/workspace/absolute", "dockerfile": "test-dockerfile"}}, ), ( {"build": {"dockerfile": "./test-dockerfile-1"}}, {"build": {"dockerfile": "./test-dockerfile-2", "args": ["ENV1=1"]}}, { "build": { "context": cwd, "dockerfile": "./test-dockerfile-2", "args": ["ENV1=1"], } }, ), ( {"build": {"dockerfile": "./test-dockerfile-1", "args": ["ENV1=1"]}}, {"build": {"dockerfile": "./test-dockerfile-2"}}, { "build": { "context": cwd, "dockerfile": "./test-dockerfile-2", "args": ["ENV1=1"], } }, ), ( {"build": {"dockerfile": "./test-dockerfile-1", "args": ["ENV1=1"]}}, {"build": {"dockerfile": "./test-dockerfile-2", "args": ["ENV2=2"]}}, { "build": { "context": cwd, "dockerfile": "./test-dockerfile-2", "args": ["ENV1=1", "ENV2=2"], } }, ), ]) def test_parse_when_multiple_composes(self, input, override, expected): compose_test_1 = {"services": {"test-service": input}} compose_test_2 = {"services": {"test-service": override}} dump_yaml(compose_test_1, "test-compose-1.yaml") dump_yaml(compose_test_2, "test-compose-2.yaml") podman_compose = PodmanCompose() set_args( podman_compose, ["test-compose-1.yaml", "test-compose-2.yaml"], no_normalize=None, ) podman_compose._parse_compose_file() actual_compose = {} if podman_compose.services: actual_compose = podman_compose.original_configuration( podman_compose.services["test-service"] ) self.assertEqual(actual_compose, expected) def set_args(podman_compose: PodmanCompose, file_names: list[str], no_normalize: bool) -> None: podman_compose.global_args = argparse.Namespace() podman_compose.global_args.file = file_names podman_compose.global_args.project_name = None podman_compose.global_args.env_file = None podman_compose.global_args.profile = [] podman_compose.global_args.in_pod = "1" podman_compose.global_args.pod_args = None podman_compose.global_args.no_normalize = no_normalize def dump_yaml(compose: dict, name: str) -> None: # Path(Path.cwd()/"subdirectory").mkdir(parents=True, exist_ok=True) with open(name, "w", encoding="utf-8") as outfile: yaml.safe_dump(compose, outfile, default_flow_style=False) def test_clean_test_yamls() -> None: test_files = ["test-compose-1.yaml", "test-compose-2.yaml", "test-compose.yaml"] for file in test_files: if os.path.exists(file): os.remove(file) podman-compose-1.6.0/tests/unit/test_normalize_service.py000066400000000000000000000073371521011662300237200ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import unittest from typing import Any from typing import Union from parameterized import parameterized from podman_compose import normalize_service class TestNormalizeService(unittest.TestCase): @parameterized.expand([ ({"test": "test"}, {"test": "test"}), ({"build": "."}, {"build": {"context": "."}}), ({"build": "./dir-1"}, {"build": {"context": "./dir-1"}}), ({"build": {"context": "./dir-1"}}, {"build": {"context": "./dir-1"}}), ( {"build": {"dockerfile": "dockerfile-1"}}, {"build": {"dockerfile": "dockerfile-1"}}, ), ( {"build": {"context": "./dir-1", "dockerfile": "dockerfile-1"}}, {"build": {"context": "./dir-1", "dockerfile": "dockerfile-1"}}, ), ( {"build": {"additional_contexts": ["ctx=../ctx", "ctx2=../ctx2"]}}, {"build": {"additional_contexts": ["ctx=../ctx", "ctx2=../ctx2"]}}, ), ( {"build": {"additional_contexts": {"ctx": "../ctx", "ctx2": "../ctx2"}}}, {"build": {"additional_contexts": ["ctx=../ctx", "ctx2=../ctx2"]}}, ), ]) def test_simple(self, input: dict[str, Any], expected: dict[str, Any]) -> None: self.assertEqual(normalize_service(input), expected) @parameterized.expand([ ({"test": "test"}, {"test": "test"}), ({"build": "."}, {"build": {"context": "./sub_dir/."}}), ({"build": "./dir-1"}, {"build": {"context": "./sub_dir/dir-1"}}), ({"build": {"context": "./dir-1"}}, {"build": {"context": "./sub_dir/dir-1"}}), ( {"build": {"dockerfile": "dockerfile-1"}}, {"build": {"context": "./sub_dir", "dockerfile": "dockerfile-1"}}, ), ( {"build": {"context": "./dir-1", "dockerfile": "dockerfile-1"}}, {"build": {"context": "./sub_dir/dir-1", "dockerfile": "dockerfile-1"}}, ), ( {"volumes": ["./nested/relative:/mnt", "../dir-in-parent:/mnt", "..:/mnt", ".:/mnt"]}, { "volumes": [ "./sub_dir/./nested/relative:/mnt", "./sub_dir/../dir-in-parent:/mnt", "./sub_dir/..:/mnt", "./sub_dir/.:/mnt", ] }, ), ( { "volumes": [ { "type": "bind", "source": "./nested/relative", "target": "/mnt", } ] }, { "volumes": [ { "type": "bind", "source": "./sub_dir/./nested/relative", "target": "/mnt", } ] }, ), ]) def test_normalize_service_with_sub_dir( self, input: dict[str, Any], expected: dict[str, Any] ) -> None: self.assertEqual(normalize_service(input, sub_dir="./sub_dir"), expected) @parameterized.expand([ ([], []), (["sh"], ["sh"]), (["sh", "-c", "date"], ["sh", "-c", "date"]), ("sh", ["sh"]), ("sleep infinity", ["sleep", "infinity"]), ( "bash -c 'sleep infinity'", ["bash", "-c", "sleep infinity"], ), ]) def test_command_like(self, input: Union[list[str], str], expected: list[str]) -> None: for key in ['command', 'entrypoint']: input_service = {} input_service[key] = input expected_service = {} expected_service[key] = expected self.assertEqual(normalize_service(input_service), expected_service) podman-compose-1.6.0/tests/unit/test_pull_image.py000066400000000000000000000122521521011662300223060ustar00rootroot00000000000000from argparse import Namespace from unittest import IsolatedAsyncioTestCase from unittest import mock from parameterized import parameterized from podman_compose import PullImageSettings from podman_compose import pull_image from podman_compose import pull_images from podman_compose import settings_to_pull_args class TestPullImageSettings(IsolatedAsyncioTestCase): def test_unsupported_policy_fallback_to_missing(self) -> None: settings = PullImageSettings("localhost/test:1", policy="unsupported") assert settings.policy == "missing" def test_update_policy(self) -> None: settings = PullImageSettings("localhost/test:1", policy="never") assert settings.policy == "never" # not supported policy settings.update_policy("unsupported") assert settings.policy == "never" settings.update_policy("missing") assert settings.policy == "missing" settings.update_policy("newer") assert settings.policy == "newer" settings.update_policy("always") assert settings.policy == "always" # Ensure policy is not downgraded settings.update_policy("build") assert settings.policy == "always" def test_pull_args(self) -> None: settings = PullImageSettings("localhost/test:1", policy="always", quiet=True) assert settings_to_pull_args(settings) == [ "--policy", "always", "--quiet", "localhost/test:1", ] settings.quiet = False assert settings_to_pull_args(settings) == ["--policy", "always", "localhost/test:1"] @mock.patch("podman_compose.Podman") async def test_pull_success(self, podman_mock: mock.Mock) -> None: settings = PullImageSettings("localhost/test:1", policy="always", quiet=True) run_mock = mock.AsyncMock(return_value=0) podman_mock.run = run_mock result = await pull_image(podman_mock, settings) assert result == 0 run_mock.assert_called_once_with( [], "pull", ["--policy", "always", "--quiet", "localhost/test:1"] ) @mock.patch("podman_compose.Podman") async def test_pull_failed(self, podman_mock: mock.Mock) -> None: settings = PullImageSettings( "localhost/test:1", policy="always", quiet=True, ignore_pull_error=True, ) podman_mock.run = mock.AsyncMock(return_value=1) # with ignore_pull_error=True, should return 0 even if pull fails result = await pull_image(podman_mock, settings) assert result == 0 # with ignore_pull_error=False, should return the actual error code settings.ignore_pull_error = False result = await pull_image(podman_mock, settings) assert result == 1 @mock.patch("podman_compose.Podman") async def test_pull_with_never_policy(self, podman_mock: mock.Mock) -> None: settings = PullImageSettings( "localhost/test:1", policy="never", quiet=True, ignore_pull_error=True, ) run_mock = mock.AsyncMock(return_value=1) podman_mock.run = run_mock result = await pull_image(podman_mock, settings) assert result == 0 assert run_mock.call_count == 0 @parameterized.expand([ ( "Local image should not pull", [{"image": "localhost/a:latest"}], [], ), ( "Remote image should pull", [{"image": "ghcr.io/a:latest"}], [ mock.call([], "pull", ["--policy", "missing", "ghcr.io/a:latest"]), ], ), ( "The same image in service should call once", [ {"image": "ghcr.io/a:latest"}, {"image": "ghcr.io/a:latest"}, {"image": "ghcr.io/b:latest"}, ], [ mock.call([], "pull", ["--policy", "missing", "ghcr.io/a:latest"]), mock.call([], "pull", ["--policy", "missing", "ghcr.io/b:latest"]), ], ), ]) @mock.patch("podman_compose.Podman") async def test_pull_image( self, desc: str, services: list[dict], calls: list, podman_mock: mock.Mock, ) -> None: run_mock = mock.AsyncMock(return_value=1) podman_mock.run = run_mock assert await pull_images(podman_mock, Namespace(), services) == 0 assert run_mock.call_count == len(calls) if calls: run_mock.assert_has_calls(calls, any_order=True) @mock.patch("podman_compose.Podman") async def test_pull_image_with_build_section( self, podman_mock: mock.Mock, ) -> None: run_mock = mock.AsyncMock(return_value=1) podman_mock.run = run_mock assert ( await pull_images( podman_mock, Namespace(), [ {"image": "ghcr.io/a:latest", "build": {"context": "."}}, ], ) == 0 ) assert run_mock.call_count == 1 run_mock.assert_called_with([], "pull", ["--policy", "missing", "ghcr.io/a:latest"]) podman-compose-1.6.0/tests/unit/test_rec_subs.py000066400000000000000000000071161521011662300220000ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 # pylint: disable=protected-access import unittest from typing import Any from parameterized import parameterized from podman_compose import rec_subs class TestRecSubs(unittest.TestCase): substitutions = [ # dict with environment variables ( "service's environment is low priority", {"environment": {"v1": "low priority", "actual-v1": "$v1"}}, {"environment": {"v1": "low priority", "actual-v1": "high priority"}}, ), ( "service's environment can be used in other values", {"environment": {"v100": "v1.0.0", "image": "abc:$v100"}}, {"environment": {"v100": "v1.0.0", "image": "abc:v1.0.0"}}, ), ( "Non-variable should not be substituted", {"environment": {"non_var": "$$v1", "vx": "$non_var"}, "image": "abc:$non_var"}, {"environment": {"non_var": "$v1", "vx": "$v1"}, "image": "abc:$v1"}, ), ( "service environment with unpopulated ${VARIABLE:-default} format", {"environment": {"v100": "${v100:-low priority}", "actual-v100": "$v100"}}, {"environment": {"v100": "low priority", "actual-v100": "low priority"}}, ), ( "service environment with populated ${VARIABLE:-default} format", {"environment": {"v1": "${v1:-low priority}", "actual-v1": "$v1"}}, {"environment": {"v1": "high priority", "actual-v1": "high priority"}}, ), # list ( "Values in list are substituted", ["$v1", "low priority"], ["high priority", "low priority"], ), # str ( "Value with ${VARIABLE} format", "${v1}", "high priority", ), ( "Value with ${VARIABLE:-default} format", ["${v1:-default}", "${empty:-default}", "${not_exits:-default}"], ["high priority", "default", "default"], ), ( "Value with ${VARIABLE-default} format", ["${v1-default}", "${empty-default}", "${not_exits-default}"], ["high priority", "", "default"], ), ( "Value $$ means $", "$$v1", "$v1", ), ] @parameterized.expand(substitutions) def test_rec_subs(self, desc: str, input: Any, expected: Any) -> None: sub_dict = {"v1": "high priority", "empty": ""} result = rec_subs(input, sub_dict) self.assertEqual(result, expected, msg=desc) def test_env_var_substitution_in_dictionary_keys(self) -> None: sub_dict = {"NAME": "TEST1", "NAME2": "TEST2"} input = { 'services': { 'test': { 'image': 'busybox', 'labels': { '$NAME and ${NAME2}': '${NAME2} and $NAME', 'test1.${NAME}': 'test1', '$NAME': '${NAME2}', '${NAME}.test2': 'Host(`${NAME2}`)', }, } } } result = rec_subs(input, sub_dict) expected = { 'services': { 'test': { 'image': 'busybox', 'labels': { 'TEST1 and TEST2': 'TEST2 and TEST1', 'test1.TEST1': 'test1', 'TEST1': 'TEST2', 'TEST1.test2': 'Host(`TEST2`)', }, } } } self.assertEqual(result, expected) podman-compose-1.6.0/tests/unit/test_var_interpolate.py000066400000000000000000000211501521011662300233630ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 import subprocess import unittest from typing import Union from parameterized import parameterized from podman_compose import var_interpolate class TestVarInterpolate(unittest.TestCase): test_cases = [ # 0. Substitution without braces ("Hello $NAME!", {"NAME": "Alice"}, "Hello Alice!", True), # 1. Substitution with braces ("Hello ${NAME}", {"NAME": "Alice"}, "Hello Alice", True), # 2. Unset variable (empty substitution) ("Hello ${NAME}", {}, "Hello ", True), # 3. Default if unset (:-) ("User: ${USER:-guest}", {}, "User: guest", True), # 4. Default if unset or empty (:-) ("User: ${USER:-guest}", {"USER": ""}, "User: guest", True), # 5. Default if unset (-) ("User: ${USER-guest}", {}, "User: guest", True), # 6. Default if unset but not if empty (-) ("User: ${USER-guest}", {"USER": ""}, "User: ", True), # 7. Required variable (error if unset or empty) ("Path: ${TEST_PATH:?TEST_PATH required}", {"TEST_PATH": "/bin"}, "Path: /bin", True), # 8. Required variable fails when missing ( "Path: ${TEST_PATH:?TEST_PATH required}", {}, ValueError("required variable TEST_PATH is missing a value: TEST_PATH required"), True, ), # 9. Required variable fails when empty (since :?) ( "Path: ${TEST_PATH:?TEST_PATH required}", {"TEST_PATH": ""}, ValueError("required variable TEST_PATH is missing a value: TEST_PATH required"), True, ), # 10. Required variable (no colon, fails only if unset) ("Config: ${CFG?missing}", {"CFG": "cfg.yaml"}, "Config: cfg.yaml", True), # 11. Required variable fails when unset ( "Config: ${CFG?missing}", {}, ValueError("required variable CFG is missing a value: missing"), True, ), # 12. Required variable passes even if empty (no colon) ("Config: ${CFG?missing}", {"CFG": ""}, "Config: ", True), # 13. Alternative if set (:+) ("Alt: ${MODE:+active}", {"MODE": "1"}, "Alt: active", True), # 14. Alternative if set, variable empty (still uses alt) ("Alt: ${MODE+active}", {"MODE": ""}, "Alt: active", True), # 15. Alternative if not set (+) ("Alt: ${MODE+active}", {}, "Alt: ", True), # 16. Combination of multiple vars ("${GREETING:-Hi}, ${NAME:-stranger}!", {}, "Hi, stranger!", True), # 17. Mix of required and default ("${ENV:?Missing ENV} - ${SERVICE:-api}", {"ENV": "prod"}, "prod - api", True), # 18. Default values with spaces ("${USER:-default user}", {}, "default user", True), # 19. Escaped dollar sign ( "Price: $$${AMOUNT}", {"AMOUNT": "100"}, "Price: $100", False, # In POSIX shells, $$ becomes PID, so skip shell test ), # 20. Empty variable substitution ("Value: ${VAR}", {"VAR": ""}, "Value: ", True), # 21. Nested default (inner default resolves) ("${OUTER:-${INNER:-default}}", {}, "default", True), # 22. Nested default (inner variable exists) ("${OUTER:-${INNER:-default}}", {"INNER": "inner_value"}, "inner_value", True), # 23. Nested default (outer variable exists) ( "${OUTER:-${INNER:-default}}", {"OUTER": "outer_value", "INNER": "inner_value"}, "outer_value", True, ), # 24. Nested fallback chain ("${A:-${B:-${C:-final}}}", {}, "final", True), # 25. Nested fallback uses middle value ("${A:-${B:-${C:-final}}}", {"B": "mid"}, "mid", True), # 26. Nested fallback uses top value ("${A:-${B:-${C:-final}}}", {"A": "top"}, "top", True), # 27. Nested required variable message ("${MAIN:-${BACKUP:?Missing BACKUP}}", {"BACKUP": "backup_value"}, "backup_value", True), # 28. Nested required variable fails (inner missing) ( "${MAIN:-${BACKUP:?Missing BACKUP}}", {}, ValueError("required variable BACKUP is missing a value: Missing BACKUP"), True, ), # 29. Nested default with error in inner fallback ("${X:-${Y:?Y required}}", {"Y": "ok"}, "ok", True), # 30. Nested default that triggers inner error ( "${X:-${Y:?Y required}}", {}, ValueError("required variable Y is missing a value: Y required"), True, ), # 31. Inner nested default that is never triggered because outer value is used ("${X:-${Y:?Y required}}", {"X": "ok"}, "ok", True), # 32. Nested alternative expansion ( "${VAR:+${ALT:-default_alt}}", {"VAR": "something", "ALT": "alt_value"}, "alt_value", True, ), # 33. Nested alternative fallback ("${VAR:+${ALT:-default_alt}}", {"VAR": "something"}, "default_alt", True), # 34. Nested with unset outer, inner provides default ("${OUTER:-prefix_${INNER:-none}}", {}, "prefix_none", True), # 35. Nested with inner variable set ("${OUTER:-prefix_${INNER:-none}}", {"INNER": "abc"}, "prefix_abc", True), # 36. Outer required, inner fallback ("${REQ:?Missing ${ALT:-something}}", {"REQ": "value"}, "value", True), # 37. Outer required triggers nested message ( "${REQ:?Missing ${ALT:-something}}", {}, ValueError("required variable REQ is missing a value: Missing something"), True, ), # 38. Nested alternative chain ("${A:+${B:+${C:-end}}}", {"A": "1", "B": "1", "C": "final"}, "final", True), # 39. Nested alternative where middle missing ("${A:+${B:+${C:-end}}}", {"A": "1"}, "", True), # 40. Triple nested default with middle empty ("${A:-${B:-${C:-${D:-default}}}}", {"B": ""}, "default", True), # 41. Mixed chain: required + default + alternative ("${ENV:-${FALLBACK:+${ALT:-alt_value}}}", {"FALLBACK": "1"}, "alt_value", True), # 42. Invalid variable name in braces (starts with digit) ( "${1INVALID}", {}, ValueError( "Invalid interpolation format: ${1INVALID}." " You may need to escape any $ with another $" ), True, ), # 43. Invalid variable name in braces (starts with non-alphanumeric) ( "${-INVALID}", {}, ValueError( "Invalid interpolation format: ${-INVALID}." " You may need to escape any $ with another $" ), True, ), # 44. Invalid variable name in braces (empty) ( "${}", {}, ValueError( "Invalid interpolation format: ${}. You may need to escape any $ with another $" ), False, ), # 45. Dollar sign not followed by valid variable name (should be treated as literal) # Disable shell comparison since POSIX shells would treat $5 as fifth argument ("Price is $5", {}, "Price is $5", False), ] @parameterized.expand(test_cases) def test_var_interpolate( self, to_interpolate: str, var_values: dict[str, str], expected: Union[str, ValueError], compare_shell_evaluation: bool, ) -> None: try: result = var_interpolate(to_interpolate, var_values) self.assertEqual(result, expected) except ValueError as e: self.assertTrue( isinstance(expected, ValueError), msg=f"Expected ValueError for input: {to_interpolate}", ) self.assertEqual(str(e), str(expected)) if not compare_shell_evaluation: return # Ensure that the behavior matches the behavior of POSIX shells process = subprocess.Popen( f'echo "{to_interpolate}"', env=var_values, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) stdout, _ = process.communicate() shell_result = stdout.rstrip(b'\n').decode() exit_code = process.returncode if isinstance(expected, ValueError): error_msg = f"Expected non-zero return code success for input: {to_interpolate}" self.assertNotEqual(exit_code, 0, msg=error_msg) else: self.assertEqual(shell_result, expected) podman-compose-1.6.0/tests/unit/test_volumes.py000066400000000000000000000010401521011662300216530ustar00rootroot00000000000000# SPDX-License-Identifier: GPL-2.0 # pylint: disable=redefined-outer-name import unittest from podman_compose import parse_short_mount class ParseShortMountTests(unittest.TestCase): def test_multi_propagation(self) -> None: self.assertEqual( parse_short_mount("/foo/bar:/baz:U,Z", "/"), { "type": "bind", "source": "/foo/bar", "target": "/baz", "bind": { "propagation": "U,Z", }, }, )