Compare commits

..

No commits in common. "main" and "3.1.2.1" have entirely different histories.

133 changed files with 4119 additions and 6941 deletions

137
.cirrus.yml Normal file
View File

@ -0,0 +1,137 @@
---
env:
DEST_BRANCH: "master"
GOPATH: "/var/tmp/go"
GOBIN: "${GOPATH}/bin"
GOCACHE: "${GOPATH}/cache"
GOSRC: "${GOPATH}/src/github.com/containers/podman"
CIRRUS_WORKING_DIR: "${GOPATH}/src/github.com/containers/podman-py"
SCRIPT_BASE: "./contrib/cirrus"
CIRRUS_SHELL: "/bin/bash"
HOME: "/root" # not set by default
####
#### Cache-image names to test with (double-quotes around names are critical)
####
FEDORA_NAME: "fedora-34"
PRIOR_FEDORA_NAME: "fedora-33"
UBUNTU_NAME: "ubuntu-2104"
PRIOR_UBUNTU_NAME: "ubuntu-2010"
# Google-cloud VM Images
IMAGE_SUFFIX: "c5012840219148288"
FEDORA_CACHE_IMAGE_NAME: "fedora-${IMAGE_SUFFIX}"
PRIOR_FEDORA_CACHE_IMAGE_NAME: "prior-fedora-${IMAGE_SUFFIX}"
UBUNTU_CACHE_IMAGE_NAME: "ubuntu-${IMAGE_SUFFIX}"
PRIOR_UBUNTU_CACHE_IMAGE_NAME: "prior-ubuntu-${IMAGE_SUFFIX}"
# Container FQIN's
FEDORA_CONTAINER_FQIN: "quay.io/libpod/fedora_podman:${IMAGE_SUFFIX}"
PRIOR_FEDORA_CONTAINER_FQIN: "quay.io/libpod/prior-fedora_podman:${IMAGE_SUFFIX}"
UBUNTU_CONTAINER_FQIN: "quay.io/libpod/ubuntu_podman:${IMAGE_SUFFIX}"
PRIOR_UBUNTU_CONTAINER_FQIN: "quay.io/libpod/prior-ubuntu_podman:${IMAGE_SUFFIX}"
gcp_credentials: ENCRYPTED[0c639039cdd3a9a93fac7746ea1bf366d432e5ff3303bf293e64a7ff38dee85fd445f71625fa5626dc438be2b8efe939]
# Default VM to use unless set or modified by task
gce_instance:
image_project: "libpod-218412"
zone: "us-central1-c" # Required by Cirrus for the time being
cpu: 2
memory: "4Gb"
disk: 200 # Required for performance reasons
image_name: "${FEDORA_CACHE_IMAGE_NAME}"
# This task is critical. It updates the "last-used by" timestamp stored
# in metadata for all VM images. This mechanism functions in tandem with
# an out-of-band pruning operation to remove disused VM images.
meta_task:
alias: meta
name: "VM img. keepalive"
# see bors.toml
skip: $CIRRUS_BRANCH =~ ".*\.tmp"
container: &smallcontainer
image: "quay.io/libpod/imgts:$IMAGE_SUFFIX"
cpu: 1
memory: 1
env:
IMGNAMES: >-
${FEDORA_CACHE_IMAGE_NAME}
${PRIOR_FEDORA_CACHE_IMAGE_NAME}
${UBUNTU_CACHE_IMAGE_NAME}
${PRIOR_UBUNTU_CACHE_IMAGE_NAME}
BUILDID: "${CIRRUS_BUILD_ID}"
REPOREF: "${CIRRUS_REPO_NAME}"
GCPJSON: ENCRYPTED[e8a53772eff6e86bf6b99107b6e6ee3216e2ca00c36252ae3bd8cb29d9b903ffb2e1a1322ea810ca251b04f833b8f8d9]
GCPNAME: ENCRYPTED[fb878daf188d35c2ed356dc777267d99b59863ff3abf0c41199d562fca50ba0668fdb0d87e109c9eaa2a635d2825feed]
GCPPROJECT: "libpod-218412"
clone_script: &noop mkdir -p $CIRRUS_WORKING_DIR
script: /usr/local/bin/entrypoint.sh
gating_task:
name: "Gating test"
alias: gating
# Only run this on PRs, never during post-merge testing. This is also required
# for proper setting of EPOCH_TEST_COMMIT value, required by validation tools.
only_if: $CIRRUS_PR != ""
# see bors.toml
skip: $CIRRUS_BRANCH =~ ".*\.tmp"
# Runs within Cirrus's "community cluster"
container:
image: "$FEDORA_CONTAINER_FQIN"
cpu: 4
memory: 12
timeout_in: 20m
script:
- export PATH="$PATH:$GOPATH/bin"
- make
- go get github.com/vbatts/git-validation
- make validate
test_task:
name: "Test on $FEDORA_NAME"
alias: test
# see bors.toml
skip: $CIRRUS_BRANCH =~ ".*\.tmp"
depends_on:
- gating
script:
- "$SCRIPT_BASE/latest_podman.sh"
- "$SCRIPT_BASE/test.sh"
# Status aggregator for all tests. This task simply ensures a defined
# set of tasks all passed, and allows confirming that based on the status
# of this task.
success_task:
name: "Total Success"
alias: success
# see bors.toml
skip: $CIRRUS_BRANCH =~ ".*\.tmp"
# N/B: ALL tasks must be listed here, minus their '_task' suffix.
depends_on:
- meta
- gating
- test
container: *smallcontainer
clone_script: *noop
script: *noop

View File

@ -1 +0,0 @@
1

View File

@ -1,55 +0,0 @@
/*
Renovate is a service similar to GitHub Dependabot, but with
(fantastically) more configuration options. So many options
in fact, if you're new I recommend glossing over this cheat-sheet
prior to the official documentation:
https://www.augmentedmind.de/2021/07/25/renovate-bot-cheat-sheet
Configuration Update/Change Procedure:
1. Make changes
2. Manually validate changes (from repo-root):
podman run -it \
-v ./.github/renovate.json5:/usr/src/app/renovate.json5:z \
docker.io/renovate/renovate:latest \
renovate-config-validator
3. Commit.
Configuration Reference:
https://docs.renovatebot.com/configuration-options/
Monitoring Dashboard:
https://app.renovatebot.com/dashboard#github/containers
Note: The Renovate bot will create/manage it's business on
branches named 'renovate/*'. Otherwise, and by
default, the only the copy of this file that matters
is the one on the `main` branch. No other branches
will be monitored or touched in any way.
*/
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
/*************************************************
****** Global/general configuration options *****
*************************************************/
// Re-use predefined sets of configuration options to DRY
"extends": [
// https://github.com/containers/automation/blob/main/renovate/defaults.json5
"github>containers/automation//renovate/defaults.json5"
],
// Permit automatic rebasing when base-branch changes by more than
// one commit.
"rebaseWhen": "behind-base-branch",
/*************************************************
*** Repository-specific configuration options ***
*************************************************/
// Don't leave dep. update. PRs "hanging", assign them to people.
"assignees": ["inknos"],
}

View File

@ -1,22 +0,0 @@
name: validate
on:
pull_request:
jobs:
commit:
runs-on: ubuntu-24.04
# Only check commits on pull requests.
if: github.event_name == 'pull_request'
steps:
- name: get pr commits
id: 'get-pr-commits'
uses: tim-actions/get-pr-commits@v1.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: check subject line length
uses: tim-actions/commit-message-checker-with-regex@v0.3.2
with:
commits: ${{ steps.get-pr-commits.outputs.commits }}
pattern: '^.{0,72}(\n.*)*$'
error: 'Subject too long (max 72)'

View File

@ -1,18 +0,0 @@
name: pre-commit
on:
pull_request:
push:
branches: [main]
jobs:
pre-commit:
runs-on: ubuntu-latest
env:
SKIP: no-commit-to-branch
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: |
3.9
3.x
- uses: pre-commit/action@v3.0.1

View File

@ -1,126 +0,0 @@
name: Publish Python 🐍 distribution 📦 to PyPI and TestPyPI
on: push
jobs:
build:
name: Build distribution 📦
# ensure the workflow is never executed on forked branches
# it would fail anyway, so we just avoid to see an error
if: ${{ github.repository == 'containers/podman-py' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.x"
- name: Install pypa/build
run: >-
python3 -m
pip install
build
--user
- name: Build a binary wheel and a source tarball
run: python3 -m build
- name: Store the distribution packages
uses: actions/upload-artifact@v4
with:
name: python-package-distributions
path: dist/
publish-to-pypi:
name: >-
Publish Python 🐍 distribution 📦 to PyPI
if: startsWith(github.ref, 'refs/tags/') && github.repository == 'containers/podman-py'
needs:
- build
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/podman
permissions:
id-token: write # IMPORTANT: mandatory for trusted publishing
steps:
- name: Download all the dists
uses: actions/download-artifact@v5
with:
name: python-package-distributions
path: dist/
- name: Publish distribution 📦 to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
github-release:
name: >-
Sign the Python 🐍 distribution 📦 with Sigstore
and upload them to GitHub Release
if: github.repository == 'containers/podman-py'
needs:
- publish-to-pypi
runs-on: ubuntu-latest
permissions:
contents: write # IMPORTANT: mandatory for making GitHub Releases
id-token: write # IMPORTANT: mandatory for sigstore
steps:
- name: Download all the dists
uses: actions/download-artifact@v5
with:
name: python-package-distributions
path: dist/
- name: Sign the dists with Sigstore
uses: sigstore/gh-action-sigstore-python@v3.0.1
with:
inputs: >-
./dist/*.tar.gz
./dist/*.whl
- name: Create GitHub Release
env:
GITHUB_TOKEN: ${{ github.token }}
run: >-
gh release create
'${{ github.ref_name }}'
--repo '${{ github.repository }}'
--generate-notes
- name: Upload artifact signatures to GitHub Release
env:
GITHUB_TOKEN: ${{ github.token }}
# Upload to GitHub Release using the `gh` CLI.
# `dist/` contains the built packages, and the
# sigstore-produced signatures and certificates.
run: >-
gh release upload
'${{ github.ref_name }}' dist/**
--repo '${{ github.repository }}'
publish-to-testpypi:
name: Publish Python 🐍 distribution 📦 to TestPyPI
if: github.repository == 'containers/podman-py'
needs:
- build
runs-on: ubuntu-latest
environment:
name: testpypi
url: https://test.pypi.org/p/podman
permissions:
id-token: write # IMPORTANT: mandatory for trusted publishing
steps:
- name: Download all the dists
uses: actions/download-artifact@v5
with:
name: python-package-distributions
path: dist/
- name: Publish distribution 📦 to TestPyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
repository-url: https://test.pypi.org/legacy/
skip_existing: true
verbose: true

1
.gitignore vendored
View File

@ -28,6 +28,7 @@ MANIFEST
# 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

View File

@ -1,173 +0,0 @@
---
# See the documentation for more information:
# https://packit.dev/docs/configuration/
upstream_tag_template: v{version}
files_to_sync:
- src: rpm/gating.yml
dest: gating.yml
delete: true
- src: pyproject.toml
dest: pyproject.toml
delete: true
- src: plans/
dest: plans/
delete: true
mkpath: true
- src: tests/
dest: tests/
delete: true
mkpath: true
- src: .fmf/
dest: .fmf/
delete: true
mkpath: true
packages:
python-podman-fedora:
pkg_tool: fedpkg
downstream_package_name: python-podman
specfile_path: rpm/python-podman.spec
python-podman-centos:
pkg_tool: centpkg
downstream_package_name: python-podman
specfile_path: rpm/python-podman.spec
python-podman-rhel:
specfile_path: rpm/python-podman.spec
srpm_build_deps:
- make
jobs:
# Copr builds for Fedora
- job: copr_build
trigger: pull_request
identifier: pr-fedora
packages: [python-podman-fedora]
targets:
- fedora-all
# Copr builds for CentOS Stream
- job: copr_build
trigger: pull_request
identifier: pr-centos
packages: [python-podman-centos]
targets:
- centos-stream-10
- centos-stream-9
# Copr builds for RHEL
- job: copr_build
trigger: pull_request
identifier: pr-rhel
packages: [python-podman-rhel]
targets:
- epel-9
# Run on commit to main branch
- job: copr_build
trigger: commit
identifier: commit-fedora
packages: [python-podman-fedora]
branch: main
owner: rhcontainerbot
project: podman-next
# Downstream sync for Fedora
- job: propose_downstream
trigger: release
packages: [python-podman-fedora]
dist_git_branches:
- fedora-all
# Downstream sync for CentOS Stream
# TODO: c9s enablement being tracked in https://issues.redhat.com/browse/RUN-2123
- job: propose_downstream
trigger: release
packages: [python-podman-centos]
dist_git_branches:
- c10s
- c9s
- job: koji_build
trigger: commit
packages: [python-podman-fedora]
dist_git_branches:
- fedora-all
- job: bodhi_update
trigger: commit
packages: [python-podman-fedora]
dist_git_branches:
- fedora-branched # rawhide updates are created automatically
# Test linting on the codebase
# This test might break based on the OS and lint used, so we follow fedora-latest as a reference
- job: tests
trigger: pull_request
identifier: distro-sanity
tmt_plan: /distro/sanity
packages: [python-podman-fedora]
targets:
- fedora-latest-stable
skip_build: true
# test unit test coverage
- job: tests
trigger: pull_request
identifier: unittest-coverage
tmt_plan: /distro/unittest_coverage
packages: [python-podman-fedora]
targets:
- fedora-latest-stable
skip_build: true
# TODO: test integration test coverage
# run all tests for all python versions on all fedoras
- job: tests
trigger: pull_request
identifier: distro-fedora-all
tmt_plan: /distro/all_python
packages: [python-podman-fedora]
targets:
- fedora-all
# run tests for the rawhide python version using podman-next packages
- job: tests
trigger: pull_request
identifier: podman-next-fedora-base
tmt_plan: /pnext/base_python
packages: [python-podman-fedora]
targets:
- fedora-rawhide
tf_extra_params:
environments:
- artifacts:
- type: repository-file
id: https://copr.fedorainfracloud.org/coprs/rhcontainerbot/podman-next/repo/fedora-$releasever/rhcontainerbot-podman-next-fedora-$releasever.repo
manual_trigger: true
labels:
- pnext
- podman-next
- job: tests
trigger: pull_request
identifier: distro-centos-base
tmt_plan: /distro/base_python
packages: [python-podman-centos]
targets:
- centos-stream-9
- centos-stream-10
- job: tests
trigger: pull_request
identifier: distro-rhel-base
tmt_plan: /distro/base_python
packages: [python-podman-rhel]
targets:
- epel-9

View File

@ -1,27 +0,0 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-yaml
exclude: "gating.yml"
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.12.8
hooks:
# Run the linter.
- id: ruff
args: [ --fix ]
# Run the formatter.
- id: ruff-format
- repo: https://github.com/teemtee/tmt.git
rev: 1.39.0
hooks:
- id: tmt-lint
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0
hooks:
- id: mypy
pass_filenames: false
args: ["--package", "podman"]

View File

@ -11,9 +11,7 @@ ignore=CVS,docs
# Add files or directories matching the regex patterns to the blacklist. The
# regex matches against base names, not paths.
# ignore-patterns=test_.*
ignore-paths=^podman/tests/.*$
ignore-patterns=test_.*
# Python code to execute, usually for sys.path manipulation such as
# pygtk.require().
@ -144,10 +142,7 @@ disable=print-statement,
comprehension-escape,
no-self-use,
no-member, # PyCQA/pylint#3157
too-many-public-methods, # tests can have many members
fixme, # don't warn on fixme stuff
duplicate-code, # current CI version doesn't honor the minimum for some reason
bad-option-value # needed until pylint version is increased to a version with it
too-many-public-methods # tests can have many members
# Enable the message, report, category or checker with the given id(s). You can
# either give multiple identifier separated by comma (,) or put this option
@ -305,6 +300,13 @@ max-line-length=100
# Maximum number of lines in a module.
max-module-lines=1000
# List of optional constructs for which whitespace checking is disabled. `dict-
# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}.
# `trailing-comma` allows a space between comma and closing bracket: (a, ).
# `empty-line` allows space-only lines.
no-space-check=trailing-comma,
dict-separator
# Allow the body of a class to be on the same line as the declaration if body
# contains single statement.
single-line-class-stmt=no
@ -322,6 +324,7 @@ single-line-if-stmt=no
# Regular expression matching correct argument names. Overrides argument-
# naming-style.
argument-rgx=[a-z_][a-z0-9_]{1,30}$
argument-name-hint=[a-z_][a-z0-9_]{1,30}$
# Naming style matching correct attribute names.
attr-naming-style=snake_case
@ -375,7 +378,6 @@ function-naming-style=snake_case
good-names=c,
e,
i,
ip,
j,
k,
r,
@ -428,6 +430,7 @@ property-classes=abc.abstractproperty
# Regular expression matching correct variable names. Overrides variable-
# naming-style.
variable-rgx=[a-z_][a-z0-9_]{2,30}$
variable-name-hint=[a-z_][a-z0-9_]{2,30}$
[SIMILARITIES]
@ -441,7 +444,7 @@ ignore-docstrings=yes
ignore-imports=no
# Minimum lines number of a similarity.
min-similarity-lines=10
min-similarity-lines=4
[VARIABLES]
@ -575,6 +578,6 @@ valid-metaclass-classmethod-first-arg=mcs
# Exceptions that will emit a warning when being caught. Defaults to
# "Exception".
overgeneral-exceptions=builtins.StandardError,
builtins.Exception,
builtins.BaseException
overgeneral-exceptions=StandardError,
Exception,
BaseException

View File

@ -1,32 +1,4 @@
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
formats:
- pdf
- epub
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.11"
jobs:
pre_build:
- sphinx-apidoc --separate --no-toc --force --templatedir docs/source/_templates/apidoc -o docs/source/ podman podman/tests
# We recommend specifying your dependencies to enable reproducible builds:
# https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
python:
install:
- method: pip
path: .
extra_requirements:
- docs
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/source/conf.py
mkdocs:
configuration: mkdocs.yml
fail_on_warning: false

View File

@ -1,3 +1,3 @@
## The podman-py Project Community Code of Conduct
The podman-py project follows the [Containers Community Code of Conduct](https://github.com/containers/common/blob/main/CODE-OF-CONDUCT.md).
The podman-py project follows the [Containers Community Code of Conduct](https://github.com/containers/common/blob/master/CODE-OF-CONDUCT.md).

View File

@ -25,9 +25,9 @@ Please don't include any private/sensitive information in your issue!
## Tools we use
- Python >= 3.9
- [pre-commit](https://pre-commit.com/)
- [ruff](https://docs.astral.sh/ruff/)
- Python 3.6
- [pylint](https://www.pylint.org/)
- [black](https://github.com/psf/black)
- [tox](https://tox.readthedocs.io/en/latest/)
- You may need to use [virtualenv](https://virtualenv.pypa.io/en/latest/) to
support Python 3.6
@ -45,45 +45,6 @@ pip install tox
tox -e coverage
```
#### Advanced testing
Always prefer to run `tox` directly, even when you want to run a specific test or scenario.
Instead of running `pytest` directly, you should run:
```
tox -e py -- podman/tests/integration/test_container_create.py -k test_container_directory_volume_mount
```
If you'd like to test against a specific `tox` environment you can do:
```
tox -e py12 -- podman/tests/integration/test_container_create.py -k test_container_directory_volume_mount
```
Pass pytest options after `--`.
#### Testing future features
Since `podman-py` follows stable releases of `podman`, tests are thought to be run against
libpod's versions that are commonly installed in the distributions. Tests can be versioned,
but preferably they should not. Occasionally, upstream can diverge and have features that
are not included in a specific version of libpod, or that will be included eventually.
To run a test against such changes, you need to have
[podman-next](https://copr.fedorainfracloud.org/coprs/rhcontainerbot/podman-next) installed.
Then, you need to mark the test as `@pytest.mark.pnext`. Marked tests willbe excluded from the
runs, unless you pass `--pnext` as a cli option.
Preferably, this should be a rare case and it's better to use this marker as a temporary solution,
with the goal of removing the marker within few PRs.
To run these tests use:
```
tox -e py -- --pnext -m pnext podman/tests/integration/test_container_create.py -k test_container_mounts_without_rw_as_default
```
The option `--pnext` **enables** the tests with the `pnext` pytest marker, and `-m pnext` will run
the marked tests **only**.
## Submitting changes
- Create a github pull request (PR)
@ -97,31 +58,25 @@ the marked tests **only**.
## Where to find other contributors
- For general questions and discussion, please use the IRC #podman channel on
irc.libera.chat.
irc.freenode.net.
- For discussions around issues/bugs and features, you can use the
GitHub [issues](https://github.com/containers/podman-py/issues) and
[PRs](https://github.com/containers/podman-py/pulls) tracking system.
## Coding conventions
- Formatting and linting are incorporated using [ruff](https://docs.astral.sh/ruff/).
- If you use [pre-commit](https://pre-commit.com/) the checks will run automatically when you commit some changes
- If you prefer to run the ckecks with pre-commit, use `pre-commit run -a` to run the pre-commit checks for you.
- If you'd like to see what's happening with the checks you can run the [linter](https://docs.astral.sh/ruff/linter/)
and [formatter](https://docs.astral.sh/ruff/formatter/) separately with `ruff check --diff` and `ruff format --diff`
- Checks need to pass pylint
- exceptions are possible, but you will need to make a good argument
- Use spaces not tabs for indentation
- Use [black](https://github.com/psf/black) code formatter. If you have tox
installed, run `tox -e black` to see what changes will be made. You can use
`tox -e black-format` to update the code formatting prior to committing.
- Pass pylint
- exceptions are possible but you will need to make a good argument
- use spaces not tabs for indentation
- This is open source software. Consider the people who will read your code,
and make it look nice for them. It's sort of like driving a car: Perhaps
you love doing donuts when you're alone, but with passengers the goal is to
make the ride as smooth as possible.
- Use Google style python [docstrings](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings)
- A general exception is made for kwargs where we use the Sphinx extension of adding a section
"Keyword Arguments" and documenting the accepted keyword arguments, their type and usage.
Example: kwarg1 (int): Description of kwarg1
Again, thank you for your interest and participation.
Again thank you for your interest and participation.
Jhon Honce `<jhonce at redhat dot com>`
Thanks to Carl Tashian, Participatory Politics Foundation for his fine

View File

@ -2,43 +2,31 @@ export GO111MODULE=off
export GOPROXY=https://proxy.golang.org
PYTHON ?= $(shell command -v python3 2>/dev/null || command -v python || which python3)
PYTHON ?= $(shell command -v python3 2>/dev/null || command -v python)
DESTDIR ?= /
DESTDIR ?=
EPOCH_TEST_COMMIT ?= $(shell git merge-base $${DEST_BRANCH:-main} HEAD)
EPOCH_TEST_COMMIT ?= $(shell git merge-base $${DEST_BRANCH:-master} HEAD)
HEAD ?= HEAD
export PODMAN_VERSION ?= "5.6.0"
export PODMAN_VERSION ?= "3.2.0"
.PHONY: podman
podman:
.PHONY: podman-py
podman-py:
rm dist/* || :
$(PYTHON) -m pip install -q build
python -m pip install --user -r requirements.txt
PODMAN_VERSION=$(PODMAN_VERSION) \
$(PYTHON) -m build
$(PYTHON) setup.py sdist bdist bdist_wheel
.PHONY: lint
lint: tox
$(PYTHON) -m tox -e format,lint,mypy
lint:
$(PYTHON) -m pylint podman || exit $$(($$? % 4));
.PHONY: tests
tests: tox
# see tox.ini for environment variable settings
$(PYTHON) -m tox -e coverage,py39,py310,py311,py312,py313
.PHONY: tests-ci-base-python-podman-next
tests-ci-base-python-podman-next:
$(PYTHON) -m tox -e py -- --pnext -m pnext
.PHONY: tests-ci-base-python
tests-ci-base-python:
$(PYTHON) -m tox -e coverage,py
# TODO: coverage is probably not necessary here and in tests-ci-base-python
# but for now it's ok to leave it here so it's run
.PHONY: tests-ci-all-python
tests-ci-all-python:
$(PYTHON) -m tox -e coverage,py39,py310,py311,py312,py313
tests:
python -m pip install --user -r test-requirements.txt
DEBUG=1 coverage run -m unittest discover -s podman/tests
coverage report -m --skip-covered --fail-under=80 --omit=./podman/tests/* --omit=.tox/* \
--omit=/usr/lib/* --omit=*/lib/python*
.PHONY: unittest
unittest:
@ -50,47 +38,25 @@ integration:
coverage run -m unittest discover -s podman/tests/integration
coverage report -m --skip-covered --fail-under=80 --omit=./podman/tests/* --omit=.tox/* --omit=/usr/lib/*
.PHONY: tox
tox:
ifeq (, $(shell which dnf))
brew install python@3.9 python@3.10 python@3.11 python@3.12 python@3.13
else
-dnf install -y python3 python3.9 python3.10 python3.11 python3.12 python3.13
endif
# ensure tox is available. It will take care of other testing requirements
$(PYTHON) -m pip install --user tox
.PHONY: test-release
test-release: SOURCE = $(shell find dist -regex '.*/podman-[0-9][0-9\.]*.tar.gz' -print)
test-release:
twine upload --verbose -r testpypi dist/*whl $(SOURCE)
# pip install -i https://test.pypi.org/simple/ podman
.PHONY: upload
upload:
twine upload --verbose -r testpypi dist/*whl dist/*zip
# pip install -i https://test.pypi.org/simple/ podman-py
.PHONY: release
release: SOURCE = $(shell find dist -regex '.*/podman-[0-9][0-9\.]*.tar.gz' -print)
release:
twine upload --verbose dist/*whl $(SOURCE)
# pip install podman
twine upload --verbose dist/*whl dist/*zip
# pip install podman-py
.PHONY: docs
docs:
sphinx-apidoc --separate --no-toc --force --templatedir docs/source/_templates/apidoc \
-o docs/source/ podman podman/tests
# HARD CODED COMMAND from readthedocs! We must conform!
# -T : traceback
# -E : do not use saved environment, always read all files
# -W : warnings reported as errors then --keep-going when getting warnings
# -b html : build html
# -d : path for cached environment and doctree files
# -D language=en : define language as en
# . : source directory
# _build/html : target
cd docs/source && python3 -m sphinx -T -E -W --keep-going -b html -d _build/doctrees -D language=en . _build/html
.PHONY: rpm
rpm: ## Build rpm packages
rpkg local
mkdir -p build/docs/source
cp -R docs/source/* build/docs/source
sphinx-apidoc --separate --no-toc --force --templatedir build/docs/source/_templates/apidoc \
-o build/docs/source \
podman podman/tests podman/api_connection.py podman/containers podman/images \
podman/manifests podman/networks podman/pods podman/system
sphinx-build build/docs/source build/html
# .PHONY: install
HEAD ?= HEAD

18
OWNERS
View File

@ -1,18 +0,0 @@
approvers:
- edsantiago
- giuseppe
- jwhonce
- lsm5
- Luap99
- mheon
- mwhahaha
- umohnani8
- vrothberg
- inknos
reviewers:
- ashley-cui
- baude
- Honny1
- rhatdan
- TomSweeneyRedHat
- Edward5hen

View File

@ -1,32 +1,15 @@
# podman-py
[![PyPI Latest Version](https://img.shields.io/pypi/v/podman)](https://pypi.org/project/podman/)
[![Build Status](https://api.cirrus-ci.com/github/containers/podman-py.svg)](https://cirrus-ci.com/github/containers/podman-py/master)
[![Bors enabled](https://bors.tech/images/badge_small.svg)](https://app.bors.tech/repositories/23171)
This python package is a library of bindings to use the RESTful API of [Podman](https://github.com/containers/podman).
It is currently under development and contributors are welcome!
## Installation
<div class="termy">
```console
pip install podman
```
</div>
---
**Documentation**: <a href="https://podman-py.readthedocs.io/en/latest/" target="_blank">https://podman-py.readthedocs.io/en/latest/</a>
**Source Code**: <a href="https://github.com/containers/podman-py" target="_blank">https://github.com/containers/podman-py</a>
---
## Dependencies
* For runtime dependencies, see \[dependencies\] in [pyproject.toml](https://github.com/containers/podman-py/blob/main/pyproject.toml)
* For testing and development dependencies, see \[project.optional.dependencies\] in [pyproject.toml](https://github.com/containers/podman-py/blob/main/pyproject.toml)
* The package is split in \[progress\_bar\], \[docs\], and \[test\]
* For runtime dependencies, see [requirements.txt](https://github.com/containers/podman-py/blob/master/requirements.txt).
* For testing and development dependencies, see [test-requirements.txt](https://github.com/containers/podman-py/blob/master/test-requirements.txt).
## Example usage
@ -53,12 +36,9 @@ with PodmanClient(base_url=uri) as client:
# find all containers
for container in client.containers.list():
# After a list call you would probably want to reload the container
# to get the information about the variables such as status.
# Note that list() ignores the sparse option and assumes True by default.
container.reload()
first_name = container['Names'][0]
container = client.containers.get(first_name)
print(container, container.id, "\n")
print(container, container.status, "\n")
# available fields
print(sorted(container.attrs.keys()))
@ -68,4 +48,4 @@ with PodmanClient(base_url=uri) as client:
## Contributing
See [CONTRIBUTING.md](https://github.com/containers/podman-py/blob/main/CONTRIBUTING.md)
See [CONTRIBUTING.md](https://github.com/containers/podman-py/blob/master/CONTRIBUTING.md)

View File

@ -1,3 +1,3 @@
## Security and Disclosure Information Policy for the podman-py Project
The podman-py Project follows the [Security and Disclosure Information Policy](https://github.com/containers/common/blob/main/SECURITY.md) for the Containers Projects.
The podman-py Project follows the [Security and Disclosure Information Policy](https://github.com/containers/common/blob/master/SECURITY.md) for the Containers Projects.

42
bors.toml Normal file
View File

@ -0,0 +1,42 @@
# Bors-ng is a service which provides a merge and review bot for github PRs.
# When approved for merging (`bors r+`) or test merging (`bors try`), all
# pending PRs at the time will be merged together in one of two special
# branches. Either 'staging' or 'trying'. In the case of `staging` branch,
# when all status tests pass (see below) the serialized set of merges will become
# the new destination branch HEAD (i.e. master). This guarantees there is never
# any conflicts with PR merge order on the destination branch(es).
#
# Note: The branches 'staging.tmp' and 'trying.tmp' must always be ignored
# by _all_ CI systems. They are by bors temporarily, and may go away at
# unpredictable times.
#
# Format Ref: https://bors.tech/documentation/#configuration-borstoml
#
# status
# ------------------
# Selects which tests are required for merging, matching against values
# from BOTH the older github 'status API' (ref: https://developer.github.com/v3/repos/statuses
# /#list-statuses-for-a-specific-ref) AND newer 'checks API'. Ref: https://developer.github.com/v3/checks
# /runs/#list-check-runs-in-a-check-suite both return JSON:
#
# Status API: Matches against '[].context' values
# Checks API: Matches against 'check_runs[].name' values
#
# Note: The wild-card character '%' is available.
status = [
"Total Success",
]
# Same as 'status' (above) but statuses that must pass on every PR
pr_status = [
"Total Success",
]
# Cirrus-CI Max Timeout is 60 * 60 * 2
timeout_sec = 7200
# List of strings: PR Labels that must NOT be present
block_labels = ["wip", "hold"]
# The number of required `bors r+` needed for a PR to merge
required_approvals = 0

28
contrib/cirrus/latest_podman.sh Executable file
View File

@ -0,0 +1,28 @@
#!/bin/bash
set -xeo pipefail
systemctl enable sshd
systemctl start sshd
systemctl status sshd ||:
ssh-keygen -t ecdsa -b 521 -f /root/.ssh/id_ecdsa -P ""
cp /root/.ssh/authorized_keys /root/.ssh/authorized_keys%
cat /root/.ssh/id_ecdsa.pub >>/root/.ssh/authorized_keys
mkdir -p "$GOPATH/src/github.com/containers/"
cd "$GOPATH/src/github.com/containers/"
systemctl stop podman.socket ||:
dnf erase podman -y
git clone --branch master https://github.com/containers/podman.git
cd podman
make binaries
make install PREFIX=/usr
systemctl enable podman.socket podman.service
systemctl start podman.socket
systemctl status podman.socket ||:
podman --version

10
contrib/cirrus/test.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/bash
set -eo pipefail
echo "Locate: $(type -P podman)"
podman --version
podman-remote --version
make
make tests

View File

@ -1,14 +1,9 @@
{%- if show_headings %}
{%- if "podman.errors" in basename %}
{{- basename | replace("podman.errors.", "") | e | heading }}
{% elif "podman.client" in basename -%}
{{- basename | replace("podman.client", "client") | e | heading }}
{% else -%}
{{- basename | replace("podman.domain.", "") | e | heading }}
{% endif -%}
{% endif -%}
{{- basename | replace("podman.", "") | e | heading }}
{% endif -%}
.. automodule:: {{ qualname }}
{%- for option in automodule_options %}
:{{ option }}:
{%- endfor %}

View File

@ -5,3 +5,4 @@
{% for docname in docnames %}
{{ docname }}
{%- endfor %}

View File

@ -15,17 +15,17 @@ import sys
from sphinx.domains.python import PythonDomain
sys.path.insert(0, os.path.abspath('../..'))
sys.path.insert(0, os.path.abspath('../../..'))
# -- Project information -----------------------------------------------------
project = 'Podman Python SDK'
copyright = '2021, Red Hat Inc'
author = 'Red Hat Inc'
project = u'Podman Python SDK'
copyright = u'2021, Red Hat Inc'
author = u'Red Hat Inc'
# The full version, including alpha/beta/rc tags
version = '3.2.1.0'
version = '3.1.2.1'
release = version
add_module_names = False
@ -35,11 +35,8 @@ add_module_names = False
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
# sphinx.ext.autodoc: Include documentation from docstrings
# sphinx.ext.napoleon: Support for NumPy and Google style docstrings
# sphinx.ext.viewcode: Add links to highlighted source code
# isort: unique-list
extensions = ['sphinx.ext.napoleon', 'sphinx.ext.autodoc', 'sphinx.ext.viewcode']
extensions = ['sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'sphinx.ext.viewcode']
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
@ -51,10 +48,6 @@ templates_path = ['_templates']
exclude_patterns = [
'podman.api.*rst',
'podman.rst',
'podman.version.rst',
'podman.tlsconfig.rst',
'podman.errors.rst',
'podman.domain.rst',
]
@ -75,9 +68,9 @@ html_theme_options = {
'logo_name': True,
'show_powered_by': False,
'extra_nav_links': {
'Report PodmanPy Issue': 'https://github.com/containers/podman-py/issues',
'Podman Reference': 'https://docs.podman.io',
'Podman on github': 'https://github.com/containers/podman',
'Report PodmanPy Issue': 'http://github.com/containers/podman-py/issues',
'Podman Reference': 'http://docs.podman.io',
'Podman on github': 'http://github.com/containers/podman',
},
}
@ -125,7 +118,9 @@ class PatchedPythonDomain(PythonDomain):
def resolve_xref(self, env, fromdocname, builder, typ, target, node, contnode):
if 'refspecific' in node:
del node['refspecific']
return super().resolve_xref(env, fromdocname, builder, typ, target, node, contnode)
return super(PatchedPythonDomain, self).resolve_xref(
env, fromdocname, builder, typ, target, node, contnode
)
def skip(app, what, name, obj, would_skip, options):

View File

@ -1,13 +1,13 @@
Podman: Python scripting for Podman services
PodmanPy: Python scripting for Podman services
==============================================
.. image:: https://img.shields.io/pypi/l/podman.svg
:target: https://pypi.org/project/podman/
.. image:: https://img.shields.io/pypi/l/podman-py.svg
:target: https://pypi.org/project/podman-py/
.. image:: https://img.shields.io/pypi/wheel/podman.svg
:target: https://pypi.org/project/podman/
.. image:: https://img.shields.io/pypi/wheel/podman-py.svg
:target: https://pypi.org/project/podman-py/
.. image:: https://img.shields.io/pypi/pyversions/podman.svg
:target: https://pypi.org/project/podman/
.. image:: https://img.shields.io/pypi/pyversions/podman-py.svg
:target: https://pypi.org/project/podman-py/
PodmanPy is a Python3 module that allows you to write Python scripts that access resources
maintained by a Podman service. It leverages the Podman service RESTful API.
@ -36,16 +36,15 @@ Example
import podman
with podman.PodmanClient() as client:
with podman.Client() as client:
if client.ping():
images = client.images.list()
for image in images:
print(image.id)
.. toctree::
:caption: Podman Client
:hidden:
:maxdepth: 2
podman.client
@ -54,19 +53,17 @@ Example
:glob:
:hidden:
podman.domain.config
podman.domain.containers*
podman.domain.images*
podman.domain.ipam
podman.domain.events
podman.domain.manager
podman.domain.manifests
podman.domain.containers
podman.domain.containers_manager
podman.domain.images
podman.domain.images_manager
podman.domain.events*
podman.domain.manifests*
podman.domain.networks*
podman.domain.pods*
podman.domain.registry_data
podman.domain.secrets
podman.domain.system
podman.domain.volumes
podman.domain.secrets*
podman.domain.system*
podman.domain.volume*
podman.errors.exceptions
Indices and tables

View File

@ -1,10 +0,0 @@
---
!Policy
product_versions:
- fedora-*
decision_contexts:
- bodhi_update_push_stable
- bodhi_update_push_testing
subject_type: koji_build
rules:
- !PassingTestCaseRule {test_case_name: fedora-ci.koji-build./plans/downstream/all.functional}

61
hack/get_ci_vm.sh Executable file
View File

@ -0,0 +1,61 @@
#!/usr/bin/env bash
#
# For help and usage information, simply execute the script w/o any arguments.
#
# This script is intended to be run by Red Hat podman-py developers who need
# to debug problems specifically related to Cirrus-CI automated testing.
# It requires that you have been granted prior access to create VMs in
# google-cloud. For non-Red Hat contributors, VMs are available as-needed,
# with supervision upon request.
set -e
SCRIPT_FILEPATH=$(realpath "${BASH_SOURCE[0]}")
SCRIPT_DIRPATH=$(dirname "$SCRIPT_FILEPATH")
REPO_DIRPATH=$(realpath "$SCRIPT_DIRPATH/../")
# Help detect if we were called by get_ci_vm container
GET_CI_VM="${GET_CI_VM:-0}"
in_get_ci_vm() {
if ((GET_CI_VM==0)); then
echo "Error: $1 is not intended for use in this context"
exit 2
fi
}
# get_ci_vm APIv1 container entrypoint calls into this script
# to obtain required repo. specific configuration options.
if [[ "$1" == "--config" ]]; then
in_get_ci_vm "$1"
cat <<EOF
DESTDIR="/var/tmp/go/src/github.com/containers/podman-py"
UPSTREAM_REPO="https://github.com/containers/podman-py.git"
CI_ENVFILE="/etc/ci_environment"
GCLOUD_PROJECT="podman-py"
GCLOUD_IMGPROJECT="libpod-218412"
GCLOUD_CFG="podman-py"
GCLOUD_ZONE="${GCLOUD_ZONE:-us-central1-c}"
GCLOUD_CPUS="2"
GCLOUD_MEMORY="4Gb"
GCLOUD_DISK="200"
EOF
elif [[ "$1" == "--setup" ]]; then
in_get_ci_vm "$1"
echo "+ Setting up and Running make" > /dev/stderr
echo 'PATH=$PATH:$GOPATH/bin' > /etc/ci_environment
make
else
# Create and access VM for specified Cirrus-CI task
mkdir -p $HOME/.config/gcloud/ssh
podman run -it --rm \
--tz=local \
-e NAME="$USER" \
-e SRCDIR=/src \
-e GCLOUD_ZONE="$GCLOUD_ZONE" \
-e DEBUG="${DEBUG:-0}" \
-v $REPO_DIRPATH:/src:O \
-v $HOME/.config/gcloud:/root/.config/gcloud:z \
-v $HOME/.config/gcloud/ssh:/root/.ssh:z \
quay.io/libpod/get_ci_vm:latest "$@"
fi

View File

@ -1,116 +0,0 @@
summary: Run Python Podman Tests
discover:
how: fmf
execute:
how: tmt
prepare:
- name: pkg dependencies
how: install
package:
- make
- python3-pip
- podman
- name: pip dependencies
how: shell
script:
- pip3 install .[test]
- name: ssh configuration
how: shell
script:
- ssh-keygen -t ecdsa -b 521 -f /root/.ssh/id_ecdsa -P ""
- cp /root/.ssh/authorized_keys /root/.ssh/authorized_keys%
- cat /root/.ssh/id_ecdsa.pub >>/root/.ssh/authorized_keys
# Run tests agains Podman Next builds.
# These tests should NOT overlap with the ones who run in the distro plan and should only include
# tests against upcoming features or upstream tests that we need to run for reasons.
/pnext:
prepare+:
- name: enable rhcontainerbot/podman-next update podman
when: initiator == packit
how: shell
script: |
COPR_REPO_FILE="/etc/yum.repos.d/*podman-next*.repo"
if compgen -G $COPR_REPO_FILE > /dev/null; then
sed -i -n '/^priority=/!p;$apriority=1' $COPR_REPO_FILE
fi
dnf -y upgrade --allowerasing
/base_python:
summary: Run Tests Upstream PRs for base Python
discover+:
filter: tag:pnext
adjust+:
enabled: false
when: initiator is not defined or initiator != packit
# Run tests against Podman buids installed from the distribution.
/distro:
prepare+:
- name: Enable testing repositories
when: initiator == packit && distro == fedora
how: shell
script: |
dnf config-manager setopt updates-testing.enabled=true
dnf -y upgrade --allowerasing --setopt=allow_vendor_change=true
/sanity:
summary: Run Sanity and Coverage checks on Python Podman
discover+:
# we want to change this to tag:stable once all the coverage tests are fixed
filter: tag:lint
/base_python:
summary: Run Tests Upstream for base Python
discover+:
filter: tag:base
/all_python:
summary: Run Tests Upstream PRs for all Python versions
prepare+:
- name: install all python versions
how: install
package:
- python3.9
- python3.10
- python3.11
- python3.12
- python3.13
discover+:
filter: tag:matrix
# TODO: replace with /coverage and include integration tests coverage
/unittest_coverage:
summary: Run Unit test coverage
discover+:
filter: tag:coverage & tag:unittest
adjust+:
enabled: false
when: initiator is not defined or initiator != packit
# Run tests against downstream Podman. These tests should be the all_python only since the sanity
# of code is tested in the distro environment
/downstream:
/all:
summary: Run Tests on bodhi / errata and dist-git PRs
prepare+:
- name: install all python versions
how: install
package:
- python3.9
- python3.10
- python3.11
- python3.12
- python3.13
discover+:
filter: tag:matrix
adjust+:
enabled: false
when: initiator == packit

1145
podman.svg

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 82 KiB

View File

@ -1,7 +1,15 @@
"""Podman client module."""
try:
from podman.api_connection import ApiConnection
except ImportError:
class ApiConnection: # pylint: disable=too-few-public-methods
def __init__(self):
raise NotImplementedError("ApiConnection deprecated, please use PodmanClient().")
from podman.api.version import __version__
from podman.client import PodmanClient, from_env
from podman.version import __version__
# isort: unique-list
__all__ = ['PodmanClient', '__version__', 'from_env']
__all__ = ['ApiConnection', 'PodmanClient', '__version__', 'from_env']

View File

@ -1,9 +1,9 @@
"""Tools for connecting to a Podman service."""
import re
from podman.api.cached_property import cached_property
from podman.api.client import APIClient
from podman.api.api_versions import VERSION, COMPATIBLE_VERSION
from podman.api.http_utils import encode_auth_header, prepare_body, prepare_filters
from podman.api.http_utils import prepare_body, prepare_filters
from podman.api.parse_utils import (
decode_header,
frames,
@ -11,23 +11,42 @@ from podman.api.parse_utils import (
prepare_cidr,
prepare_timestamp,
stream_frames,
stream_helper,
)
from podman.api.tar_utils import create_tar, prepare_containerfile, prepare_containerignore
from . import version
DEFAULT_CHUNK_SIZE = 2 * 1024 * 1024
def _api_version(release: str, significant: int = 3) -> str:
"""Return API version removing any additional identifiers from the release version.
This is a simple lexicographical parsing, no semantics are applied, e.g. semver checking.
"""
items = re.split(r"\.|-|\+", release)
parts = items[0:significant]
return ".".join(parts)
VERSION: str = _api_version(version.__version__)
COMPATIBLE_VERSION: str = _api_version(version.__compatible_version__, 2)
try:
from typing_extensions import Literal
except ModuleNotFoundError:
from typing import Literal
# isort: unique-list
__all__ = [
'APIClient',
'COMPATIBLE_VERSION',
'DEFAULT_CHUNK_SIZE',
'Literal',
'VERSION',
'cached_property',
'create_tar',
'decode_header',
'encode_auth_header',
'frames',
'parse_repository',
'prepare_body',
@ -37,5 +56,4 @@ __all__ = [
'prepare_filters',
'prepare_timestamp',
'stream_frames',
'stream_helper',
]

View File

@ -1,7 +1,5 @@
"""Utility functions for working with Adapters."""
from typing import NamedTuple
from collections.abc import Mapping
from typing import NamedTuple, Mapping
def _key_normalizer(key_class: NamedTuple, request_context: Mapping) -> Mapping:

View File

@ -1,18 +0,0 @@
"""Constants API versions"""
import re
from .. import version
def _api_version(release: str, significant: int = 3) -> str:
"""Return API version removing any additional identifiers from the release version.
This is a simple lexicographical parsing, no semantics are applied, e.g. semver checking.
"""
items = re.split(r"\.|-|\+", release)
parts = items[0:significant]
return ".".join(parts)
VERSION: str = _api_version(version.__version__)
COMPATIBLE_VERSION: str = _api_version(version.__compatible_version__, 2)

View File

@ -1,10 +1,9 @@
"""Provide cached_property for Python <=3.8 programs."""
import functools
try:
from functools import cached_property # pylint: disable=unused-import
except ImportError:
def cached_property(fn): # type: ignore[no-redef]
def cached_property(fn):
return property(functools.lru_cache()(fn))

View File

@ -1,51 +1,32 @@
"""APIClient for connecting to Podman service."""
import json
import warnings
import urllib.parse
from typing import (
Any,
ClassVar,
IO,
Optional,
Union,
)
from collections.abc import Iterable, Mapping
from typing import IO, Any, ClassVar, Iterable, List, Mapping, Optional, Tuple, Union, Type
import requests
from requests.adapters import HTTPAdapter
from podman.api.api_versions import VERSION, COMPATIBLE_VERSION
from podman import api
from podman.api.ssh import SSHAdapter
from podman.api.version import __version__
from podman.api.uds import UDSAdapter
from podman.errors import APIError, NotFound, PodmanError
from podman.errors import APIError, NotFound
from podman.tlsconfig import TLSConfig
from podman.version import __version__
_Data = Union[
None,
str,
bytes,
Mapping[str, Any],
Iterable[tuple[str, Optional[str]]],
Iterable[Tuple[str, Optional[str]]],
IO,
]
"""Type alias for request data parameter."""
_Timeout = Union[None, float, tuple[float, float], tuple[float, None]]
_Timeout = Union[None, float, Tuple[float, float], Tuple[float, None]]
"""Type alias for request timeout parameter."""
class ParameterDeprecationWarning(DeprecationWarning):
"""
Custom DeprecationWarning for deprecated parameters.
"""
# Make the ParameterDeprecationWarning visible for user.
warnings.simplefilter('always', ParameterDeprecationWarning)
class APIResponse:
"""APIResponse proxy requests.Response objects.
@ -65,7 +46,9 @@ class APIResponse:
"""Forward any query for an attribute not defined in this proxy class to wrapped class."""
return getattr(self._response, item)
def raise_for_status(self, not_found: type[APIError] = NotFound) -> None:
def raise_for_status(
self, not_found: Type[APIError] = NotFound
) -> None: # pylint: disable=arguments-differ
"""Raises exception when Podman service reports one."""
if self.status_code < 400:
return
@ -86,16 +69,10 @@ class APIClient(requests.Session):
"""Client for Podman service API."""
# Abstract methods (delete,get,head,post) are specialized and pylint cannot walk hierarchy.
# pylint: disable=too-many-instance-attributes,arguments-differ,arguments-renamed
# pylint: disable=arguments-differ
# pylint: disable=too-many-instance-attributes
supported_schemes: ClassVar[list[str]] = (
"unix",
"http+unix",
"ssh",
"http+ssh",
"tcp",
"http",
)
supported_schemes: ClassVar[List[str]] = ("unix", "http+unix", "ssh", "http+ssh", "tcp", "http")
def __init__(
self,
@ -107,9 +84,9 @@ class APIClient(requests.Session):
num_pools: Optional[int] = None,
credstore_env: Optional[Mapping[str, str]] = None,
use_ssh_client=True,
max_pool_size=None,
max_pools_size=None,
**kwargs,
): # pylint: disable=unused-argument,too-many-positional-arguments
): # pylint: disable=unused-argument
"""Instantiate APIClient object.
Args:
@ -135,47 +112,40 @@ class APIClient(requests.Session):
self.base_url = self._normalize_url(base_url)
adapter_kwargs = kwargs.copy()
# The HTTPAdapter doesn't handle the "**kwargs", so it needs special structure
# where the parameters are set specifically.
http_adapter_kwargs = {}
if num_pools is not None:
adapter_kwargs["pool_connections"] = num_pools
http_adapter_kwargs["pool_connections"] = num_pools
if max_pool_size is not None:
adapter_kwargs["pool_maxsize"] = max_pool_size
http_adapter_kwargs["pool_maxsize"] = max_pool_size
if max_pools_size is not None:
adapter_kwargs["pool_maxsize"] = max_pools_size
if timeout is not None:
adapter_kwargs["timeout"] = timeout
if self.base_url.scheme == "http+unix":
self.mount("http://", UDSAdapter(self.base_url.geturl(), **adapter_kwargs))
self.mount("https://", UDSAdapter(self.base_url.geturl(), **adapter_kwargs))
# ignore proxies from the env vars
self.trust_env = False
elif self.base_url.scheme == "http+ssh":
self.mount("http://", SSHAdapter(self.base_url.geturl(), **adapter_kwargs))
self.mount("https://", SSHAdapter(self.base_url.geturl(), **adapter_kwargs))
elif self.base_url.scheme == "http":
self.mount("http://", HTTPAdapter(**http_adapter_kwargs))
self.mount("https://", HTTPAdapter(**http_adapter_kwargs))
self.mount("http://", HTTPAdapter(**adapter_kwargs))
self.mount("https://", HTTPAdapter(**adapter_kwargs))
else:
raise PodmanError("APIClient.supported_schemes changed without adding a branch here.")
assert False, "APIClient.supported_schemes changed without adding a branch here."
self.version = version or VERSION
self.version = version or api.VERSION
self.path_prefix = f"/v{self.version}/libpod/"
self.compatible_version = kwargs.get("compatible_version", COMPATIBLE_VERSION)
self.compatible_version = kwargs.get("compatible_version", api.COMPATIBLE_VERSION)
self.compatible_prefix = f"/v{self.compatible_version}/"
self.timeout = timeout
self.pool_maxsize = num_pools or requests.adapters.DEFAULT_POOLSIZE
self.credstore_env = credstore_env or {}
self.credstore_env = credstore_env or dict()
self.user_agent = user_agent or (
f"PodmanPy/{__version__} (API v{self.version}; Compatible v{self.compatible_version})"
self.user_agent = (
user_agent
or f"PodmanPy/{__version__} (API v{self.version}"
f"; Compatible v{self.compatible_version})"
)
self.headers.update({"User-Agent": self.user_agent})
@ -206,7 +176,6 @@ class APIClient(requests.Session):
def delete(
self,
path: Union[str, bytes],
*,
params: Union[None, bytes, Mapping[str, str]] = None,
headers: Optional[Mapping[str, str]] = None,
timeout: _Timeout = None,
@ -241,8 +210,7 @@ class APIClient(requests.Session):
def get(
self,
path: Union[str, bytes],
*,
params: Union[None, bytes, Mapping[str, list[str]]] = None,
params: Union[None, bytes, Mapping[str, List[str]]] = None,
headers: Optional[Mapping[str, str]] = None,
timeout: _Timeout = None,
stream: Optional[bool] = False,
@ -276,7 +244,6 @@ class APIClient(requests.Session):
def head(
self,
path: Union[str, bytes],
*,
params: Union[None, bytes, Mapping[str, str]] = None,
headers: Optional[Mapping[str, str]] = None,
timeout: _Timeout = None,
@ -311,7 +278,6 @@ class APIClient(requests.Session):
def post(
self,
path: Union[str, bytes],
*,
params: Union[None, bytes, Mapping[str, str]] = None,
data: _Data = None,
headers: Optional[Mapping[str, str]] = None,
@ -331,7 +297,6 @@ class APIClient(requests.Session):
Keyword Args:
compatible: Will override the default path prefix with compatible prefix
verify: Whether to verify TLS certificates.
Raises:
APIError: when service returns an error
@ -350,7 +315,6 @@ class APIClient(requests.Session):
def put(
self,
path: Union[str, bytes],
*,
params: Union[None, bytes, Mapping[str, str]] = None,
data: _Data = None,
headers: Optional[Mapping[str, str]] = None,
@ -389,7 +353,6 @@ class APIClient(requests.Session):
self,
method: str,
path: Union[str, bytes],
*,
data: _Data = None,
params: Union[None, bytes, Mapping[str, str]] = None,
headers: Optional[Mapping[str, str]] = None,
@ -408,13 +371,12 @@ class APIClient(requests.Session):
Keyword Args:
compatible: Will override the default path prefix with compatible prefix
verify: Whether to verify TLS certificates.
Raises:
APIError: when service returns an error
"""
# Only set timeout if one is given, lower level APIs will not override None
timeout_kw = {}
timeout_kw = dict()
timeout = timeout or self.timeout
if timeout_kw is not None:
timeout_kw["timeout"] = timeout
@ -424,10 +386,10 @@ class APIClient(requests.Session):
path = path.lstrip("/") # leading / makes urljoin crazy...
scheme = "https" if kwargs.get("verify", None) else "http"
# TODO should we have an option for HTTPS support?
# Build URL for operation from base_url
uri = urllib.parse.ParseResult(
scheme,
"http",
self.base_url.netloc,
urllib.parse.urljoin(path_prefix, path),
self.base_url.params,
@ -442,9 +404,8 @@ class APIClient(requests.Session):
uri.geturl(),
params=params,
data=data,
headers=(headers or {}),
headers=(headers or dict()),
stream=stream,
verify=kwargs.get("verify", None),
**timeout_kw,
)
)

View File

@ -1,19 +1,16 @@
"""Utility functions for working with URLs."""
import base64
import collections.abc
import json
from typing import Optional, Union, Any
from collections.abc import Mapping
from typing import Dict, List, Mapping, Optional, Union, Any
def prepare_filters(filters: Union[str, list[str], Mapping[str, str]]) -> Optional[str]:
"""Return filters as an URL quoted JSON dict[str, list[Any]]."""
def prepare_filters(filters: Union[str, List[str], Mapping[str, str]]) -> Optional[str]:
"""Return filters as an URL quoted JSON Dict[str, List[Any]]."""
if filters is None or len(filters) == 0:
return None
criteria: dict[str, list[str]] = {}
criteria: Dict[str, List[str]] = dict()
if isinstance(filters, str):
_format_string(filters, criteria)
elif isinstance(filters, collections.abc.Mapping):
@ -43,12 +40,12 @@ def _format_dict(filters, criteria):
for key, value in filters.items():
if value is None:
continue
str_value = str(value)
value = str(value)
if key in criteria:
criteria[key].append(str_value)
criteria[key].append(value)
else:
criteria[key] = [str_value]
criteria[key] = [value]
def _format_string(filters, criteria):
@ -68,38 +65,26 @@ def prepare_body(body: Mapping[str, Any]) -> str:
return json.dumps(body, sort_keys=True)
def _filter_values(mapping: Mapping[str, Any], recursion=False) -> dict[str, Any]:
def _filter_values(mapping: Mapping[str, Any]) -> Dict[str, Any]:
"""Returns a canonical dictionary with values == None or empty Iterables removed.
Dictionary is walked using recursion.
"""
canonical = {}
canonical = dict()
for key, value in mapping.items():
# quick filter if possible...
if (
value is None
or (isinstance(value, collections.abc.Sized) and len(value) <= 0)
and not recursion
):
if value is None or (isinstance(value, collections.abc.Sized) and len(value) <= 0):
continue
# depending on type we need details...
proposal: Any
if isinstance(value, collections.abc.Mapping):
proposal = _filter_values(value, recursion=True)
proposal = _filter_values(value)
elif isinstance(value, collections.abc.Iterable) and not isinstance(value, str):
proposal = [i for i in value if i is not None]
else:
proposal = value
if not recursion and proposal not in (None, "", [], {}):
canonical[key] = proposal
elif recursion and proposal not in (None, [], {}):
if proposal not in (None, str(), list(), dict()):
canonical[key] = proposal
return canonical
def encode_auth_header(auth_config: dict[str, str]) -> bytes:
return base64.urlsafe_b64encode(json.dumps(auth_config).encode('utf-8'))

View File

@ -1,49 +0,0 @@
"""Utility functions for dealing with stdout and stderr."""
HEADER_SIZE = 8
STDOUT = 1
STDERR = 2
# pylint: disable=line-too-long
def demux_output(data_bytes):
"""Demuxes the output of a container stream into stdout and stderr streams.
Stream data is expected to be in the following format:
- 1 byte: stream type (1=stdout, 2=stderr)
- 3 bytes: padding
- 4 bytes: payload size (big-endian)
- N bytes: payload data
ref: https://docs.podman.io/en/latest/_static/api.html?version=v5.0#tag/containers/operation/ContainerAttachLibpod
Args:
data_bytes: Bytes object containing the combined stream data.
Returns:
A tuple containing two bytes objects: (stdout, stderr).
"""
stdout = b""
stderr = b""
while len(data_bytes) >= HEADER_SIZE:
# Extract header information
header, data_bytes = data_bytes[:HEADER_SIZE], data_bytes[HEADER_SIZE:]
stream_type = header[0]
payload_size = int.from_bytes(header[4:HEADER_SIZE], "big")
# Check if data is sufficient for payload
if len(data_bytes) < payload_size:
break # Incomplete frame, wait for more data
# Extract and process payload
payload = data_bytes[:payload_size]
if stream_type == STDOUT:
stdout += payload
elif stream_type == STDERR:
stderr += payload
else:
# todo: Handle unexpected stream types
pass
# Update data for next frame
data_bytes = data_bytes[payload_size:]
return stdout or None, stderr or None

View File

@ -1,38 +1,38 @@
"""Helper functions for parsing strings."""
import base64
import ipaddress
import json
import struct
from datetime import datetime, timezone
from typing import Any, Optional, Union
from collections.abc import Iterator
from datetime import datetime
from typing import Any, Dict, Iterator, Optional, Tuple, Union
from podman.api.client import APIResponse
from .output_utils import demux_output
from requests import Response
def parse_repository(name: str) -> tuple[str, Optional[str]]:
"""Parse repository image name from tag.
def parse_repository(name: str) -> Tuple[str, Optional[str]]:
"""Parse repository image name from tag or digest
Returns:
item 1: repository name
item 2: Either tag or None
item 2: Either digest and tag, tag, or None
"""
# split image name and digest
elements = name.split("@", 1)
if len(elements) == 2:
return elements[0], elements[1]
# split repository and image name from tag
# tags need to be split from the right since
# a port number might increase the split list len by 1
elements = name.rsplit(":", 1)
elements = name.split(":", 1)
if len(elements) == 2 and "/" not in elements[1]:
return elements[0], elements[1]
return name, None
def decode_header(value: Optional[str]) -> dict[str, Any]:
def decode_header(value: Optional[str]) -> Dict[str, Any]:
"""Decode a base64 JSON header value."""
if value is None:
return {}
return dict()
value = base64.b64decode(value)
text = value.decode("utf-8")
@ -48,15 +48,13 @@ def prepare_timestamp(value: Union[datetime, int, None]) -> Optional[int]:
return value
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
delta = value - datetime.fromtimestamp(0, timezone.utc)
delta = value - datetime.utcfromtimestamp(0)
return delta.seconds + delta.days * 24 * 3600
raise ValueError(f"Type '{type(value)}' is not supported by prepare_timestamp()")
def prepare_cidr(value: Union[ipaddress.IPv4Network, ipaddress.IPv6Network]) -> tuple[str, str]:
def prepare_cidr(value: Union[ipaddress.IPv4Network, ipaddress.IPv6Network]) -> (str, str):
"""Returns network address and Base64 encoded netmask from CIDR.
The return values are dictated by the Go JSON decoder.
@ -64,7 +62,7 @@ def prepare_cidr(value: Union[ipaddress.IPv4Network, ipaddress.IPv6Network]) ->
return str(value.network_address), base64.b64encode(value.netmask.packed).decode("utf-8")
def frames(response: APIResponse) -> Iterator[bytes]:
def frames(response: Response) -> Iterator[bytes]:
"""Returns each frame from multiplexed payload, all results are expected in the payload.
The stdout and stderr frames are undifferentiated as they are returned.
@ -80,13 +78,11 @@ def frames(response: APIResponse) -> Iterator[bytes]:
yield response.content[frame_begin:frame_end]
def stream_frames(
response: APIResponse, demux: bool = False
) -> Iterator[Union[bytes, tuple[bytes, bytes]]]:
def stream_frames(response: Response) -> Iterator[bytes]:
"""Returns each frame from multiplexed streamed payload.
If ``demux`` then output will be tuples where the first position is ``STDOUT`` and the second
is ``STDERR``.
Notes:
The stdout and stderr frames are undifferentiated as they are returned.
"""
while True:
header = response.raw.read(8)
@ -98,21 +94,6 @@ def stream_frames(
continue
data = response.raw.read(frame_length)
if demux:
data = demux_output(header + data)
if not data:
return
yield data
def stream_helper(
response: APIResponse, decode_to_json: bool = False
) -> Union[Iterator[bytes], Iterator[dict[str, Any]]]:
"""Helper to stream results and optionally decode to json"""
for value in response.iter_lines():
if decode_to_json:
yield json.loads(value)
else:
yield value

View File

@ -1,54 +0,0 @@
"""Helper functions for managing paths"""
import errno
import getpass
import os
import stat
def get_runtime_dir() -> str:
"""Returns the runtime directory for the current user
The value in XDG_RUNTIME_DIR is preferred, but that is not always set, for
example, on headless servers. /run/user/$UID is defined in the XDG documentation.
"""
try:
return os.environ['XDG_RUNTIME_DIR']
except KeyError:
user = getpass.getuser()
run_user = f'/run/user/{os.getuid()}'
if os.path.isdir(run_user):
return run_user
fallback = f'/tmp/podmanpy-runtime-dir-fallback-{user}'
try:
# This must be a real directory, not a symlink, so attackers can't
# point it elsewhere. So we use lstat to check it.
fallback_st = os.lstat(fallback)
except OSError as e:
if e.errno == errno.ENOENT:
os.mkdir(fallback, 0o700)
else:
raise
else:
# The fallback must be a directory
if not stat.S_ISDIR(fallback_st.st_mode):
os.unlink(fallback)
os.mkdir(fallback, 0o700)
# Must be owned by the user and not accessible by anyone else
elif (fallback_st.st_uid != os.getuid()) or (
fallback_st.st_mode & (stat.S_IRWXG | stat.S_IRWXO)
):
os.rmdir(fallback)
os.mkdir(fallback, 0o700)
return fallback
def get_xdg_config_home() -> str:
"""Returns the XDG_CONFIG_HOME directory for the current user"""
try:
return os.environ["XDG_CONFIG_HOME"]
except KeyError:
return os.path.join(os.path.expanduser("~"), ".config")

View File

@ -2,25 +2,26 @@
See Podman go bindings for more details.
"""
import collections
import functools
import http.client
import logging
import pathlib
import random
import socket
import subprocess
import time
import urllib.parse
from contextlib import suppress
from typing import Optional, Union
import time
import urllib3
import urllib3.connection
import xdg.BaseDirectory
from requests.adapters import DEFAULT_POOLBLOCK, DEFAULT_RETRIES, HTTPAdapter
from podman.api.path_utils import get_runtime_dir
try:
import urllib3
except ImportError:
import requests.packages.urllib3 as urllib3
from .adapter_utils import _key_normalizer
@ -46,7 +47,7 @@ class SSHSocket(socket.socket):
self.identity = identity
self._proc: Optional[subprocess.Popen] = None
runtime_dir = pathlib.Path(get_runtime_dir()) / "podman"
runtime_dir = pathlib.Path(xdg.BaseDirectory.get_runtime_dir(strict=False)) / "podman"
runtime_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
self.local_sock = runtime_dir / f"podman-forward-{random.getrandbits(80):x}.sock"
@ -73,7 +74,7 @@ class SSHSocket(socket.socket):
command += ["-i", str(path)]
command += [f"ssh://{uri.netloc}"]
self._proc = subprocess.Popen( # pylint: disable=consider-using-with
self._proc = subprocess.Popen(
command,
shell=False,
stdout=subprocess.PIPE,
@ -149,7 +150,7 @@ class SSHSocket(socket.socket):
super().close()
class SSHConnection(urllib3.connection.HTTPConnection):
class SSHConnection(http.client.HTTPConnection):
"""Specialization of HTTPConnection to use a SSH forwarded socket."""
def __init__(
@ -203,7 +204,7 @@ class SSHConnection(urllib3.connection.HTTPConnection):
class SSHConnectionPool(urllib3.HTTPConnectionPool):
"""Specialized HTTPConnectionPool for holding SSH connections."""
ConnectionCls = SSHConnection # pylint: disable=invalid-name
ConnectionCls = SSHConnection
class SSHPoolManager(urllib3.PoolManager):
@ -250,7 +251,7 @@ class SSHAdapter(HTTPAdapter):
max_retries: int = DEFAULT_RETRIES,
pool_block: int = DEFAULT_POOLBLOCK,
**kwargs,
): # pylint: disable=too-many-positional-arguments
):
"""Initialize SSHAdapter.
Args:

View File

@ -1,17 +1,16 @@
"""Utility functions for working with tarballs."""
import pathlib
import random
import shutil
import tarfile
import tempfile
from fnmatch import fnmatch
from typing import BinaryIO, Optional
from typing import BinaryIO, List, Optional
import sys
def prepare_containerignore(anchor: str) -> list[str]:
def prepare_containerignore(anchor: str) -> List[str]:
"""Return the list of patterns for filenames to exclude.
.containerignore takes precedence over .dockerignore.
@ -21,11 +20,11 @@ def prepare_containerignore(anchor: str) -> list[str]:
if not ignore.exists():
continue
with ignore.open(encoding='utf-8') as file:
with ignore.open() as file:
return list(
filter(
lambda i: i and not i.startswith("#"),
(line.strip() for line in file.readlines()),
lambda l: len(l) > 0 and not l.startswith("#"),
list(line.strip() for line in file.readlines()),
)
)
return []
@ -45,7 +44,7 @@ def prepare_containerfile(anchor: str, dockerfile: str) -> str:
dockerfile_path = pathlib.Path(dockerfile)
if dockerfile_path.parent.samefile(anchor_path):
return dockerfile_path.name
return dockerfile
proxy_path = anchor_path / f".containerfile.{random.getrandbits(160):x}"
shutil.copy2(dockerfile_path, proxy_path, follow_symlinks=False)
@ -53,7 +52,7 @@ def prepare_containerfile(anchor: str, dockerfile: str) -> str:
def create_tar(
anchor: str, name: str = None, exclude: list[str] = None, gzip: bool = False
anchor: str, name: str = None, exclude: List[str] = None, gzip: bool = False
) -> BinaryIO:
"""Create a tarfile from context_dir to send to Podman service.
@ -98,13 +97,12 @@ def create_tar(
return info
if name is None:
# pylint: disable=consider-using-with
name = tempfile.NamedTemporaryFile(prefix="podman_context", suffix=".tar")
else:
name = pathlib.Path(name)
if exclude is None:
exclude = []
exclude = list()
else:
exclude = exclude.copy()
@ -116,16 +114,16 @@ def create_tar(
with tarfile.open(name.name, mode) as tar:
tar.add(anchor, arcname="", recursive=True, filter=add_filter)
return open(name.name, "rb") # pylint: disable=consider-using-with
return open(name.name, "rb")
def _exclude_matcher(path: str, exclude: list[str]) -> bool:
def _exclude_matcher(path: str, exclude: List[str]) -> bool:
"""Returns True if path matches an entry in exclude.
Note:
FIXME Not compatible, support !, **, etc
"""
if not exclude:
if len(exclude) == 0:
return False
for pattern in exclude:

View File

@ -1,19 +1,21 @@
"""Specialized Transport Adapter for UNIX domain sockets."""
import collections
import functools
import http.client
import logging
import socket
from typing import Optional, Union
from urllib.parse import unquote, urlparse
import urllib3
import urllib3.connection
from requests.adapters import DEFAULT_POOLBLOCK, DEFAULT_POOLSIZE, DEFAULT_RETRIES, HTTPAdapter
from ..errors import APIError
try:
import urllib3
except ImportError:
import requests.packages.urllib3 as urllib3
from .adapter_utils import _key_normalizer
logger = logging.getLogger("podman.uds_adapter")
@ -43,7 +45,7 @@ class UDSSocket(socket.socket):
raise APIError(f"Unable to make connection to UDS '{netloc}'") from e
class UDSConnection(urllib3.connection.HTTPConnection):
class UDSConnection(http.client.HTTPConnection):
"""Specialization of HTTPConnection to use a UNIX domain sockets."""
def __init__(
@ -90,7 +92,7 @@ class UDSConnection(urllib3.connection.HTTPConnection):
class UDSConnectionPool(urllib3.HTTPConnectionPool):
"""Specialization of HTTPConnectionPool for holding UNIX domain sockets."""
ConnectionCls = UDSConnection # pylint: disable=invalid-name
ConnectionCls = UDSConnection
class UDSPoolManager(urllib3.PoolManager):
@ -137,7 +139,7 @@ class UDSAdapter(HTTPAdapter):
max_retries=DEFAULT_RETRIES,
pool_block=DEFAULT_POOLBLOCK,
**kwargs,
): # pylint: disable=too-many-positional-arguments
):
"""Initialize UDSAdapter.
Args:
@ -153,7 +155,7 @@ class UDSAdapter(HTTPAdapter):
Examples:
requests.Session.mount(
"http://", UDSAdapter("http+unix:///run/user/1000/podman/podman.sock"))
"http://", UDSAdapater("http+unix:///run/user/1000/podman/podman.sock"))
"""
self.poolmanager: Optional[UDSPoolManager] = None

View File

@ -1,4 +1,4 @@
"""Version of PodmanPy."""
__version__ = "5.6.0"
__version__ = "3.1.2.1"
__compatible_version__ = "1.40"

175
podman/api_connection.py Normal file
View File

@ -0,0 +1,175 @@
""" Provides a Connection to a Podman service."""
import json
import logging
import socket
import urllib.parse
import warnings
from contextlib import AbstractContextManager
from http import HTTPStatus
from http.client import HTTPConnection
import podman.containers as containers
import podman.errors as errors
import podman.images as images
import podman.system as system
class ApiConnection(HTTPConnection, AbstractContextManager):
"""
ApiConnection provides a specialized HTTPConnection
to a Podman service.
"""
def __init__(self, url, base="/v2.0.0/libpod", *args, **kwargs): # pylint: disable-msg=W1113
if url is None or not url:
raise ValueError("url is required for service connection.")
super().__init__("localhost", *args, **kwargs)
supported_schemes = ("unix", "ssh")
uri = urllib.parse.urlparse(url)
if uri.scheme not in supported_schemes:
raise ValueError(
"The scheme '{}' is not supported, only {}".format(uri.scheme, supported_schemes)
)
self.uri = uri
self.base = base
warnings.warn("APIConnection() and supporting classes.", PendingDeprecationWarning)
def connect(self):
"""Connect to the URL given when initializing class"""
if self.uri.scheme == "unix":
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.connect(self.uri.path)
self.sock = sock
else:
raise NotImplementedError("Scheme {} not yet implemented".format(self.uri.scheme))
def delete(self, path, params=None):
"""Basic DELETE wrapper for requests
Send a delete request with params added to the url as a query string
:param path: url part to the call, appended to self.base
:param params: optional dictionary of query params added to the request
:return: http response object
"""
return self.request("DELETE", self.join(path, params))
def get(self, path, params=None):
"""Basic GET wrapper for requests
Send a get request with params added to the url as a query string
:param path: url part to the call, appended to self.base
:param params: optional dictionary of query params added to the request
:return: http response object
"""
return self.request("GET", self.join(path, params))
def post(self, path, params=None, headers=None, encode=False):
"""Basic POST wrapper for requests
Send a POST request with params converted into a urlencoded form to be
sent with the post.
:param path: url part to the call, appended to self.base
:param params: optional dictionary of query params added to the post
request as url encoded form data
:param headers: optional dictionary of request headers
:param encode: flag to indicate if you want the params to be encoded
prior to posting. When set to False, params are posted
directly as the body of the request.
:return: http response object
"""
data = params
if not headers:
headers = {}
if encode:
if "content-type" not in set(key.lower() for key in headers) and params:
headers["content-type"] = "application/x-www-form-urlencoded"
data = urllib.parse.urlencode(params)
return self.request('POST', self.join(path), body=data, headers=headers)
def request(self, method, url, body=None, headers=None, *, encode_chunked=False):
"""Make request to Podman service."""
if headers is None:
headers = {}
super().request(method, url, body, headers, encode_chunked=encode_chunked)
response = super().getresponse()
# Errors are mapped to exceptions
if HTTPStatus.OK <= response.status < HTTPStatus.MULTIPLE_CHOICES:
pass
elif HTTPStatus.NOT_FOUND == response.status:
raise errors.NotFoundError(
"Request {}:{} failed: {}".format(
method,
url,
HTTPStatus.NOT_FOUND.description or HTTPStatus.NOT_FOUND.phrase,
),
response,
)
elif (
response.status >= HTTPStatus.BAD_REQUEST
and response.status < HTTPStatus.INTERNAL_SERVER_ERROR
):
raise errors.RequestError(
"Request {}:{} failed: {}".format(
method,
url,
response.reason or "Response Status Code {}".format(response.status),
),
response,
)
elif response.status >= HTTPStatus.INTERNAL_SERVER_ERROR:
try:
error_body = response.read()
error_message = json.loads(error_body)["message"]
except: # pylint: disable=bare-except
error_message = (
HTTPStatus.INTERNAL_SERVER_ERROR.description
or HTTPStatus.INTERNAL_SERVER_ERROR.phrase
)
raise errors.InternalServerError(
"Request {}:{} failed: {}".format(method, url, error_message),
response,
)
return response
def join(self, path, query=None):
"""Create a service URL. Join base + path + query parameters"""
path = self.base + path
if query is not None:
query = urllib.parse.urlencode(query)
path = path + "?" + query
return path
@staticmethod
def quote(value):
"""Quote value for use in a URL"""
return urllib.parse.quote(value)
@staticmethod
def raise_not_found(exc, response, exception_type=errors.ImageNotFound):
"""helper function to raise a not found exception of exception_type"""
body = json.loads(response.read())
logging.info(body["cause"])
raise exception_type(body["message"]) from exc
def __exit__(self, exc_type, exc_value, traceback):
super().close()
if __name__ == "__main__": # pragma: no cover
with ApiConnection("unix:///run/podman/podman.sock") as api:
print(system.version(api))
print(images.list_images(api))
print(containers.list_containers(api))
try:
images.inspect(api, "bozo the clown")
except errors.ImageNotFound as e:
print(e)

View File

@ -1,14 +1,14 @@
"""Client for connecting to Podman service."""
import logging
import os
from contextlib import AbstractContextManager
from pathlib import Path
from typing import Any, Optional
from typing import Any, Dict, Optional
import xdg.BaseDirectory
from podman.api import cached_property
from podman.api.client import APIClient
from podman.api.path_utils import get_runtime_dir
from podman.domain.config import PodmanConfig
from podman.domain.containers_manager import ContainersManager
from podman.domain.events import EventsManager
@ -31,7 +31,6 @@ class PodmanClient(AbstractContextManager):
with PodmanClient(base_url="ssh://root@api.example:22/run/podman/podman.sock?secure=True",
identity="~alice/.ssh/api_ed25519")
"""
def __init__(self, **kwargs) -> None:
"""Initialize PodmanClient.
@ -69,7 +68,9 @@ class PodmanClient(AbstractContextManager):
# Override configured identity, if provided in arguments
api_kwargs["identity"] = kwargs.get("identity", str(connection.identity))
elif "base_url" not in api_kwargs:
path = str(Path(get_runtime_dir()) / "podman" / "podman.sock")
path = str(
Path(xdg.BaseDirectory.get_runtime_dir(strict=False)) / "podman" / "podman.sock"
)
api_kwargs["base_url"] = "http+unix://" + path
self.api = APIClient(**api_kwargs)
@ -82,14 +83,13 @@ class PodmanClient(AbstractContextManager):
@classmethod
def from_env(
cls,
*,
version: str = "auto",
timeout: Optional[int] = None,
max_pool_size: Optional[int] = None,
ssl_version: Optional[int] = None, # pylint: disable=unused-argument
assert_hostname: bool = False, # pylint: disable=unused-argument
environment: Optional[dict[str, str]] = None,
credstore_env: Optional[dict[str, str]] = None,
environment: Optional[Dict[str, str]] = None,
credstore_env: Optional[Dict[str, str]] = None,
use_ssh_client: bool = True, # pylint: disable=unused-argument
) -> "PodmanClient":
"""Returns connection to service using environment variables and parameters.
@ -112,34 +112,28 @@ class PodmanClient(AbstractContextManager):
Returns:
Client used to communicate with a Podman service.
Raises:
ValueError when required environment variable is not set
"""
environment = environment or os.environ
credstore_env = credstore_env or {}
credstore_env = credstore_env or dict()
if version == "auto":
version = None
kwargs = {
'version': version,
'timeout': timeout,
'tls': False,
'credstore_env': credstore_env,
'max_pool_size': max_pool_size,
}
host = environment.get("CONTAINER_HOST") or environment.get("DOCKER_HOST") or None
if host is not None:
kwargs['base_url'] = host
return PodmanClient(**kwargs)
return PodmanClient(
base_url=host,
version=version,
timeout=timeout,
tls=False,
credstore_env=credstore_env,
max_pool_size=max_pool_size,
)
@cached_property
def containers(self) -> ContainersManager:
"""Returns Manager for operations on containers stored by a Podman service."""
return ContainersManager(client=self.api, podman_client=self)
return ContainersManager(client=self.api)
@cached_property
def images(self) -> ImagesManager:
@ -175,7 +169,7 @@ class PodmanClient(AbstractContextManager):
def system(self):
return SystemManager(client=self.api)
def df(self) -> dict[str, Any]: # pylint: disable=missing-function-docstring,invalid-name
def df(self) -> Dict[str, Any]: # pylint: disable=missing-function-docstring,invalid-name
return self.system.df()
df.__doc__ = SystemManager.df.__doc__

View File

@ -0,0 +1,419 @@
"""containers provides the operations against containers for a Podman service.
"""
import json
from http import HTTPStatus
import podman.errors as errors
def attach(api, name):
"""Attach to a container"""
raise NotImplementedError('Attach not implemented yet')
def changes(api, name):
"""Get files added, deleted or modified in a container"""
try:
response = api.get('/containers/{}/changes'.format(api.quote(name)))
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def checkpoint(
api,
name,
export_image=None,
ignore_root_fs=None,
keep=None,
leave_running=None,
tcp_established=None,
):
"""Copy tar of files into a container"""
path = '/containers/{}/checkpoint'.format(api.quote(name))
params = {}
if export_image is not None:
params['export'] = export_image
if ignore_root_fs is not None:
params['ignoreRootFS'] = ignore_root_fs
if keep is not None:
params['keep'] = keep
if leave_running is not None:
params['leaveRunning'] = leave_running
if tcp_established is not None:
params['tcpEstablished'] = tcp_established
try:
response = api.post(path, params=params, headers={'content-type': 'application/json'})
return response.read()
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def copy(api, name, file_path, pause_container=None):
"""Copy tar of files into a container"""
path = '/containers/{}/copy'.format(api.quote(name))
params = {'path': file_path}
if pause_container is not None:
params['pause'] = pause_container
try:
response = api.post(path, params=params, headers={'content-type': 'application/json'})
response.read()
return True
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def container_exists(api, name):
"""Check if container exists"""
try:
api.get("/containers/{}/exists".format(api.quote(name)))
return True
except errors.NotFoundError:
return False
def create(api, container_data):
"""Create a container with the provided attributes
container_data is a dictionary contining the container attributes for
creation. See documentation for specifics.
https://docs.podman.io/en/latest/_static/api.html#operation/libpodCreateContainer
"""
try:
response = api.post(
"/containers/create",
params=container_data,
headers={'content-type': 'application/json'},
)
response.read()
return response.status == HTTPStatus.CREATED
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def export(api, name):
"""Container export"""
try:
response = api.get("/containers/{}/export".format(api.quote(name)))
return response.read()
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def healthcheck(api, name):
"""Execute container healthcheck"""
try:
response = api.get('/containers/{}/healthcheck'.format(api.quote(name)))
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def init(api, name):
"""Initialize a container
Returns true if successful, false if already done
"""
try:
response = api.post('/containers/{}/init'.format(api.quote(name)))
response.read()
return response.status == HTTPStatus.NO_CONTENT
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def inspect(api, name):
"""Report on named container for a Podman service.
Name may also be a container ID.
"""
try:
response = api.get('/containers/{}/json'.format(api.quote(name)))
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def kill(api, name, signal=None):
"""kill named/identified container"""
path = "/containers/{}/kill".format(api.quote(name))
params = {}
if signal is not None:
params = {'signal': signal}
try:
response = api.post(path, params=params, headers={'content-type': 'application/json'})
# returns an empty bytes object
response.read()
return True
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def list_containers(api, all_=None, filters=None, limit=None, size=None, sync=None):
"""List all images for a Podman service."""
query = {}
if all_ is not None:
query["all"] = True
if filters is not None:
query["filters"] = filters
if limit is not None:
query["limit"] = limit
if size is not None:
query["size"] = size
if sync is not None:
query["sync"] = sync
response = api.get("/containers/json", query)
# observed to return None when no containers
return json.loads(str(response.read(), "utf-8")) or []
def logs(api, name, follow=None, since=None, stderr=None, tail=None, timestamps=None, until=None):
"""Get stdout and stderr logs"""
raise NotImplementedError('Logs not implemented yet')
def mount(api, name):
"""Mount container to the filesystem"""
path = "/containers/{}/mount".format(api.quote(name))
try:
response = api.post(path, headers={'content-type': 'application/json'})
return json.loads(str(response.read(), "utf-8"))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def pause(api, name):
"""Pause a container"""
path = "/containers/{}/pause".format(api.quote(name))
try:
response = api.post(path, headers={'content-type': 'application/json'})
response.read()
return response.status == HTTPStatus.NO_CONTENT
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def prune(api, name, filters=None):
"""Remove stopped containers"""
path = "/containers/{}/prune".format(api.quote(name))
params = {}
if filters is not None:
params['filters'] = filters
response = api.post(path, params=params, headers={'content-type': 'application/json'})
return json.loads(str(response.read(), "utf-8"))
def remove(api, name, force=None, delete_volumes=None):
"""Delete container"""
path = "/containers/{}".format(api.quote(name))
params = {}
if force is not None:
params['force'] = force
if delete_volumes is not None:
params['v'] = delete_volumes
try:
response = api.delete(path, params)
# returns an empty bytes object
response.read()
return True
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def resize(api, name, height, width):
"""Resize container tty"""
path = "/containers/{}/resize".format(api.quote(name))
params = {'h': height, 'w': width}
try:
response = api.post(path, params=params, headers={'content-type': 'application/json'})
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def restart(api, name, timeout=None):
"""Restart container"""
path = "/containers/{}/restart".format(api.quote(name))
params = {}
if timeout is not None:
params['t'] = timeout
try:
response = api.post(path, params=params, headers={'content-type': 'application/json'})
response.read()
return response.status == HTTPStatus.NO_CONTENT
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def restore(
api,
name,
ignore_root_fs=None,
ignore_static_ip=None,
ignore_static_mac=None,
import_arg=None,
keep=None,
leave_running=None,
container_name=None,
tcp_established=None,
):
"""Restore container"""
path = "/containers/{}/restore".format(api.quote(name))
params = {}
if ignore_root_fs is not None:
params['ignoreRootFS'] = ignore_root_fs
if ignore_static_ip is not None:
params['ignoreStaticIP'] = ignore_static_ip
if ignore_static_mac is not None:
params['ignoreStaticMAC'] = ignore_static_mac
if import_arg is not None:
params['import'] = import_arg
if keep is not None:
params['keep'] = keep
if leave_running is not None:
params['leaveRunning'] = leave_running
if container_name is not None:
params['name'] = container_name
if tcp_established is not None:
params['tcpEstablished'] = tcp_established
try:
response = api.post(path, params=params, headers={'content-type': 'application/json'})
# TODO(mwhahaha): handle returned tarball better
return response.read()
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def show_mounted(api):
"""Show mounted containers"""
response = api.get('/containers/showmounted')
return json.loads(str(response.read(), 'utf-8'))
def start(api, name, detach_keys=None):
"""Start container"""
path = "/containers/{}/start".format(api.quote(name))
params = {}
if detach_keys is not None:
params['detachKeys'] = detach_keys
try:
response = api.post(path, params=params, headers={'content-type': 'application/json'})
response.read()
return response.status == HTTPStatus.NO_CONTENT
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def stats(api, containers=None, stream=True):
"""Get container stats container
When stream is set to true, the raw HTTPResponse is returned.
"""
path = "/containers/stats"
params = {'stream': stream}
if containers is not None:
params['containers'] = containers
try:
response = api.get(path, params=params)
if stream:
return response
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def stop(api, name, timeout=None):
"""Stop container"""
path = "/containers/{}/stop".format(api.quote(name))
params = {}
if timeout is not None:
params['t'] = timeout
try:
response = api.post(path, params=params, headers={'content-type': 'application/json'})
response.read()
return response.status == HTTPStatus.NO_CONTENT
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def top(api, name, ps_args=None, stream=True):
"""List processes in a container
When stream is set to true, the raw HTTPResponse is returned.
"""
path = "/containers/{}/top".format(api.quote(name))
params = {'stream': stream}
if ps_args is not None:
params['ps_args'] = ps_args
try:
response = api.get(path, params=params)
if stream:
return response
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def unmount(api, name):
"""Unmount container"""
path = "/containers/{}/unmount".format(api.quote(name))
try:
response = api.post(path, headers={'content-type': 'application/json'})
response.read()
return response.status == HTTPStatus.NO_CONTENT
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def unpause(api, name):
"""Unpause container"""
path = "/containers/{}/unpause".format(api.quote(name))
try:
response = api.post(path, headers={'content-type': 'application/json'})
response.read()
return response.status == HTTPStatus.NO_CONTENT
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
def wait(api, name, condition=None):
"""Wait for a container state"""
path = "/containers/{}/wait".format(api.quote(name))
params = {}
if condition is not None:
params['condition'] = condition
try:
response = api.post(path, params=params, headers={'content-type': 'application/json'})
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ContainerNotFound)
__all__ = [
"attach",
"changes",
"checkpoint",
"copy",
"container_exists",
"export",
"healthcheck",
"inspect",
"kill",
"list_containers",
"logs",
"mount",
"pause",
"prune",
"remove",
"resize",
"restore",
"show_mounted",
"start",
"stop",
"top",
"unmount",
"unpause",
"wait",
]

View File

@ -1,30 +1,22 @@
"""Read containers.conf file."""
import sys
import urllib
from pathlib import Path
from typing import Optional
import json
from typing import Dict, Optional
import xdg.BaseDirectory
try:
import toml
except ImportError:
import pytoml as toml
from podman.api import cached_property
from podman.api.path_utils import get_xdg_config_home
if sys.version_info >= (3, 11):
from tomllib import loads as toml_loads
else:
try:
from tomli import loads as toml_loads
except ImportError:
try:
from toml import loads as toml_loads
except ImportError:
from pytoml import loads as toml_loads
class ServiceConnection:
"""ServiceConnection defines a connection to the Podman service."""
def __init__(self, name: str, attrs: dict[str, str]):
def __init__(self, name: str, attrs: Dict[str, str]):
"""Create a Podman ServiceConnection."""
self.name = name
self.attrs = attrs
@ -48,16 +40,12 @@ class ServiceConnection:
@cached_property
def url(self):
"""urllib.parse.ParseResult: Returns URL for service connection."""
if self.attrs.get("uri"):
return urllib.parse.urlparse(self.attrs.get("uri"))
return urllib.parse.urlparse(self.attrs.get("URI"))
@cached_property
def identity(self):
"""Path: Returns Path to identity file for service connection."""
if self.attrs.get("identity"):
return Path(self.attrs.get("identity"))
return Path(self.attrs.get("Identity"))
class PodmanConfig:
@ -66,46 +54,17 @@ class PodmanConfig:
def __init__(self, path: Optional[str] = None):
"""Read Podman configuration from users XDG_CONFIG_HOME."""
self.is_default = False
if path is None:
home = Path(get_xdg_config_home())
self.path = home / "containers" / "podman-connections.json"
old_toml_file = home / "containers" / "containers.conf"
self.is_default = True
# this elif is only for testing purposes
elif "@@is_test@@" in path:
test_path = path.replace("@@is_test@@", '')
self.path = Path(test_path) / "podman-connections.json"
old_toml_file = Path(test_path) / "containers.conf"
self.is_default = True
home = Path(xdg.BaseDirectory.xdg_config_home)
self.path = home / "containers" / "containers.conf"
else:
self.path = Path(path)
old_toml_file = None
self.attrs = {}
self.attrs = dict()
if self.path.exists():
try:
with open(self.path, encoding='utf-8') as file:
self.attrs = json.load(file)
except Exception:
# if the user specifies a path, it can either be a JSON file
# or a TOML file - so try TOML next
try:
with self.path.open(encoding='utf-8') as file:
with self.path.open() as file:
buffer = file.read()
loaded_toml = toml_loads(buffer)
self.attrs.update(loaded_toml)
except Exception as e:
raise AttributeError(
"The path given is neither a JSON nor a TOML connections file"
) from e
# Read the old toml file configuration
if self.is_default and old_toml_file.exists():
with old_toml_file.open(encoding='utf-8') as file:
buffer = file.read()
loaded_toml = toml_loads(buffer)
self.attrs.update(loaded_toml)
self.attrs = toml.loads(buffer)
def __hash__(self) -> int:
return hash(tuple(self.path.name))
@ -122,16 +81,15 @@ class PodmanConfig:
@cached_property
def services(self):
"""dict[str, ServiceConnection]: Returns list of service connections.
"""Dict[str, ServiceConnection]: Returns list of service connections.
Examples:
podman_config = PodmanConfig()
address = podman_config.services["testing"]
print(f"Testing service address {address}")
"""
services: dict[str, ServiceConnection] = {}
services: Dict[str, ServiceConnection] = dict()
# read the keys of the toml file first
engine = self.attrs.get("engine")
if engine:
destinations = engine.get("service_destinations")
@ -139,35 +97,17 @@ class PodmanConfig:
connection = ServiceConnection(key, attrs=destinations[key])
services[key] = connection
# read the keys of the json file next
# this will ensure that if the new json file and the old toml file
# has a connection with the same name defined, we always pick the
# json one
connection = self.attrs.get("Connection")
if connection:
destinations = connection.get("Connections")
for key in destinations:
connection = ServiceConnection(key, attrs=destinations[key])
services[key] = connection
return services
@cached_property
def active_service(self):
"""Optional[ServiceConnection]: Returns active connection."""
# read the new json file format
connection = self.attrs.get("Connection")
if connection:
active = connection.get("Default")
destinations = connection.get("Connections")
return ServiceConnection(active, attrs=destinations[active])
# if we are here, that means there was no default in the new json file
engine = self.attrs.get("engine")
if engine:
active = engine.get("active_service")
destinations = engine.get("service_destinations")
return ServiceConnection(active, attrs=destinations[active])
for key in destinations:
if key == active:
return ServiceConnection(key, attrs=destinations[key])
return None

View File

@ -1,16 +1,15 @@
"""Model and Manager for Container resources."""
import io
import json
import logging
import shlex
from collections.abc import Iterable, Iterator, Mapping
from contextlib import suppress
from typing import Any, Optional, Union
from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence, Tuple, Union
import requests
from requests import Response
from podman import api
from podman.api.output_utils import demux_output
from podman.api import Literal
from podman.domain.images import Image
from podman.domain.images_manager import ImagesManager
from podman.domain.manager import PodmanResource
@ -26,9 +25,7 @@ class Container(PodmanResource):
def name(self):
"""str: Returns container's name."""
with suppress(KeyError):
if 'Name' in self.attrs:
return self.attrs["Name"].lstrip("/")
return self.attrs["Names"][0].lstrip("/")
return None
@property
@ -36,6 +33,8 @@ class Container(PodmanResource):
"""podman.domain.images.Image: Returns Image object used to create Container."""
if "Image" in self.attrs:
image_id = self.attrs["Image"]
if ":" in image_id:
image_id = image_id.split(":")[1]
return ImagesManager(client=self.client).get(image_id)
return Image()
@ -43,20 +42,13 @@ class Container(PodmanResource):
@property
def labels(self):
"""dict[str, str]: Returns labels associated with container."""
labels = None
with suppress(KeyError):
# Container created from ``list()`` operation
if "Labels" in self.attrs:
labels = self.attrs["Labels"]
# Container created from ``get()`` operation
else:
labels = self.attrs["Config"].get("Labels", {})
return labels or {}
return self.attrs["Config"]["Labels"]
return dict()
@property
def status(self):
"""Literal["created", "initialized", "running", "stopped", "exited", "unknown"]:
Returns status of container."""
"""Literal["running", "stopped", "exited", "unknown"]: Returns status of container."""
with suppress(KeyError):
return self.attrs["State"]["Status"]
return "unknown"
@ -66,7 +58,7 @@ class Container(PodmanResource):
"""dict[str, int]: Return ports exposed by container."""
with suppress(KeyError):
return self.attrs["NetworkSettings"]["Ports"]
return {}
return dict()
def attach(self, **kwargs) -> Union[str, Iterator[str]]:
"""Attach to container's tty.
@ -99,8 +91,8 @@ class Container(PodmanResource):
Keyword Args:
author (str): Name of commit author
changes (list[str]): Instructions to apply during commit
comment (str): Commit message to include with Image, overrides keyword message
changes (List[str]): Instructions to apply during commit
comment (List[str]): Instructions to apply while committing in Dockerfile format
conf (dict[str, Any]): Ignored.
format (str): Format of the image manifest and metadata
message (str): Commit message to include with Image
@ -109,7 +101,7 @@ class Container(PodmanResource):
params = {
"author": kwargs.get("author"),
"changes": kwargs.get("changes"),
"comment": kwargs.get("comment", kwargs.get("message")),
"comment": kwargs.get("comment"),
"container": self.id,
"format": kwargs.get("format"),
"pause": kwargs.get("pause"),
@ -120,9 +112,9 @@ class Container(PodmanResource):
response.raise_for_status()
body = response.json()
return ImagesManager(client=self.client).get(body["Id"])
return ImagesManager(client=self.client).get(body["ID"])
def diff(self) -> list[dict[str, int]]:
def diff(self) -> List[Dict[str, int]]:
"""Report changes of a container's filesystem.
Raises:
@ -132,11 +124,9 @@ class Container(PodmanResource):
response.raise_for_status()
return response.json()
# pylint: disable=too-many-arguments
def exec_run(
self,
cmd: Union[str, list[str]],
*,
cmd: Union[str, List[str]],
stdout: bool = True,
stderr: bool = True,
stdin: bool = False,
@ -145,14 +135,13 @@ class Container(PodmanResource):
user=None,
detach: bool = False,
stream: bool = False,
socket: bool = False, # pylint: disable=unused-argument
environment: Union[Mapping[str, str], list[str]] = None,
socket: bool = False,
environment: Union[Mapping[str, str], List[str]] = None,
workdir: str = None,
demux: bool = False,
) -> tuple[
Optional[int],
Union[Iterator[Union[bytes, tuple[bytes, bytes]]], Any, tuple[bytes, bytes]],
]:
) -> Tuple[
Optional[int], Union[Iterator[bytes], Any, Tuple[bytes, bytes]]
]: # pylint: disable=too-many-arguments,unused-argument
"""Run given command inside container and return results.
Args:
@ -162,70 +151,29 @@ class Container(PodmanResource):
stdin: Attach to stdin. Default: False
tty: Allocate a pseudo-TTY. Default: False
privileged: Run as privileged.
user: User to execute command as.
user: User to execute command as. Default: root
detach: If true, detach from the exec command.
Default: False
stream: Stream response data. Ignored if ``detach`` is ``True``. Default: False
stream: Stream response data. Default: False
socket: Return the connection socket to allow custom
read/write operations. Default: False
environment: A dictionary or a list[str] in
environment: A dictionary or a List[str] in
the following format ["PASSWORD=xxx"] or
{"PASSWORD": "xxx"}.
workdir: Path to working directory for this exec session
demux: Return stdout and stderr separately
Returns:
A tuple of (``response_code``, ``output``).
``response_code``:
The exit code of the provided command. ``None`` if ``stream``.
``output``:
If ``stream``, then a generator yielding response chunks.
If ``demux``, then a tuple of (``stdout``, ``stderr``).
Else the response content.
TBD
Raises:
NotImplementedError: method not implemented.
APIError: when service reports error
"""
# pylint: disable-msg=too-many-locals
if isinstance(environment, dict):
environment = [f"{k}={v}" for k, v in environment.items()]
data = {
"AttachStderr": stderr,
"AttachStdin": stdin,
"AttachStdout": stdout,
"Cmd": cmd if isinstance(cmd, list) else shlex.split(cmd),
# "DetachKeys": detach, # This is something else
"Env": environment,
"Privileged": privileged,
"Tty": tty,
"WorkingDir": workdir,
}
if user:
data["User"] = user
if user is None:
user = "root"
stream = stream and not detach
# create the exec instance
response = self.client.post(f"/containers/{self.name}/exec", data=json.dumps(data))
response.raise_for_status()
exec_id = response.json()['Id']
# start the exec instance, this will store command output
start_resp = self.client.post(
f"/exec/{exec_id}/start", data=json.dumps({"Detach": detach, "Tty": tty}), stream=stream
)
start_resp.raise_for_status()
if stream:
return None, api.stream_frames(start_resp, demux=demux)
# get and return exec information
response = self.client.get(f"/exec/{exec_id}/json")
response.raise_for_status()
if demux:
stdout_data, stderr_data = demux_output(start_resp.content)
return response.json().get('ExitCode'), (stdout_data, stderr_data)
return response.json().get('ExitCode'), start_resp.content
raise NotImplementedError()
def export(self, chunk_size: int = api.DEFAULT_CHUNK_SIZE) -> Iterator[bytes]:
"""Download container's filesystem contents as a tar archive.
@ -243,11 +191,12 @@ class Container(PodmanResource):
response = self.client.get(f"/containers/{self.id}/export", stream=True)
response.raise_for_status()
yield from response.iter_content(chunk_size=chunk_size)
for out in response.iter_content(chunk_size=chunk_size):
yield out
def get_archive(
self, path: str, chunk_size: int = api.DEFAULT_CHUNK_SIZE
) -> tuple[Iterable, dict[str, Any]]:
) -> Tuple[Iterable, Dict[str, Any]]:
"""Download a file or folder from the container's filesystem.
Args:
@ -265,21 +214,6 @@ class Container(PodmanResource):
stat = api.decode_header(stat)
return response.iter_content(chunk_size=chunk_size), stat
def init(self) -> None:
"""Initialize the container."""
response = self.client.post(f"/containers/{self.id}/init")
response.raise_for_status()
def inspect(self) -> dict:
"""Inspect a container.
Raises:
APIError: when service reports an error
"""
response = self.client.get(f"/containers/{self.id}/json")
response.raise_for_status()
return response.json()
def kill(self, signal: Union[str, int, None] = None) -> None:
"""Send signal to container.
@ -306,21 +240,20 @@ class Container(PodmanResource):
until (Union[datetime, int]): Show logs that occurred before the given
datetime or integer epoch (in seconds)
"""
stream = bool(kwargs.get("stream", False))
params = {
"follow": kwargs.get("follow", kwargs.get("stream", None)),
"since": api.prepare_timestamp(kwargs.get("since")),
"stderr": kwargs.get("stderr", True),
"stderr": kwargs.get("stderr", None),
"stdout": kwargs.get("stdout", True),
"tail": kwargs.get("tail"),
"timestamps": kwargs.get("timestamps"),
"until": api.prepare_timestamp(kwargs.get("until")),
}
response = self.client.get(f"/containers/{self.id}/logs", stream=stream, params=params)
response = self.client.get(f"/containers/{self.id}/logs", params=params)
response.raise_for_status()
if stream:
if bool(kwargs.get("stream", False)):
return api.stream_frames(response)
return api.frames(response)
@ -401,7 +334,7 @@ class Container(PodmanResource):
timeout (int): Seconds to wait for container to stop before killing container.
"""
params = {"timeout": kwargs.get("timeout")}
post_kwargs = {}
post_kwargs = dict()
if kwargs.get("timeout"):
post_kwargs["timeout"] = float(params["timeout"]) * 1.5
@ -419,9 +352,7 @@ class Container(PodmanResource):
)
response.raise_for_status()
def stats(
self, **kwargs
) -> Union[bytes, dict[str, Any], Iterator[bytes], Iterator[dict[str, Any]]]:
def stats(self, **kwargs) -> Union[Sequence[Dict[str, bytes]], bytes]:
"""Return statistics for container.
Keyword Args:
@ -441,13 +372,27 @@ class Container(PodmanResource):
"stream": stream,
}
response = self.client.get("/containers/stats", params=params, stream=stream)
response = self.client.get("/containers/stats", params=params)
response.raise_for_status()
if stream:
return api.stream_helper(response, decode_to_json=decode)
return self._stats_helper(decode, response.iter_lines())
return json.loads(response.content) if decode else response.content
with io.StringIO() as buffer:
for entry in response.text:
buffer.writer(json.dumps(entry) + "\n")
return buffer.getvalue()
@staticmethod
def _stats_helper(
decode: bool, body: List[Dict[str, Any]]
) -> Iterator[Union[str, Dict[str, Any]]]:
"""Helper needed to allow stats() to return either a generator or a str."""
for entry in body:
if decode:
yield json.loads(entry)
else:
yield entry
def stop(self, **kwargs) -> None:
"""Stop container.
@ -459,7 +404,7 @@ class Container(PodmanResource):
"""
params = {"all": kwargs.get("all"), "timeout": kwargs.get("timeout")}
post_kwargs = {}
post_kwargs = dict()
if kwargs.get("timeout"):
post_kwargs["timeout"] = float(params["timeout"]) * 1.5
@ -476,7 +421,7 @@ class Container(PodmanResource):
body = response.json()
raise APIError(body["cause"], response=response, explanation=body["message"])
def top(self, **kwargs) -> Union[Iterator[dict[str, Any]], dict[str, Any]]:
def top(self, **kwargs) -> Union[Iterator[Dict[str, Any]], Dict[str, Any]]:
"""Report on running processes in the container.
Keyword Args:
@ -487,256 +432,43 @@ class Container(PodmanResource):
NotFound: when the container no longer exists
APIError: when the service reports an error
"""
stream = kwargs.get("stream", False)
params = {
"stream": stream,
"ps_args": kwargs.get("ps_args"),
"stream": kwargs.get("stream", False),
}
response = self.client.get(f"/containers/{self.id}/top", params=params, stream=stream)
response = self.client.get(f"/containers/{self.id}/top", params=params)
response.raise_for_status()
if stream:
return api.stream_helper(response, decode_to_json=True)
if params["stream"]:
self._top_helper(response)
return response.json()
@staticmethod
def _top_helper(response: Response) -> Iterator[Dict[str, Any]]:
for line in response.iter_lines():
yield line
def unpause(self) -> None:
"""Unpause processes in container."""
response = self.client.post(f"/containers/{self.id}/unpause")
response.raise_for_status()
def update(self, **kwargs) -> None:
def update(self, **kwargs):
"""Update resource configuration of the containers.
Keyword Args:
Please refer to Podman API documentation for details:
https://docs.podman.io/en/latest/_static/api.html#tag/containers/operation/ContainerUpdateLibpod
restart_policy (str): New restart policy for the container.
restart_retries (int): New amount of retries for the container's restart policy.
Only allowed if restartPolicy is set to on-failure
blkio_weight_device tuple(str, int):Block IO weight (relative device weight)
in the form: (device_path, weight)
blockio (dict): LinuxBlockIO for Linux cgroup 'blkio' resource management
Example:
blockio = {
"leafWeight": 0
"throttleReadBpsDevice": [{
"major": 0,
"minor": 0,
"rate": 0
}],
"throttleReadIopsDevice": [{
"major": 0,
"minor": 0,
"rate": 0
}],
"throttleWriteBpsDevice": [{
"major": 0,
"minor": 0,
"rate": 0
}],
"throttleWriteIopsDevice": [{
"major": 0,
"minor": 0,
"rate": 0
}],
"weight": 0,
"weightDevice": [{
"leafWeight": 0,
"major": 0,
"minor": 0,
"weight": 0
}],
}
cpu (dict): LinuxCPU for Linux cgroup 'cpu' resource management
Example:
cpu = {
"burst": 0,
"cpus": "string",
"idle": 0,
"mems": "string",
"period": 0
"quota": 0,
"realtimePeriod": 0,
"realtimeRuntime": 0,
"shares": 0
}
device_read_bps (list(dict)): Limit read rate (bytes per second) from a device,
in the form: [{"Path": "string", "Rate": 0}]
device_read_iops (list(dict)): Limit read rate (IO operations per second) from a device,
in the form: [{"Path": "string", "Rate": 0}]
device_write_bps (list(dict)): Limit write rate (bytes per second) to a device,
in the form: [{"Path": "string", "Rate": 0}]
device_write_iops (list(dict)): Limit write rate (IO operations per second) to a device,
in the form: [{"Path": "string", "Rate": 0}]
devices (list(dict)): Devices configures the device allowlist.
Example:
devices = [{
access: "string"
allow: 0,
major: 0,
minor: 0,
type: "string"
}]
health_cmd (str): set a healthcheck command for the container ('None' disables the
existing healthcheck)
health_interval (str): set an interval for the healthcheck (a value of disable results
in no automatic timer setup)(Changing this setting resets timer.) (default "30s")
health_log_destination (str): set the destination of the HealthCheck log. Directory
path, local or events_logger (local use container state file)(Warning: Changing
this setting may cause the loss of previous logs.) (default "local")
health_max_log_count (int): set maximum number of attempts in the HealthCheck log file.
('0' value means an infinite number of attempts in the log file) (default 5)
health_max_logs_size (int): set maximum length in characters of stored HealthCheck log.
('0' value means an infinite log length) (default 500)
health_on_failure (str): action to take once the container turns unhealthy
(default "none")
health_retries (int): the number of retries allowed before a healthcheck is considered
to be unhealthy (default 3)
health_start_period (str): the initialization time needed for a container to bootstrap
(default "0s")
health_startup_cmd (str): Set a startup healthcheck command for the container
health_startup_interval (str): Set an interval for the startup healthcheck. Changing
this setting resets the timer, depending on the state of the container.
(default "30s")
health_startup_retries (int): Set the maximum number of retries before the startup
healthcheck will restart the container
health_startup_success (int): Set the number of consecutive successes before the
startup healthcheck is marked as successful and the normal healthcheck begins
(0 indicates any success will start the regular healthcheck)
health_startup_timeout (str): Set the maximum amount of time that the startup
healthcheck may take before it is considered failed (default "30s")
health_timeout (str): the maximum time allowed to complete the healthcheck before an
interval is considered failed (default "30s")
no_healthcheck (bool): Disable healthchecks on container
hugepage_limits (list(dict)): Hugetlb limits (in bytes).
Default to reservation limits if supported.
Example:
huugepage_limits = [{"limit": 0, "pageSize": "string"}]
memory (dict): LinuxMemory for Linux cgroup 'memory' resource management
Example:
memory = {
"checkBeforeUpdate": True,
"disableOOMKiller": True,
"kernel": 0,
"kernelTCP": 0,
"limit": 0,
"reservation": 0,
"swap": 0,
"swappiness": 0,
"useHierarchy": True,
}
network (dict): LinuxNetwork identification and priority configuration
Example:
network = {
"classID": 0,
"priorities": {
"name": "string",
"priority": 0
}
)
pids (dict): LinuxPids for Linux cgroup 'pids' resource management (Linux 4.3)
Example:
pids = {
"limit": 0
}
rdma (dict): Rdma resource restriction configuration. Limits are a set of key value
pairs that define RDMA resource limits, where the key is device name and value
is resource limits.
Example:
rdma = {
"property1": {
"hcaHandles": 0
"hcaObjects": 0
},
"property2": {
"hcaHandles": 0
"hcaObjects": 0
},
...
}
unified (dict): Unified resources.
Example:
unified = {
"property1": "value1",
"property2": "value2",
...
}
Raises:
NotImplementedError: Podman service unsupported operation.
"""
raise NotImplementedError("Container.update() is not supported by Podman service.")
data = {}
params = {}
health_commands_data = [
"health_cmd",
"health_interval",
"health_log_destination",
"health_max_log_count",
"health_max_logs_size",
"health_on_failure",
"health_retries",
"health_start_period",
"health_startup_cmd",
"health_startup_interval",
"health_startup_retries",
"health_startup_success",
"health_startup_timeout",
"health_timeout",
]
# the healthcheck section of parameters accepted can be either no_healthcheck or a series
# of healthcheck parameters
if kwargs.get("no_healthcheck"):
for command in health_commands_data:
if command in kwargs:
raise ValueError(f"Cannot set {command} when no_healthcheck is True")
data["no_healthcheck"] = kwargs.get("no_healthcheck")
else:
for hc in health_commands_data:
if hc in kwargs:
data[hc] = kwargs.get(hc)
data_mapping = {
"BlkIOWeightDevice": "blkio_weight_device",
"blockio": "blockIO",
"cpu": "cpu",
"device_read_bps": "DeviceReadBPs",
"device_read_iops": "DeviceReadIOps",
"device_write_bps": "DeviceWriteBPs",
"device_write_iops": "DeviceWriteIOps",
"devices": "devices",
"hugepage_limits": "hugepageLimits",
"memory": "memory",
"network": "network",
"pids": "pids",
"rdma": "rdma",
"unified": "unified",
}
for kwarg_key, data_key in data_mapping.items():
value = kwargs.get(kwarg_key)
if value is not None:
data[data_key] = value
if kwargs.get("restart_policy"):
params["restartPolicy"] = kwargs.get("restart_policy")
if kwargs.get("restart_retries"):
params["restartRetries"] = kwargs.get("restart_retries")
response = self.client.post(
f"/containers/{self.id}/update", params=params, data=json.dumps(data)
)
response.raise_for_status()
def wait(self, **kwargs) -> int:
def wait(self, **kwargs) -> Dict[Literal["StatusCode", "Error"], Any]:
"""Block until the container enters given state.
Keyword Args:
condition (Union[str, list[str]]): Container state on which to release.
One or more of: "configured", "created", "running", "stopped",
"paused", "exited", "removing", "stopping".
interval (int): Time interval to wait before polling for completion.
condition (str): Container state on which to release, values:
not-running (default), next-exit or removed.
timeout (int): Ignored.
Returns:
"Error" key has a dictionary value with the key "Message".
@ -750,17 +482,6 @@ class Container(PodmanResource):
if isinstance(condition, str):
condition = [condition]
interval = kwargs.get("interval")
params = {}
if condition != []:
params["condition"] = condition
if interval != "":
params["interval"] = interval
# This API endpoint responds with a JSON encoded integer.
# See:
# https://docs.podman.io/en/latest/_static/api.html#tag/containers/operation/ContainerWaitLibpod
response = self.client.post(f"/containers/{self.id}/wait", params=params)
response = self.client.post(f"/containers/{self.id}/wait", params={"condition": condition})
response.raise_for_status()
return response.json()

View File

@ -1,33 +1,23 @@
"""Mixin to provide Container create() method."""
# pylint: disable=line-too-long
import copy
import logging
import re
from contextlib import suppress
from typing import Any, Union
from collections.abc import MutableMapping
from typing import Any, Dict, List, MutableMapping, Union
from podman import api
from podman.domain.containers import Container
from podman.domain.images import Image
from podman.domain.pods import Pod
from podman.domain.secrets import Secret
from podman.errors import ImageNotFound
logger = logging.getLogger("podman.containers")
NAMED_VOLUME_PATTERN = re.compile(r"[a-zA-Z0-9][a-zA-Z0-9_.-]*")
class CreateMixin: # pylint: disable=too-few-public-methods
"""Class providing create method for ContainersManager."""
def create(
self,
image: Union[Image, str],
command: Union[str, list[str], None] = None,
**kwargs,
self, image: Union[Image, str], command: Union[str, List[str], None] = None, **kwargs
) -> Container:
"""Create a container.
@ -38,12 +28,12 @@ class CreateMixin: # pylint: disable=too-few-public-methods
Keyword Args:
auto_remove (bool): Enable auto-removal of the container on daemon side when the
container's process exits.
blkio_weight_device (dict[str, Any]): Block IO weight (relative device weight)
blkio_weight_device (Dict[str, Any]): Block IO weight (relative device weight)
in the form of: [{"Path": "device_path", "Weight": weight}].
blkio_weight (int): Block IO weight (relative weight), accepts a weight value
between 10 and 1000.
cap_add (list[str]): Add kernel capabilities. For example: ["SYS_ADMIN", "MKNOD"]
cap_drop (list[str]): Drop kernel capabilities.
cap_add (List[str]): Add kernel capabilities. For example: ["SYS_ADMIN", "MKNOD"]
cap_drop (List[str]): Drop kernel capabilities.
cgroup_parent (str): Override the default parent cgroup.
cpu_count (int): Number of usable CPUs (Windows only).
cpu_percent (int): Usable percentage of the available CPUs (Windows only).
@ -56,48 +46,47 @@ class CreateMixin: # pylint: disable=too-few-public-methods
cpuset_mems (str): Memory nodes (MEMs) in which to allow execution (0-3, 0,1).
Only effective on NUMA systems.
detach (bool): Run container in the background and return a Container object.
device_cgroup_rules (list[str]): A list of cgroup rules to apply to the container.
device_cgroup_rules (List[str]): A list of cgroup rules to apply to the container.
device_read_bps: Limit read rate (bytes per second) from a device in the form of:
`[{"Path": "device_path", "Rate": rate}]`
device_read_iops: Limit read rate (IO per second) from a device.
device_write_bps: Limit write rate (bytes per second) from a device.
device_write_iops: Limit write rate (IO per second) from a device.
devices (list[str]): Expose host devices to the container, as a list[str] in the form
devices (List[str): Expose host devices to the container, as a List[str] in the form
<path_on_host>:<path_in_container>:<cgroup_permissions>.
For example:
/dev/sda:/dev/xvda:rwm allows the container to have read-write access to the
host's /dev/sda via a node named /dev/xvda inside the container.
dns (list[str]): Set custom DNS servers.
dns_opt (list[str]): Additional options to be added to the container's resolv.conf file.
dns_search (list[str]): DNS search domains.
domainname (Union[str, list[str]]): Set custom DNS search domains.
entrypoint (Union[str, list[str]]): The entrypoint for the container.
environment (Union[dict[str, str], list[str]): Environment variables to set inside
the container, as a dictionary or a list[str] in the format
dns (List[str]): Set custom DNS servers.
dns_opt (List[str]): Additional options to be added to the container's resolv.conf file.
dns_search (List[str]): DNS search domains.
domainname (Union[str, List[str]]): Set custom DNS search domains.
entrypoint (Union[str, List[str]]): The entrypoint for the container.
environment (Union[Dict[str, str], List[str]): Environment variables to set inside
the container, as a dictionary or a List[str] in the format
["SOMEVARIABLE=xxx", "SOMEOTHERVARIABLE=xyz"].
extra_hosts (dict[str, str]): Additional hostnames to resolve inside the container,
extra_hosts (Dict[str, str]): Additional hostnames to resolve inside the container,
as a mapping of hostname to IP address.
group_add (list[str]): List of additional group names and/or IDs that the container
group_add (List[str]): List of additional group names and/or IDs that the container
process will run as.
healthcheck (dict[str,Any]): Specify a test to perform to check that the
healthcheck (Dict[str,Any]): Specify a test to perform to check that the
container is healthy.
health_check_on_failure_action (int): Specify an action if a healthcheck fails.
hostname (str): Optional hostname for the container.
init (bool): Run an init inside the container that forwards signals and reaps processes
init_path (str): Path to the docker-init binary
ipc_mode (str): Set the IPC mode for the container.
isolation (str): Isolation technology to use. Default: `None`.
kernel_memory (int or str): Kernel memory limit
labels (Union[dict[str, str], list[str]): A dictionary of name-value labels (e.g.
labels (Union[Dict[str, str], List[str]): A dictionary of name-value labels (e.g.
{"label1": "value1", "label2": "value2"}) or a list of names of labels to set
with empty values (e.g. ["label1", "label2"])
links (Optional[dict[str, str]]): Mapping of links using the {'container': 'alias'}
links (Optional[Dict[str, str]]): Mapping of links using the {'container': 'alias'}
format. The alias is optional. Containers declared in this dict will be linked to
the new container using the provided alias. Default: None.
log_config (LogConfig): Logging configuration.
lxc_config (dict[str, str]): LXC config.
lxc_config (Dict[str, str]): LXC config.
mac_address (str): MAC address to assign to the container.
mem_limit (Union[int, str]): Memory limit. Accepts float values (which represent the
memory limit of the created container in bytes) or a string with a units
@ -108,58 +97,14 @@ class CreateMixin: # pylint: disable=too-few-public-methods
between 0 and 100.
memswap_limit (Union[int, str]): Maximum amount of memory + swap a container is allowed
to consume.
mounts (list[Mount]): Specification for mounts to be added to the container. More
mounts (List[Mount]): Specification for mounts to be added to the container. More
powerful alternative to volumes. Each item in the list is expected to be a
Mount object.
For example:
[
{
"type": "bind",
"source": "/a/b/c1",
"target" "/d1",
"read_only": True,
"relabel": "Z"
},
{
"type": "tmpfs",
# If this was not passed, the regular directory
# would be created rather than tmpfs mount !!!
# as this will cause to have invalid entry
# in /proc/self/mountinfo
"source": "tmpfs",
"target" "/d2",
"size": "100k",
"chown": True
}
]
name (str): The name for this container.
nano_cpus (int): CPU quota in units of 1e-9 CPUs.
networks (dict[str, dict[str, Union[str, list[str]]):
Networks which will be connected to container during container creation
Values of the network configuration can be :
- string
- list of strings (e.g. Aliases)
network (str): Name of the network this container will be connected to at creation time.
You can connect to additional networks using Network.connect.
Incompatible with network_mode.
network_disabled (bool): Disable networking.
network_mode (str): One of:
@ -169,7 +114,6 @@ class CreateMixin: # pylint: disable=too-few-public-methods
- container:<name|id>: Reuse another container's network
stack.
- host: Use the host network stack.
- ns:<path>: User defined netns path.
Incompatible with network.
oom_kill_disable (bool): Whether to disable OOM killer.
@ -180,23 +124,8 @@ class CreateMixin: # pylint: disable=too-few-public-methods
pids_limit (int): Tune a container's pids limit. Set -1 for unlimited.
platform (str): Platform in the format os[/arch[/variant]]. Only used if the method
needs to pull the requested image.
ports (
dict[
Union[int, str],
Union[
int,
Tuple[str, int],
list[int],
dict[
str,
Union[
int,
Tuple[str, int],
list[int]
]
]
]
]): Ports to bind inside the container.
ports (Dict[str, Union[int, Tuple[str, int], List[int]]]): Ports to bind inside
the container.
The keys of the dictionary are the ports to bind inside the container, either as an
integer or a string in the form port/protocol, where the protocol is either
@ -206,159 +135,73 @@ class CreateMixin: # pylint: disable=too-few-public-methods
which can be either:
- The port number, as an integer.
For example: {'2222/tcp': 3333} will expose port 2222 inside the container
as port 3333 on the host.
- None, to assign a random host port.
For example: {'2222/tcp': None}.
- A tuple of (address, port) if you want to specify the host interface.
For example: {'1111/tcp': ('127.0.0.1', 1111)}.
- A list of integers or tuples of (address, port), if you want to bind
multiple host ports to a single container port.
- A list of integers, if you want to bind multiple host ports to a single container
port.
For example: {'1111/tcp': [1234, 4567]}.
For example: {'1111/tcp': [1234, ("127.0.0.1", 4567)]}.
For example: {'9090': 7878, '10932/tcp': '8781',
"8989/tcp": ("127.0.0.1", 9091)}
- A dictionary of the options mentioned above except for random host port.
The dictionary has additional option "range",
which allows binding range of ports.
For example:
- {'2222/tcp': {"port": 3333, "range": 4}}
- {'1111/tcp': {"port": ('127.0.0.1', 1111), "range": 4}}
- {'1111/tcp': [
{"port": 1234, "range": 4},
{"ip": "127.0.0.1", "port": 4567}
]
}
privileged (bool): Give extended privileges to this container.
publish_all_ports (bool): Publish all ports to the host.
read_only (bool): Mount the container's root filesystem as read only.
read_write_tmpfs (bool): Mount temporary file systems as read write,
in case of read_only options set to True. Default: False
remove (bool): Remove the container when it has finished running. Default: False.
restart_policy (dict[str, Union[str, int]]): Restart the container when it exits.
restart_policy (Dict[str, Union[str, int]]): Restart the container when it exits.
Configured as a dictionary with keys:
- Name: One of on-failure, or always.
- MaximumRetryCount: Number of times to restart the container on failure.
For example: {"Name": "on-failure", "MaximumRetryCount": 5}
runtime (str): Runtime to use with this container.
secrets (list[Union[str, Secret, dict[str, Union[str, int]]]]): Secrets to
mount to this container.
For example:
- As list of strings, each string representing a secret's ID or name:
['my_secret', 'my_secret2']
- As list of Secret objects the corresponding IDs are read from:
[Secret, Secret]
- As list of dictionaries:
[
{
"source": "my_secret", # A string representing the ID or name of
# a secret
"target": "/my_secret", # An optional target to mount source to,
# falls back to /run/secrets/source
"uid": 1000, # An optional UID that falls back to 0
# if not given
"gid": 1000, # An optional GID that falls back to 0
# if not given
"mode": 0o400, # An optional mode to apply to the target,
# use an 0o prefix for octal integers
},
]
secret_env (dict[str, str]): Secrets to add as environment variables available in the
container.
For example: {"VARIABLE1": "NameOfSecret", "VARIABLE2": "NameOfAnotherSecret"}
security_opt (list[str]): A list[str]ing values to customize labels for MLS systems,
security_opt (List[str]): A List[str]ing values to customize labels for MLS systems,
such as SELinux.
shm_size (Union[str, int]): Size of /dev/shm (e.g. 1G).
stdin_open (bool): Keep STDIN open even if not attached.
stdout (bool): Return logs from STDOUT when detach=False. Default: True.
stderr (bool): Return logs from STDERR when detach=False. Default: False.
stop_signal (str): The stop signal to use to stop the container (e.g. SIGINT).
storage_opt (dict[str, str]): Storage driver options per container as a
storage_opt (Dict[str, str]): Storage driver options per container as a
key-value mapping.
stream (bool): If true and detach is false, return a log generator instead of a string.
Ignored if detach is true. Default: False.
sysctls (dict[str, str]): Kernel parameters to set in the container.
tmpfs (dict[str, str]): Temporary filesystems to mount, as a dictionary mapping a
sysctls (Dict[str, str]): Kernel parameters to set in the container.
tmpfs (Dict[str, str]): Temporary filesystems to mount, as a dictionary mapping a
path inside the container to options for that path.
For example: {'/mnt/vol2': '', '/mnt/vol1': 'size=3G,uid=1000'}
tty (bool): Allocate a pseudo-TTY.
ulimits (list[Ulimit]): Ulimits to set inside the container.
ulimits (List[Ulimit]): Ulimits to set inside the container.
use_config_proxy (bool): If True, and if the docker client configuration
file (~/.config/containers/config.json by default) contains a proxy configuration,
the corresponding environment variables will be set in the container being built.
user (Union[str, int]): Username or UID to run commands as inside the container.
userns_mode (str): Sets the user namespace mode for the container when user namespace
remapping option is enabled. Supported values documented
`here <https://docs.podman.io/en/latest/markdown/options/userns.container.html#userns-mode>`_
remapping option is enabled. Supported values are: host
uts_mode (str): Sets the UTS namespace mode for the container.
`These <https://docs.podman.io/en/latest/markdown/options/uts.container.html>`_
are the supported values.
Supported values are: host
version (str): The version of the API to use. Set to auto to automatically detect
the server's version. Default: 3.0.0
volume_driver (str): The name of a volume driver/plugin.
volumes (dict[str, dict[str, Union[str, list]]]): A dictionary to configure
volumes mounted inside the container.
The key is either the host path or a volume name, and the value is
volumes (Dict[str, Dict[str, str]]): A dictionary to configure volumes mounted inside
the container. The key is either the host path or a volume name, and the value is
a dictionary with the keys:
- bind: The path to mount the volume inside the container
- mode: Either rw to mount the volume read/write, or ro to mount it read-only.
Kept for docker-py compatibility
- extended_mode: List of options passed to volume mount.
For example:
{
{'/home/user1/': {'bind': '/mnt/vol2', 'mode': 'rw'},
'/var/www': {'bind': '/mnt/vol1', 'mode': 'ro'}}
'test_bind_1':
{'bind': '/mnt/vol1', 'mode': 'rw'},
'test_bind_2':
{'bind': '/mnt/vol2', 'extended_mode': ['ro', 'noexec']},
'test_bind_3':
{'bind': '/mnt/vol3', 'extended_mode': ['noexec'], 'mode': 'rw'}
}
volumes_from (list[str]): List of container names or IDs to get volumes from.
volumes_from (List[str]): List of container names or IDs to get volumes from.
working_dir (str): Path to the working directory.
workdir (str): Alias of working_dir - Path to the working directory.
Returns:
A Container object.
Raises:
ImageNotFound: when Image not found by Podman service
@ -366,8 +209,6 @@ class CreateMixin: # pylint: disable=too-few-public-methods
"""
if isinstance(image, Image):
image = image.id
if isinstance(command, str):
command = [command]
payload = {"image": image, "command": command}
payload.update(kwargs)
@ -375,61 +216,16 @@ class CreateMixin: # pylint: disable=too-few-public-methods
payload = api.prepare_body(payload)
response = self.client.post(
"/containers/create",
headers={"content-type": "application/json"},
data=payload,
"/containers/create", headers={"content-type": "application/json"}, data=payload
)
response.raise_for_status(not_found=ImageNotFound)
container_id = response.json()["Id"]
return self.get(container_id)
@staticmethod
def _convert_env_list_to_dict(env_list):
"""Convert a list of environment variables to a dictionary.
Args:
env_list (List[str]): List of environment variables in the format ["KEY=value"]
Returns:
Dict[str, str]: Dictionary of environment variables
Raises:
ValueError: If any environment variable is not in the correct format
"""
if not isinstance(env_list, list):
raise TypeError(f"Expected list, got {type(env_list).__name__}")
env_dict = {}
for env_var in env_list:
if not isinstance(env_var, str):
raise TypeError(
f"Environment variable must be a string, "
f"got {type(env_var).__name__}: {repr(env_var)}"
)
# Handle empty strings
if not env_var.strip():
raise ValueError("Environment variable cannot be empty")
if "=" not in env_var:
raise ValueError(
f"Environment variable '{env_var}' is not in the correct format. "
"Expected format: 'KEY=value'"
)
key, value = env_var.split("=", 1) # Split on first '=' only
# Validate key is not empty
if not key.strip():
raise ValueError(f"Environment variable has empty key: '{env_var}'")
env_dict[key] = value
return env_dict
body = response.json()
return self.get(body["Id"])
# pylint: disable=too-many-locals,too-many-statements,too-many-branches
@staticmethod
def _render_payload(kwargs: MutableMapping[str, Any]) -> dict[str, Any]:
def _render_payload(kwargs: MutableMapping[str, Any]) -> Dict[str, Any]:
"""Map create/run kwargs into body parameters."""
args = copy.copy(kwargs)
@ -454,23 +250,6 @@ class CreateMixin: # pylint: disable=too-few-public-methods
with suppress(KeyError):
del args[key]
# Handle environment variables
environment = args.pop("environment", None)
if environment is not None:
if isinstance(environment, list):
try:
environment = CreateMixin._convert_env_list_to_dict(environment)
except ValueError as e:
raise ValueError(
"Failed to convert environment variables list to dictionary. "
f"Error: {str(e)}"
) from e
elif not isinstance(environment, dict):
raise TypeError(
"Environment variables must be provided as either a dictionary "
"or a list of strings in the format ['KEY=value']"
)
# These keywords are not supported for various reasons.
unsupported_keys = set(args.keys()).intersection(
(
@ -482,6 +261,7 @@ class CreateMixin: # pylint: disable=too-few-public-methods
"device_requests", # FIXME In addition to device Major/Minor include path
"device_write_bps", # FIXME In addition to device Major/Minor include path
"device_write_iops", # FIXME In addition to device Major/Minor include path
"devices", # FIXME In addition to device Major/Minor include path
"domainname",
"network_disabled", # FIXME Where to map for Podman API?
"storage_opt", # FIXME Where to map for Podman API?
@ -497,52 +277,9 @@ class CreateMixin: # pylint: disable=too-few-public-methods
def pop(k):
return args.pop(k, None)
def normalize_nsmode(
mode: Union[str, MutableMapping[str, str]],
) -> dict[str, str]:
if isinstance(mode, dict):
return mode
return {"nsmode": mode}
def to_bytes(size: Union[int, str, None]) -> Union[int, None]:
"""
Converts str or int to bytes.
Input can be in the following forms :
0) None - e.g. None -> returns None
1) int - e.g. 100 == 100 bytes
2) str - e.g. '100' == 100 bytes
3) str with suffix - available suffixes:
b | B - bytes
k | K = kilobytes
m | M = megabytes
g | G = gigabytes
e.g. '100m' == 104857600 bytes
"""
size_type = type(size)
if size is None:
return size
if size_type is int:
return size
if size_type is str:
try:
return int(size)
except ValueError as bad_size:
mapping = {"b": 0, "k": 1, "m": 2, "g": 3}
mapping_regex = "".join(mapping.keys())
search = re.search(rf"^(\d+)([{mapping_regex}])$", size.lower())
if search:
return int(search.group(1)) * (1024 ** mapping[search.group(2)])
raise TypeError(
f"Passed string size {size} should be in format\\d+[bBkKmMgG] (e.g. '100m')"
) from bad_size
else:
raise TypeError(
f"Passed size {size} should be a type of unicode, str "
f"or int (found : {size_type})"
)
# Transform keywords into parameters
params = {
"aliases": pop("aliases"), # TODO document, podman only
"annotations": pop("annotations"), # TODO document, podman only
"apparmor_profile": pop("apparmor_profile"), # TODO document, podman only
"cap_add": pop("cap_add"),
@ -553,18 +290,16 @@ class CreateMixin: # pylint: disable=too-few-public-methods
"command": args.pop("command", args.pop("cmd", None)),
"conmon_pid_file": pop("conmon_pid_file"), # TODO document, podman only
"containerCreateCommand": pop("containerCreateCommand"), # TODO document, podman only
"devices": [],
"dns_option": pop("dns_opt"),
"dns_options": pop("dns_opt"),
"dns_search": pop("dns_search"),
"dns_server": pop("dns"),
"entrypoint": pop("entrypoint"),
"env": environment,
"env": pop("environment"),
"env_host": pop("env_host"), # TODO document, podman only
"expose": {},
"expose": dict(),
"groups": pop("group_add"),
"healthconfig": pop("healthcheck"),
"health_check_on_failure_action": pop("health_check_on_failure_action"),
"hostadd": [],
"hostadd": pop("extra_hosts"),
"hostname": pop("hostname"),
"httpproxy": pop("use_config_proxy"),
"idmappings": pop("idmappings"), # TODO document, podman only
@ -575,36 +310,36 @@ class CreateMixin: # pylint: disable=too-few-public-methods
"init_path": pop("init_path"),
"isolation": pop("isolation"),
"labels": pop("labels"),
"log_configuration": {},
"log_configuration": dict(),
"lxc_config": pop("lxc_config"),
"mask": pop("masked_paths"),
"mounts": [],
"mounts": list(),
"name": pop("name"),
"namespace": pop("namespace"), # TODO What is this for?
"network_options": pop("network_options"), # TODO document, podman only
"networks": pop("networks"),
"no_new_privileges": pop("no_new_privileges"), # TODO document, podman only
"oci_runtime": pop("runtime"),
"oom_score_adj": pop("oom_score_adj"),
"overlay_volumes": pop("overlay_volumes"), # TODO document, podman only
"portmappings": [],
"pormappings": list(),
"privileged": pop("privileged"),
"procfs_opts": pop("procfs_opts"), # TODO document, podman only
"publish_image_ports": pop("publish_all_ports"),
"r_limits": [],
"r_limits": list(),
"raw_image_name": pop("raw_image_name"), # TODO document, podman only
"read_only_filesystem": pop("read_only"),
"read_write_tmpfs": pop("read_write_tmpfs"),
"remove": args.pop("remove", args.pop("auto_remove", None)),
"resource_limits": {},
"resource_limits": dict(),
"rootfs": pop("rootfs"),
"rootfs_propagation": pop("rootfs_propagation"),
"sdnotifyMode": pop("sdnotifyMode"), # TODO document, podman only
"seccomp_policy": pop("seccomp_policy"), # TODO document, podman only
"seccomp_profile_path": pop("seccomp_profile_path"), # TODO document, podman only
"secrets": [], # TODO document, podman only
"secrets": pop("secrets"), # TODO document, podman only
"selinux_opts": pop("security_opt"),
"shm_size": to_bytes(pop("shm_size")),
"shm_size": pop("shm_size"),
"static_ip": pop("static_ip"), # TODO document, podman only
"static_ipv6": pop("static_ipv6"), # TODO document, podman only
"static_mac": pop("mac_address"),
"stdin": pop("stdin_open"),
"stop_signal": pop("stop_signal"),
@ -620,21 +355,15 @@ class CreateMixin: # pylint: disable=too-few-public-methods
"use_image_resolve_conf": pop("use_image_resolve_conf"), # TODO document, podman only
"user": pop("user"),
"version": pop("version"),
"volumes": [],
"volumes": list(),
"volumes_from": pop("volumes_from"),
"work_dir": pop("workdir") or pop("working_dir"),
"working_dir": pop("working_dir"),
}
for device in args.pop("devices", []):
params["devices"].append({"path": device})
for item in args.pop("exposed_ports", []):
for item in args.pop("exposed_ports", list()):
port, protocol = item.split("/")
params["expose"][int(port)] = protocol
for hostname, ip in args.pop("extra_hosts", {}).items():
params["hostadd"].append(f"{hostname}:{ip}")
if "log_config" in args:
params["log_configuration"]["driver"] = args["log_config"].get("Type")
@ -644,34 +373,25 @@ class CreateMixin: # pylint: disable=too-few-public-methods
params["log_configuration"]["options"] = args["log_config"]["Config"].get("options")
args.pop("log_config")
for item in args.pop("mounts", []):
normalized_item = {key.lower(): value for key, value in item.items()}
for item in args.pop("mounts", list()):
mount_point = {
"destination": normalized_item.get("target"),
"destination": item.get("target"),
"options": [],
"source": normalized_item.get("source"),
"type": normalized_item.get("type"),
"source": item.get("source"),
"type": item.get("type"),
}
# some names are different for podman-py vs REST API due to compatibility with docker
# some (e.g. chown) despite listed in podman-run documentation fails with error
names_dict = {"read_only": "ro", "chown": "U"}
options = []
simple_options = ["propagation", "relabel"]
bool_options = ["read_only", "U", "chown"]
regular_options = ["consistency", "mode", "size"]
for k, v in item.items():
_k = k.lower()
option_name = names_dict.get(_k, _k)
if _k in bool_options and v is True:
options.append(option_name)
elif _k in regular_options:
options.append(f"{option_name}={v}")
elif _k in simple_options:
options.append(v)
options = list()
if "read_only" in item:
options.append("ro")
if "consistency" in item:
options.append(f"consistency={item['consistency']}")
if "mode" in item:
options.append(f"mode={item['mode']}")
if "propagation" in item:
options.append(item["propagation"])
if "size" in item:
options.append(f"size={item['size']}")
mount_point["options"] = options
params["mounts"].append(mount_point)
@ -682,84 +402,55 @@ class CreateMixin: # pylint: disable=too-few-public-methods
pod = pod.id
params["pod"] = pod # TODO document, podman only
def parse_host_port(_container_port, _protocol, _host):
result = []
port_map = {"container_port": int(_container_port), "protocol": _protocol}
if _host is None:
result.append(port_map)
elif isinstance(_host, int) or isinstance(_host, str) and _host.isdigit():
port_map["host_port"] = int(_host)
result.append(port_map)
elif isinstance(_host, tuple):
port_map["host_ip"] = _host[0]
port_map["host_port"] = int(_host[1])
result.append(port_map)
elif isinstance(_host, list):
for host_list in _host:
host_list_result = parse_host_port(_container_port, _protocol, host_list)
result.extend(host_list_result)
elif isinstance(_host, dict):
_host_port = _host.get("port")
if _host_port is not None:
if (
isinstance(_host_port, int)
or isinstance(_host_port, str)
and _host_port.isdigit()
):
port_map["host_port"] = int(_host_port)
elif isinstance(_host_port, tuple):
port_map["host_ip"] = _host_port[0]
port_map["host_port"] = int(_host_port[1])
if _host.get("range"):
port_map["range"] = _host.get("range")
if _host.get("ip"):
port_map["host_ip"] = _host.get("ip")
result.append(port_map)
return result
for item in args.pop("ports", list()):
container, host = item
container_port, protocol = container.split("/")
for container, host in args.pop("ports", {}).items():
# avoid redefinition of the loop variable, then ensure it's a string
str_container = container
if isinstance(str_container, int):
str_container = str(str_container)
if "/" in str_container:
container_port, protocol = str_container.split("/")
port_map = {"container_port": container_port, "protocol": protocol}
if host is None:
pass
elif isinstance(host, int):
port_map["host_port"] = host
elif isinstance(host, tuple):
port_map["host_ip"] = host[0]
port_map["host_port"] = host[1]
elif isinstance(host, list):
raise ValueError(
"Podman API does not support multiple port bound to a single host port."
)
else:
container_port, protocol = str_container, "tcp"
raise ValueError(f"'ports' value of '{host}' is not supported.")
port_map_list = parse_host_port(container_port, protocol, host)
params["portmappings"].extend(port_map_list)
params["pormappings"].append(port_map)
if "restart_policy" in args:
params["restart_policy"] = args["restart_policy"].get("Name")
params["restart_tries"] = args["restart_policy"].get("MaximumRetryCount")
args.pop("restart_policy")
params["resource_limits"]["pids"] = {"limit": args.pop("pids_limit", None)}
params["resource_limits"]["pids"] = dict()
params["resource_limits"]["pids"]["limit"] = args.pop("pids_limit", None)
params["resource_limits"]["cpu"] = {
"cpus": args.pop("cpuset_cpus", None),
"mems": args.pop("cpuset_mems", None),
"period": args.pop("cpu_period", None),
"quota": args.pop("cpu_quota", None),
"realtimePeriod": args.pop("cpu_rt_period", None),
"realtimeRuntime": args.pop("cpu_rt_runtime", None),
"shares": args.pop("cpu_shares", None),
}
params["resource_limits"]["cpu"] = dict()
params["resource_limits"]["cpu"]["cpus"] = args.pop("cpuset_cpus", None)
params["resource_limits"]["cpu"]["mems"] = args.pop("cpuset_mems", None)
params["resource_limits"]["cpu"]["period"] = args.pop("cpu_period", None)
params["resource_limits"]["cpu"]["quota"] = args.pop("cpu_quota", None)
params["resource_limits"]["cpu"]["realtimePeriod"] = args.pop("cpu_rt_period", None)
params["resource_limits"]["cpu"]["realtimeRuntime"] = args.pop("cpu_rt_runtime", None)
params["resource_limits"]["cpu"]["shares"] = args.pop("cpu_shares", None)
params["resource_limits"]["memory"] = {
"disableOOMKiller": args.pop("oom_kill_disable", None),
"kernel": to_bytes(args.pop("kernel_memory", None)),
"kernelTCP": args.pop("kernel_memory_tcp", None),
"limit": to_bytes(args.pop("mem_limit", None)),
"reservation": to_bytes(args.pop("mem_reservation", None)),
"swap": to_bytes(args.pop("memswap_limit", None)),
"swappiness": args.pop("mem_swappiness", None),
"useHierarchy": args.pop("mem_use_hierarchy", None),
}
params["resource_limits"]["memory"] = dict()
params["resource_limits"]["memory"]["disableOOMKiller"] = args.pop("oom_kill_disable", None)
params["resource_limits"]["memory"]["kernel"] = args.pop("kernel_memory", None)
params["resource_limits"]["memory"]["kernelTCP"] = args.pop("kernel_memory_tcp", None)
params["resource_limits"]["memory"]["limit"] = args.pop("mem_limit", None)
params["resource_limits"]["memory"]["reservation"] = args.pop("mem_reservation", None)
params["resource_limits"]["memory"]["swap"] = args.pop("memswap_limit", None)
params["resource_limits"]["memory"]["swappiness"] = args.pop("mem_swappiness", None)
params["resource_limits"]["memory"]["useHierarchy"] = args.pop("mem_use_hierarchy", None)
for item in args.pop("ulimits", []):
for item in args.pop("ulimits", list()):
params["r_limits"].append(
{
"type": item["Name"],
@ -768,73 +459,32 @@ class CreateMixin: # pylint: disable=too-few-public-methods
}
)
for item in args.pop("volumes", {}).items():
for item in args.pop("volumes", dict()):
key, value = item
extended_mode = value.get("extended_mode", [])
if not isinstance(extended_mode, list):
raise ValueError("'extended_mode' value should be a list")
options = extended_mode
mode = value.get("mode")
if mode is not None:
if not isinstance(mode, str):
raise ValueError("'mode' value should be a str")
options.append(mode)
# The Podman API only supports named volumes through the ``volume`` parameter. Directory
# mounting needs to happen through the ``mounts`` parameter. Luckily the translation
# isn't too complicated so we can just do it for the user if we suspect that the key
# isn't a named volume.
if NAMED_VOLUME_PATTERN.match(key):
volume = {"Name": key, "Dest": value["bind"], "Options": options}
params["volumes"].append(volume)
else:
mount_point = {
"destination": value["bind"],
"options": options,
"source": key,
"type": "bind",
volume = {
"Name": key,
"Dest": value["bind"],
"Options": [value["mode"]],
}
params["mounts"].append(mount_point)
for item in args.pop("secrets", []):
if isinstance(item, Secret):
params["secrets"].append({"source": item.id})
elif isinstance(item, str):
params["secrets"].append({"source": item})
elif isinstance(item, dict):
secret = {}
secret_opts = ["source", "target", "uid", "gid", "mode"]
for k, v in item.items():
if k in secret_opts:
secret.update({k: v})
params["secrets"].append(secret)
if "secret_env" in args:
params["secret_env"] = args.pop("secret_env", {})
params["volumes"].append(volume)
if "cgroupns" in args:
params["cgroupns"] = normalize_nsmode(args.pop("cgroupns"))
params["cgroupns"] = {"nsmode": args.pop("cgroupns")}
if "ipc_mode" in args:
params["ipcns"] = normalize_nsmode(args.pop("ipc_mode"))
params["ipcns"] = {"nsmode": args.pop("ipc_mode")}
if "network_mode" in args:
network_mode = args.pop("network_mode")
details = network_mode.split(":")
if len(details) == 2 and details[0] == "ns":
params["netns"] = {"nsmode": "path", "value": details[1]}
else:
params["netns"] = {"nsmode": network_mode}
params["netns"] = {"nsmode": args.pop("network_mode")}
if "pid_mode" in args:
params["pidns"] = normalize_nsmode(args.pop("pid_mode"))
params["pidns"] = {"nsmode": args.pop("pid_mode")}
if "userns_mode" in args:
params["userns"] = normalize_nsmode(args.pop("userns_mode"))
params["userns"] = {"nsmode": args.pop("userns_mode")}
if "uts_mode" in args:
params["utsns"] = normalize_nsmode(args.pop("uts_mode"))
params["utsns"] = {"nsmode": args.pop("uts_mode")}
if len(args) > 0:
raise TypeError(

View File

@ -1,9 +1,7 @@
"""PodmanResource manager subclassed for Containers."""
import logging
import urllib
from collections.abc import Mapping
from typing import Any, Union
from typing import Any, Dict, List, Mapping, Union
from podman import api
from podman.domain.containers import Container
@ -27,30 +25,23 @@ class ContainersManager(RunMixin, CreateMixin, Manager):
response = self.client.get(f"/containers/{key}/exists")
return response.ok
def get(self, key: str, **kwargs) -> Container:
# pylint is flagging 'container_id' here vs. 'key' parameter in super.get()
def get(self, container_id: str) -> Container: # pylint: disable=arguments-differ
"""Get container by name or id.
Args:
key: Container name or id.
Keyword Args:
compatible (bool): Use Docker compatibility endpoint
Returns:
A `Container` object corresponding to `key`.
container_id: Container name or id.
Raises:
NotFound: when Container does not exist
APIError: when an error return by service
"""
compatible = kwargs.get("compatible", False)
container_id = urllib.parse.quote_plus(key)
response = self.client.get(f"/containers/{container_id}/json", compatible=compatible)
container_id = urllib.parse.quote_plus(container_id)
response = self.client.get(f"/containers/{container_id}/json")
response.raise_for_status()
return self.prepare_model(attrs=response.json())
def list(self, **kwargs) -> list[Container]:
def list(self, **kwargs) -> List[Container]:
"""Report on containers.
Keyword Args:
@ -63,7 +54,7 @@ class ContainersManager(RunMixin, CreateMixin, Manager):
- exited (int): Only containers with specified exit code
- status (str): One of restarting, running, paused, exited
- label (Union[str, list[str]]): Format either "key", "key=value" or a list of such.
- label (Union[str, List[str]]): Format either "key", "key=value" or a list of such.
- id (str): The id of the container.
- name (str): The name of the container.
- ancestor (str): Filter by container ancestor. Format of
@ -72,29 +63,15 @@ class ContainersManager(RunMixin, CreateMixin, Manager):
Give the container name or id.
- since (str): Only containers created after a particular container.
Give container name or id.
sparse: If False, return basic container information without additional
inspection requests. This improves performance when listing many containers
but might provide less detail. You can call Container.reload() on individual
containers later to retrieve complete attributes. Default: True.
When Docker compatibility is enabled with `compatible=True`: Default: False.
sparse: Ignored
ignore_removed: If True, ignore failures due to missing containers.
Raises:
APIError: when service returns an error
"""
compatible = kwargs.get("compatible", False)
# Set sparse default based on mode:
# Libpod behavior: default is sparse=True (faster, requires reload for full details)
# Docker behavior: default is sparse=False (full details immediately, compatible)
if "sparse" in kwargs:
sparse = kwargs["sparse"]
else:
sparse = not compatible # True for libpod, False for compat
params = {
"all": kwargs.get("all"),
"filters": kwargs.get("filters", {}),
"filters": kwargs.get("filters", dict()),
"limit": kwargs.get("limit"),
}
if "before" in kwargs:
@ -105,33 +82,22 @@ class ContainersManager(RunMixin, CreateMixin, Manager):
# filters formatted last because some kwargs may need to be mapped into filters
params["filters"] = api.prepare_filters(params["filters"])
response = self.client.get("/containers/json", params=params, compatible=compatible)
response = self.client.get("/containers/json", params=params)
response.raise_for_status()
containers: list[Container] = [self.prepare_model(attrs=i) for i in response.json()]
return [self.prepare_model(attrs=i) for i in response.json()]
# If sparse is False, reload each container to get full details
if not sparse:
for container in containers:
try:
container.reload(compatible=compatible)
except APIError:
# Skip containers that might have been removed
pass
return containers
def prune(self, filters: Mapping[str, str] = None) -> dict[str, Any]:
def prune(self, filters: Mapping[str, str] = None) -> Dict[str, Any]:
"""Delete stopped containers.
Args:
filters: Criteria for determining containers to remove. Available keys are:
- until (str): Delete containers before this time
- label (list[str]): Labels associated with containers
- label (List[str]): Labels associated with containers
Returns:
Keys:
- ContainersDeleted (list[str]): Identifiers of deleted containers.
- ContainersDeleted (List[str]): Identifiers of deleted containers.
- SpaceReclaimed (int): Amount of disk space reclaimed in bytes.
Raises:
@ -141,16 +107,12 @@ class ContainersManager(RunMixin, CreateMixin, Manager):
response = self.client.post("/containers/prune", params=params)
response.raise_for_status()
results = {"ContainersDeleted": [], "SpaceReclaimed": 0}
results = {"ContainersDeleted": list(), "SpaceReclaimed": 0}
for entry in response.json():
if entry.get("Err") is not None:
raise APIError(
entry["Err"],
response=response,
explanation=f"""Failed to prune container '{entry["Id"]}'""",
)
if entry.get("error") is not None:
raise APIError(entry["error"], response=response, explanation=entry["error"])
results["ContainersDeleted"].append(entry["Id"]) # type: ignore[attr-defined]
results["ContainersDeleted"].append(entry["Id"])
results["SpaceReclaimed"] += entry["Size"]
return results
@ -170,8 +132,10 @@ class ContainersManager(RunMixin, CreateMixin, Manager):
if isinstance(container_id, Container):
container_id = container_id.id
# v is used for the compat endpoint while volumes is used for the libpod endpoint
params = {"v": kwargs.get("v"), "force": kwargs.get("force"), "volumes": kwargs.get("v")}
params = {
"v": kwargs.get("v"),
"force": kwargs.get("force"),
}
response = self.client.delete(f"/containers/{container_id}", params=params)
response.raise_for_status()

View File

@ -1,10 +1,8 @@
"""Mixin to provide Container run() method."""
import logging
import threading
from contextlib import suppress
from typing import Union
from collections.abc import Generator, Iterator
from typing import Generator, Iterator, List, Union
from podman.domain.containers import Container
from podman.domain.images import Image
@ -19,8 +17,7 @@ class RunMixin: # pylint: disable=too-few-public-methods
def run(
self,
image: Union[str, Image],
command: Union[str, list[str], None] = None,
*,
command: Union[str, List[str], None] = None,
stdout=True,
stderr=False,
remove: bool = False,
@ -31,27 +28,17 @@ class RunMixin: # pylint: disable=too-few-public-methods
By default, run() will wait for the container to finish and return its logs.
If detach=True, run() will start the container and return a Container object rather
than logs. In this case, if remove=True, run() will monitor and remove the
container after it finishes running; the logs will be lost in this case.
than logs.
Args:
image: Image to run.
command: Command to run in the container.
stdout: Include stdout. Default: True.
stderr: Include stderr. Default: False.
remove: Delete container on the client side when the container's processes exit.
The `auto_remove` flag is also available to manage the removal on the daemon
side. Default: False.
remove: Delete container when the container's processes exit. Default: False.
Keyword Args:
- These args are directly used to pull an image when the image is not found.
auth_config (Mapping[str, str]): Override the credentials that are found in the
config for this request. auth_config should contain the username and password
keys to be valid.
platform (str): Platform in the format os[/arch[/variant]]
policy (str): Pull policy. "missing" (default), "always", "never", "newer"
- See the create() method for other keyword arguments.
- See the create() method for keyword arguments.
Returns:
- When detach is True, return a Container
@ -73,30 +60,14 @@ class RunMixin: # pylint: disable=too-few-public-methods
try:
container = self.create(image=image, command=command, **kwargs)
except ImageNotFound:
self.podman_client.images.pull(
image,
auth_config=kwargs.get("auth_config"),
platform=kwargs.get("platform"),
policy=kwargs.get("policy", "missing"),
)
self.client.images.pull(image, platform=kwargs.get("platform"))
container = self.create(image=image, command=command, **kwargs)
container.start()
container.wait(condition="running")
container.reload()
def remove_container(container_object: Container) -> None:
"""
Wait the container to finish and remove it.
Args:
container_object: Container object
"""
container_object.wait() # Wait for the container to finish
container_object.remove() # Remove the container
if kwargs.get("detach", False):
if remove:
# Start a background thread to remove the container after finishing
threading.Thread(target=remove_container, args=(container,)).start()
return container
with suppress(KeyError):
@ -106,7 +77,7 @@ class RunMixin: # pylint: disable=too-few-public-methods
if log_type in ("json-file", "journald"):
log_iter = container.logs(stdout=stdout, stderr=stderr, stream=True, follow=True)
exit_status = container.wait()
exit_status = container.wait()["StatusCode"]
if exit_status != 0:
log_iter = None
if not kwargs.get("auto_remove", False):

View File

@ -1,10 +1,8 @@
"""Model and Manager for Event resources."""
import json
import logging
from datetime import datetime
from typing import Any, Optional, Union
from collections.abc import Iterator
from typing import Any, Dict, Optional, Union, Iterator
from podman import api
from podman.api.client import APIClient
@ -27,9 +25,9 @@ class EventsManager: # pylint: disable=too-few-public-methods
self,
since: Union[datetime, int, None] = None,
until: Union[datetime, int, None] = None,
filters: Optional[dict[str, Any]] = None,
filters: Optional[Dict[str, Any]] = None,
decode: bool = False,
) -> Iterator[Union[str, dict[str, Any]]]:
) -> Iterator[Union[str, Dict[str, Any]]]:
"""Report on networks.
Args:
@ -39,7 +37,7 @@ class EventsManager: # pylint: disable=too-few-public-methods
until: Get events older than this time.
Yields:
When decode is True, Iterator[dict[str, Any]]
When decode is True, Iterator[Dict[str, Any]]
When decode is False, Iterator[str]
"""

View File

@ -1,17 +1,10 @@
"""Model and Manager for Image resources."""
import logging
from typing import Any, Optional, Literal, Union, TYPE_CHECKING
from collections.abc import Iterator
from typing import Any, Dict, Iterator, List, Optional, Union
import urllib.parse
from podman.api import DEFAULT_CHUNK_SIZE
from podman import api
from podman.domain.manager import PodmanResource
from podman.errors import ImageNotFound, InvalidArgument
if TYPE_CHECKING:
from podman.domain.images_manager import ImagesManager
from podman.errors import ImageNotFound
logger = logging.getLogger("podman.images")
@ -19,8 +12,6 @@ logger = logging.getLogger("podman.images")
class Image(PodmanResource):
"""Details and configuration for an Image managed by the Podman service."""
manager: "ImagesManager"
def __repr__(self) -> str:
return f"""<{self.__class__.__name__}: '{"', '".join(self.tags)}'>"""
@ -29,7 +20,7 @@ class Image(PodmanResource):
"""dict[str, str]: Return labels associated with Image."""
image_labels = self.attrs.get("Labels")
if image_labels is None or len(image_labels) == 0:
return {}
return dict()
return image_labels
@ -38,11 +29,11 @@ class Image(PodmanResource):
"""list[str]: Return tags from Image."""
repo_tags = self.attrs.get("RepoTags")
if repo_tags is None or len(repo_tags) == 0:
return []
return list()
return [tag for tag in repo_tags if tag != "<none>:<none>"]
def history(self) -> list[dict[str, Any]]:
def history(self) -> List[Dict[str, Any]]:
"""Returns history of the Image.
Raises:
@ -55,7 +46,7 @@ class Image(PodmanResource):
def remove(
self, **kwargs
) -> list[dict[Literal["Deleted", "Untagged", "Errors", "ExitCode"], Union[str, int]]]:
) -> List[Dict[api.Literal["Deleted", "Untagged", "Errors", "ExitCode"], Union[str, int]]]:
"""Delete image from Podman service.
Podman only
@ -75,8 +66,8 @@ class Image(PodmanResource):
def save(
self,
chunk_size: Optional[int] = DEFAULT_CHUNK_SIZE,
named: Union[str, bool] = False,
chunk_size: Optional[int] = api.DEFAULT_CHUNK_SIZE,
named: Union[str, bool] = False, # pylint: disable=unused-argument
) -> Iterator[bytes]:
"""Returns Image as tarball.
@ -85,28 +76,13 @@ class Image(PodmanResource):
Args:
chunk_size: If None, data will be streamed in received buffer size.
If not None, data will be returned in sized buffers. Default: 2MB
named (str or bool): If ``False`` (default), the tarball will not
retain repository and tag information for this image. If set
to ``True``, the first tag in the :py:attr:`~tags` list will
be used to identify the image. Alternatively, any element of
the :py:attr:`~tags` list can be used as an argument to use
that specific tag as the saved identifier.
named: Ignored.
Raises:
APIError: When service returns an error
InvalidArgument: When the provided Tag name is not valid for the image.
APIError: when service returns an error
"""
img = self.id
if named:
img = urllib.parse.quote(self.tags[0] if self.tags else img)
if isinstance(named, str):
if named not in self.tags:
raise InvalidArgument(f"'{named}' is not a valid tag for this image")
img = urllib.parse.quote(named)
response = self.client.get(
f"/images/{img}/get", params={"format": ["docker-archive"]}, stream=True
f"/images/{self.id}/get", params={"format": ["docker-archive"]}, stream=True
)
response.raise_for_status(not_found=ImageNotFound)
return response.iter_content(chunk_size=chunk_size)

View File

@ -1,5 +1,4 @@
"""Mixin for Image build support."""
import json
import logging
import pathlib
@ -7,8 +6,7 @@ import random
import re
import shutil
import tempfile
from typing import Any
from collections.abc import Iterator
from typing import Any, Dict, Iterator, List, Tuple
import itertools
@ -23,7 +21,7 @@ class BuildMixin:
"""Class providing build method for ImagesManager."""
# pylint: disable=too-many-locals,too-many-branches,too-few-public-methods,too-many-statements
def build(self, **kwargs) -> tuple[Image, Iterator[bytes]]:
def build(self, **kwargs) -> Tuple[Image, Iterator[bytes]]:
"""Returns built image.
Keyword Args:
@ -34,13 +32,13 @@ class BuildMixin:
nocache (bool) Dont use the cache when set to True
rm (bool) Remove intermediate containers. Default True
timeout (int) HTTP timeout
custom_context (bool) Optional if using fileobj
custom_context (bool) Optional if using fileobj (ignored)
encoding (str) The encoding for a stream. Set to gzip for compressing (ignored)
pull (bool) Downloads any updates to the FROM image in Dockerfile
forcerm (bool) Always remove intermediate containers, even after unsuccessful builds
dockerfile (str) full path to the Dockerfile / Containerfile
dockerfile (str) path within the build context to the Dockerfile
buildargs (Mapping[str,str) A dictionary of build arguments
container_limits (dict[str, Union[int,str]])
container_limits (Dict[str, Union[int,str]])
A dictionary of limits applied to each container created by the build process.
Valid keys:
@ -53,19 +51,16 @@ class BuildMixin:
shmsize (int) Size of /dev/shm in bytes. The size must be greater than 0.
If omitted the system uses 64MB
labels (Mapping[str,str]) A dictionary of labels to set on the image
cache_from (list[str]) A list of image's identifier used for build cache resolution
cache_from (List[str]) A list of image's identifier used for build cache resolution
target (str) Name of the build-stage to build in a multi-stage Dockerfile
network_mode (str) networking mode for the run commands during build
squash (bool) Squash the resulting images layers into a single layer.
extra_hosts (dict[str,str]) Extra hosts to add to /etc/hosts in building
extra_hosts (Dict[str,str]) Extra hosts to add to /etc/hosts in building
containers, as a mapping of hostname to IP address.
platform (str) Platform in the format os[/arch[/variant]].
isolation (str) Isolation technology used during build. (ignored)
use_config_proxy (bool) (ignored)
http_proxy (bool) - Inject http proxy environment variables into container (Podman only)
layers (bool) - Cache intermediate layers during build.
output (str) - specifies if any custom build output is selected for following build.
outputformat (str) - The format of the output image's manifest and configuration data.
Returns:
first item is the podman.domain.images.Image built
@ -82,40 +77,23 @@ class BuildMixin:
body = None
path = None
if kwargs.get("custom_context"):
if "fileobj" not in kwargs:
raise PodmanError(
"Custom context requires fileobj to be set to a binary file-like object "
"containing a build-directory tarball."
)
if "dockerfile" not in kwargs:
# TODO: Scan the tarball for either a Dockerfile or a Containerfile.
# This could be slow if the tarball is large,
# and could require buffering/copying the tarball if `fileobj` is not seekable.
# As a workaround for now, don't support omitting the filename.
raise PodmanError(
"Custom context requires specifying the name of the Dockerfile "
"(typically 'Dockerfile' or 'Containerfile')."
)
body = kwargs["fileobj"]
elif "fileobj" in kwargs:
path = tempfile.TemporaryDirectory() # pylint: disable=consider-using-with
if "fileobj" in kwargs:
path = tempfile.TemporaryDirectory()
filename = pathlib.Path(path.name) / params["dockerfile"]
with open(filename, "w", encoding='utf-8') as file:
with open(filename, "w") as file:
shutil.copyfileobj(kwargs["fileobj"], file)
body = api.create_tar(anchor=path.name, gzip=kwargs.get("gzip", False))
elif "path" in kwargs:
filename = pathlib.Path(kwargs["path"]) / params["dockerfile"]
# The Dockerfile will be copied into the context_dir if needed
params["dockerfile"] = api.prepare_containerfile(kwargs["path"], str(filename))
params["dockerfile"] = api.prepare_containerfile(kwargs["path"], params["dockerfile"])
excludes = api.prepare_containerignore(kwargs["path"])
body = api.create_tar(
anchor=kwargs["path"], exclude=excludes, gzip=kwargs.get("gzip", False)
)
post_kwargs = {}
post_kwargs = dict()
if kwargs.get("timeout"):
post_kwargs["timeout"] = float(kwargs.get("timeout"))
@ -157,7 +135,7 @@ class BuildMixin:
raise BuildError(unknown or "Unknown", report_stream)
@staticmethod
def _render_params(kwargs) -> dict[str, list[Any]]:
def _render_params(kwargs) -> Dict[str, List[Any]]:
"""Map kwargs to query parameters.
All unsupported kwargs are silently ignored.
@ -183,15 +161,12 @@ class BuildMixin:
"squash": kwargs.get("squash"),
"t": kwargs.get("tag"),
"target": kwargs.get("target"),
"layers": kwargs.get("layers"),
"output": kwargs.get("output"),
"outputformat": kwargs.get("outputformat"),
}
if "buildargs" in kwargs:
params["buildargs"] = json.dumps(kwargs.get("buildargs"))
if "cache_from" in kwargs:
params["cachefrom"] = json.dumps(kwargs.get("cache_from"))
params["cacheform"] = json.dumps(kwargs.get("cache_from"))
if "container_limits" in kwargs:
params["cpuperiod"] = kwargs["container_limits"].get("cpuperiod")

View File

@ -1,35 +1,19 @@
"""PodmanResource manager subclassed for Images."""
import builtins
import io
import json
import logging
import os
import urllib.parse
from typing import Any, Literal, Optional, Union
from collections.abc import Iterator, Mapping, Generator
from pathlib import Path
from typing import Any, Dict, Generator, Iterator, List, Mapping, Optional, Union
import requests
from podman import api
from podman.api.parse_utils import parse_repository
from podman.api import Literal
from podman.domain.images import Image
from podman.domain.images_build import BuildMixin
from podman.domain.json_stream import json_stream
from podman.domain.manager import Manager
from podman.domain.registry_data import RegistryData
from podman.errors import APIError, ImageNotFound, PodmanError
try:
from rich.progress import (
Progress,
TextColumn,
BarColumn,
TaskProgressColumn,
TimeRemainingColumn,
)
except (ImportError, ModuleNotFoundError):
Progress = None
from podman.errors import APIError, ImageNotFound
logger = logging.getLogger("podman.images")
@ -43,33 +27,29 @@ class ImagesManager(BuildMixin, Manager):
return Image
def exists(self, key: str) -> bool:
"""Return true when image exists."""
key = urllib.parse.quote_plus(key)
response = self.client.get(f"/images/{key}/exists")
return response.ok
def list(self, **kwargs) -> builtins.list[Image]:
def list(self, **kwargs) -> List[Image]:
"""Report on images.
Keyword Args:
name (str) Only show images belonging to the repository name
all (bool) Show intermediate image layers. By default, these are filtered out.
filters (Mapping[str, Union[str, list[str]]) Filters to be used on the image list.
filters (Mapping[str, Union[str, List[str]]) Filters to be used on the image list.
Available filters:
- dangling (bool)
- label (Union[str, list[str]]): format either "key" or "key=value"
- label (Union[str, List[str]]): format either "key" or "key=value"
Raises:
APIError: when service returns an error
"""
filters = kwargs.get("filters", {}).copy()
if name := kwargs.get("name"):
filters["reference"] = name
params = {
"all": kwargs.get("all"),
"filters": api.prepare_filters(filters=filters),
"name": kwargs.get("name"),
"filters": api.prepare_filters(kwargs.get("filters")),
}
response = self.client.get("/images/json", params=params)
if response.status_code == requests.codes.not_found:
@ -79,7 +59,7 @@ class ImagesManager(BuildMixin, Manager):
return [self.prepare_model(attrs=i) for i in response.json()]
# pylint is flagging 'name' here vs. 'key' parameter in super.get()
def get(self, name: str) -> Image: # pylint: disable=arguments-differ,arguments-renamed
def get(self, name: str) -> Image: # pylint: disable=arguments-differ
"""Returns an image by name or id.
Args:
@ -120,96 +100,51 @@ class ImagesManager(BuildMixin, Manager):
collection=self,
)
def load(
self, data: Optional[bytes] = None, file_path: Optional[os.PathLike] = None
) -> Generator[Image, None, None]:
def load(self, data: bytes) -> Generator[Image, None, None]:
"""Restore an image previously saved.
Args:
data: Image to be loaded in tarball format.
file_path: Path of the Tarball.
It works with both str and Path-like objects
Raises:
APIError: When service returns an error.
PodmanError: When the arguments are not set correctly.
APIError: when service returns an error
"""
# TODO fix podman swagger cannot use this header!
# headers = {"Content-type": "application/x-www-form-urlencoded"}
# Check that exactly one of the data or file_path is provided
if not data and not file_path:
raise PodmanError("The 'data' or 'file_path' parameter should be set.")
if data and file_path:
raise PodmanError(
"Only one parameter should be set from 'data' and 'file_path' parameters."
)
post_data = data
if file_path:
# Convert to Path if file_path is a string
file_path_object = Path(file_path)
post_data = file_path_object.read_bytes() # Read the tarball file as bytes
# Make the client request before entering the generator
response = self.client.post(
"/images/load", data=post_data, headers={"Content-type": "application/x-tar"}
"/images/load", data=data, headers={"Content-type": "application/x-tar"}
)
response.raise_for_status() # Catch any errors before proceeding
response.raise_for_status()
def _generator(body: dict) -> Generator[Image, None, None]:
# Iterate and yield images from response body
body = response.json()
for item in body["Names"]:
yield self.get(item)
# Pass the response body to the generator
return _generator(response.json())
def prune(
self,
all: Optional[bool] = False, # pylint: disable=redefined-builtin
external: Optional[bool] = False,
filters: Optional[Mapping[str, Any]] = None,
) -> dict[Literal["ImagesDeleted", "SpaceReclaimed"], Any]:
self, filters: Optional[Mapping[str, Any]] = None
) -> Dict[Literal["ImagesDeleted", "SpaceReclaimed"], Any]:
"""Delete unused images.
The Untagged keys will always be "".
Args:
all: Remove all images not in use by containers, not just dangling ones.
external: Remove images even when they are used by external containers
(e.g, by build containers).
filters: Qualify Images to prune. Available filters:
- dangling (bool): when true, only delete unused and untagged images.
- label: (dict): filter by label.
Examples:
filters={"label": {"key": "value"}}
filters={"label!": {"key": "value"}}
- until (str): Delete images older than this timestamp.
Raises:
APIError: when service returns an error
"""
params = {
"all": all,
"external": external,
"filters": api.prepare_filters(filters),
}
response = self.client.post("/images/prune", params=params)
response = self.client.post(
"/images/prune", params={"filters": api.prepare_filters(filters)}
)
response.raise_for_status()
deleted: builtins.list[dict[str, str]] = []
error: builtins.list[str] = []
deleted: List[Dict[str, str]] = []
error: List[str] = []
reclaimed: int = 0
# If the prune doesn't remove images, the API returns "null"
# and it's interpreted as None (NoneType)
# so the for loop throws "TypeError: 'NoneType' object is not iterable".
# The below if condition fixes this issue.
if response.json() is not None:
for element in response.json():
if "Err" in element and element["Err"] is not None:
error.append(element["Err"])
@ -229,7 +164,7 @@ class ImagesManager(BuildMixin, Manager):
"SpaceReclaimed": reclaimed,
}
def prune_builds(self) -> dict[Literal["CachesDeleted", "SpaceReclaimed"], Any]:
def prune_builds(self) -> Dict[Literal["CachesDeleted", "SpaceReclaimed"], Any]:
"""Delete builder cache.
Method included to complete API, the operation always returns empty
@ -239,7 +174,7 @@ class ImagesManager(BuildMixin, Manager):
def push(
self, repository: str, tag: Optional[str] = None, **kwargs
) -> Union[str, Iterator[Union[str, dict[str, Any]]]]:
) -> Union[str, Iterator[Union[str, Dict[str, Any]]]]:
"""Push Image or repository to the registry.
Args:
@ -249,37 +184,27 @@ class ImagesManager(BuildMixin, Manager):
Keyword Args:
auth_config (Mapping[str, str]: Override configured credentials. Must include
username and password keys.
decode (bool): return data from server as dict[str, Any]. Ignored unless stream=True.
decode (bool): return data from server as Dict[str, Any]. Ignored unless stream=True.
destination (str): alternate destination for image. (Podman only)
stream (bool): return output as blocking generator. Default: False.
tlsVerify (bool): Require TLS verification.
format (str): Manifest type (oci, v2s1, or v2s2) to use when pushing an image.
Default is manifest type of source, with fallbacks.
Raises:
APIError: when service returns an error
"""
auth_config: Optional[dict[str, str]] = kwargs.get("auth_config")
# TODO set X-Registry-Auth
headers = {
# A base64url-encoded auth configuration
"X-Registry-Auth": api.encode_auth_header(auth_config) if auth_config else ""
"X-Registry-Auth": ""
}
params = {
"destination": kwargs.get("destination"),
"tlsVerify": kwargs.get("tlsVerify"),
"format": kwargs.get("format"),
}
stream = kwargs.get("stream", False)
decode = kwargs.get("decode", False)
name = f'{repository}:{tag}' if tag else repository
name = urllib.parse.quote_plus(name)
response = self.client.post(
f"/images/{name}/push", params=params, stream=stream, headers=headers
)
name = urllib.parse.quote_plus(repository)
response = self.client.post(f"/images/{name}/push", params=params, headers=headers)
response.raise_for_status(not_found=ImageNotFound)
tag_count = 0 if tag is None else 1
@ -294,6 +219,8 @@ class ImagesManager(BuildMixin, Manager):
},
]
stream = kwargs.get("stream", False)
decode = kwargs.get("decode", False)
if stream:
return self._push_helper(decode, body)
@ -304,8 +231,8 @@ class ImagesManager(BuildMixin, Manager):
@staticmethod
def _push_helper(
decode: bool, body: builtins.list[dict[str, Any]]
) -> Iterator[Union[str, dict[str, Any]]]:
decode: bool, body: List[Dict[str, Any]]
) -> Iterator[Union[str, Dict[str, Any]]]:
"""Helper needed to allow push() to return either a generator or a str."""
for entry in body:
if decode:
@ -315,12 +242,8 @@ class ImagesManager(BuildMixin, Manager):
# pylint: disable=too-many-locals,too-many-branches
def pull(
self,
repository: str,
tag: Optional[str] = None,
all_tags: bool = False,
**kwargs,
) -> Union[Image, builtins.list[Image], Iterator[str]]:
self, repository: str, tag: Optional[str] = None, all_tags: bool = False, **kwargs
) -> Union[Image, List[Image]]:
"""Request Podman service to pull image(s) from repository.
Args:
@ -332,44 +255,26 @@ class ImagesManager(BuildMixin, Manager):
auth_config (Mapping[str, str]) Override the credentials that are found in the
config for this request. auth_config should contain the username and password
keys to be valid.
compatMode (bool) Return the same JSON payload as the Docker-compat endpoint.
Default: True.
decode (bool) Decode the JSON data from the server into dicts.
Only applies with ``stream=True``
platform (str) Platform in the format os[/arch[/variant]]
policy (str) - Pull policy. "always" (default), "missing", "never", "newer"
progress_bar (bool) - Display a progress bar with the image pull progress (uses
the compat endpoint). Default: False
tls_verify (bool) - Require TLS verification. Default: True.
stream (bool) - When True, the pull progress will be published as received.
Default: False.
Returns:
When stream is True, return a generator publishing the service pull progress.
If all_tags is True, return list of Image's rather than Image pulled.
Raises:
APIError: when service returns an error
"""
if tag is None or len(tag) == 0:
repository, parsed_tag = parse_repository(repository)
if parsed_tag is not None:
tag = parsed_tag
tokens = repository.split(":")
if len(tokens) == 2:
repository = tokens[0]
tag = tokens[1]
else:
tag = "latest"
auth_config: Optional[dict[str, str]] = kwargs.get("auth_config")
headers = {
# A base64url-encoded auth configuration
"X-Registry-Auth": api.encode_auth_header(auth_config) if auth_config else ""
}
params = {
"policy": kwargs.get("policy", "always"),
"reference": repository,
"tlsVerify": kwargs.get("tls_verify", True),
"compatMode": kwargs.get("compatMode", True),
"tlsVerify": kwargs.get("tls_verify"),
}
if all_tags:
@ -377,8 +282,7 @@ class ImagesManager(BuildMixin, Manager):
else:
params["reference"] = f"{repository}:{tag}"
# Check if "platform" in kwargs AND it has value.
if "platform" in kwargs and kwargs["platform"]:
if "platform" in kwargs:
tokens = kwargs.get("platform").split("/")
if 1 < len(tokens) > 3:
raise ValueError(f'\'{kwargs.get("platform")}\' is not a legal platform.')
@ -389,87 +293,34 @@ class ImagesManager(BuildMixin, Manager):
if len(tokens) > 2:
params["Variant"] = tokens[2]
stream = kwargs.get("stream", False)
# if the user wants a progress bar, we need to use the compat endpoint
# so set that to true as well as stream so we can parse that output for the
# progress bar
progress_bar = kwargs.get("progress_bar", False)
if progress_bar:
if Progress is None:
raise ModuleNotFoundError('progress_bar requires \'rich.progress\' module')
params["compatMode"] = True
stream = True
if "auth_config" in kwargs:
username = kwargs["auth_config"].get("username")
password = kwargs["auth_config"].get("password")
if username is None or password is None:
raise ValueError("'auth_config' requires keys 'username' and 'password'")
params["credentials"] = f"{username}:{password}"
response = self.client.post("/images/pull", params=params, stream=stream, headers=headers)
response = self.client.post("/images/pull", params=params)
response.raise_for_status(not_found=ImageNotFound)
if progress_bar:
tasks = {}
print("Pulling", params["reference"])
progress = Progress(
TextColumn("[progress.description]{task.description}"),
BarColumn(complete_style="default", finished_style="green"),
TaskProgressColumn(),
TimeRemainingColumn(),
)
with progress:
for line in response.iter_lines():
decoded_line = json.loads(line.decode('utf-8'))
self.__show_progress_bar(decoded_line, progress, tasks)
return None
if stream:
return self._stream_helper(response, decode=kwargs.get("decode"))
for item in reversed(list(response.iter_lines())):
obj = json.loads(item)
if all_tags and "images" in obj:
images: builtins.list[Image] = []
for name in obj["images"]:
for item in response.iter_lines():
body = json.loads(item)
if all_tags and "images" in body:
images: List[Image] = []
for name in body["images"]:
images.append(self.get(name))
return images
if "id" in obj:
return self.get(obj["id"])
if "id" in body:
return self.get(body["id"])
return self.resource()
def __show_progress_bar(self, line, progress, tasks):
completed = False
if line['status'] == 'Download complete':
description = f'[green][Download complete {line["id"]}]'
completed = True
elif line['status'] == 'Downloading':
description = f'[bold][Downloading {line["id"]}]'
else:
# skip other statuses
return
task_id = line["id"]
if task_id not in tasks.keys():
if completed:
# some layers are really small that they download immediately without showing
# anything as Downloading in the stream.
# For that case, show a completed progress bar
tasks[task_id] = progress.add_task(description, total=100, completed=100)
else:
tasks[task_id] = progress.add_task(
description, total=line['progressDetail']['total']
)
else:
if completed:
# due to the stream, the Download complete output can happen before the Downloading
# bar outputs the 100%. So when we detect that the download is in fact complete,
# update the progress bar to show 100%
progress.update(tasks[task_id], description=description, total=100, completed=100)
else:
progress.update(tasks[task_id], completed=line['progressDetail']['current'])
def remove(
self,
image: Union[Image, str],
force: Optional[bool] = None,
noprune: bool = False, # pylint: disable=unused-argument
) -> builtins.list[dict[Literal["Deleted", "Untagged", "Errors", "ExitCode"], Union[str, int]]]:
) -> List[Dict[Literal["Deleted", "Untagged", "Errors", "ExitCode"], Union[str, int]]]:
"""Delete image from Podman service.
Args:
@ -488,7 +339,7 @@ class ImagesManager(BuildMixin, Manager):
response.raise_for_status(not_found=ImageNotFound)
body = response.json()
results: builtins.list[dict[str, Union[int, str]]] = []
results: List[Dict[str, Union[int, str]]] = []
for key in ("Deleted", "Untagged", "Errors"):
if key in body:
for element in body[key]:
@ -496,14 +347,14 @@ class ImagesManager(BuildMixin, Manager):
results.append({"ExitCode": body["ExitCode"]})
return results
def search(self, term: str, **kwargs) -> builtins.list[dict[str, Any]]:
def search(self, term: str, **kwargs) -> List[Dict[str, Any]]:
"""Search Images on registries.
Args:
term: Used to target Image results.
Keyword Args:
filters (Mapping[str, list[str]): Refine results of search. Available filters:
filters (Mapping[str, List[str]): Refine results of search. Available filters:
- is-automated (bool): Image build is automated.
- is-official (bool): Image build is owned by product provider.
@ -511,7 +362,6 @@ class ImagesManager(BuildMixin, Manager):
noTrunc (bool): Do not truncate any result string. Default: True.
limit (int): Maximum number of results.
listTags (bool): list the available tags in the repository. Default: False
Raises:
APIError: when service returns an error
@ -523,57 +373,6 @@ class ImagesManager(BuildMixin, Manager):
"term": [term],
}
if "listTags" in kwargs:
params["listTags"] = kwargs.get("listTags")
response = self.client.get("/images/search", params=params)
response.raise_for_status(not_found=ImageNotFound)
return response.json()
def scp(
self,
source: str,
dest: Optional[str] = None,
quiet: Optional[bool] = False,
) -> str:
"""Securely copy images between hosts.
Args:
source: source connection/image
dest: destination connection/image
quiet: do not print save/load output, only the image
Returns:
A string containing the loaded image
Raises:
APIError: when service returns an error
"""
params = {"quiet": quiet}
if dest is not None:
params["destination"] = dest
response = self.client.post(f"/images/scp/{source}", params=params)
response.raise_for_status()
return response.json()
def _stream_helper(self, response, decode=False):
"""Generator for data coming from a chunked-encoded HTTP response."""
if response.raw._fp.chunked:
if decode:
yield from json_stream(self._stream_helper(response, False))
else:
reader = response.raw
while not reader.closed:
# this read call will block until we get a chunk
data = reader.read(1)
if not data:
break
if reader._fp.chunk_left:
data += reader.read(reader._fp.chunk_left)
yield data
else:
# Response isn't chunked, meaning we probably
# encountered an error immediately
yield self._result(response, json=decode)

View File

@ -2,9 +2,7 @@
Provided for compatibility
"""
from typing import Any, Optional
from collections.abc import Mapping
from typing import Any, List, Mapping, Optional
class IPAMPool(dict):
@ -41,8 +39,8 @@ class IPAMConfig(dict):
def __init__(
self,
driver: Optional[str] = "host-local",
pool_configs: Optional[list[IPAMPool]] = None,
driver: Optional[str] = "default",
pool_configs: Optional[List[IPAMPool]] = None,
options: Optional[Mapping[str, Any]] = None,
):
"""Create IPAMConfig.
@ -55,8 +53,8 @@ class IPAMConfig(dict):
super().__init__()
self.update(
{
"Config": pool_configs or [],
"Config": pool_configs or list(),
"Driver": driver,
"Options": options or {},
"Options": options or dict(),
}
)

View File

@ -1,75 +0,0 @@
import json
import json.decoder
from podman.errors import StreamParseError
json_decoder = json.JSONDecoder()
def stream_as_text(stream):
"""
Given a stream of bytes or text, if any of the items in the stream
are bytes convert them to text.
This function can be removed once we return text streams
instead of byte streams.
"""
for data in stream:
_data = data
if not isinstance(data, str):
_data = data.decode('utf-8', 'replace')
yield _data
def json_splitter(buffer):
"""Attempt to parse a json object from a buffer. If there is at least one
object, return it and the rest of the buffer, otherwise return None.
"""
buffer = buffer.strip()
try:
obj, index = json_decoder.raw_decode(buffer)
rest = buffer[json.decoder.WHITESPACE.match(buffer, index).end() :]
return obj, rest
except ValueError:
return None
def json_stream(stream):
"""Given a stream of text, return a stream of json objects.
This handles streams which are inconsistently buffered (some entries may
be newline delimited, and others are not).
"""
return split_buffer(stream, json_splitter, json_decoder.decode)
def line_splitter(buffer, separator='\n'):
index = buffer.find(str(separator))
if index == -1:
return None
return buffer[: index + 1], buffer[index + 1 :]
def split_buffer(stream, splitter=None, decoder=lambda a: a):
"""Given a generator which yields strings and a splitter function,
joins all input, splits on the separator and yields each chunk.
Unlike string.split(), each chunk includes the trailing
separator, except for the last one if none was found on the end
of the input.
"""
splitter = splitter or line_splitter
buffered = ''
for data in stream_as_text(stream):
buffered += data
while True:
buffer_split = splitter(buffered)
if buffer_split is None:
break
item, buffered = buffer_split
yield item
if buffered:
try:
yield decoder(buffered)
except Exception as e:
raise StreamParseError(e) from e

View File

@ -1,20 +1,15 @@
"""Base classes for PodmanResources and Manager's."""
from abc import ABC, abstractmethod
from collections import abc
from typing import Any, Optional, TypeVar, Union, TYPE_CHECKING
from collections.abc import Mapping
from typing import Any, List, Mapping, Optional, TypeVar, Union
from podman.api.client import APIClient
if TYPE_CHECKING:
from podman import PodmanClient
# Methods use this Type when a subclass of PodmanResource is expected.
PodmanResourceType: TypeVar = TypeVar("PodmanResourceType", bound="PodmanResource")
# Methods use this Type when a sub-class of PodmanResource is expected.
PodmanResourceType = TypeVar("PodmanResourceType", bound="PodmanResource")
class PodmanResource(ABC): # noqa: B024
class PodmanResource(ABC):
"""Base class for representing resource of a Podman service.
Attributes:
@ -26,7 +21,6 @@ class PodmanResource(ABC): # noqa: B024
attrs: Optional[Mapping[str, Any]] = None,
client: Optional[APIClient] = None,
collection: Optional["Manager"] = None,
podman_client: Optional["PodmanClient"] = None,
):
"""Initialize base class for PodmanResource's.
@ -34,14 +28,12 @@ class PodmanResource(ABC): # noqa: B024
attrs: Mapping of attributes for resource from Podman service.
client: Configured connection to a Podman service.
collection: Manager of this category of resource, named `collection` for compatibility
podman_client: PodmanClient() configured to connect to Podman object.
"""
super().__init__()
self.client = client
self.manager = collection
self.podman_client = podman_client
self.attrs = {}
self.attrs = dict()
if attrs is not None:
self.attrs.update(attrs)
@ -70,13 +62,9 @@ class PodmanResource(ABC): # noqa: B024
return self.id[:17]
return self.id[:10]
def reload(self, **kwargs) -> None:
"""Refresh this object's data from the service.
Keyword Args:
compatible (bool): Use Docker compatibility endpoint
"""
latest = self.manager.get(self.id, **kwargs)
def reload(self) -> None:
"""Refresh this object's data from the service."""
latest = self.manager.get(self.id)
self.attrs = latest.attrs
@ -88,18 +76,14 @@ class Manager(ABC):
def resource(self):
"""Type[PodmanResource]: Class which the factory method prepare_model() will use."""
def __init__(
self, client: Optional[APIClient] = None, podman_client: Optional["PodmanClient"] = None
) -> None:
def __init__(self, client: APIClient = None) -> None:
"""Initialize Manager() object.
Args:
client: APIClient() configured to connect to Podman service.
podman_client: PodmanClient() configured to connect to Podman object.
"""
super().__init__()
self.client = client
self.podman_client = podman_client
@abstractmethod
def exists(self, key: str) -> bool:
@ -116,7 +100,7 @@ class Manager(ABC):
"""Returns representation of resource."""
@abstractmethod
def list(self, **kwargs) -> list[PodmanResourceType]:
def list(self, **kwargs) -> List[PodmanResourceType]:
"""Returns list of resources."""
def prepare_model(self, attrs: Union[PodmanResource, Mapping[str, Any]]) -> PodmanResourceType:
@ -125,7 +109,6 @@ class Manager(ABC):
# Refresh existing PodmanResource.
if isinstance(attrs, PodmanResource):
attrs.client = self.client
attrs.podman_client = self.podman_client
attrs.collection = self
return attrs
@ -133,9 +116,6 @@ class Manager(ABC):
if isinstance(attrs, abc.Mapping):
# TODO Determine why pylint is reporting typing.Type not callable
# pylint: disable=not-callable
return self.resource(
attrs=attrs, client=self.client, podman_client=self.podman_client, collection=self
)
return self.resource(attrs=attrs, client=self.client, collection=self)
# pylint: disable=broad-exception-raised
raise Exception(f"Can't create {self.resource.__name__} from {attrs}")

View File

@ -1,9 +1,8 @@
"""Model and Manager for Manifest resources."""
import logging
import urllib.parse
from contextlib import suppress
from typing import Any, Optional, Union
from typing import List, Optional, Union
from podman import api
from podman.domain.images import Image
@ -18,18 +17,20 @@ class Manifest(PodmanResource):
@property
def id(self):
"""str: Returns the identifier of the manifest list."""
"""str: Returns the identifier of the manifest."""
with suppress(KeyError, TypeError, IndexError):
digest = self.attrs["manifests"][0]["digest"]
if digest.startswith("sha256:"):
return digest[7:]
return digest
return self.attrs["manifests"][0]["digest"]
return self.name
@property
def name(self):
"""str: Returns the human-formatted identifier of the manifest list."""
return self.attrs.get("names")
"""str: Returns the identifier of the manifest."""
try:
if len(self.names[0]) == 0:
raise ValueError("Manifest attribute 'names' is empty.")
return self.names[0]
except (TypeError, IndexError) as e:
raise ValueError("Manifest attribute 'names' is missing.") from e
@property
def quoted_name(self):
@ -38,8 +39,8 @@ class Manifest(PodmanResource):
@property
def names(self):
"""list[str]: Returns the identifier of the manifest."""
return self.name
""" List[str]: Returns the identifier of the manifest."""
return self.attrs.get("names")
@property
def media_type(self):
@ -51,7 +52,7 @@ class Manifest(PodmanResource):
"""int: Returns the schema version type for this manifest."""
return self.attrs.get("schemaVersion")
def add(self, images: list[Union[Image, str]], **kwargs) -> None:
def add(self, images: List[Union[Image, str]], **kwargs) -> None:
"""Add Image to manifest list.
Args:
@ -59,9 +60,9 @@ class Manifest(PodmanResource):
Keyword Args:
all (bool):
annotation (dict[str, str]):
annotation (Dict[str, str]):
arch (str):
features (list[str]):
features (List[str]):
os (str):
os_version (str):
variant (str):
@ -70,26 +71,23 @@ class Manifest(PodmanResource):
ImageNotFound: when Image(s) could not be found
APIError: when service reports an error
"""
data = {
params = {
"all": kwargs.get("all"),
"annotation": kwargs.get("annotation"),
"arch": kwargs.get("arch"),
"features": kwargs.get("features"),
"images": [],
"images": list(),
"os": kwargs.get("os"),
"os_version": kwargs.get("os_version"),
"variant": kwargs.get("variant"),
"operation": "update",
}
for item in images:
# avoid redefinition of the loop variable, then ensure it's an image
img_item = item
if isinstance(img_item, Image):
img_item = img_item.attrs["RepoTags"][0]
data["images"].append(img_item)
if isinstance(item, Image):
item = item.attrs["RepoTags"][0]
params["images"].append(item)
data = api.prepare_body(data)
response = self.client.put(f"/manifests/{self.quoted_name}", data=data)
data = api.prepare_body(params)
response = self.client.post(f"/manifests/{self.quoted_name}/add", data=data)
response.raise_for_status(not_found=ImageNotFound)
return self.reload()
@ -97,7 +95,6 @@ class Manifest(PodmanResource):
self,
destination: str,
all: Optional[bool] = None, # pylint: disable=redefined-builtin
**kwargs,
) -> None:
"""Push a manifest list or image index to a registry.
@ -105,32 +102,15 @@ class Manifest(PodmanResource):
destination: Target for push.
all: Push all images.
Keyword Args:
auth_config (Mapping[str, str]: Override configured credentials. Must include
username and password keys.
Raises:
NotFound: when the Manifest could not be found
APIError: when service reports an error
"""
auth_config: Optional[dict[str, str]] = kwargs.get("auth_config")
headers = {
# A base64url-encoded auth configuration
"X-Registry-Auth": api.encode_auth_header(auth_config) if auth_config else ""
}
params = {
"all": all,
"destination": destination,
}
destination_quoted = urllib.parse.quote_plus(destination)
response = self.client.post(
f"/manifests/{self.quoted_name}/registry/{destination_quoted}",
params=params,
headers=headers,
)
response = self.client.post(f"/manifests/{self.quoted_name}/push", params=params)
response.raise_for_status()
def remove(self, digest: str) -> None:
@ -147,10 +127,7 @@ class Manifest(PodmanResource):
if "@" in digest:
digest = digest.split("@", maxsplit=2)[1]
data = {"operation": "remove", "images": [digest]}
data = api.prepare_body(data)
response = self.client.put(f"/manifests/{self.quoted_name}", data=data)
response = self.client.delete(f"/manifests/{self.quoted_name}", params={"digest": digest})
response.raise_for_status(not_found=ImageNotFound)
return self.reload()
@ -170,14 +147,14 @@ class ManifestsManager(Manager):
def create(
self,
name: str,
images: Optional[list[Union[Image, str]]] = None,
names: List[str],
images: Optional[List[Union[Image, str]]] = None,
all: Optional[bool] = None, # pylint: disable=redefined-builtin
) -> Manifest:
"""Create a Manifest.
Args:
name: Name of manifest list.
names: Identifiers to be added to the manifest. There must be at least one.
images: Images or Image identifiers to be included in the manifest.
all: When True, add all contents from images given.
@ -185,29 +162,29 @@ class ManifestsManager(Manager):
ValueError: when no names are provided
NotFoundImage: when a given image does not exist
"""
params: dict[str, Any] = {}
if names is None or len(names) == 0:
raise ValueError("At least one manifest name is required.")
params = {"name": names}
if images is not None:
params["images"] = []
params["image"] = list()
for item in images:
# avoid redefinition of the loop variable, then ensure it's an image
img_item = item
if isinstance(img_item, Image):
img_item = img_item.attrs["RepoTags"][0]
params["images"].append(img_item)
if isinstance(item, Image):
item = item.attrs["RepoTags"][0]
params["image"].append(item)
if all is not None:
params["all"] = all
name_quoted = urllib.parse.quote_plus(name)
response = self.client.post(f"/manifests/{name_quoted}", params=params)
response = self.client.post("/manifests/create", params=params)
response.raise_for_status(not_found=ImageNotFound)
body = response.json()
manifest = self.get(body["Id"])
manifest.attrs["names"] = name
manifest.attrs["names"] = names
if manifest.attrs["manifests"] is None:
manifest.attrs["manifests"] = []
manifest.attrs["manifests"] = list()
return manifest
def exists(self, key: str) -> bool:
@ -221,6 +198,9 @@ class ManifestsManager(Manager):
To have Manifest conform with other PodmanResource's, we use the key that
retrieved the Manifest be its name.
See https://issues.redhat.com/browse/RUN-1217 for details on refactoring Podman service
manifests API.
Args:
key: Manifest name for which to search
@ -233,23 +213,10 @@ class ManifestsManager(Manager):
response.raise_for_status()
body = response.json()
if "names" not in body:
body["names"] = key
body["names"] = [key]
return self.prepare_model(attrs=body)
def list(self, **kwargs) -> list[Manifest]:
def list(self, **kwargs) -> List[Manifest]:
"""Not Implemented."""
raise NotImplementedError("Podman service currently does not support listing manifests.")
def remove(self, name: Union[Manifest, str]) -> dict[str, Any]:
"""Delete the manifest list from the Podman service."""
if isinstance(name, Manifest):
name = name.name
response = self.client.delete(f"/manifests/{name}")
response.raise_for_status(not_found=ImageNotFound)
body = response.json()
body["ExitCode"] = response.status_code
return body

View File

@ -1,12 +1,9 @@
"""Model for Network resources.
"""Model and Manager for Network resources.
Example:
with PodmanClient(base_url="unix:///run/user/1000/podman/podman.sock") as client:
net = client.networks.get("db_network")
print(net.name, "\n")
By default, most methods in this module uses the Podman compatible API rather than the
libpod API as the results are so different. To use the libpod API add the keyword argument
compatible=False to any method call.
"""
import hashlib
import json
import logging
@ -24,7 +21,7 @@ class Network(PodmanResource):
"""Details and configuration for a networks managed by the Podman service.
Attributes:
attrs (dict[str, Any]): Attributes of Network reported from Podman service
attrs (Dict[str, Any]): Attributes of Network reported from Podman service
"""
@property
@ -41,16 +38,15 @@ class Network(PodmanResource):
@property
def containers(self):
"""list[Container]: Returns list of Containers connected to network."""
""" List[Container]: Returns list of Containers connected to network."""
with suppress(KeyError):
container_manager = ContainersManager(client=self.client)
return [container_manager.get(ident) for ident in self.attrs["Containers"].keys()]
return []
return dict()
@property
def name(self):
"""str: Returns the name of the network."""
if "Name" in self.attrs:
return self.attrs["Name"]
@ -71,47 +67,50 @@ class Network(PodmanResource):
container: To add to this Network
Keyword Args:
aliases (list[str]): Aliases to add for this endpoint
driver_opt (dict[str, Any]): Options to provide to network driver
aliases (List[str]): Aliases to add for this endpoint
compatible (bool): Should compatible API be used. Default: True
driver_opt (Dict[str, Any]): Options to provide to network driver
ipv4_address (str): IPv4 address for given Container on this network
ipv6_address (str): IPv6 address for given Container on this network
link_local_ips (list[str]): list of link-local addresses
links (list[Union[str, Containers]]): Ignored
link_local_ips (List[str]): list of link-local addresses
links (List[Union[str, Containers]]): Ignored
Raises:
APIError: when Podman service reports an error
"""
compatible = kwargs.get("compatible", True)
if isinstance(container, Container):
container = container.id
# TODO Talk with baude on which IPAddress field is needed...
ipam = {
"IPv4Address": kwargs.get('ipv4_address'),
"IPv6Address": kwargs.get('ipv6_address'),
"Links": kwargs.get("link_local_ips"),
}
ipam = dict(
IPv4Address=kwargs.get("ipv4_address"),
IPv6Address=kwargs.get("ipv6_address"),
Links=kwargs.get("link_local_ips"),
)
ipam = {k: v for (k, v) in ipam.items() if not (v is None or len(v) == 0)}
endpoint_config = {
"Aliases": kwargs.get("aliases"),
"DriverOpts": kwargs.get("driver_opt"),
"IPAddress": kwargs.get("ipv4_address", kwargs.get("ipv6_address")),
"IPAMConfig": ipam,
"Links": kwargs.get("link_local_ips"),
"NetworkID": self.id,
}
endpoint_config = dict(
Aliases=kwargs.get("aliases"),
DriverOpts=kwargs.get("driver_opt"),
IPAddress=kwargs.get("ipv4_address", kwargs.get("ipv6_address")),
IPAMConfig=ipam,
Links=kwargs.get("link_local_ips"),
NetworkID=self.id,
)
endpoint_config = {
k: v for (k, v) in endpoint_config.items() if not (v is None or len(v) == 0)
}
data = {"Container": container, "EndpointConfig": endpoint_config}
data = dict(Container=container, EndpointConfig=endpoint_config)
data = {k: v for (k, v) in data.items() if not (v is None or len(v) == 0)}
response = self.client.post(
f"/networks/{self.name}/connect",
data=json.dumps(data),
headers={"Content-type": "application/json"},
**kwargs,
compatible=compatible,
)
response.raise_for_status()
@ -127,11 +126,15 @@ class Network(PodmanResource):
Raises:
APIError: when Podman service reports an error
"""
compatible = kwargs.get("compatible", True)
if isinstance(container, Container):
container = container.id
data = {"Container": container, "Force": kwargs.get("force")}
response = self.client.post(f"/networks/{self.name}/disconnect", data=json.dumps(data))
response = self.client.post(
f"/networks/{self.name}/disconnect", data=json.dumps(data), compatible=compatible
)
response.raise_for_status()
def remove(self, force: Optional[bool] = None, **kwargs) -> None:
@ -140,6 +143,9 @@ class Network(PodmanResource):
Args:
force: Remove network and any associated containers
Keyword Args:
compatible (bool): Should compatible API be used. Default: True
Raises:
APIError: when Podman service reports an error
"""

View File

@ -1,20 +1,16 @@
"""PodmanResource manager subclassed for Network resources.
"""PodmanResource manager subclassed for Networks.
Classes and methods for manipulating network resources via Podman API service.
Example:
with PodmanClient(base_url="unix:///run/user/1000/podman/podman.sock") as client:
for net in client.networks.list():
print(net.id, "\n")
By default, most methods in this module uses the Podman compatible API rather than the
libpod API as the results are so different. To use the libpod API add the keyword argument
compatible=False to any method call.
"""
import ipaddress
import logging
from contextlib import suppress
from typing import Any, Optional, Literal, Union
from typing import Any, Dict, List, Optional
from podman.api import http_utils, prepare_filters
import podman.api.http_utils
from podman import api
from podman.domain.manager import Manager
from podman.domain.networks import Network
from podman.errors import APIError
@ -31,7 +27,7 @@ class NetworksManager(Manager):
return Network
def create(self, name: str, **kwargs) -> Network:
"""Create a Network resource.
"""Create a Network.
Args:
name: Name of network to be created
@ -39,100 +35,118 @@ class NetworksManager(Manager):
Keyword Args:
attachable (bool): Ignored, always False.
check_duplicate (bool): Ignored, always False.
dns_enabled (bool): When True, do not provision DNS for this network.
disabled_dns (bool): When True, do not provision DNS for this network.
driver (str): Which network driver to use when creating network.
enable_ipv6 (bool): Enable IPv6 on the network.
ingress (bool): Ignored, always False.
internal (bool): Restrict external access to the network.
ipam (IPAMConfig): Optional custom IP scheme for the network.
labels (dict[str, str]): Map of labels to set on the network.
options (dict[str, Any]): Driver options.
labels (Dict[str, str]): Map of labels to set on the network.
macvlan (str):
options (Dict[str, Any]): Driver options.
scope (str): Ignored, always "local".
Raises:
APIError: when Podman service reports an error
"""
data = {
"name": name,
"driver": kwargs.get("driver"),
"dns_enabled": kwargs.get("dns_enabled"),
"subnets": kwargs.get("subnets"),
"ipv6_enabled": kwargs.get("enable_ipv6"),
"internal": kwargs.get("internal"),
"labels": kwargs.get("labels"),
"options": kwargs.get("options"),
"DisabledDNS": kwargs.get("disabled_dns"),
"Driver": kwargs.get("driver"),
"Internal": kwargs.get("internal"),
"IPv6": kwargs.get("enable_ipv6"),
"Labels": kwargs.get("labels"),
"MacVLAN": kwargs.get("macvlan"),
"Options": kwargs.get("options"),
}
with suppress(KeyError):
self._prepare_ipam(data, kwargs["ipam"])
ipam = kwargs["ipam"]
if len(ipam["Config"]) > 0:
if len(ipam["Config"]) > 1:
raise ValueError("Podman service only supports one IPAM config.")
ip_config = ipam["Config"][0]
data["Gateway"] = ip_config.get("Gateway")
if "IPRange" in ip_config:
iprange = ipaddress.ip_network(ip_config["IPRange"])
iprange, mask = api.prepare_cidr(iprange)
data["Range"] = {
"IP": iprange,
"Mask": mask,
}
if "Subnet" in ip_config:
subnet = ipaddress.ip_network(ip_config["Subnet"])
subnet, mask = api.prepare_cidr(subnet)
data["Subnet"] = {
"IP": subnet,
"Mask": mask,
}
response = self.client.post(
"/networks/create",
data=http_utils.prepare_body(data),
params={"name": name},
data=podman.api.http_utils.prepare_body(data),
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
return self.prepare_model(attrs=response.json())
def _prepare_ipam(self, data: dict[str, Any], ipam: dict[str, Any]):
if "Driver" in ipam:
data["ipam_options"] = {"driver": ipam["Driver"]}
if "Config" not in ipam:
return
data["subnets"] = []
for cfg in ipam["Config"]:
subnet = {
"gateway": cfg.get("Gateway"),
"subnet": cfg.get("Subnet"),
}
with suppress(KeyError):
net = ipaddress.ip_network(cfg["IPRange"])
subnet["lease_range"] = {
"start_ip": str(net[1]),
"end_ip": str(net[-2]),
}
data["subnets"].append(subnet)
return self.get(name, **kwargs)
def exists(self, key: str) -> bool:
response = self.client.get(f"/networks/{key}/exists")
return response.ok
def get(self, key: str) -> Network:
# pylint is flagging 'network_id' here vs. 'key' parameter in super.get()
def get(self, network_id: str, *_, **kwargs) -> Network: # pylint: disable=arguments-differ
"""Return information for the network_id.
Args:
key: Network name or id.
network_id: Network name or id.
Keyword Args:
compatible (bool): Should compatible API be used. Default: True
Raises:
NotFound: when Network does not exist
APIError: when error returned by service
Note:
The compatible API is used, this allows the server to provide dynamic fields.
id is the most important example.
"""
response = self.client.get(f"/networks/{key}")
compatible = kwargs.get("compatible", True)
path = f"/networks/{network_id}" + ("" if compatible else "/json")
response = self.client.get(path, compatible=compatible)
response.raise_for_status()
return self.prepare_model(attrs=response.json())
body = response.json()
if not compatible:
body = body[0]
def list(self, **kwargs) -> list[Network]:
return self.prepare_model(attrs=body)
def list(self, **kwargs) -> List[Network]:
"""Report on networks.
Keyword Args:
names (list[str]): List of names to filter by.
ids (list[str]): List of identifiers to filter by.
names (List[str]): List of names to filter by.
ids (List[str]): List of identifiers to filter by.
filters (Mapping[str,str]): Criteria for listing networks. Available filters:
- driver="bridge": Matches a network's driver. Only "bridge" is supported.
- label=(Union[str, list[str]]): format either "key", "key=value"
- label=(Union[str, List[str]]): format either "key", "key=value"
or a list of such.
- type=(str): Filters networks by type, legal values are:
- "custom"
- "builtin"
- plugin=(list[str]]): Matches CNI plugins included in a network, legal
- plugin=(List[str]]): Matches CNI plugins included in a network, legal
values are (Podman only):
- bridge
@ -148,20 +162,24 @@ class NetworksManager(Manager):
Raises:
APIError: when error returned by service
"""
filters = kwargs.get("filters", {})
compatible = kwargs.get("compatible", True)
filters = kwargs.get("filters", dict())
filters["name"] = kwargs.get("names")
filters["id"] = kwargs.get("ids")
filters = prepare_filters(filters)
filters = api.prepare_filters(filters)
params = {"filters": filters}
response = self.client.get("/networks/json", params=params)
path = f"/networks{'' if compatible else '/json'}"
response = self.client.get(path, params=params, compatible=compatible)
response.raise_for_status()
return [self.prepare_model(i) for i in response.json()]
def prune(
self, filters: Optional[dict[str, Any]] = None
) -> dict[Literal["NetworksDeleted", "SpaceReclaimed"], Any]:
self, filters: Optional[Dict[str, Any]] = None, **kwargs
) -> Dict[api.Literal["NetworksDeleted", "SpaceReclaimed"], Any]:
"""Delete unused Networks.
SpaceReclaimed always reported as 0
@ -169,15 +187,25 @@ class NetworksManager(Manager):
Args:
filters: Criteria for selecting volumes to delete. Ignored.
Keyword Args:
compatible (bool): Should compatible API be used. Default: True
Raises:
APIError: when service reports error
"""
params = {"filters": prepare_filters(filters)}
response = self.client.post("/networks/prune", params=params)
compatible = kwargs.get("compatible", True)
response = self.client.post(
"/networks/prune", filters=api.prepare_filters(filters), compatible=compatible
)
response.raise_for_status()
deleted: list[str] = []
for item in response.json():
body = response.json()
if compatible:
return body
deleted: List[str] = list()
for item in body:
if item["Error"] is not None:
raise APIError(
item["Error"],
@ -188,18 +216,27 @@ class NetworksManager(Manager):
return {"NetworksDeleted": deleted, "SpaceReclaimed": 0}
def remove(self, name: Union[Network, str], force: Optional[bool] = None) -> None:
"""Remove Network resource.
def remove(self, name: [Network, str], force: Optional[bool] = None, **kwargs) -> None:
"""Remove this network.
Args:
name: Identifier of Network to delete.
force: Remove network and any associated containers
Keyword Args:
compatible (bool): Should compatible API be used. Default: True
Raises:
APIError: when Podman service reports an error
Notes:
Podman only.
"""
if isinstance(name, Network):
name = name.name
response = self.client.delete(f"/networks/{name}", params={"force": force})
compatible = kwargs.get("compatible", True)
response = self.client.delete(
f"/networks/{name}", params={"force": force}, compatible=compatible
)
response.raise_for_status()

View File

@ -1,14 +1,10 @@
"""Model and Manager for Pod resources."""
import logging
from typing import Any, Optional, Union, TYPE_CHECKING
from typing import Any, Dict, Tuple, Union, Optional
from podman.domain.manager import PodmanResource
if TYPE_CHECKING:
from podman.domain.pods_manager import PodsManager
_Timeout = Union[None, int, tuple[int, int], tuple[int, None]]
_Timeout = Union[None, float, Tuple[float, float], Tuple[float, None]]
logger = logging.getLogger("podman.pods")
@ -16,8 +12,6 @@ logger = logging.getLogger("podman.pods")
class Pod(PodmanResource):
"""Details and configuration for a pod managed by the Podman service."""
manager: "PodsManager"
@property
def id(self): # pylint: disable=invalid-name
return self.attrs.get("ID", self.attrs.get("Id"))
@ -93,7 +87,7 @@ class Pod(PodmanResource):
response = self.client.post(f"/pods/{self.id}/stop", params=params)
response.raise_for_status()
def top(self, **kwargs) -> dict[str, Any]:
def top(self, **kwargs) -> Dict[str, Any]:
"""Report on running processes in pod.
Keyword Args:
@ -110,8 +104,6 @@ class Pod(PodmanResource):
response = self.client.get(f"/pods/{self.id}/top", params=params)
response.raise_for_status()
if len(response.text) == 0:
return {"Processes": [], "Titles": []}
return response.json()
def unpause(self) -> None:

View File

@ -1,10 +1,7 @@
"""PodmanResource manager subclassed for Networks."""
import builtins
import json
import logging
from typing import Any, Optional, Union
from collections.abc import Iterator
from typing import Any, Dict, List, Optional, Union
from podman import api
from podman.domain.manager import Manager
@ -30,7 +27,7 @@ class PodsManager(Manager):
https://docs.podman.io/en/latest/_static/api.html#operation/CreatePod] for
complete list of keywords.
"""
data = {} if kwargs is None else kwargs.copy()
data = dict() if kwargs is None else kwargs.copy()
data["name"] = name
response = self.client.post("/pods/create", data=json.dumps(data))
@ -45,7 +42,7 @@ class PodsManager(Manager):
return response.ok
# pylint is flagging 'pod_id' here vs. 'key' parameter in super.get()
def get(self, pod_id: str) -> Pod: # pylint: disable=arguments-differ,arguments-renamed
def get(self, pod_id: str) -> Pod: # pylint: disable=arguments-differ
"""Return information for Pod by name or id.
Args:
@ -59,24 +56,24 @@ class PodsManager(Manager):
response.raise_for_status()
return self.prepare_model(attrs=response.json())
def list(self, **kwargs) -> builtins.list[Pod]:
def list(self, **kwargs) -> List[Pod]:
"""Report on pods.
Keyword Args:
filters (Mapping[str, str]): Criteria for listing pods. Available filters:
- ctr-ids (list[str]): list of container ids to filter by.
- ctr-names (list[str]): list of container names to filter by.
- ctr-number (list[int]): list pods with given number of containers.
- ctr-status (list[str]): list pods with containers in given state.
- ctr-ids (List[str]): List of container ids to filter by.
- ctr-names (List[str]): List of container names to filter by.
- ctr-number (List[int]): list pods with given number of containers.
- ctr-status (List[str]): List pods with containers in given state.
Legal values are: "created", "running", "paused", "stopped",
"exited", or "unknown"
- id (str) - List pod with this id.
- name (str) - List pod with this name.
- status (list[str]): List pods in given state. Legal values are:
- status (List[str]): List pods in given state. Legal values are:
"created", "running", "paused", "stopped", "exited", or "unknown"
- label (list[str]): List pods with given labels.
- network (list[str]): List pods associated with given Network Ids (not Names).
- label (List[str]): List pods with given labels.
- network (List[str]): List pods associated with given Network Ids (not Names).
Raises:
APIError: when an error returned by service
@ -86,12 +83,12 @@ class PodsManager(Manager):
response.raise_for_status()
return [self.prepare_model(attrs=i) for i in response.json()]
def prune(self, filters: Optional[dict[str, str]] = None) -> dict[str, Any]:
def prune(self, filters: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
"""Delete unused Pods.
Returns:
Dictionary Keys:
- PodsDeleted (list[str]): List of pod ids deleted.
- PodsDeleted (List[str]): List of pod ids deleted.
- SpaceReclaimed (int): Always zero.
Raises:
@ -100,13 +97,13 @@ class PodsManager(Manager):
response = self.client.post("/pods/prune", params={"filters": api.prepare_filters(filters)})
response.raise_for_status()
deleted: builtins.list[str] = []
deleted: List[str] = list()
for item in response.json():
if item["Err"] is not None:
raise APIError(
item["Err"],
response=response,
explanation=f"""Failed to prune pod '{item["Id"]}'""",
explanation=f"""Failed to prune network '{item["Id"]}'""",
)
deleted.append(item["Id"])
return {"PodsDeleted": deleted, "SpaceReclaimed": 0}
@ -131,16 +128,12 @@ class PodsManager(Manager):
response = self.client.delete(f"/pods/{pod_id}", params={"force": force})
response.raise_for_status()
def stats(
self, **kwargs
) -> Union[builtins.list[dict[str, Any]], Iterator[builtins.list[dict[str, Any]]]]:
def stats(self, **kwargs) -> Dict[str, Any]:
"""Resource usage statistics for the containers in pods.
Keyword Args:
all (bool): Provide statistics for all running pods.
name (Union[str, list[str]]): Pods to include in report.
stream (bool): Stream statistics until cancelled. Default: False.
decode (bool): If True, response will be decoded into dict. Default: False.
name (Union[str, List[str]]): Pods to include in report.
Raises:
NotFound: when pod not found
@ -149,20 +142,10 @@ class PodsManager(Manager):
if "all" in kwargs and "name" in kwargs:
raise ValueError("Keywords 'all' and 'name' are mutually exclusive.")
# Keeping the default for stream as False to not break existing users
# Should probably be changed in a newer major version to match behavior of container.stats
stream = kwargs.get("stream", False)
decode = kwargs.get("decode", False)
params = {
"all": kwargs.get("all"),
"namesOrIDs": kwargs.get("name"),
"stream": stream,
}
response = self.client.get("/pods/stats", params=params, stream=stream)
response = self.client.get("/pods/stats", params=params)
response.raise_for_status()
if stream:
return api.stream_helper(response, decode_to_json=decode)
return json.loads(response.content) if decode else response.content
return response.json()

View File

@ -1,8 +1,6 @@
"""Module for tracking registry metadata."""
import logging
from typing import Any, Optional, Union
from collections.abc import Mapping
from typing import Any, Mapping, Optional, Union
from podman import api
from podman.domain.images import Image
@ -40,7 +38,7 @@ class RegistryData(PodmanResource):
Args:
platform: Platform for which to pull Image. Default: None (all platforms.)
"""
repository, _ = api.parse_repository(self.image_name)
repository = api.parse_repository(self.image_name)
return self.manager.pull(repository, tag=self.id, platform=platform)
def has_platform(self, platform: Union[str, Mapping[str, Any]]) -> bool:

View File

@ -1,8 +1,6 @@
"""Model and Manager for Secrets resources."""
from contextlib import suppress
from typing import Any, Optional, Union
from collections.abc import Mapping
from typing import Any, List, Mapping, Optional, Union
from podman.api import APIClient
from podman.domain.manager import Manager, PodmanResource
@ -62,7 +60,7 @@ class SecretsManager(Manager):
return response.ok
# pylint is flagging 'secret_id' here vs. 'key' parameter in super.get()
def get(self, secret_id: str) -> Secret: # pylint: disable=arguments-differ,arguments-renamed
def get(self, secret_id: str) -> Secret: # pylint: disable=arguments-differ
"""Return information for Secret by name or id.
Args:
@ -74,13 +72,13 @@ class SecretsManager(Manager):
"""
response = self.client.get(f"/secrets/{secret_id}/json")
response.raise_for_status()
return self.prepare_model(attrs=response.json())
return self.prepare_model(attrs=(response.json()))
def list(self, **kwargs) -> list[Secret]:
def list(self, **kwargs) -> List[Secret]:
"""Report on Secrets.
Keyword Args:
filters (dict[str, Any]): Ignored.
filters (Dict[str, Any]): Ignored.
Raises:
APIError: when error returned by service

View File

@ -1,10 +1,8 @@
"""SystemManager to provide system level information from Podman service."""
import logging
from typing import Any, Optional, Union
from typing import Any, Dict, Optional
from podman.api.client import APIClient
from podman import api
logger = logging.getLogger("podman.system")
@ -20,7 +18,7 @@ class SystemManager:
"""
self.client = client
def df(self) -> dict[str, Any]: # pylint: disable=invalid-name
def df(self) -> Dict[str, Any]: # pylint: disable=invalid-name
"""Disk usage by Podman resources.
Returns:
@ -30,25 +28,21 @@ class SystemManager:
response.raise_for_status()
return response.json()
def info(self, *_, **__) -> dict[str, Any]:
def info(self, *_, **__) -> Dict[str, Any]:
"""Returns information on Podman service."""
response = self.client.get("/info")
response.raise_for_status()
return response.json()
def login( # pylint: disable=too-many-arguments,too-many-positional-arguments,unused-argument
def login(
self,
username: str,
password: Optional[str] = None,
email: Optional[str] = None,
registry: Optional[str] = None,
reauth: Optional[bool] = False,
reauth: bool = False,
dockercfg_path: Optional[str] = None,
auth: Optional[str] = None,
identitytoken: Optional[str] = None,
registrytoken: Optional[str] = None,
tls_verify: Optional[Union[bool, str]] = None,
) -> dict[str, Any]:
) -> Dict[str, Any]:
"""Log into Podman service.
Args:
@ -57,41 +51,17 @@ class SystemManager:
email: Registry account email address
registry: URL for registry access. For example,
https://quay.io/v2
reauth: Ignored: If True, refresh existing authentication. Default: False
dockercfg_path: Ignored: Path to custom configuration file.
auth: TODO: Add description based on the source code of Podman.
identitytoken: IdentityToken is used to authenticate the user and
get an access token for the registry.
registrytoken: RegistryToken is a bearer token to be sent to a registry
tls_verify: Whether to verify TLS certificates.
reauth: If True, refresh existing authentication. Default: False
dockercfg_path: Path to custom configuration file.
Default: $HOME/.config/containers/config.json
"""
payload = {
"username": username,
"password": password,
"email": email,
"serveraddress": registry,
"auth": auth,
"identitytoken": identitytoken,
"registrytoken": registrytoken,
}
payload = api.prepare_body(payload)
response = self.client.post(
path="/auth",
headers={"Content-type": "application/json"},
data=payload,
compatible=True,
verify=tls_verify, # Pass tls_verify to the client
)
response.raise_for_status()
return response.json()
def ping(self) -> bool:
"""Returns True if service responded with OK."""
response = self.client.head("/_ping")
return response.ok
def version(self, **kwargs) -> dict[str, Any]:
def version(self, **kwargs) -> Dict[str, Any]:
"""Get version information from service.
Keyword Args:

View File

@ -1,11 +1,11 @@
"""Model and Manager for Volume resources."""
import logging
from typing import Any, Literal, Optional, Union
from typing import Any, Dict, List, Optional, Union
import requests
from podman import api
from podman.api import Literal
from podman.domain.manager import Manager, PodmanResource
from podman.errors import APIError
@ -35,23 +35,6 @@ class Volume(PodmanResource):
"""
self.manager.remove(self.name, force=force)
def inspect(self, **kwargs) -> dict:
"""Inspect this volume
Keyword Args:
tls_verify (bool) - Require TLS verification. Default: True.
Returns:
Display attributes of volume.
Raises:
APIError: when service reports an error
"""
params = {"tlsVerify": kwargs.get("tls_verify", True)}
response = self.client.get(f"/volumes/{self.id}/json", params=params)
response.raise_for_status()
return response.json()
class VolumesManager(Manager):
"""Specialized Manager for Volume resources."""
@ -69,8 +52,8 @@ class VolumesManager(Manager):
Keyword Args:
driver (str): Volume driver to use
driver_opts (dict[str, str]): Options to use with driver
labels (dict[str, str]): Labels to apply to volume
driver_opts (Dict[str, str]): Options to use with driver
labels (Dict[str, str]): Labels to apply to volume
Raises:
APIError: when service reports error
@ -87,14 +70,14 @@ class VolumesManager(Manager):
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
return self.prepare_model(attrs=response.json())
return self.prepare_model(attrs=(response.json()))
def exists(self, key: str) -> bool:
response = self.client.get(f"/volumes/{key}/exists")
return response.ok
# pylint is flagging 'volume_id' here vs. 'key' parameter in super.get()
def get(self, volume_id: str) -> Volume: # pylint: disable=arguments-differ,arguments-renamed
def get(self, volume_id: str) -> Volume: # pylint: disable=arguments-differ
"""Returns and volume by name or id.
Args:
@ -108,14 +91,14 @@ class VolumesManager(Manager):
response.raise_for_status()
return self.prepare_model(attrs=response.json())
def list(self, *_, **kwargs) -> list[Volume]:
def list(self, *_, **kwargs) -> List[Volume]:
"""Report on volumes.
Keyword Args:
filters (dict[str, str]): criteria to filter Volume list
filters (Dict[str, str]): criteria to filter Volume list
- driver (str): filter volumes by their driver
- label (dict[str, str]): filter by label and/or value
- label (Dict[str, str]): filter by label and/or value
- name (str): filter by volume's name
"""
filters = api.prepare_filters(kwargs.get("filters"))
@ -128,9 +111,8 @@ class VolumesManager(Manager):
return [self.prepare_model(i) for i in response.json()]
def prune(
self,
filters: Optional[dict[str, str]] = None, # pylint: disable=unused-argument
) -> dict[Literal["VolumesDeleted", "SpaceReclaimed"], Any]:
self, filters: Optional[Dict[str, str]] = None # pylint: disable=unused-argument
) -> Dict[Literal["VolumesDeleted", "SpaceReclaimed"], Any]:
"""Delete unused volumes.
Args:
@ -143,7 +125,7 @@ class VolumesManager(Manager):
data = response.json()
response.raise_for_status()
volumes: list[str] = []
volumes: List[str] = list()
space_reclaimed = 0
for item in data:
if "Err" in item:

View File

@ -6,7 +6,6 @@ is not supported. PodmanClient related errors take precedence over APIConnection
ApiConnection and associated classes have been deprecated.
"""
import warnings
from http.client import HTTPException
@ -21,7 +20,6 @@ __all__ = [
'NotFound',
'NotFoundError',
'PodmanError',
'StreamParseError',
]
try:
@ -33,7 +31,6 @@ try:
InvalidArgument,
NotFound,
PodmanError,
StreamParseError,
)
except ImportError:
pass
@ -48,9 +45,7 @@ class NotFoundError(HTTPException):
def __init__(self, message, response=None):
super().__init__(message)
self.response = response
warnings.warn(
"APIConnection() and supporting classes.", PendingDeprecationWarning, stacklevel=2
)
warnings.warn("APIConnection() and supporting classes.", PendingDeprecationWarning)
# If found, use new ImageNotFound otherwise old class
@ -58,7 +53,7 @@ try:
from .exceptions import ImageNotFound
except ImportError:
class ImageNotFound(NotFoundError): # type: ignore[no-redef]
class ImageNotFound(NotFoundError):
"""HTTP request returned a http.HTTPStatus.NOT_FOUND.
Specialized for Image not found. Deprecated.
@ -102,9 +97,7 @@ class RequestError(HTTPException):
def __init__(self, message, response=None):
super().__init__(message)
self.response = response
warnings.warn(
"APIConnection() and supporting classes.", PendingDeprecationWarning, stacklevel=2
)
warnings.warn("APIConnection() and supporting classes.", PendingDeprecationWarning)
class InternalServerError(HTTPException):
@ -116,6 +109,4 @@ class InternalServerError(HTTPException):
def __init__(self, message, response=None):
super().__init__(message)
self.response = response
warnings.warn(
"APIConnection() and supporting classes.", PendingDeprecationWarning, stacklevel=2
)
warnings.warn("APIConnection() and supporting classes.", PendingDeprecationWarning)

View File

@ -1,13 +1,12 @@
"""Podman API Errors."""
from typing import Optional, Union, TYPE_CHECKING
from collections.abc import Iterable
import typing
from typing import Iterable, List, Optional, Union
from requests import Response
from requests.exceptions import HTTPError
# Break circular import
if TYPE_CHECKING:
if typing.TYPE_CHECKING:
from podman.domain.containers import Container
from podman.api.client import APIResponse
@ -113,10 +112,10 @@ class ContainerError(PodmanError):
self,
container: "Container",
exit_status: int,
command: Union[str, list[str]],
command: Union[str, List[str]],
image: str,
stderr: Optional[Iterable[str]] = None,
): # pylint: disable=too-many-positional-arguments
):
"""Initialize ContainerError.
Args:
@ -143,8 +142,3 @@ class ContainerError(PodmanError):
class InvalidArgument(PodmanError):
"""Parameter to method/function was not valid."""
class StreamParseError(RuntimeError):
def __init__(self, reason):
self.msg = reason

77
podman/images/__init__.py Normal file
View File

@ -0,0 +1,77 @@
"""images provides the operations against images for a Podman service."""
import json
from http import HTTPStatus
import podman.errors as errors
def list_images(api):
"""List all images for a Podman service."""
response = api.get("/images/json")
return json.loads(str(response.read(), "utf-8"))
def inspect(api, name):
"""Report on named image for a Podman service.
Name may also be an image ID.
"""
try:
response = api.get("/images/{}/json".format(api.quote(name)))
return json.loads(str(response.read(), "utf-8"))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response)
def image_exists(api, name):
"""Checks if an image exists in the local store"""
try:
api.get("/images/{}/exists".format(api.quote(name)))
return True
except errors.NotFoundError:
return False
def remove(api, name, force=None):
"""Remove named/identified image from Podman storage."""
params = {}
path = "/images/{}".format(api.quote(name))
if force is not None:
params = {"force": force}
try:
response = api.delete(path, params)
return json.loads(str(response.read(), "utf-8"))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response)
def tag_image(api, name, repo, tag):
"""create an image tag using repo and tag
:param repo: string for the image repo
:param tag: string for the image tag
:return boolean
"""
data = {"repo": repo, "tag": tag}
try:
response = api.post("/images/{}/tag".format(api.quote(name)), data)
return response.status == HTTPStatus.CREATED
except errors.NotFoundError as e:
api.raise_image_not_found(e, e.response)
def history(api, name):
"""get image history"""
try:
response = api.get("/images/{}/history".format(api.quote(name)))
return json.loads(str(response.read(), "utf-8"))
except errors.NotFoundError as e:
api.raise_image_not_found(e, e.response)
__all__ = [
"list_images",
"inspect",
"image_exists",
"remove",
"tag_image",
]

View File

@ -0,0 +1,87 @@
# Copyright 2020 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""manifests provides the manifest operations for a Podman service"""
import json
from http import HTTPStatus
import podman.errors as errors
def add(api, name, manifest):
"""Add image to a manifest list"""
path = '/manifests/{}/add'.format(api.quote(name))
try:
response = api.post(path, params=manifest, headers={'content-type': 'application/json'})
return response.status == HTTPStatus.OK
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ManifestNotFound)
def create(api, name, image=None, all_contents=None):
"""create a manifest"""
params = {'name': name}
if image:
params['image'] = image
if all_contents:
params['all'] = all_contents
path = '/manifests/create'
try:
response = api.post(path, params=params)
return response.status == HTTPStatus.OK
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ManifestNotFound)
def inspect(api, name):
"""inspect a manifest"""
try:
response = api.get('/manifests/{}/json'.format(api.quote(name)))
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ManifestNotFound)
def push(api, name, destination, all_images=None):
"""push a manifest"""
params = {'destination': destination}
if all_images:
params['all'] = all_images
try:
response = api.post('/manifests/{}/push'.format(api.quote(name)), params=params)
return response.status == HTTPStatus.OK
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ManifestNotFound)
def remove(api, name, digest=None):
"""Remove manifest digest."""
params = {}
if digest:
params['digest'] = digest
path = '/manifests/{}'.format(api.quote(name))
try:
response = api.delete(path, params)
return response.status == HTTPStatus.OK
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.ManifestNotFound)
__all__ = [
"add",
"create",
"inspect",
"push",
"remove",
]

View File

@ -0,0 +1,76 @@
# Copyright 2020 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""network provides the network operations for a Podman service"""
import json
import podman.errors as errors
def create(api, name, network):
"""create a network"""
if not isinstance(network, str):
data = json.dumps(network)
else:
data = network
path = '/networks/create?name={}'.format(api.quote(name))
response = api.post(path, params=data, headers={'content-type': 'application/json'})
return json.loads(str(response.read(), 'utf-8'))
def inspect(api, name):
"""inspect a network"""
try:
response = api.get('/networks/{}/json'.format(api.quote(name)))
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.NetworkNotFound)
def list_networks(api, filters=None):
"""list networks using filters"""
filters_param = {}
if filters:
filters_param = {'filter': filters}
response = api.get('/networks/json', filters_param)
return json.loads(str(response.read(), 'utf-8'))
def remove(api, name, force=None):
"""remove a named network"""
params = {}
path = '/networks/{}'.format(api.quote(name))
if force is not None:
params = {'force': force}
try:
response = api.delete(path, params)
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.NetworkNotFound)
def prune(api):
"""prune unused networks"""
path = '/networks/prune'
response = api.post(path, headers={'content-type': 'application/json'})
return json.loads(str(response.read(), 'utf-8'))
__all__ = [
"create",
"inspect",
"list_networks",
"remove",
"prune",
]

176
podman/pods/__init__.py Normal file
View File

@ -0,0 +1,176 @@
# Copyright 2020 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""pod provides the pod operations for a Podman service"""
import json
import podman.errors as errors
def create(api, name, pod):
"""create a pod"""
if not isinstance(pod, str):
data = json.dumps(pod)
else:
data = pod
path = '/pods/create?name={}'.format(api.quote(name))
response = api.post(path, params=data, headers={'content-type': 'application/json'})
return json.loads(str(response.read(), 'utf-8'))
def exists(api, name):
"""inspect a pod"""
try:
api.get('/pods/{}/exists'.format(api.quote(name)))
return True
except errors.NotFoundError:
return False
def inspect(api, name):
"""inspect a pod"""
try:
response = api.get('/pods/{}/json'.format(api.quote(name)))
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.PodNotFound)
def kill(api, name, signal=None):
"""kill a pod"""
data = {}
if signal:
data['signal'] = signal
try:
response = api.post('/pods/{}/kill'.format(api.quote(name)), data)
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.PodNotFound)
def list_pods(api, filters=None):
"""list pod using filter"""
filters_param = {}
if filters:
filters_param = {'filter': filters}
response = api.get('/pods/json', filters_param)
return json.loads(str(response.read(), 'utf-8'))
def list_processes(api, name, stream=None, ps_args=None):
"""list processes from a pod"""
params = {}
# TODO(mwhahaha): test stream
if stream:
params['stream'] = stream
if ps_args:
params['ps_args'] = ps_args
response = api.get('/pods/{}/top'.format(api.quote(name)), params)
return json.loads(str(response.read(), 'utf-8'))
def pause(api, name):
"""pause a pod"""
try:
response = api.post('/pods/{}/pause'.format(api.quote(name)))
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.PodNotFound)
def prune(api):
"""prune pods"""
response = api.get('/pods/prune')
return json.loads(str(response.read(), 'utf-8'))
def remove(api, name, force=None):
"""Remove named/identified image from Podman storage."""
params = {}
path = '/pods/{}'.format(api.quote(name))
if force is not None:
params = {'force': force}
try:
response = api.delete(path, params)
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.PodNotFound)
def restart(api, name):
"""restart a pod"""
try:
response = api.post('/pods/{}/restart'.format(api.quote(name)))
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.PodNotFound)
def start(api, name):
"""start a pod"""
try:
response = api.post('/pods/{}/start'.format(api.quote(name)))
return json.loads(str(response.read(), 'utf-8'))
# TODO(mwhahaha): handle 304 warning
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.PodNotFound)
def stats(api, all_pods=True, pods=None):
"""get pods stats"""
params = {'all': all_pods}
if pods:
params['namesOrIDs'] = pods
try:
response = api.post('/pods/stats', params)
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.PodNotFound)
def stop(api, name):
"""stop a pod"""
try:
response = api.post('/pods/{}/stop'.format(api.quote(name)))
return json.loads(str(response.read(), 'utf-8'))
# TODO(mwhahaha): handle 304 warning
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.PodNotFound)
def unpause(api, name):
"""unpause a pod"""
try:
response = api.post('/pods/{}/unpause'.format(api.quote(name)))
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response, errors.PodNotFound)
__all__ = [
"create",
"exists",
"inspect",
"kill",
"list_pods",
"list_processes",
"pause",
"prune",
"remove",
"restart",
"stats",
"start",
"stop",
"unpause",
]

64
podman/system/__init__.py Normal file
View File

@ -0,0 +1,64 @@
"""Provide system level information for the Podman service."""
import json
import logging
from http import HTTPStatus
import podman.errors as errors
def version(api, verify_version=False):
"""Obtain a dictionary of versions for the Podman components."""
versions = {}
response = api.get("/version")
if response.status == HTTPStatus.OK:
versions = json.loads(str(response.read(), 'utf-8'))
# pylint: disable=fixme
# TODO: verify api.base and header[Api-Version] compatible
if verify_version:
pass
return versions
def get_info(api):
"""Returns information on the system and libpod configuration"""
try:
response = api.get("/info")
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response)
# this **looks** a lot like the above but is not equivalent - at all !
# the difference lies with calling api.join()
# and the output have nothing to do with one another
# xxx the naming is going to be confusing
def info(api):
"""Returns information on the system and libpod configuration"""
response = api.get("/info")
return json.loads(response.read())
def show_disk_usage(api):
"""
Return information about disk usage for containers,
images and volumes
"""
try:
response = api.get("/system/df")
return json.loads(str(response.read(), 'utf-8'))
except errors.NotFoundError as e:
api.raise_not_found(e, e.response)
def _report_not_found(e, response):
body = json.loads(response.read())
logging.info(body["cause"])
raise errors.ImageNotFound(body["message"]) from e
__all__ = [
"version",
"get_info",
"info",
"show_disk_usage",
]

View File

@ -7,3 +7,4 @@
## Coverage Reporting Framework
`coverage.py` see https://coverage.readthedocs.io/en/coverage-5.0.3/#quick-start

View File

@ -3,5 +3,5 @@
# Do not auto-update these from version.py,
# as test code should be changed to reflect changes in Podman API versions
BASE_SOCK = "unix:///run/api.sock"
LIBPOD_URL = "http://%2Frun%2Fapi.sock/v5.6.0/libpod"
LIBPOD_URL = "http://%2Frun%2Fapi.sock/v3.1.2/libpod"
COMPATIBLE_URL = "http://%2Frun%2Fapi.sock/v1.40"

View File

@ -1,21 +0,0 @@
import pytest
def pytest_addoption(parser):
parser.addoption(
"--pnext", action="store_true", default=False, help="run tests against podman_next copr"
)
def pytest_configure(config):
config.addinivalue_line("markers", "pnext: mark test as run against podman_next")
def pytest_collection_modifyitems(config, items):
if config.getoption("--pnext"):
# --pnext given in cli: run tests marked as pnext
return
podman_next = pytest.mark.skip(reason="need --pnext option to run")
for item in items:
if "pnext" in item.keywords:
item.add_marker(podman_next)

View File

@ -13,15 +13,12 @@
# under the License.
#
"""Base integration test code"""
import logging
import os
import shutil
import uuid
import os
import fixtures
from podman.tests.integration import utils
import podman.tests.integration.utils as utils
class IntegrationTest(fixtures.TestWithFixtures):
@ -38,22 +35,24 @@ class IntegrationTest(fixtures.TestWithFixtures):
@classmethod
def setUpClass(cls) -> None:
super(fixtures.TestWithFixtures, cls).setUpClass()
command = os.environ.get("PODMAN_BINARY", "podman")
if shutil.which(command) is None:
raise AssertionError(f"'{command}' not found.")
IntegrationTest.podman = command
# This log_level is for our python code
log_level = os.environ.get("PODMAN_LOG_LEVEL", "INFO")
log_level = logging.getLevelName(log_level)
logging.basicConfig(level=log_level)
# For testing, lock in logging configuration
if "DEBUG" in os.environ:
logging.basicConfig(level=logging.DEBUG)
else:
logging.basicConfig(level=logging.INFO)
def setUp(self):
super().setUp()
self.log_level = os.environ.get("PODMAN_LOG_LEVEL", "INFO")
# This is the log_level to pass to podman service
self.log_level = logging.WARNING
if "DEBUG" in os.environ:
self.log_level = logging.DEBUG
self.test_dir = self.useFixture(fixtures.TempDir()).path
self.socket_file = os.path.join(self.test_dir, uuid.uuid4().hex)

View File

@ -1,4 +1,5 @@
import getpass
import os
import unittest
import time
@ -39,10 +40,10 @@ class AdapterIntegrationTest(base.IntegrationTest):
podman.start(check_socket=False)
time.sleep(0.5)
with PodmanClient(base_url="tcp:localhost:8889") as client:
with PodmanClient(base_url=f"tcp:localhost:8889") as client:
self.assertTrue(client.ping())
with PodmanClient(base_url="http://localhost:8889") as client:
with PodmanClient(base_url=f"http://localhost:8889") as client:
self.assertTrue(client.ping())
finally:
podman.stop()

View File

@ -1,493 +0,0 @@
import unittest
import re
import os
import pytest
import podman.tests.integration.base as base
from podman import PodmanClient
from podman.tests.utils import PODMAN_VERSION
# @unittest.skipIf(os.geteuid() != 0, 'Skipping, not running as root')
class ContainersIntegrationTest(base.IntegrationTest):
"""Containers Integration tests."""
def setUp(self):
super().setUp()
self.client = PodmanClient(base_url=self.socket_uri)
self.addCleanup(self.client.close)
self.alpine_image = self.client.images.pull("quay.io/libpod/alpine", tag="latest")
self.containers = []
def tearDown(self):
for container in self.containers:
container.remove(force=True)
def test_container_named_volume_mount(self):
with self.subTest("Check volume mount"):
volumes = {
'test_bind_1': {'bind': '/mnt/vol1', 'mode': 'rw'},
'test_bind_2': {'bind': '/mnt/vol2', 'extended_mode': ['ro', 'noexec']},
'test_bind_3': {'bind': '/mnt/vol3', 'extended_mode': ['noexec'], 'mode': 'rw'},
}
container = self.client.containers.create(self.alpine_image, volumes=volumes)
container_mounts = container.attrs.get('Mounts', {})
self.assertEqual(len(container_mounts), len(volumes))
for mount in container_mounts:
name = mount.get('Name')
self.assertIn(name, volumes)
test_mount = volumes.get(name)
test_mode = test_mount.get('mode', '')
test_extended_mode = test_mount.get('extended_mode', [])
# check RO/RW
if 'ro' in test_mode or 'ro' in test_extended_mode:
self.assertEqual(mount.get('RW'), False)
if 'rw' in test_mode or 'rw' in test_extended_mode:
self.assertEqual(mount.get('RW'), True)
other_options = [o for o in test_extended_mode if o not in ['ro', 'rw']]
for o in other_options:
self.assertIn(o, mount.get('Options'))
def test_container_directory_volume_mount(self):
"""Test that directories can be mounted with the ``volume`` parameter."""
with self.subTest("Check bind mount"):
volumes = {
"/etc/hosts": dict(bind="/test_ro", mode='ro'),
"/etc/hosts": dict(bind="/test_rw", mode='rw'), # noqa: F601
}
container = self.client.containers.create(
self.alpine_image, command=["cat", "/test_ro", "/test_rw"], volumes=volumes
)
container_mounts = container.attrs.get('Mounts', {})
self.assertEqual(len(container_mounts), len(volumes))
self.containers.append(container)
for directory, mount_spec in volumes.items():
self.assertIn(
f"{directory}:{mount_spec['bind']}:{mount_spec['mode']},rprivate,rbind",
container.attrs.get('HostConfig', {}).get('Binds', list()),
)
# check if container can be started and exits with EC == 0
container.start()
container.wait()
self.assertEqual(container.attrs.get('State', dict()).get('ExitCode', 256), 0)
def test_container_extra_hosts(self):
"""Test Container Extra hosts"""
extra_hosts = {"host1 host3": "127.0.0.2", "host2": "127.0.0.3"}
with self.subTest("Check extra hosts in container object"):
proper_container = self.client.containers.create(
self.alpine_image, command=["cat", "/etc/hosts"], extra_hosts=extra_hosts
)
self.containers.append(proper_container)
formatted_hosts = [f"{hosts}:{ip}" for hosts, ip in extra_hosts.items()]
self.assertEqual(
proper_container.attrs.get('HostConfig', dict()).get('ExtraHosts', list()),
formatted_hosts,
)
with self.subTest("Check extra hosts in running container"):
proper_container.start()
proper_container.wait()
logs = b"\n".join(proper_container.logs()).decode()
formatted_hosts = [f"{ip}\t{hosts}" for hosts, ip in extra_hosts.items()]
for hosts_entry in formatted_hosts:
self.assertIn(hosts_entry, logs)
def test_container_environment_variables(self):
"""Test environment variables passed to the container."""
with self.subTest("Check environment variables as dictionary"):
env_dict = {"MY_VAR": "123", "ANOTHER_VAR": "456"}
container = self.client.containers.create(
self.alpine_image, command=["env"], environment=env_dict
)
self.containers.append(container)
container_env = container.attrs.get('Config', {}).get('Env', [])
for key, value in env_dict.items():
self.assertIn(f"{key}={value}", container_env)
container.start()
container.wait()
logs = b"\n".join(container.logs()).decode()
for key, value in env_dict.items():
self.assertIn(f"{key}={value}", logs)
with self.subTest("Check environment variables as list"):
env_list = ["MY_VAR=123", "ANOTHER_VAR=456"]
container = self.client.containers.create(
self.alpine_image, command=["env"], environment=env_list
)
self.containers.append(container)
container_env = container.attrs.get('Config', {}).get('Env', [])
for env in env_list:
self.assertIn(env, container_env)
container.start()
container.wait()
logs = b"\n".join(container.logs()).decode()
for env in env_list:
self.assertIn(env, logs)
def _test_memory_limit(self, parameter_name, host_config_name, set_mem_limit=False):
"""Base for tests which checks memory limits"""
memory_limit_tests = [
{'value': 1000, 'expected_value': 1000},
{'value': '1000', 'expected_value': 1000},
{'value': '1234b', 'expected_value': 1234},
{'value': '123k', 'expected_value': 123 * 1024},
{'value': '44m', 'expected_value': 44 * 1024 * 1024},
{'value': '2g', 'expected_value': 2 * 1024 * 1024 * 1024},
]
for test in memory_limit_tests:
parameters = {parameter_name: test['value']}
if set_mem_limit:
parameters['mem_limit'] = test['expected_value'] - 100
container = self.client.containers.create(self.alpine_image, **parameters)
self.containers.append(container)
self.assertEqual(
container.attrs.get('HostConfig', dict()).get(host_config_name),
test['expected_value'],
)
def test_container_ports(self):
"""Test ports binding"""
port_tests = [
{
'input': {'97/tcp': '43'},
'expected_output': {'97/tcp': [{'HostIp': '', 'HostPort': '43'}]},
},
{
'input': {'2/udp': ('127.0.0.1', '939')},
'expected_output': {'2/udp': [{'HostIp': '127.0.0.1', 'HostPort': '939'}]},
},
{
'input': {
'11123/tcp': [('127.0.0.1', '11123'), ('127.0.0.1', '112'), '1123', '159']
},
'expected_output': {
'11123/tcp': [
{'HostIp': '127.0.0.1', 'HostPort': '11123'},
{'HostIp': '', 'HostPort': '112'},
{'HostIp': '', 'HostPort': '1123'},
{'HostIp': '', 'HostPort': '159'},
]
},
},
{
'input': {'1111/tcp': {"port": ('127.0.0.1', 1111), "range": 3}},
'expected_output': {
'1111/tcp': [{'HostIp': '127.0.0.1', 'HostPort': '1111'}],
'1112/tcp': [{'HostIp': '127.0.0.1', 'HostPort': '1112'}],
'1113/tcp': [{'HostIp': '127.0.0.1', 'HostPort': '1113'}],
},
},
{
'input': {
'1222/tcp': [{"port": 1234, "range": 2}, {"ip": "127.0.0.1", "port": 4567}]
},
'expected_output': {
'1222/tcp': [
{'HostIp': '', 'HostPort': '1234'},
{'HostIp': '127.0.0.1', 'HostPort': '4567'},
],
'1223/tcp': [{'HostIp': '', 'HostPort': '1235'}],
},
},
{
'input': {
2244: 3344,
},
'expected_output': {
'2244/tcp': [
{'HostIp': '', 'HostPort': '3344'},
],
},
},
]
for port_test in port_tests:
container = self.client.containers.create(self.alpine_image, ports=port_test['input'])
self.containers.append(container)
self.assertTrue(
all(
[
x in port_test['expected_output']
for x in container.attrs.get('HostConfig', {}).get('PortBindings')
]
)
)
def test_container_dns_option(self):
expected_dns_opt = ['edns0']
container = self.client.containers.create(
self.alpine_image, command=["cat", "/etc/resolv.conf"], dns_opt=expected_dns_opt
)
self.containers.append(container)
with self.subTest("Check HostConfig"):
self.assertEqual(
container.attrs.get('HostConfig', {}).get('DnsOptions'), expected_dns_opt
)
with self.subTest("Check content of /etc/resolv.conf"):
container.start()
container.wait()
self.assertTrue(
all([opt in b"\n".join(container.logs()).decode() for opt in expected_dns_opt])
)
def test_container_healthchecks(self):
"""Test passing various healthcheck options"""
parameters = {
'healthcheck': {'Test': ['CMD-SHELL curl http://localhost || exit']},
'health_check_on_failure_action': 1,
}
container = self.client.containers.create(self.alpine_image, **parameters)
self.containers.append(container)
def test_container_mem_limit(self):
"""Test passing memory limit"""
self._test_memory_limit('mem_limit', 'Memory')
def test_container_memswap_limit(self):
"""Test passing memory swap limit"""
self._test_memory_limit('memswap_limit', 'MemorySwap', set_mem_limit=True)
def test_container_mem_reservation(self):
"""Test passing memory reservation"""
self._test_memory_limit('mem_reservation', 'MemoryReservation')
def test_container_shm_size(self):
"""Test passing shared memory size"""
self._test_memory_limit('shm_size', 'ShmSize')
@pytest.mark.skipif(os.geteuid() != 0, reason='Skipping, not running as root')
@pytest.mark.skipif(
PODMAN_VERSION >= (5, 6, 0),
reason="Test against this feature in Podman 5.6.0 or greater https://github.com/containers/podman/pull/25942",
)
def test_container_mounts(self):
"""Test passing mounts"""
with self.subTest("Check bind mount"):
mount = {
"type": "bind",
"source": "/etc/hosts",
"target": "/test",
"read_only": True,
"relabel": "Z",
}
container = self.client.containers.create(
self.alpine_image, command=["cat", "/test"], mounts=[mount]
)
self.containers.append(container)
self.assertIn(
f"{mount['source']}:{mount['target']}:ro,Z,rprivate,rbind",
container.attrs.get('HostConfig', {}).get('Binds', list()),
)
# check if container can be started and exits with EC == 0
container.start()
container.wait()
self.assertEqual(container.attrs.get('State', dict()).get('ExitCode', 256), 0)
with self.subTest("Check tmpfs mount"):
mount = {"type": "tmpfs", "source": "tmpfs", "target": "/test", "size": "456k"}
container = self.client.containers.create(
self.alpine_image, command=["df", "-h"], mounts=[mount]
)
self.containers.append(container)
self.assertEqual(
container.attrs.get('HostConfig', {}).get('Tmpfs', {}).get(mount['target']),
f"size={mount['size']},rw,rprivate,nosuid,nodev,tmpcopyup",
)
container.start()
container.wait()
logs = b"\n".join(container.logs()).decode()
self.assertTrue(
re.search(
rf"{mount['size'].replace('k', '.0K')}.*?{mount['target']}",
logs,
flags=re.MULTILINE,
)
)
with self.subTest("Check uppercase mount option attributes"):
mount = {
"TypE": "bind",
"SouRce": "/etc/hosts",
"TarGet": "/test",
"Read_Only": True,
"ReLabel": "Z",
}
container = self.client.containers.create(
self.alpine_image, command=["cat", "/test"], mounts=[mount]
)
self.containers.append(container)
self.assertIn(
f"{mount['SouRce']}:{mount['TarGet']}:ro,Z,rprivate,rbind",
container.attrs.get('HostConfig', {}).get('Binds', list()),
)
# check if container can be started and exits with EC == 0
container.start()
container.wait()
self.assertEqual(container.attrs.get('State', dict()).get('ExitCode', 256), 0)
@pytest.mark.skipif(os.geteuid() != 0, reason='Skipping, not running as root')
@pytest.mark.skipif(
PODMAN_VERSION < (5, 6, 0),
reason="Test against this feature before Podman 5.6.0 https://github.com/containers/podman/pull/25942",
)
def test_container_mounts_without_rw_as_default(self):
"""Test passing mounts"""
with self.subTest("Check bind mount"):
mount = {
"type": "bind",
"source": "/etc/hosts",
"target": "/test",
"read_only": True,
"relabel": "Z",
}
container = self.client.containers.create(
self.alpine_image, command=["cat", "/test"], mounts=[mount]
)
self.containers.append(container)
self.assertIn(
f"{mount['source']}:{mount['target']}:ro,Z,rprivate,rbind",
container.attrs.get('HostConfig', {}).get('Binds', list()),
)
# check if container can be started and exits with EC == 0
container.start()
container.wait()
self.assertEqual(container.attrs.get('State', dict()).get('ExitCode', 256), 0)
with self.subTest("Check tmpfs mount"):
mount = {"type": "tmpfs", "source": "tmpfs", "target": "/test", "size": "456k"}
container = self.client.containers.create(
self.alpine_image, command=["df", "-h"], mounts=[mount]
)
self.containers.append(container)
self.assertEqual(
container.attrs.get('HostConfig', {}).get('Tmpfs', {}).get(mount['target']),
f"size={mount['size']},rprivate,nosuid,nodev,tmpcopyup",
)
def test_container_devices(self):
devices = ["/dev/null:/dev/foo", "/dev/zero:/dev/bar"]
container = self.client.containers.create(
self.alpine_image, devices=devices, command=["ls", "-l", "/dev/"]
)
self.containers.append(container)
container_devices = container.attrs.get('HostConfig', {}).get('Devices', [])
with self.subTest("Check devices in container object"):
for device in devices:
path_on_host, path_in_container = device.split(':', 1)
self.assertTrue(
any(
[
c.get('PathOnHost') == path_on_host
and c.get('PathInContainer') == path_in_container
for c in container_devices
]
)
)
with self.subTest("Check devices in running container object"):
container.start()
container.wait()
logs = b"\n".join(container.logs()).decode()
device_regex = r'(\d+, *?\d+).*?{}\n'
for device in devices:
# check whether device exists
source_device, destination_device = device.split(':', 1)
source_match = re.search(
device_regex.format(source_device.rsplit("/", 1)[-1]), logs
)
destination_match = re.search(
device_regex.format(destination_device.rsplit("/", 1)[-1]), logs
)
self.assertIsNotNone(source_match)
self.assertIsNotNone(destination_match)
# validate if proper device was added (by major/minor numbers)
self.assertEqual(source_match.group(1), destination_match.group(1))
def test_read_write_tmpfs(self):
test_cases = [
{"read_write_tmpfs": True, "failed_container": False},
{
"read_write_tmpfs": False,
"failed_container": True,
"expected_output": "Read-only file system",
},
{
"read_write_tmpfs": None,
"failed_container": True,
"expected_output": "Read-only file system",
},
]
for test in test_cases:
read_write_tmpfs = test.get('read_write_tmpfs')
with self.subTest(f"Check read_write_tmpfs set to {read_write_tmpfs}"):
kwargs = (
{"read_write_tmpfs": read_write_tmpfs} if read_write_tmpfs is not None else {}
)
container = self.client.containers.create(
self.alpine_image,
read_only=True,
command=["/bin/touch", "/tmp/test_file"],
**kwargs,
)
self.containers.append(container)
container.start()
container.wait()
inspect = container.inspect()
logs = b"\n".join(container.logs(stderr=True)).decode()
if test.get("failed_container") is True:
self.assertNotEqual(inspect.get("State", {}).get("ExitCode", -1), 0)
else:
self.assertEqual(inspect.get("State", {}).get("ExitCode", -1), 0)
expected_output = test.get("expected_output")
if expected_output:
print(inspect)
self.assertIn(expected_output, logs)
if __name__ == '__main__':
unittest.main()

View File

@ -1,122 +0,0 @@
import podman.tests.integration.base as base
from podman import PodmanClient
# @unittest.skipIf(os.geteuid() != 0, 'Skipping, not running as root')
class ContainersExecIntegrationTests(base.IntegrationTest):
"""Containers integration tests for exec"""
def setUp(self):
super().setUp()
self.client = PodmanClient(base_url=self.socket_uri)
self.addCleanup(self.client.close)
self.alpine_image = self.client.images.pull("quay.io/libpod/alpine", tag="latest")
self.containers = []
def tearDown(self):
for container in self.containers:
container.remove(force=True)
def test_container_exec_run(self):
"""Test any command that will return code 0 and no output"""
container = self.client.containers.create(self.alpine_image, command=["top"], detach=True)
container.start()
error_code, stdout = container.exec_run("echo hello")
self.assertEqual(error_code, 0)
self.assertEqual(stdout, b'\x01\x00\x00\x00\x00\x00\x00\x06hello\n')
def test_container_exec_run_errorcode(self):
"""Test a failing command with stdout and stderr in a single bytestring"""
container = self.client.containers.create(self.alpine_image, command=["top"], detach=True)
container.start()
error_code, output = container.exec_run("ls nonexistent")
self.assertEqual(error_code, 1)
self.assertEqual(
output, b"\x02\x00\x00\x00\x00\x00\x00+ls: nonexistent: No such file or directory\n"
)
def test_container_exec_run_demux(self):
"""Test a failing command with stdout and stderr in a bytestring tuple"""
container = self.client.containers.create(self.alpine_image, command=["top"], detach=True)
container.start()
error_code, output = container.exec_run("ls nonexistent", demux=True)
self.assertEqual(error_code, 1)
self.assertEqual(output[0], None)
self.assertEqual(output[1], b"ls: nonexistent: No such file or directory\n")
def test_container_exec_run_stream(self):
"""Test streaming the output from a long running command."""
container = self.client.containers.create(self.alpine_image, command=["top"], detach=True)
container.start()
command = [
'/bin/sh',
'-c',
'echo 0 ; sleep .1 ; echo 1 ; sleep .1 ; echo 2 ; sleep .1 ;',
]
error_code, output = container.exec_run(command, stream=True)
self.assertEqual(error_code, None)
self.assertEqual(
list(output),
[
b'0\n',
b'1\n',
b'2\n',
],
)
def test_container_exec_run_stream_demux(self):
"""Test streaming the output from a long running command with demux enabled."""
container = self.client.containers.create(self.alpine_image, command=["top"], detach=True)
container.start()
command = [
'/bin/sh',
'-c',
'echo 0 ; >&2 echo 1 ; sleep .1 ; '
+ 'echo 2 ; >&2 echo 3 ; sleep .1 ; '
+ 'echo 4 ; >&2 echo 5 ; sleep .1 ;',
]
error_code, output = container.exec_run(command, stream=True, demux=True)
self.assertEqual(error_code, None)
self.assertEqual(
list(output),
[
(b'0\n', None),
(None, b'1\n'),
(b'2\n', None),
(None, b'3\n'),
(b'4\n', None),
(None, b'5\n'),
],
)
def test_container_exec_run_stream_detach(self):
"""Test streaming the output from a long running command with detach enabled."""
container = self.client.containers.create(self.alpine_image, command=["top"], detach=True)
container.start()
command = [
'/bin/sh',
'-c',
'echo 0 ; sleep .1 ; echo 1 ; sleep .1 ; echo 2 ; sleep .1 ;',
]
error_code, output = container.exec_run(command, stream=True, detach=True)
# Detach should make the ``exec_run`` ignore the ``stream`` flag so we will
# assert against the standard, non-streaming behavior.
self.assertEqual(error_code, 0)
# The endpoint should return immediately, before we are able to actually
# get any of the output.
self.assertEqual(
output,
b'\n',
)

View File

@ -1,15 +1,8 @@
import io
import random
import tarfile
import tempfile
import unittest
try:
# Python >= 3.10
from collections.abc import Iterator
except ImportError:
# Python < 3.10
from collections.abc import Iterator
from collections import Iterator
import podman.tests.integration.base as base
from podman import PodmanClient
@ -17,6 +10,7 @@ from podman.domain.containers import Container
from podman.domain.images import Image
from podman.errors import NotFound
# @unittest.skipIf(os.geteuid() != 0, 'Skipping, not running as root')
@ -42,9 +36,7 @@ class ContainersIntegrationTest(base.IntegrationTest):
with self.subTest("Create from Alpine Image"):
container = self.client.containers.create(
self.alpine_image,
command=["echo", random_string],
ports={'2222/tcp': 3333, 2244: 3344},
self.alpine_image, command=["echo", random_string]
)
self.assertIsInstance(container, Container)
self.assertGreater(len(container.attrs), 0)
@ -60,15 +52,6 @@ class ContainersIntegrationTest(base.IntegrationTest):
self.assertIsInstance(actual, Container)
self.assertEqual(actual.id, container.id)
self.assertIn("2222/tcp", container.attrs["NetworkSettings"]["Ports"])
self.assertEqual(
"3333", container.attrs["NetworkSettings"]["Ports"]["2222/tcp"][0]["HostPort"]
)
self.assertIn("2244/tcp", container.attrs["NetworkSettings"]["Ports"])
self.assertEqual(
"3344", container.attrs["NetworkSettings"]["Ports"]["2244/tcp"][0]["HostPort"]
)
file_contents = b"This is an integration test for archive."
file_buffer = io.BytesIO(file_contents)
@ -111,7 +94,7 @@ class ContainersIntegrationTest(base.IntegrationTest):
self.assertIsInstance(logs_iter, Iterator)
logs = list(logs_iter)
self.assertIn((random_string + "\n").encode("utf-8"), logs)
self.assertIn(random_string.encode("utf-8"), logs)
with self.subTest("Delete Container"):
container.remove()
@ -142,133 +125,16 @@ class ContainersIntegrationTest(base.IntegrationTest):
top_ctnr.reload()
self.assertIn(top_ctnr.status, ("exited", "stopped"))
with self.subTest("Create-Init-Start Container"):
top_ctnr = self.client.containers.create(
self.alpine_image, ["/usr/bin/top"], name="TestInitPs", detach=True
)
self.assertEqual(top_ctnr.status, "created")
top_ctnr.init()
top_ctnr.reload()
self.assertEqual(top_ctnr.status, "initialized")
top_ctnr.start()
top_ctnr.reload()
self.assertEqual(top_ctnr.status, "running")
top_ctnr.stop()
top_ctnr.reload()
self.assertIn(top_ctnr.status, ("exited", "stopped"))
with self.subTest("Prune Containers"):
report = self.client.containers.prune()
self.assertIn(top_ctnr.id, report["ContainersDeleted"])
# SpaceReclaimed is the size of the content created during the running of the container
# TODO: This should probably check if the podman version is >= 4.6 (guess)
self.assertGreater(report["SpaceReclaimed"], 0)
self.assertEqual(report["SpaceReclaimed"], 0)
with self.assertRaises(NotFound):
self.client.containers.get(top_ctnr.id)
def test_container_commit(self):
"""Commit new image."""
busybox = self.client.images.pull("quay.io/libpod/busybox", tag="latest")
container = self.client.containers.create(
busybox, command=["echo", f"{random.getrandbits(160):x}"]
)
image = container.commit(repository="busybox.local", tag="unittest")
self.assertIn("localhost/busybox.local:unittest", image.attrs["RepoTags"])
busybox.remove(force=True)
def test_container_rm_anonymous_volume(self):
with self.subTest("Check anonymous volume is removed"):
container_file = """
FROM alpine
VOLUME myvol
ENV foo=bar
"""
tmp_file = tempfile.mktemp()
file = open(tmp_file, 'w')
file.write(container_file)
file.close()
self.client.images.build(dockerfile=tmp_file, tag="test-img", path=".")
# get existing number of containers and volumes
existing_containers = self.client.containers.list(all=True)
existing_volumes = self.client.volumes.list()
container = self.client.containers.create("test-img")
container_list = self.client.containers.list(all=True)
self.assertEqual(len(container_list), len(existing_containers) + 1)
volume_list = self.client.volumes.list()
self.assertEqual(len(volume_list), len(existing_volumes) + 1)
# remove the container with v=True
container.remove(v=True)
container_list = self.client.containers.list(all=True)
self.assertEqual(len(container_list), len(existing_containers))
volume_list = self.client.volumes.list()
self.assertEqual(len(volume_list), len(existing_volumes))
def test_container_labels(self):
labels = {'label1': 'value1', 'label2': 'value2'}
labeled_container = self.client.containers.create(self.alpine_image, labels=labels)
unlabeled_container = self.client.containers.create(
self.alpine_image,
)
# inspect and list have 2 different schemas so we need to verify that we can
# successfully retrieve the labels on both
try:
# inspect schema
self.assertEqual(labeled_container.labels, labels)
self.assertEqual(unlabeled_container.labels, {})
# list schema
for container in self.client.containers.list(all=True):
if container.id == labeled_container.id:
self.assertEqual(container.labels, labels)
elif container.id == unlabeled_container.id:
self.assertEqual(container.labels, {})
finally:
labeled_container.remove(v=True)
unlabeled_container.remove(v=True)
def test_container_update(self):
"""Update container"""
to_update_container = self.client.containers.run(
self.alpine_image, name="to_update_container", detach=True
)
with self.subTest("Test container update changing the healthcheck"):
to_update_container.update(health_cmd="ls")
self.assertEqual(
to_update_container.inspect()['Config']['Healthcheck']['Test'], ['CMD-SHELL', 'ls']
)
with self.subTest("Test container update disabling the healthcheck"):
to_update_container.update(no_healthcheck=True)
self.assertEqual(
to_update_container.inspect()['Config']['Healthcheck']['Test'], ['NONE']
)
with self.subTest("Test container update passing payload and data"):
to_update_container.update(
restart_policy="always", health_cmd="echo", health_timeout="10s"
)
self.assertEqual(
to_update_container.inspect()['Config']['Healthcheck']['Test'],
['CMD-SHELL', 'echo'],
)
self.assertEqual(
to_update_container.inspect()['Config']['Healthcheck']['Timeout'], 10000000000
)
self.assertEqual(
to_update_container.inspect()['HostConfig']['RestartPolicy']['Name'], 'always'
)
to_update_container.remove(v=True)
if __name__ == '__main__':
unittest.main()

View File

@ -13,17 +13,14 @@
# under the License.
#
"""Images integration tests."""
import io
import platform
import tarfile
import types
import unittest
import podman.tests.integration.base as base
from podman import PodmanClient
from podman.domain.images import Image
from podman.errors import APIError, ImageNotFound, PodmanError
from podman.errors import ImageNotFound, APIError
# @unittest.skipIf(os.geteuid() != 0, 'Skipping, not running as root')
@ -42,7 +39,7 @@ class ImagesIntegrationTest(base.IntegrationTest):
"""Test Image CRUD.
Notes:
Written to maximize reuse of pulled image.
Written to maximize re-use of pulled image.
"""
with self.subTest("Pull Alpine Image"):
@ -107,97 +104,23 @@ class ImagesIntegrationTest(base.IntegrationTest):
self.assertIn(image.id, deleted)
self.assertGreater(actual["SpaceReclaimed"], 0)
with self.subTest("Export Image to tarball (in memory) with named mode"):
alpine_image = self.client.images.pull("quay.io/libpod/alpine", tag="latest")
image_buffer = io.BytesIO()
for chunk in alpine_image.save(named=True):
image_buffer.write(chunk)
image_buffer.seek(0, 0)
with tarfile.open(fileobj=image_buffer, mode="r") as tar:
items_in_tar = tar.getnames()
# Check if repositories file is available in the tarball
self.assertIn("repositories", items_in_tar)
# Extract the 'repositories' file
repositories_file = tar.extractfile("repositories")
if repositories_file is not None:
# Check the content of the "repositories" file.
repositories_content = repositories_file.read().decode("utf-8")
# Check if "repositories" file contains the name of the Image (named).
self.assertTrue("alpine" in str(repositories_content))
def test_search(self):
# N/B: This is an infrequently used feature, that tends to flake a lot.
# Just check that it doesn't throw an exception and move on.
self.client.images.search("alpine")
actual = self.client.images.search("alpine", filters={"is-official": True})
self.assertEqual(len(actual), 1)
self.assertEqual(actual[0]["Official"], "[OK]")
@unittest.skip("Needs Podman 3.1.0")
def test_corrupt_load(self):
with self.assertRaises(APIError) as e:
next(self.client.images.load(b"This is a corrupt tarball"))
next(self.client.images.load("This is a corrupt tarball".encode("utf-8")))
self.assertIn("payload does not match", e.exception.explanation)
def test_build(self):
buffer = io.StringIO("""FROM quay.io/libpod/alpine_labels:latest""")
buffer = io.StringIO(
f"""FROM quay.io/libpod/alpine_labels:latest
"""
)
image, stream = self.client.images.build(fileobj=buffer)
self.assertIsNotNone(image)
self.assertIsNotNone(image.id)
def test_build_with_context(self):
context = io.BytesIO()
with tarfile.open(fileobj=context, mode="w") as tar:
def add_file(name: str, content: str):
binary_content = content.encode("utf-8")
fileobj = io.BytesIO(binary_content)
tarinfo = tarfile.TarInfo(name=name)
tarinfo.size = len(binary_content)
tar.addfile(tarinfo, fileobj)
# Use a non-standard Dockerfile name to test the 'dockerfile' argument
add_file(
"MyDockerfile", ("FROM quay.io/libpod/alpine_labels:latest\nCOPY example.txt .\n")
)
add_file("example.txt", "This is an example file.\n")
# Rewind to the start of the generated file so we can read it
context.seek(0)
with self.assertRaises(PodmanError):
# If requesting a custom context, must provide the context as `fileobj`
self.client.images.build(custom_context=True, path='invalid')
with self.assertRaises(PodmanError):
# If requesting a custom context, currently must specify the dockerfile name
self.client.images.build(custom_context=True, fileobj=context)
image, stream = self.client.images.build(
fileobj=context,
dockerfile="MyDockerfile",
custom_context=True,
)
self.assertIsNotNone(image)
self.assertIsNotNone(image.id)
@unittest.skipIf(platform.architecture()[0] == "32bit", "no 32-bit image available")
def test_pull_stream(self):
generator = self.client.images.pull("ubi8", tag="latest", stream=True)
self.assertIsInstance(generator, types.GeneratorType)
@unittest.skipIf(platform.architecture()[0] == "32bit", "no 32-bit image available")
def test_pull_stream_decode(self):
generator = self.client.images.pull("ubi8", tag="latest", stream=True, decode=True)
self.assertIsInstance(generator, types.GeneratorType)
def test_scp(self):
with self.assertRaises(APIError) as e:
next(
self.client.images.scp(
source="randuser@fake.ip.addr:22::quay.io/libpod/alpine", quiet=False
)
)
self.assertRegex(
e.exception.explanation,
r"failed to connect: dial tcp: lookup fake\.ip\.addr.+no such host",
)

View File

@ -5,7 +5,6 @@ from podman import PodmanClient
from podman.errors import APIError, ImageNotFound
from podman.tests.integration import base
# @unittest.skipIf(os.geteuid() != 0, 'Skipping, not running as root')
@ -17,33 +16,27 @@ class ManifestsIntegrationTest(base.IntegrationTest):
self.addCleanup(self.client.close)
self.alpine_image = self.client.images.pull("quay.io/libpod/alpine", tag="latest")
self.invalid_manifest_name = "InvalidManifestName"
def tearDown(self) -> None:
if self.client.images.exists(self.invalid_manifest_name):
self.client.images.remove(self.invalid_manifest_name, force=True)
self.client.images.remove(self.alpine_image, force=True)
with suppress(ImageNotFound):
self.client.images.remove("localhost/unittest/alpine", force=True)
self.client.images.remove("quay.io/unittest/alpine:latest", force=True)
def test_manifest_crud(self):
"""Test Manifest CRUD."""
self.assertFalse(
self.client.manifests.exists("localhost/unittest/alpine"),
self.client.manifests.exists("quay.io/unittest/alpine:latest"),
"Image store is corrupt from previous run",
)
with self.subTest("Create"):
manifest = self.client.manifests.create(
"localhost/unittest/alpine", ["quay.io/libpod/alpine:latest"]
)
self.assertEqual(len(manifest.attrs["manifests"]), 1, manifest.attrs)
self.assertTrue(self.client.manifests.exists(manifest.names), manifest.id)
manifest = self.client.manifests.create(["quay.io/unittest/alpine:latest"])
self.assertEqual(len(manifest.attrs["manifests"]), 0)
self.assertTrue(self.client.manifests.exists(manifest.id))
with self.assertRaises(APIError):
self.client.manifests.create("123456!@#$%^")
self.client.manifests.create(["123456!@#$%^"])
with self.subTest("Add"):
manifest.add([self.alpine_image])
@ -56,22 +49,20 @@ class ManifestsIntegrationTest(base.IntegrationTest):
)
with self.subTest("Inspect"):
actual = self.client.manifests.get("quay.io/libpod/alpine:latest")
actual = self.client.manifests.get("quay.io/unittest/alpine:latest")
self.assertEqual(actual.id, manifest.id)
actual = self.client.manifests.get(manifest.name)
self.assertEqual(actual.id, manifest.id)
self.assertEqual(actual.version, 2)
with self.subTest("Remove digest"):
manifest.remove(self.alpine_image.attrs["RepoDigests"][0])
self.assertEqual(manifest.attrs["manifests"], [])
self.assertEqual(len(manifest.attrs["manifests"]), 0)
def test_create_409(self):
"""Test that invalid Image names are caught and not corrupt storage."""
with self.assertRaises(APIError):
self.client.manifests.create(self.invalid_manifest_name)
self.client.manifests.create(["InvalidManifestName"])
if __name__ == '__main__':

View File

@ -13,7 +13,7 @@
# under the License.
#
"""Network integration tests."""
import os
import random
import unittest
from contextlib import suppress
@ -27,29 +27,26 @@ from podman.errors import NotFound
class NetworksIntegrationTest(base.IntegrationTest):
"""networks call integration test"""
pool = IPAMPool(subnet="10.11.13.0/24", iprange="10.11.13.0/26", gateway="10.11.13.1")
pool = IPAMPool(subnet="172.16.0.0/16", iprange="172.16.0.0/24", gateway="172.16.0.1")
ipam = IPAMConfig(pool_configs=[pool])
def setUp(self):
super().setUp()
self.client = PodmanClient(base_url=self.socket_uri)
self.addCleanup(self.client.close)
def tearDown(self):
with suppress(NotFound):
self.client.networks.get("integration_test").remove(force=True)
super().tearDown()
def test_network_crud(self):
"""integration: networks create and remove calls"""
with self.subTest("Create Network"):
network = self.client.networks.create(
"integration_test",
dns_enabled=False,
disabled_dns=True,
enable_ipv6=False,
ipam=NetworksIntegrationTest.ipam,
)
self.assertEqual(network.name, "integration_test")
@ -71,7 +68,7 @@ class NetworksIntegrationTest(base.IntegrationTest):
with self.assertRaises(NotFound):
self.client.networks.get("integration_test")
@unittest.skip("Skipping, libpod endpoint does not report container count")
@unittest.skipIf(os.geteuid() != 0, 'Skipping, not running as root')
def test_network_connect(self):
self.alpine_image = self.client.images.pull("quay.io/libpod/alpine", tag="latest")

View File

@ -1,5 +1,5 @@
import random
import unittest
import random
from podman import PodmanClient
from podman.errors import NotFound
@ -16,27 +16,25 @@ class PodsIntegrationTest(base.IntegrationTest):
self.addCleanup(self.client.close)
self.alpine_image = self.client.images.pull("quay.io/libpod/alpine", tag="latest")
self.pod_name = f"pod_{random.getrandbits(160):x}"
# TODO should this use podman binary instead?
for container in self.client.containers.list():
container.remove(force=True)
def tearDown(self):
if self.client.pods.exists(self.pod_name):
self.client.pods.remove(self.pod_name)
super().tearDown()
def test_pod_crud(self):
"""Test Pod CRUD."""
pod_name = f"pod_{random.getrandbits(160):x}"
with self.subTest("Create no_infra"):
pod = self.client.pods.create(
self.pod_name,
pod_name,
labels={
"unittest": "true",
},
no_infra=True,
)
self.assertEqual(self.pod_name, pod.name)
self.assertEqual(pod_name, pod.name)
self.assertTrue(self.client.pods.exists(pod.id))
with self.subTest("Inspect"):
@ -45,7 +43,7 @@ class PodsIntegrationTest(base.IntegrationTest):
self.assertNotIn("Containers", actual.attrs)
with self.subTest("Exists"):
self.assertTrue(self.client.pods.exists(self.pod_name))
self.assertTrue(self.client.pods.exists(pod_name))
# TODO Need method for deterministic prune...
# with self.subTest("Prune"):
@ -62,23 +60,35 @@ class PodsIntegrationTest(base.IntegrationTest):
with self.assertRaises(NotFound):
pod.reload()
def test_pod_crud_infra(self):
"""Test Pod CRUD with infra container."""
with self.subTest("Create with infra"):
pod = self.client.pods.create(
self.pod_name,
pod_name,
labels={
"unittest": "true",
},
)
self.assertEqual(self.pod_name, pod.name)
self.assertEqual(pod_name, pod.name)
with self.subTest("Inspect"):
actual = self.client.pods.get(pod.id)
self.assertEqual(actual.name, pod.name)
self.assertIn("Containers", actual.attrs)
self.assertEqual(actual.attrs["State"], "Created")
with self.subTest("Stop/Start"):
actual.stop()
actual.start()
with self.subTest("Restart"):
actual.restart()
with self.subTest("Pause/Unpause"):
actual.pause()
actual.reload()
self.assertEqual(actual.attrs["State"], "Paused")
actual.unpause()
actual.reload()
self.assertEqual(actual.attrs["State"], "Running")
with self.subTest("Add container"):
container = self.client.containers.create(self.alpine_image, command=["ls"], pod=actual)
@ -87,6 +97,12 @@ class PodsIntegrationTest(base.IntegrationTest):
ids = {c["Id"] for c in actual.attrs["Containers"]}
self.assertIn(container.id, ids)
with self.subTest("Ps"):
procs = actual.top()
self.assertGreater(len(procs["Processes"]), 0)
self.assertGreater(len(procs["Titles"]), 0)
with self.subTest("List"):
pods = self.client.pods.list()
self.assertGreaterEqual(len(pods), 1)
@ -94,64 +110,15 @@ class PodsIntegrationTest(base.IntegrationTest):
ids = {p.id for p in pods}
self.assertIn(actual.id, ids)
with self.subTest("Stats"):
report = self.client.pods.stats(all=True)
self.assertGreaterEqual(len(report), 1)
with self.subTest("Delete"):
pod.remove(force=True)
with self.assertRaises(NotFound):
pod.reload()
def test_ps(self):
pod = self.client.pods.create(
self.pod_name,
labels={
"unittest": "true",
},
no_infra=True,
)
self.assertTrue(self.client.pods.exists(pod.id))
self.client.containers.create(
self.alpine_image, command=["top"], detach=True, tty=True, pod=pod
)
pod.start()
pod.reload()
with self.subTest("top"):
# this is the API top call not the
# top command running in the container
procs = pod.top()
self.assertGreater(len(procs["Processes"]), 0)
self.assertGreater(len(procs["Titles"]), 0)
with self.subTest("stats"):
report = self.client.pods.stats(all=True)
self.assertGreaterEqual(len(report), 1)
with self.subTest("Stop/Start"):
pod.stop()
pod.reload()
self.assertIn(pod.attrs["State"], ("Stopped", "Exited"))
pod.start()
pod.reload()
self.assertEqual(pod.attrs["State"], "Running")
with self.subTest("Restart"):
pod.stop()
pod.restart()
pod.reload()
self.assertEqual(pod.attrs["State"], "Running")
with self.subTest("Pause/Unpause"):
pod.pause()
pod.reload()
self.assertEqual(pod.attrs["State"], "Paused")
pod.unpause()
pod.reload()
self.assertEqual(pod.attrs["State"], "Running")
pod.stop()
if __name__ == '__main__':
unittest.main()

View File

@ -1,5 +1,4 @@
"""Secrets integration tests."""
import random
import unittest
import uuid

View File

@ -14,9 +14,8 @@
#
"""system call integration tests"""
from podman import system, PodmanClient
import podman.tests.integration.base as base
from podman import PodmanClient
from podman.errors import APIError
class SystemIntegrationTest(base.IntegrationTest):
@ -45,22 +44,3 @@ class SystemIntegrationTest(base.IntegrationTest):
self.assertTrue('Images' in output)
self.assertTrue('Containers' in output)
self.assertTrue('Volumes' in output)
def test_login(self):
"""integration: system login call"""
# here, we just test the sanity of the endpoint
# confirming that we get through to podman, and get tcp rejected.
with self.assertRaises(APIError) as e:
next(
self.client.login(
"fake_user", "fake_password", "fake_email@fake_domain.test", "fake_registry"
)
)
self.assertRegex(
e.exception.explanation,
r"lookup fake_registry.+no such host",
)
def test_from_env(self):
"""integration: from_env() no error"""
PodmanClient.from_env()

View File

@ -13,18 +13,15 @@
# under the License.
#
"""Integration Test Utils"""
import logging
import os
import shutil
import subprocess
import threading
from contextlib import suppress
from typing import Optional
import time
from typing import List, Optional
from podman.tests import errors
import podman.tests.errors as errors
logger = logging.getLogger("podman.service")
@ -38,7 +35,7 @@ class PodmanLauncher:
podman_path: Optional[str] = None,
timeout: int = 0,
privileged: bool = False,
log_level: str = "WARNING",
log_level: int = logging.WARNING,
) -> None:
"""create a launcher and build podman command"""
podman_exe: str = podman_path
@ -50,19 +47,16 @@ class PodmanLauncher:
self.socket_file: str = socket_uri.replace('unix://', '')
self.log_level = log_level
self.proc: Optional[subprocess.Popen[bytes]] = None
self.proc = None
self.reference_id = hash(time.monotonic())
self.cmd: list[str] = []
self.cmd: List[str] = []
if privileged:
self.cmd.append('sudo')
self.cmd.append(podman_exe)
logger.setLevel(logging.getLevelName(log_level))
# Map from python to go logging levels, FYI trace level breaks cirrus logging
self.cmd.append(f"--log-level={log_level.lower()}")
self.cmd.append(f"--log-level={logging.getLevelName(log_level).lower()}")
if os.environ.get("container") == "oci":
self.cmd.append("--storage-driver=vfs")
@ -98,7 +92,7 @@ class PodmanLauncher:
def consume(line: str):
logger.debug(line.strip("\n") + f" refid={self.reference_id}")
self.proc = subprocess.Popen(self.cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) # pylint: disable=consider-using-with
self.proc = subprocess.Popen(self.cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
threading.Thread(target=consume_lines, args=[self.proc.stdout, consume]).start()
if not check_socket:
@ -124,7 +118,4 @@ class PodmanLauncher:
return_code = self.proc.wait()
self.proc = None
with suppress(FileNotFoundError):
os.remove(self.socket_file)
logger.info("Command return Code: %d refid=%s", return_code, self.reference_id)

View File

@ -0,0 +1,392 @@
"""podman.containers unit tests"""
import unittest
import urllib.parse
from unittest import mock
import podman.containers
import podman.errors
import podman.system
class TestContainers(unittest.TestCase):
"""Test the containers calls."""
def setUp(self):
super().setUp()
self.request = mock.MagicMock()
self.response = mock.MagicMock()
self.request.return_value = self.response
self.api = mock.MagicMock()
self.api.get = self.request
self.api.post = self.request
self.api.delete = self.request
self.api.quote = urllib.parse.quote
def test_checkpoint(self):
"""test checkpoint call"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 200
self.response.read = mock_read
expected = b''
ret = podman.containers.checkpoint(self.api, 'foo')
self.assertEqual(ret, expected)
self.request.assert_called_once_with(
"/containers/foo/checkpoint", params={}, headers={'content-type': 'application/json'}
)
def test_checkpoint_options(self):
"""test checkpoint call with options"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 200
self.response.read = mock_read
expected = b''
ret = podman.containers.checkpoint(self.api, 'foo', False, False, False, False, False)
self.assertEqual(ret, expected)
params = {
'export': False,
'ignoreRootFS': False,
'keep': False,
'leaveRunning': False,
'tcpEstablished': False,
}
self.request.assert_called_once_with(
"/containers/foo/checkpoint",
params=params,
headers={'content-type': 'application/json'},
)
def test_copy(self):
"""test copy call"""
mock_read = mock.MagicMock()
self.response.status = 200
self.response.read = mock_read
ret = podman.containers.copy(self.api, 'foo', '/foo.tar', False)
self.assertTrue(ret)
params = {'path': '/foo.tar', 'pause': False}
self.request.assert_called_once_with(
"/containers/foo/copy", params=params, headers={'content-type': 'application/json'}
)
def test_container_exists(self):
"""test checkpoint call"""
self.request.side_effect = [mock.MagicMock(), podman.errors.NotFoundError('')]
ret = podman.containers.container_exists(self.api, 'foo')
self.assertTrue(ret)
ret = podman.containers.container_exists(self.api, 'foo')
self.assertFalse(ret)
calls = [
mock.call('/containers/foo/exists'),
mock.call('/containers/foo/exists'),
]
self.request.assert_has_calls(calls)
def test_create(self):
"""test create call"""
self.response.status = 201
ret = podman.containers.create(self.api, {'container': 'data'})
self.assertTrue(ret)
params = {'container': 'data'}
self.request.assert_called_once_with(
"/containers/create", params=params, headers={'content-type': 'application/json'}
)
def test_export(self):
"""test export call"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 200
self.response.read = mock_read
ret = podman.containers.export(self.api, 'foo')
self.assertEqual(ret, b'')
self.request.assert_called_once_with("/containers/foo/export")
def test_healthcheck(self):
"""test healthcheck call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"data": "stuff"}'
self.response.status = 200
self.response.read = mock_read
ret = podman.containers.healthcheck(self.api, 'foo')
self.assertEqual(ret, {'data': 'stuff'})
self.request.assert_called_once_with("/containers/foo/healthcheck")
def test_init(self):
"""test init call"""
self.response.status = 204
ret = podman.containers.init(self.api, 'foo')
self.assertTrue(ret)
self.request.assert_called_once_with("/containers/foo/init")
def test_inspect(self):
"""test inspect call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Id": "foo"}'
self.response.status = 200
self.response.read = mock_read
expected = {"Id": "foo"}
ret = podman.containers.inspect(self.api, 'foo')
self.assertEqual(ret, expected)
self.request.assert_called_once_with("/containers/foo/json")
def test_kill(self):
"""test kill call"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 204
self.response.read = mock_read
ret = podman.containers.kill(self.api, 'foo')
self.assertTrue(ret)
self.request.assert_called_once_with(
"/containers/foo/kill", params={}, headers={'content-type': 'application/json'}
)
def test_kill_signal(self):
"""test kill call with signal"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 200
self.response.read = mock_read
ret = podman.containers.kill(self.api, 'foo', 'HUP')
self.assertTrue(ret)
self.request.assert_called_once_with(
"/containers/foo/kill",
params={'signal': 'HUP'},
headers={'content-type': 'application/json'},
)
def test_list_containers(self):
"""test list call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"Id": "foo"}]'
self.response.status = 200
self.response.read = mock_read
expected = [{"Id": "foo"}]
ret = podman.containers.list_containers(self.api)
self.assertEqual(ret, expected)
self.request.assert_called_once_with("/containers/json", {})
def test_list_containers_all(self):
"""test list call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"Id": "foo"}]'
self.response.status = 200
self.response.read = mock_read
expected = [{"Id": "foo"}]
ret = podman.containers.list_containers(self.api, True)
self.assertEqual(ret, expected)
self.request.assert_called_once_with("/containers/json", {"all": True})
def test_logs(self):
"""test logs call"""
self.assertRaises(NotImplementedError, podman.containers.logs, self.api, 'foo')
def test_mount(self):
"""test mount call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'"/stuff"'
self.response.status = 200
self.response.read = mock_read
ret = podman.containers.mount(self.api, 'foo')
self.assertEqual(ret, '/stuff')
self.request.assert_called_once_with(
"/containers/foo/mount", headers={'content-type': 'application/json'}
)
def test_pause(self):
"""test pause call"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 204
self.response.read = mock_read
ret = podman.containers.pause(self.api, 'foo')
self.assertTrue(ret)
self.request.assert_called_once_with(
"/containers/foo/pause", headers={'content-type': 'application/json'}
)
def test_prune(self):
"""test prune call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"Id": "foo"}]'
self.response.status = 200
self.response.read = mock_read
ret = podman.containers.prune(self.api, 'foo')
self.assertEqual(ret, [{"Id": "foo"}])
self.request.assert_called_once_with(
"/containers/foo/prune", params={}, headers={'content-type': 'application/json'}
)
def test_remove(self):
"""test remove call"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 204
self.response.read = mock_read
ret = podman.containers.remove(self.api, 'foo')
self.assertTrue(ret)
self.request.assert_called_once_with("/containers/foo", {})
def test_remove_options(self):
"""test remove call with options"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 204
self.response.read = mock_read
ret = podman.containers.remove(self.api, 'foo', True, True)
self.assertTrue(ret)
self.request.assert_called_once_with("/containers/foo", {'force': True, 'v': True})
def test_resize(self):
"""test resize call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{}'
self.response.status = 200
self.response.read = mock_read
ret = podman.containers.resize(self.api, 'foo', 80, 80)
self.assertEqual(ret, {})
self.request.assert_called_once_with(
"/containers/foo/resize",
params={'h': 80, 'w': 80},
headers={'content-type': 'application/json'},
)
def test_restart(self):
"""test restart call"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 204
self.response.read = mock_read
ret = podman.containers.restart(self.api, 'foo')
self.assertTrue(ret)
self.request.assert_called_once_with(
"/containers/foo/restart", params={}, headers={'content-type': 'application/json'}
)
def test_restore(self):
"""test restore call"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 204
self.response.read = mock_read
ret = podman.containers.restore(self.api, 'foo')
self.assertEqual(ret, b'')
self.request.assert_called_once_with(
"/containers/foo/restore", params={}, headers={'content-type': 'application/json'}
)
def test_show_mounted(self):
"""test show mounted call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Id": "foo"}'
self.response.status = 200
self.response.read = mock_read
ret = podman.containers.show_mounted(self.api)
self.assertEqual(ret, {"Id": "foo"})
self.request.assert_called_once_with("/containers/showmounted")
def test_start(self):
"""test start call"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 204
self.response.read = mock_read
ret = podman.containers.start(self.api, 'foo')
self.assertTrue(ret)
self.request.assert_called_once_with(
"/containers/foo/start", params={}, headers={'content-type': 'application/json'}
)
def test_stats(self):
"""test stats call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'""'
self.response.status = 200
self.response.read = mock_read
ret = podman.containers.stats(self.api, stream=False)
self.assertEqual(ret, '')
params = {'stream': False}
self.request.assert_called_once_with("/containers/stats", params=params)
def test_stats_stream(self):
"""test stream call with stream"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 200
self.response.read = mock_read
ret = podman.containers.stats(self.api, 'foo', True)
self.assertEqual(ret, self.response)
params = {'stream': True, 'containers': 'foo'}
self.request.assert_called_once_with("/containers/stats", params=params)
def test_stop(self):
"""test stop call"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 204
self.response.read = mock_read
ret = podman.containers.stop(self.api, 'foo')
self.assertTrue(ret)
self.request.assert_called_once_with(
"/containers/foo/stop", params={}, headers={'content-type': 'application/json'}
)
def test_top(self):
"""test top call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'""'
self.response.status = 200
self.response.read = mock_read
ret = podman.containers.top(self.api, 'foo', stream=False)
self.assertEqual(ret, '')
params = {'stream': False}
self.request.assert_called_once_with("/containers/foo/top", params=params)
def test_top_stream(self):
"""test top call with stream"""
mock_read = mock.MagicMock()
mock_read.return_value = b'""'
self.response.status = 200
self.response.read = mock_read
ret = podman.containers.top(self.api, 'foo', '-a')
self.assertEqual(ret, self.response)
params = {'stream': True, 'ps_args': '-a'}
self.request.assert_called_once_with("/containers/foo/top", params=params)
def test_unmount(self):
"""test unmount call"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 204
self.response.read = mock_read
ret = podman.containers.unmount(self.api, 'foo')
self.assertTrue(ret)
self.request.assert_called_once_with(
"/containers/foo/unmount", headers={'content-type': 'application/json'}
)
def test_unpause(self):
"""test unpause call"""
mock_read = mock.MagicMock()
mock_read.return_value = b''
self.response.status = 204
self.response.read = mock_read
ret = podman.containers.unpause(self.api, 'foo')
self.assertTrue(ret)
self.request.assert_called_once_with(
"/containers/foo/unpause", headers={'content-type': 'application/json'}
)
def test_wait(self):
"""test wait call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"StatusCode": 0}'
self.response.status = 200
self.response.read = mock_read
ret = podman.containers.wait(self.api, 'foo')
self.assertEqual(ret, {"StatusCode": 0})
self.request.assert_called_once_with(
"/containers/foo/wait", params={}, headers={'content-type': 'application/json'}
)

View File

View File

@ -0,0 +1,139 @@
# Copyright 2020 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""podman.images unit tests"""
import unittest
import urllib.parse
from unittest import mock
import podman.errors
import podman.system
class TestImages(unittest.TestCase):
"""Test the images calls."""
def setUp(self):
super().setUp()
self.request = mock.MagicMock()
self.response = mock.MagicMock()
self.request.return_value = self.response
self.api = mock.MagicMock()
self.api.get = self.request
self.api.post = self.request
self.api.delete = self.request
self.api.quote = urllib.parse.quote
def test_list_images(self):
"""test list call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"Id": "foo"}]'
self.response.status = 200
self.response.read = mock_read
expected = [{"Id": "foo"}]
ret = podman.images.list_images(self.api)
self.assertEqual(ret, expected)
self.request.assert_called_once_with("/images/json")
def test_inspect(self):
"""test inspect call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Id": "foo"}'
self.response.status = 200
self.response.read = mock_read
expected = {"Id": "foo"}
ret = podman.images.inspect(self.api, "foo")
self.assertEqual(ret, expected)
self.request.assert_called_once_with("/images/foo/json")
def test_image_exists(self):
"""test exists call"""
self.response.status = 204
self.assertTrue(podman.images.image_exists(self.api, "foo"))
self.request.assert_called_once_with("/images/foo/exists")
def test_image_exists_missing(self):
"""test exists call with missing image"""
self.request.side_effect = podman.errors.NotFoundError("nope")
self.assertFalse(podman.images.image_exists(self.api, "foo"))
def test_remove(self):
"""test remove call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"deleted": "str","untagged": ["str"]}]'
self.response.status = 200
self.response.read = mock_read
expected = [{"deleted": "str", "untagged": ["str"]}]
ret = podman.images.remove(self.api, "foo")
self.assertEqual(ret, expected)
self.api.delete.assert_called_once_with("/images/foo", {})
def test_remove_force(self):
"""test remove call with force"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"deleted": "str","untagged": ["str"]}]'
self.response.status = 200
self.response.read = mock_read
expected = [{"deleted": "str", "untagged": ["str"]}]
ret = podman.images.remove(self.api, "foo", True)
self.assertEqual(ret, expected)
self.api.delete.assert_called_once_with("/images/foo", {"force": True})
def test_remove_missing(self):
"""test remove call with missing image"""
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.ImageNotFound("yikes")
self.api.raise_not_found = mock_raise
self.request.side_effect = podman.errors.NotFoundError("nope")
self.assertRaises(podman.errors.ImageNotFound, podman.images.remove, self.api, "foo")
def test_tag_image(self):
"""test tag image"""
self.response.status = 201
self.assertTrue(podman.images.tag_image(self.api, "foo", "bar", "baz"))
def test_tag_image_fail(self):
"""test remove call with missing image"""
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.ImageNotFound("yikes")
self.api.raise_image_not_found = mock_raise
self.request.side_effect = podman.errors.NotFoundError("nope")
self.assertRaises(
podman.errors.ImageNotFound,
podman.images.tag_image,
self.api,
"foo",
"bar",
"baz",
)
def test_history(self):
"""test image history"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Id": "a"}'
self.response.status = 200
self.response.read = mock_read
expected = {"Id": "a"}
ret = podman.images.history(self.api, "foo")
self.assertEqual(ret, expected)
self.api.delete.assert_called_once_with("/images/foo/history")
def test_history_missing_image(self):
"""test history with missing image"""
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.ImageNotFound("yikes")
self.api.raise_image_not_found = mock_raise
self.request.side_effect = podman.errors.NotFoundError("nope")
self.assertRaises(podman.errors.ImageNotFound, podman.images.history, self.api, "foo")

View File

View File

@ -0,0 +1,124 @@
# Copyright 2020 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""podman.manifests unit tests"""
import unittest
import urllib.parse
from unittest import mock
import podman.errors
import podman.manifests
class TestManifests(unittest.TestCase):
"""Test the manifest calls."""
def setUp(self):
super().setUp()
self.request = mock.MagicMock()
self.response = mock.MagicMock()
self.request.return_value = self.response
self.api = mock.MagicMock()
self.api.get = self.request
self.api.delete = self.request
self.api.post = self.request
self.api.quote = urllib.parse.quote
def test_add(self):
"""test add call"""
manifest = {'all': False, 'annotation': {'prop1': 'string', 'prop2': 'string'}}
self.response.status = 200
self.assertTrue(podman.manifests.add(self.api, 'foo', manifest))
def test_add_missing(self):
"""test add call missing manifest"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.ManifestNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(podman.errors.ManifestNotFound, podman.manifests.add, self.api, 'foo', {})
def test_create(self):
"""test create call"""
self.response.status = 200
self.assertTrue(podman.manifests.create(self.api, 'test', 'image', True))
self.request.assert_called_once_with(
'/manifests/create', params={'name': 'test', 'image': 'image', 'all': True}
)
def test_create_fail(self):
"""test create call with an error"""
self.response.status = 400
self.request.side_effect = podman.errors.RequestError('meh', self.response)
self.assertRaises(podman.errors.RequestError, podman.manifests.create, self.api, 'test')
def test_create_missing(self):
"""test create call with missing manifest"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.ManifestNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(podman.errors.ManifestNotFound, podman.manifests.create, self.api, 'test')
def test_inspect(self):
"""test manifests inspect call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"Instances":["podman"]}]'
self.response.read = mock_read
ret = podman.manifests.inspect(self.api, 'podman')
self.assertEqual(ret, [{'Instances': ['podman']}])
self.request.assert_called_once_with('/manifests/podman/json')
def test_inspect_missing(self):
"""test inspect missing pod"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.ManifestNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(
podman.errors.ManifestNotFound, podman.manifests.inspect, self.api, 'podman'
)
def test_push(self):
"""test manifest push call"""
self.response.status = 200
self.assertTrue(podman.manifests.push(self.api, 'foo', 'dest', True))
self.api.post.assert_called_once_with(
'/manifests/foo/push', params={'destination': 'dest', 'all': True}
)
def test_push_fail(self):
"""test manifest push fails"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.ManifestNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(
podman.errors.ManifestNotFound, podman.manifests.push, self.api, 'podman', 'dest'
)
def test_remove(self):
"""test remove call"""
self.response.status = 200
self.assertTrue(podman.manifests.remove(self.api, 'foo', 'bar'))
self.api.delete.assert_called_once_with('/manifests/foo', {'digest': 'bar'})
def test_remove_missing(self):
"""test remove call with missing pod"""
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.ManifestNotFound('yikes')
self.api.raise_not_found = mock_raise
self.request.side_effect = podman.errors.NotFoundError('nope')
self.assertRaises(podman.errors.ManifestNotFound, podman.manifests.remove, self.api, 'foo')

View File

View File

@ -0,0 +1,165 @@
# Copyright 2020 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""podman.networks unit tests"""
import json
import unittest
import urllib.parse
from unittest import mock
import podman.errors
import podman.networks
class TestNetwork(unittest.TestCase):
"""Test the network calls."""
def setUp(self):
super().setUp()
self.request = mock.MagicMock()
self.response = mock.MagicMock()
self.request.return_value = self.response
self.api = mock.MagicMock()
self.api.get = self.request
self.api.delete = self.request
self.api.post = self.request
self.api.quote = urllib.parse.quote
def test_create(self):
"""test create call"""
network = {
'DisableDNS': False,
}
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Filename":"string"}'
self.response.read = mock_read
ret = podman.networks.create(self.api, 'test', network)
self.assertEqual(ret, {'Filename': 'string'})
self.request.assert_called_once_with(
'/networks/create?name=test',
headers={'content-type': 'application/json'},
params='{"DisableDNS": false}',
)
def test_create_with_string(self):
"""test create call with string"""
network = {
'DisableDNS': False,
}
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Filename":"string"}'
self.response.read = mock_read
ret = podman.networks.create(self.api, 'test', json.dumps(network))
self.assertEqual(ret, {'Filename': 'string'})
self.request.assert_called_once_with(
'/networks/create?name=test',
headers={'content-type': 'application/json'},
params='{"DisableDNS": false}',
)
def test_create_fail(self):
"""test create call with an error"""
network = {
'DisableDNS': False,
}
self.response.status = 400
self.request.side_effect = podman.errors.RequestError('meh', self.response)
self.assertRaises(
podman.errors.RequestError,
podman.networks.create,
self.api,
'test',
json.dumps(network),
)
def test_inspect(self):
"""test inspect call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"Name":"podman"}]'
self.response.read = mock_read
ret = podman.networks.inspect(self.api, 'podman')
self.assertEqual(ret, [{'Name': 'podman'}])
self.request.assert_called_once_with('/networks/podman/json')
def test_inspect_missing(self):
"""test inspect missing network"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.NetworkNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(
podman.errors.NetworkNotFound, podman.networks.inspect, self.api, 'podman'
)
def test_list_networks(self):
"""test networks list call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"Name":"podman"}]'
self.response.read = mock_read
ret = podman.networks.list_networks(self.api)
self.assertEqual(ret, [{'Name': 'podman'}])
self.request.assert_called_once_with('/networks/json', {})
def test_list_networks_filter(self):
"""test networks list call with a filter"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"Name":"podman"}]'
self.response.read = mock_read
ret = podman.networks.list_networks(self.api, 'name=podman')
self.assertEqual(ret, [{'Name': 'podman'}])
self.request.assert_called_once_with('/networks/json', {'filter': 'name=podman'})
def test_remove(self):
"""test remove call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"deleted": "str","untagged": ["str"]}]'
self.response.status = 200
self.response.read = mock_read
expected = [{'deleted': 'str', 'untagged': ['str']}]
ret = podman.networks.remove(self.api, 'foo')
self.assertEqual(ret, expected)
self.api.delete.assert_called_once_with('/networks/foo', {})
def test_remove_force(self):
"""test remove call with force"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"deleted": "str","untagged": ["str"]}]'
self.response.status = 200
self.response.read = mock_read
expected = [{'deleted': 'str', 'untagged': ['str']}]
ret = podman.networks.remove(self.api, 'foo', True)
self.assertEqual(ret, expected)
self.api.delete.assert_called_once_with('/networks/foo', {'force': True})
def test_remove_missing(self):
"""test remove call with missing network"""
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.NetworkNotFound('yikes')
self.api.raise_not_found = mock_raise
self.request.side_effect = podman.errors.NotFoundError('nope')
self.assertRaises(podman.errors.NetworkNotFound, podman.networks.remove, self.api, 'foo')
def test_prune(self):
"""test prune call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"Name": "net1"}, {"Name": "net2"}]'
self.response.read = mock_read
ret = podman.networks.prune(self.api)
expected = [{'Name': 'net1'}, {"Name": "net2"}]
self.assertEqual(ret, expected)
self.request.assert_called_once_with(
'/networks/prune',
headers={'content-type': 'application/json'},
)

View File

View File

@ -0,0 +1,307 @@
# Copyright 2020 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""podman.pods unit tests"""
import json
import unittest
import urllib.parse
from unittest import mock
import podman.errors
import podman.pods
class TestNetwork(unittest.TestCase):
"""Test the pod calls."""
def setUp(self):
super().setUp()
self.request = mock.MagicMock()
self.response = mock.MagicMock()
self.request.return_value = self.response
self.api = mock.MagicMock()
self.api.get = self.request
self.api.delete = self.request
self.api.post = self.request
self.api.quote = urllib.parse.quote
def test_create(self):
"""test create call"""
pod = {
'name': 'foo',
}
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Id":"string"}'
self.response.read = mock_read
ret = podman.pods.create(self.api, 'test', pod)
self.assertEqual(ret, {'Id': 'string'})
self.request.assert_called_once_with(
'/pods/create?name=test',
headers={'content-type': 'application/json'},
params='{"name": "foo"}',
)
def test_create_fail(self):
"""test create call with an error"""
pod = {
'name': 'foo',
}
self.response.status = 400
self.request.side_effect = podman.errors.RequestError('meh', self.response)
self.assertRaises(
podman.errors.RequestError,
podman.pods.create,
self.api,
'test',
json.dumps(pod),
)
def test_exists(self):
"""test pods exists call"""
self.assertTrue(podman.pods.exists(self.api, 'test'))
def test_exists_missing(self):
"""test pods exists call returns false for missing pod"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
self.assertFalse(podman.pods.exists(self.api, 'test'))
def test_inspect(self):
"""test pods inspect call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"Name":"podman"}]'
self.response.read = mock_read
ret = podman.pods.inspect(self.api, 'podman')
self.assertEqual(ret, [{'Name': 'podman'}])
self.request.assert_called_once_with('/pods/podman/json')
def test_inspect_missing(self):
"""test inspect missing pod"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.PodNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(podman.errors.PodNotFound, podman.pods.inspect, self.api, 'podman')
def test_kill(self):
"""test pods kill call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Id":"string"}'
self.response.read = mock_read
ret = podman.pods.kill(self.api, 'test', 'foo')
self.assertEqual(ret, {'Id': 'string'})
self.request.assert_called_once_with(
'/pods/test/kill',
{"signal": "foo"},
)
def test_kill_fail(self):
"""test pods kill call failure"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.PodNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(podman.errors.PodNotFound, podman.pods.kill, self.api, 'podman')
def test_list_pods(self):
"""test pods list call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"Name":"podman"}]'
self.response.read = mock_read
ret = podman.pods.list_pods(self.api)
self.assertEqual(ret, [{'Name': 'podman'}])
self.request.assert_called_once_with('/pods/json', {})
def test_list_pods_filter(self):
"""test pods list call with a filter"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"Name":"podman"}]'
self.response.read = mock_read
ret = podman.pods.list_pods(self.api, 'name=podman')
self.assertEqual(ret, [{'Name': 'podman'}])
self.request.assert_called_once_with('/pods/json', {'filter': 'name=podman'})
def test_list_processes(self):
"""test pods list processes"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Processes": [[]], "Titles": []}'
self.response.read = mock_read
ret = podman.pods.list_processes(self.api, 'podman')
self.assertEqual(ret, {'Processes': [[]], 'Titles': []})
self.request.assert_called_once_with('/pods/podman/top', {})
def test_list_processes_with_options(self):
"""test pods list processes"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Processes": [[]], "Titles": []}'
self.response.read = mock_read
ret = podman.pods.list_processes(self.api, 'podman', True, '-a')
self.assertEqual(ret, {'Processes': [[]], 'Titles': []})
self.request.assert_called_once_with('/pods/podman/top', {'stream': True, 'ps_args': '-a'})
def test_pause(self):
"""test pods pause call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Id": "string"}'
self.response.read = mock_read
ret = podman.pods.pause(self.api, 'podman')
self.assertEqual(ret, {'Id': "string"})
self.request.assert_called_once_with('/pods/podman/pause')
def test_pause_fail(self):
"""test pods pause fails"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.PodNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(podman.errors.PodNotFound, podman.pods.pause, self.api, 'podman')
def test_prune(self):
"""test pods prune call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Id": "string"}'
self.response.read = mock_read
ret = podman.pods.prune(self.api)
self.assertEqual(ret, {'Id': "string"})
self.request.assert_called_once_with('/pods/prune')
def test_prune_fail(self):
"""test prune call with an error"""
self.response.status = 400
self.request.side_effect = podman.errors.RequestError('meh', self.response)
self.assertRaises(podman.errors.RequestError, podman.pods.prune, self.api)
def test_remove(self):
"""test remove call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"deleted": "str","untagged": ["str"]}]'
self.response.status = 200
self.response.read = mock_read
expected = [{'deleted': 'str', 'untagged': ['str']}]
ret = podman.pods.remove(self.api, 'foo')
self.assertEqual(ret, expected)
self.api.delete.assert_called_once_with('/pods/foo', {})
def test_remove_force(self):
"""test remove call with force"""
mock_read = mock.MagicMock()
mock_read.return_value = b'[{"deleted": "str","untagged": ["str"]}]'
self.response.status = 200
self.response.read = mock_read
expected = [{'deleted': 'str', 'untagged': ['str']}]
ret = podman.pods.remove(self.api, 'foo', True)
self.assertEqual(ret, expected)
self.api.delete.assert_called_once_with('/pods/foo', {'force': True})
def test_remove_missing(self):
"""test remove call with missing pod"""
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.PodNotFound('yikes')
self.api.raise_not_found = mock_raise
self.request.side_effect = podman.errors.NotFoundError('nope')
self.assertRaises(podman.errors.PodNotFound, podman.pods.remove, self.api, 'foo')
def test_restart(self):
"""test pods restart call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Id": "string"}'
self.response.read = mock_read
ret = podman.pods.restart(self.api, 'podman')
self.assertEqual(ret, {'Id': "string"})
self.request.assert_called_once_with('/pods/podman/restart')
def test_restart_fail(self):
"""test pods restart fail"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.PodNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(podman.errors.PodNotFound, podman.pods.restart, self.api, 'podman')
def test_start(self):
"""test pods start call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Id": "string"}'
self.response.read = mock_read
ret = podman.pods.start(self.api, 'podman')
self.assertEqual(ret, {'Id': "string"})
self.request.assert_called_once_with('/pods/podman/start')
def test_start_fail(self):
"""test pods start fail"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.PodNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(podman.errors.PodNotFound, podman.pods.start, self.api, 'podman')
def test_stats(self):
"""test pods stats call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Processes": [[]], "Titles": []}'
self.response.read = mock_read
ret = podman.pods.stats(self.api)
self.assertEqual(ret, {'Processes': [[]], 'Titles': []})
self.request.assert_called_once_with('/pods/stats', {'all': True})
def test_stats_with_names(self):
"""test pods stats call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Processes": [[]], "Titles": []}'
self.response.read = mock_read
ret = podman.pods.stats(self.api, False, ['test'])
self.assertEqual(ret, {'Processes': [[]], 'Titles': []})
self.request.assert_called_once_with('/pods/stats', {'all': False, 'namesOrIDs': ['test']})
def test_stats_fail(self):
"""test pods stats fails"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.PodNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(podman.errors.PodNotFound, podman.pods.stats, self.api, 'podman')
def test_stop(self):
"""test pods stop call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Id": "string"}'
self.response.read = mock_read
ret = podman.pods.stop(self.api, 'podman')
self.assertEqual(ret, {'Id': "string"})
self.request.assert_called_once_with('/pods/podman/stop')
def test_stop_fail(self):
"""test pods stop fail"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.PodNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(podman.errors.PodNotFound, podman.pods.stop, self.api, 'podman')
def test_unpause(self):
"""test pods unpause call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Id": "string"}'
self.response.read = mock_read
ret = podman.pods.unpause(self.api, 'podman')
self.assertEqual(ret, {'Id': "string"})
self.request.assert_called_once_with('/pods/podman/unpause')
def test_unpause_fail(self):
"""test pods unpause fails"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.PodNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(podman.errors.PodNotFound, podman.pods.unpause, self.api, 'podman')

View File

View File

@ -0,0 +1,84 @@
# Copyright 2020 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""podman.system unit tests"""
import unittest
from unittest import mock
import podman.errors
import podman.system
class TestSystem(unittest.TestCase):
"""Test the system calls."""
def setUp(self):
super().setUp()
self.request = mock.MagicMock()
self.response = mock.MagicMock()
self.request.return_value = self.response
self.api = mock.MagicMock()
self.api.get = self.request
def test_version(self):
"""test version call"""
self.response.status = 200
mock_read = mock.MagicMock()
mock_read.return_value = b'{"Version": "2.1.1"}'
expected = {'Version': '2.1.1'}
self.response.read = mock_read
ret = podman.system.version(self.api)
self.assertEqual(ret, expected)
self.request.assert_called_once_with('/version')
def test_version_not_ok(self):
"""test version call"""
self.response.status = 404
ret = podman.system.version(self.api)
self.assertEqual(ret, {})
def test_get_info(self):
"""test info call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"ID":"a"}'
self.response.read = mock_read
ret = podman.system.get_info(self.api)
self.assertEqual(ret, {'ID': 'a'})
self.request.assert_called_once_with('/info')
def test_get_info_fail(self):
"""test info call fails"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.ImageNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(podman.errors.ImageNotFound, podman.system.get_info, self.api)
def test_show_disk_usage(self):
"""test df call"""
mock_read = mock.MagicMock()
mock_read.return_value = b'{"LayersSize":0}'
self.response.read = mock_read
ret = podman.system.show_disk_usage(self.api)
self.assertEqual(ret, {'LayersSize': 0})
self.request.assert_called_once_with('/system/df')
def test_show_disk_usage_fail(self):
"""test df call fails"""
self.request.side_effect = podman.errors.NotFoundError('yikes')
mock_raise = mock.MagicMock()
mock_raise.side_effect = podman.errors.ImageNotFound('yikes')
self.api.raise_not_found = mock_raise
self.assertRaises(podman.errors.ImageNotFound, podman.system.show_disk_usage, self.api)

Some files were not shown because too many files have changed in this diff Show More