diff --git a/.github/workflows/podman_container.yml b/.github/workflows/podman_container.yml index d0821641e..c8f9f6f46 100644 --- a/.github/workflows/podman_container.yml +++ b/.github/workflows/podman_container.yml @@ -170,7 +170,6 @@ jobs: ansible --version python3 -m pip install --user --force-reinstall --upgrade . - - name: Run collection tests for podman container run: | export PATH=~/.local/bin:$PATH diff --git a/.github/workflows/podman_container_api.yml b/.github/workflows/podman_container_api.yml new file mode 100644 index 000000000..0b35c01e5 --- /dev/null +++ b/.github/workflows/podman_container_api.yml @@ -0,0 +1,117 @@ +name: Podman API container + +on: + push: + paths: + - '.github/workflows/podman_container_api.yml' + - 'ci/*.yml' + - 'ci/run_containers_tests.sh' + - 'ci/playbooks/containers/podman_container_api.yml' + #- 'plugins/modules/podman_container.py' + - 'plugins/module_utils/podman/podman_container_lib.py' + - 'plugins/module_utils/podman/podman_api.py' + - 'plugins/module_utils/podman/common.py' + - 'tests/integration/targets/podman_container_api/**' + branches: + - master + pull_request: + paths: + - '.github/workflows/podman_container_api.yml' + - 'ci/*.yml' + - 'ci/run_containers_tests.sh' + - 'ci/playbooks/containers/podman_container_api.yml' + #- 'plugins/modules/podman_container.py' + - 'plugins/module_utils/podman/podman_container_lib.py' + - 'plugins/module_utils/podman/podman_api.py' + - 'plugins/module_utils/podman/common.py' + - 'tests/integration/targets/podman_container_api/**' + schedule: + - cron: 4 0 * * * # Run daily at 0:03 UTC + +jobs: + + test_podman_container_api: + name: Podman API container ${{ matrix.ansible-version }}-${{ matrix.os || 'ubuntu-latest' }} + runs-on: ${{ matrix.os || 'ubuntu-latest' }} + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + ansible-version: + - ansible<2.10 + - git+https://github.com/ansible/ansible.git@stable-2.11 + - git+https://github.com/ansible/ansible.git@devel + os: + - ubuntu-20.04 + python-version: + - 3.7 + + steps: + + - name: Check out repository + uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + - name: Upgrade pip and display Python and PIP versions + run: | + sudo apt-get update + sudo apt-get install -y python*-wheel python*-yaml + python -m pip install --upgrade pip + python -V + pip --version + + - name: Set up pip cache + uses: actions/cache@v1 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ github.ref }}-units-VMs + restore-keys: | + ${{ runner.os }}-pip- + ${{ runner.os }}- + + - name: Install Ansible ${{ matrix.ansible-version }} + run: python3 -m pip install --user --force-reinstall --upgrade '${{ matrix.ansible-version }}' + + - name: Build and install the collection tarball + run: | + export PATH=~/.local/bin:$PATH + + echo "Run ansible version" + command -v ansible + ansible --version + rm -rf /tmp/just_new_collection + ~/.local/bin/ansible-galaxy collection build --output-path /tmp/just_new_collection --force + ~/.local/bin/ansible-galaxy collection install -vvv --force /tmp/just_new_collection/*.tar.gz + + - name: Run collection tests for podman container API + run: | + export PATH=~/.local/bin:$PATH + + if [[ '${{ matrix.ansible-version }}' == 'git+https://github.com/ansible/ansible.git@devel' ]]; then + export ANSIBLE_CONFIG=$(pwd)/ci/ansible-dev.cfg + elif [[ '${{ matrix.ansible-version }}' == 'ansible<2.10' ]]; then + export ANSIBLE_CONFIG=$(pwd)/ci/ansible-2.9.cfg + fi + python3 -m pip install --user requests + podman system service --time=0 unix:///tmp/podman.sock & + + echo $ANSIBLE_CONFIG + command -v ansible-playbook + pip --version + python --version + ansible-playbook --version + + ansible-playbook -vv ci/playbooks/pre.yml \ + -e host=localhost \ + -i localhost, \ + -e ansible_connection=local \ + -e setup_python=false + + TEST2RUN=podman_container_api ./ci/run_containers_tests.sh + shell: bash diff --git a/bindep.txt b/bindep.txt new file mode 100644 index 000000000..5247d5911 --- /dev/null +++ b/bindep.txt @@ -0,0 +1 @@ +podman diff --git a/ci/playbooks/containers/podman_container_api.yml b/ci/playbooks/containers/podman_container_api.yml new file mode 100644 index 000000000..ae4ffe145 --- /dev/null +++ b/ci/playbooks/containers/podman_container_api.yml @@ -0,0 +1,12 @@ +--- +- hosts: all + gather_facts: true + module_defaults: + containers.podman.podman_container: + podman_socket: unix:///tmp/podman.sock + tasks: + - include_role: + name: podman_container_api + vars: + idem_image: idempotency_test + ansible_python_interpreter: "{{ _ansible_python_interpreter }}" diff --git a/plugins/module_utils/podman/common.py b/plugins/module_utils/podman/common.py index e0de789a9..32dc69d44 100644 --- a/plugins/module_utils/podman/common.py +++ b/plugins/module_utils/podman/common.py @@ -4,6 +4,14 @@ from __future__ import absolute_import, division, print_function __metaclass__ = type +try: + import requests + from ansible_collections.containers.podman.plugins.module_utils.podman.podman_api import PodmanAPIClient + HAS_REQUESTS = True +except ImportError: + PodmanAPIClient = object + HAS_REQUESTS = False + def run_podman_command(module, executable='podman', args=None, expected_rc=0, ignore_errors=False): if not isinstance(executable, list): @@ -25,3 +33,15 @@ def lower_keys(x): return dict((k.lower(), lower_keys(v)) for k, v in x.items()) else: return x + + +class PodmanAPI: + def __init__(self, module, module_params): + if module_params.get('podman_socket') and not HAS_REQUESTS: + module.fail_json( + msg="Requests module is not installed while socket was provided!") + self.client = PodmanAPIClient(module_params.get('podman_socket')) + try: + self.client.version() + except Exception as api_error: + module.fail_json(msg="Podman API error: %s" % str(api_error)) diff --git a/plugins/module_utils/podman/podman_api.py b/plugins/module_utils/podman/podman_api.py new file mode 100644 index 000000000..94b1d8f0d --- /dev/null +++ b/plugins/module_utils/podman/podman_api.py @@ -0,0 +1,268 @@ + +# The follwing code is taken from +# https://github.com/msabramo/requests-unixsocket/blob/master/ +# requests_unixsocket/adapters.py +from __future__ import (absolute_import, division, print_function) + +import socket + +import requests +from requests.adapters import HTTPAdapter +from requests.compat import urlparse, unquote, quote + +try: + from requests.packages import urllib3 +except ImportError: + import urllib3 + +try: + import http.client as httplib +except ImportError: + import httplib + +import json + + +__metaclass__ = type + + +DEFAULT_SCHEME = 'http+unix://' + + +# The following was adapted from some code from docker-py +# https://github.com/docker/docker-py/blob/master/docker/transport/unixconn.py +class UnixHTTPConnection(httplib.HTTPConnection, object): + + def __init__(self, unix_socket_url, timeout=60): + """Create an HTTP connection to a unix domain socket + :param unix_socket_url: A URL with a scheme of 'http+unix' and the + netloc is a percent-encoded path to a unix domain socket. E.g.: + 'http+unix://%2Ftmp%2Fprofilesvc.sock/status/pid' + """ + super(UnixHTTPConnection, self).__init__('localhost', timeout=timeout) + self.unix_socket_url = unix_socket_url + self.timeout = timeout + self.sock = None + + def __del__(self): # base class does not have d'tor + if self.sock: + self.sock.close() + + def connect(self): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.timeout) + socket_path = unquote(urlparse(self.unix_socket_url).netloc) + sock.connect(socket_path) + self.sock = sock + + +class UnixHTTPConnectionPool(urllib3.connectionpool.HTTPConnectionPool): + + def __init__(self, socket_path, timeout=60): + super(UnixHTTPConnectionPool, self).__init__( + 'localhost', timeout=timeout) + self.socket_path = socket_path + self.timeout = timeout + + def _new_conn(self): + return UnixHTTPConnection(self.socket_path, self.timeout) + + +class UnixAdapter(HTTPAdapter): + + def __init__(self, timeout=60, pool_connections=25, *args, **kwargs): + super(UnixAdapter, self).__init__(*args, **kwargs) + self.timeout = timeout + self.pools = urllib3._collections.RecentlyUsedContainer( + pool_connections, dispose_func=lambda p: p.close() + ) + + def get_connection(self, url, proxies=None): + proxies = proxies or {} + proxy = proxies.get(urlparse(url.lower()).scheme) + + if proxy: + raise ValueError('%s does not support specifying proxies' + % self.__class__.__name__) + + with self.pools.lock: + pool = self.pools.get(url) + if pool: + return pool + + pool = UnixHTTPConnectionPool(url, self.timeout) + self.pools[url] = pool + + return pool + + def request_url(self, request, proxies): + return request.path_url + + def close(self): + self.pools.clear() + + +class APISession(requests.Session): + def __init__(self, url_scheme=DEFAULT_SCHEME, *args, **kwargs): + super(APISession, self).__init__(*args, **kwargs) + self.mount(url_scheme, UnixAdapter()) + + +class PodmanAPIHTTP: + def __init__(self, base_url): + self.api_url = "".join((DEFAULT_SCHEME, + quote(base_url, safe=""), + "/v2.0.0/libpod")) + self.session = APISession() + + def request(self, method, url, **kwargs): + return self.session.request(method=method, url=self.api_url + url, **kwargs) + + def get(self, url, **kwargs): + kwargs.setdefault('allow_redirects', True) + return self.request('get', url, **kwargs) + + def head(self, url, **kwargs): + kwargs.setdefault('allow_redirects', False) + return self.request('head', url, **kwargs) + + def post(self, url, data=None, json=None, **kwargs): + return self.request('post', url, data=data, json=json, **kwargs) + + def patch(self, url, data=None, **kwargs): + return self.request('patch', url, data=data, **kwargs) + + def put(self, url, data=None, **kwargs): + return self.request('put', url, data=data, **kwargs) + + def delete(self, url, **kwargs): + return self.request('delete', url, **kwargs) + + def options(self, url, **kwargs): + kwargs.setdefault('allow_redirects', True) + return self.request('options', url, **kwargs) + + +class PodmanAPIClient: + def __init__(self, base_url): + socket_opt = urlparse(base_url) + if socket_opt.scheme != "unix": + raise Exception("Scheme %s is not supported! Use %s" % ( + socket_opt.scheme, + DEFAULT_SCHEME + )) + self.api = PodmanAPIHTTP(socket_opt.path) + self.containers = PodmanAPIContainers(self.api) + self.images = PodmanAPIImages(api=self.api) + + def version(self): + response = self.api.get( + '/version') + return response.json() + + +class PodmanAPIContainers: + def __init__(self, api): + self.api = api + self.quote = quote + + def list( + self, 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 = self.api.get("/containers/json", params=query) + # observed to return None when no containers + return response.json() or [] + + def create(self, **container_data): + response = self.api.post( + "/containers/create", + json=container_data, + ) + if response.ok: + return response.json() + raise Exception("Container %s failed to create! Error: %s" % + (container_data.get('name'), response.text)) + + def get(self, name): + response = self.api.get( + '/containers/{0}/json'.format(self.quote(name))) + data = response.json() + if data.get('response') == 404: + data = {} + # raise Exception("Container %s not found!" % name) + return data + + def run(self, **container_data): + _ = self.create(**container_data) # pylint: disable=blacklisted-name + name = container_data.get("name") + _ = self.api.post( # pylint: disable=blacklisted-name + "/containers/{0}/start".format(self.quote(name)), + ) + return self.get(name) + + def start(self, name): + _ = self.api.post( # pylint: disable=blacklisted-name + "/containers/{0}/start".format(self.quote(name)), + ) + return self.get(name) + + def stop(self, name): + _ = self.api.post( # pylint: disable=blacklisted-name + "/containers/{0}/stop".format(self.quote(name)), + ) + return self.get(name) + + def remove(self, name, force=False): + _ = self.api.delete( # pylint: disable=blacklisted-name + "/containers/{0}".format(self.quote(name)), + params={"force": force} + ) + return + + +class PodmanAPIImages: + def __init__(self, api): + self.api = api + self.quote = quote + self.inspect = self.get + + def exists(self, name): + response = self.api.get( + '/images/{0}/exists'.format(self.quote(name))) + return response.status_code == 204 + + def pull(self, reference): + response = self.api.post( + '/images/pull', + params={'reference': reference} + ) + if response.ok: + correct_response = {'stream': ''} + for i in response.text.splitlines(): + if '"images"' in i: + correct_response['images'] = json.loads(i)['images'] + elif '"id"' in i: + correct_response['id'] = json.loads(i)['id'] + elif '"stream"' in i: + correct_response['stream'] += json.loads(i)['stream'] + elif '"error"' in i: + correct_response['error'] = json.loads(i)['error'] + correct_response['code'] = response.status_code + return correct_response + return {"error": "HTTP %s Error: %s" % (response.json()['message'])} + + def get(self, name): + response = self.api.get( + '/images/{0}/json'.format(self.quote(name))) + return response.json() diff --git a/plugins/module_utils/podman/podman_container_lib.py b/plugins/module_utils/podman/podman_container_lib.py index a0f6af27e..7cd3054b4 100644 --- a/plugins/module_utils/podman/podman_container_lib.py +++ b/plugins/module_utils/podman/podman_container_lib.py @@ -1,16 +1,21 @@ from __future__ import (absolute_import, division, print_function) import json # noqa: F402 import shlex # noqa: F402 +import time # noqa: F402 from distutils.version import LooseVersion # noqa: F402 from ansible.module_utils._text import to_bytes, to_native # noqa: F402 from ansible_collections.containers.podman.plugins.module_utils.podman.common import lower_keys +from ansible_collections.containers.podman.plugins.module_utils.podman.common import PodmanAPI + __metaclass__ = type +USE_API = False ARGUMENTS_SPEC_CONTAINER = dict( name=dict(required=True, type='str'), executable=dict(default='podman', type='str'), + podman_socket=dict(type='str'), state=dict(type='str', default='started', choices=[ 'absent', 'present', 'stopped', 'started', 'created']), image=dict(type='str'), @@ -44,7 +49,7 @@ device_write_iops=dict(type='list', elements='str'), dns=dict(type='list', elements='str', aliases=['dns_servers']), dns_option=dict(type='str', aliases=['dns_opts']), - dns_search=dict(type='str', aliases=['dns_search_domains']), + dns_search=dict(type='list', elements='str', aliases=['dns_search_domains']), entrypoint=dict(type='str'), env=dict(type='dict'), env_file=dict(type='path'), @@ -127,6 +132,52 @@ volumes_from=dict(type='list', elements='str'), workdir=dict(type='str', aliases=['working_dir']) ) +NON_PODMAN_ARGS = [ + 'state', + 'podman_socket', + 'executable', 'debug', + 'force_restart', + 'image_strict', + 'recreate' +] +API_TRANSLATION = { + 'annotation': 'annotations', + 'conmon_pidfile': 'conmon_pid_file', + 'etc_hosts': 'hostadd', + 'add_hosts': 'hostadd', + 'http_proxy': 'httpproxy', + 'label': 'labels', + 'publish_all': 'publish_image_ports', + 'rm': 'remove', + 'auto_remove': 'remove', + 'volume': 'volumes', + 'workdir': 'work_dir', + 'working_dir': 'work_dir', + 'dns_search_domains': 'dns_search', + 'dns': 'dns_server', + 'dns_opts': 'dns_option', + 'publish': 'portmappings', + 'published': 'portmappings', + 'published_ports': 'portmappings', + 'ports': 'portmappings', + 'pids_mode': 'pidns', + 'pid': 'pidns', + 'ipc_mode': 'ipcns', + 'ipc': 'ipcns', + 'uts': 'utsns', + 'userns_mode': 'userns', + 'tty': 'terminal', + 'device': 'devices', + 'exposed': 'expose', + 'exposed_ports': 'expose', + 'group_add': 'groups', + 'ulimit': 'r_limits', + 'ulimits': 'r_limits', + 'read_only': 'read_only_filesystem', + 'ip': 'static_ip', + 'mac_address': 'static_mac', + 'no_hosts': 'use_image_hosts', +} def init_options(): @@ -173,6 +224,142 @@ def set_container_opts(input_vars): return options_dict +class PodmanModuleParamsAPI: + """Creates Podman API call. + + Arguments: + action {str} -- action type from 'run', 'stop', 'create', 'delete', + 'start' + params {dict} -- dictionary of module parameters + + """ + def __init__(self, params, podman_version, module): + self.params = params + self.podman_version = podman_version + self.module = module + self.new_params = {} + + def translate(self): + self.new_params = { + k: v for k, v in self.params.items() + if v is not None and k not in NON_PODMAN_ARGS + } + if self.new_params.get('command') and not isinstance( + self.new_params.get('command'), list): + self.new_params['command'] = shlex.split(self.new_params['command']) + transformed = {} + for k in self.new_params: + key = API_TRANSLATION.get(k, k) + transformed[key] = self.new_params[k] + if transformed.get('env'): + for k, v in transformed['env'].items(): + transformed['env'][k] = str(v) + if transformed.get('labels'): + for k, v in transformed['labels'].items(): + transformed['labels'][k] = str(v) + if transformed.get('entrypoint') is not None: + transformed['entrypoint'] = shlex.split(transformed['entrypoint']) + if transformed.get('hostadd') is not None: + hosts = [] + for k, v in transformed['hostadd'].items(): + hosts.append(":".join((k, v))) + transformed['hostadd'] = hosts + if transformed.get('volumes'): + mounts = [] + volumes = [] + for v in transformed['volumes']: + + vols = v.split(":") + if len(vols) < 2 or "/" not in vols[0]: + name = vols[0] if len(vols) > 1 else "" + volumes.append( + { + "Name": name, + "Dest": vols[1] if len(vols) > 1 else v, + } + ) + continue + source = vols[0] + dest = vols[1].split(",")[0] # remove options + options = [] + if len(vols) > 2: + opts = vols[2].split(",") + # work on options + for opt in opts: + if opt.lower() == 'ro': + options.append('ro') + elif opt.lower().lstrip("r") in ( + 'shared', 'slave', 'private', 'unbindable'): + options.append(opt) + mounts.append( + {'destination': dest, + 'source': source, + 'type': 'bind', + 'options': options, + } + ) + transformed['volumes'] = volumes + transformed['mounts'] = mounts + if transformed.get('portmappings') is not None: + total_ports = [] + for p in transformed.get('portmappings'): + parts = p.split(":") + if len(parts) == 1: + c_port, protocol = (parts[0].split("/") + ["tcp"])[:2] + total_ports.append({ + "container_port": int(p), + "protocol": protocol, + # "host_port": int(parts[0].split("/")[0]) + }) + elif len(parts) == 2: + c_port, protocol = (parts[1].split("/") + ["tcp"])[:2] + total_ports.append( + { + "container_port": int(c_port), + "host_port": int(parts[0].split("/")[0]), + "protocol": protocol, + }) + elif len(parts) == 3: + c_port, protocol = (parts[1].split("/") + ["tcp"])[:2] + total_ports.append( + { + "container_port": int(c_port), + "host_port": int(parts[2].split("/")[0]), + "protocol": protocol, + "host_ip": parts[0], + }) + transformed['portmappings'] = total_ports + if transformed.get('pod'): + # API doesn't support creating Pod + transformed['pod'] = transformed['pod'].replace("new:", "") + if transformed.get('pidns'): + transformed['pidns'] = {"nsmode": transformed['pidns']} + if transformed.get('ipcns'): + transformed['ipcns'] = {"nsmode": transformed['ipcns']} + if transformed.get('utsns'): + transformed['utsns'] = {"nsmode": transformed['utsns']} + if transformed.get('userns'): + transformed['userns'] = {"nsmode": transformed['userns']} + if transformed.get('cgroupns'): + transformed['cgroupns'] = {"nsmode": transformed['cgroupns']} + if transformed.get('network'): + if (len(transformed['network']) > 1 + or len(transformed['network'][0].split(",")) > 1 + and transformed['network'][0] not in ('none', 'host', 'bridge', 'private') + and 'container:' not in transformed['network'][0] + and 'ns:' not in transformed['network'][0] + and 'slirp4netns:' not in transformed['network'][0]): + if "," in transformed['network'][0]: + transformed['cni_networks'] = transformed['network'][0].split(",") + else: + transformed['cni_networks'] = transformed['network'] + transformed['netns'] = {"nsmode": 'bridge'} + else: + transformed['netns'] = {"nsmode": transformed['network'][0]} + self.module.log("PODMAN-DEBUG-API: %s" % transformed) + return transformed + + class PodmanModuleParams: """Creates list of arguments for podman CLI command. @@ -339,7 +526,7 @@ def addparam_dns_option(self, c): return c + ['--dns-option', self.params['dns_option']] def addparam_dns_search(self, c): - return c + ['--dns-search', self.params['dns_search']] + return c + ['--dns-search', ','.join(self.params['dns_search'])] def addparam_entrypoint(self, c): return c + ['--entrypoint', self.params['entrypoint']] @@ -1284,7 +1471,7 @@ def is_different(self): return different -def ensure_image_exists(module, image, module_params): +def ensure_image_exists(module, image, module_params, client): """If image is passed, ensure it exists, if not - pull it or fail. Arguments: @@ -1298,13 +1485,25 @@ def ensure_image_exists(module, image, module_params): module_exec = module_params['executable'] if not image: return image_actions - rc, out, err = module.run_command([module_exec, 'image', 'exists', image]) - if rc == 0: - return image_actions - rc, out, err = module.run_command([module_exec, 'image', 'pull', image]) - if rc != 0: - module.fail_json(msg="Can't pull image %s" % image, stdout=out, - stderr=err) + if USE_API: + if client.images.exists(image): + return image_actions + else: + rc, out, err = module.run_command( + [module_exec, 'image', 'exists', image]) + if rc == 0: + return image_actions + if USE_API: + img = client.images.pull(image) + if not img.get('id'): + module.fail_json(msg="Can't find and pull image %s: %s" % ( + image, img.get('error', 'Unknown error'))) + else: + rc, out, err = module.run_command( + [module_exec, 'image', 'pull', image]) + if rc != 0: + module.fail_json(msg="Can't pull image %s" % image, stdout=out, + stderr=err) image_actions.append("pulled image %s" % image) return image_actions @@ -1315,7 +1514,7 @@ class PodmanContainer: Manages podman container, inspects it and checks its current state """ - def __init__(self, module, name, module_params): + def __init__(self, module, name, module_params, client): """Initialize PodmanContainer class. Arguments: @@ -1324,6 +1523,7 @@ def __init__(self, module, name, module_params): """ self.module = module + self.client = client self.module_params = module_params self.name = name self.stdout, self.stderr = '', '' @@ -1370,6 +1570,12 @@ def stopped(self): def get_info(self): """Inspect container and gather info about it.""" # pylint: disable=unused-variable + if USE_API: + try: + container = self.client.containers.get(self.name) + return container + except Exception: + return {} rc, out, err = self.module.run_command( [self.module_params['executable'], b'container', b'inspect', self.name]) return json.loads(out)[0] if rc == 0 else {} @@ -1377,12 +1583,21 @@ def get_info(self): def get_image_info(self): """Inspect container image and gather info about it.""" # pylint: disable=unused-variable - rc, out, err = self.module.run_command( - [self.module_params['executable'], b'image', b'inspect', self.module_params['image']]) - return json.loads(out)[0] if rc == 0 else {} + if USE_API: + try: + img = self.client.images.get(self.module_params['image']) + return img + except Exception: + return {} + else: + rc, out, err = self.module.run_command( + [self.module_params['executable'], b'image', b'inspect', self.module_params['image']]) + return json.loads(out)[0] if rc == 0 else {} def _get_podman_version(self): # pylint: disable=unused-variable + if USE_API: + return self.client.version()['Version'] rc, out, err = self.module.run_command( [self.module_params['executable'], b'--version']) if rc != 0 or not out or "version" not in out: @@ -1397,34 +1612,66 @@ def _perform_action(self, action): action {str} -- action to perform - start, create, stop, run, delete """ - b_command = PodmanModuleParams(action, - self.module_params, - self.version, - self.module, - ).construct_command_from_params() - if action == 'create': - b_command.remove(b'--detach=True') - full_cmd = " ".join([self.module_params['executable']] - + [to_native(i) for i in b_command]) - self.actions.append(full_cmd) - if self.module.check_mode: - self.module.log( - "PODMAN-CONTAINER-DEBUG (check_mode): %s" % full_cmd) + if USE_API: + new_params = PodmanModuleParamsAPI(self.module_params, + self.version, + self.module, + ).translate() + + if action in ('start', 'stop', 'delete'): + container = self.client.containers.get(self.name) + if not container: + self.module.fail_json(msg="Container %s doesn't exist") + if action == 'start': + self.client.containers.start(self.name) + elif action == 'stop': + self.client.containers.stop(self.name) + elif action == 'delete': + self.client.containers.remove(self.name, force=True) + elif action == 'create': + new_params.pop('detach') + try: + container = self.client.containers.create( + **new_params + ) + except Exception as e: + self.module.fail_json(msg=str(e)) + elif action == 'run': + try: + container = self.client.containers.run( + **new_params + ) + except Exception as e: + self.module.fail_json(msg=str(e)) else: - rc, out, err = self.module.run_command( - [self.module_params['executable'], b'container'] + b_command, - expand_user_and_vars=False) - self.module.log("PODMAN-CONTAINER-DEBUG: %s" % full_cmd) - if self.module_params['debug']: - self.module.log("PODMAN-CONTAINER-DEBUG STDOUT: %s" % out) - self.module.log("PODMAN-CONTAINER-DEBUG STDERR: %s" % err) - self.module.log("PODMAN-CONTAINER-DEBUG RC: %s" % rc) - self.stdout = out - self.stderr = err - if rc != 0: - self.module.fail_json( - msg="Can't %s container %s" % (action, self.name), - stdout=out, stderr=err) + b_command = PodmanModuleParams(action, + self.module_params, + self.version, + self.module, + ).construct_command_from_params() + if action == 'create': + b_command.remove(b'--detach=True') + full_cmd = " ".join([self.module_params['executable']] + + [to_native(i) for i in b_command]) + self.actions.append(full_cmd) + if self.module.check_mode: + self.module.log( + "PODMAN-CONTAINER-DEBUG (check_mode): %s" % full_cmd) + else: + rc, out, err = self.module.run_command( + [self.module_params['executable'], b'container'] + b_command, + expand_user_and_vars=False) + self.module.log("PODMAN-CONTAINER-DEBUG: %s" % full_cmd) + if self.module_params['debug']: + self.module.log("PODMAN-CONTAINER-DEBUG STDOUT: %s" % out) + self.module.log("PODMAN-CONTAINER-DEBUG STDERR: %s" % err) + self.module.log("PODMAN-CONTAINER-DEBUG RC: %s" % rc) + self.stdout = out + self.stderr = err + if rc != 0: + self.module.fail_json( + msg="Can't %s container %s" % (action, self.name), + stdout=out, stderr=err) def run(self): """Run the container.""" @@ -1487,18 +1734,41 @@ def __init__(self, module, params): } self.module_params = params self.name = self.module_params['name'] - self.executable = \ - self.module.get_bin_path(self.module_params['executable'], - required=True) + self.client = None + if self.module_params['podman_socket']: + global USE_API + USE_API = True + api = PodmanAPI(self.module, self.module_params) + self.client = api.client + else: + self.executable = \ + self.module.get_bin_path(self.module_params['executable'], + required=True) self.image = self.module_params['image'] image_actions = ensure_image_exists( - self.module, self.image, self.module_params) + self.module, self.image, self.module_params, self.client) self.results['actions'] += image_actions self.state = self.module_params['state'] self.restart = self.module_params['force_restart'] self.recreate = self.module_params['recreate'] self.container = PodmanContainer( - self.module, self.name, self.module_params) + self.module, self.name, self.module_params, self.client) + + def api_wait(self): + """In case of detach=False and API call, wait until container + is finished. + """ + if (self.container.module_params.get("detach") is None + or self.container.module_params.get("detach")): + return + status = True + time.sleep(2) # Give time to container to start + while status: + info = self.container.get_info() + if not info: + return + status = info['State']['Running'] + time.sleep(2) def update_container_result(self, changed=True): """Inspect the current container, update results with last info, exit. @@ -1507,6 +1777,8 @@ def update_container_result(self, changed=True): changed {bool} -- whether any action was performed (default: {True}) """ + if USE_API: + self.api_wait() facts = self.container.get_info() if changed else self.container.info out, err = self.container.stdout, self.container.stderr self.results.update({'changed': changed, 'container': facts, diff --git a/plugins/modules/podman_container.py b/plugins/modules/podman_container.py index 1e8dbe8a4..d003d14b4 100644 --- a/plugins/modules/podman_container.py +++ b/plugins/modules/podman_container.py @@ -32,6 +32,11 @@ machine running C(podman) default: 'podman' type: str + podman_socket: + description: + - Unix socket address for API connection. If API is not available, the + module will fail. + type: str state: description: - I(absent) - A container matching the specified name will be stopped and @@ -242,7 +247,8 @@ description: - Set custom DNS search domains (Use dns_search with '' if you don't wish to set the search domain) - type: str + type: list + elements: str aliases: - dns_search_domains entrypoint: diff --git a/plugins/modules/podman_container_info.py b/plugins/modules/podman_container_info.py index bbdd29fb9..47d8a6e76 100644 --- a/plugins/modules/podman_container_info.py +++ b/plugins/modules/podman_container_info.py @@ -31,6 +31,11 @@ machine running C(podman) default: 'podman' type: str + podman_socket: + description: + - Unix socket address for API connection. If API is not available, the + module will fail. + type: str ''' EXAMPLES = r""" @@ -316,8 +321,12 @@ import json import time + +from ..module_utils.podman.common import PodmanAPI from ansible.module_utils.basic import AnsibleModule +USE_API = False + def get_containers_facts(module, executable, name): """Collect containers facts for all containers or for specified in 'name'. @@ -332,6 +341,23 @@ def get_containers_facts(module, executable, name): """ retry = 0 retry_limit = 4 + if module.params['podman_socket']: + global USE_API + USE_API = True + api = PodmanAPI(module, module.params) + client = api.client + all_names = client.containers.list(all_=True) + all_data = [] + cycle = name or [j['Id'] for j in all_names] + for c in cycle: + try: + container_attrs = client.containers.get(c) + all_data.append(container_attrs) + except Exception: + if name: + module.fail_json(msg="Container %s can't be found!" % c) + continue + return all_data, '', '' if not name: all_names = [executable, 'container', 'ls', '-q', '-a'] rc, out, err = module.run_command(all_names) @@ -360,7 +386,8 @@ def get_containers_facts(module, executable, name): if len(name) < 2: return [], out, err return cycle_over(module, executable, name) - module.fail_json(msg="Unable to gather info for %s: %s" % (",".join(name), err)) + module.fail_json(msg="Unable to gather info for %s: %s" % + (",".join(name), err)) def cycle_over(module, executable, name): @@ -380,7 +407,8 @@ def cycle_over(module, executable, name): command = [executable, 'container', 'inspect', container] rc, out, err = module.run_command(command) if rc != 0 and 'no such ' not in err: - module.fail_json(msg="Unable to gather info for %s: %s" % (container, err)) + module.fail_json( + msg="Unable to gather info for %s: %s" % (container, err)) if rc == 0 and out: json_out = json.loads(out) if json_out: @@ -394,12 +422,16 @@ def main(): argument_spec={ 'executable': {'type': 'str', 'default': 'podman'}, 'name': {'type': 'list', 'elements': 'str'}, + 'podman_socket': {'type': 'str'} }, supports_check_mode=True, ) name = module.params['name'] - executable = module.get_bin_path(module.params['executable'], required=True) + executable = None + if not module.params['podman_socket']: + executable = module.get_bin_path( + module.params['executable'], required=True) # pylint: disable=unused-variable inspect_results, out, err = get_containers_facts(module, executable, name) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..f2293605c --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/tests/integration/targets/podman_container/tasks/main.yml b/tests/integration/targets/podman_container/tasks/main.yml index 1d3800727..34850f5d1 100644 --- a/tests/integration/targets/podman_container/tasks/main.yml +++ b/tests/integration/targets/podman_container/tasks/main.yml @@ -91,7 +91,9 @@ assert: that: - imagefail is failed - - imagefail.msg == "Can't pull image ineverneverneverexist" + - imagefail.msg == "Can't pull image ineverneverneverexist" or + imagefail.msg == "Can't find and pull image ineverneverneverexist" or + '"find and pull image ineverneverneverexist" in imagefail.msg' - name: Force container recreate @@ -474,9 +476,11 @@ assert: that: - "'podman rm -f testidem' in remove.podman_actions" + ignore_errors: true - # - name: Create a pod - # shell: podman pod create --name testidempod + - name: Create a pod + containers.podman.podman_pod: + name: testidempod - name: Check basic idempotency of pod container containers.podman.podman_container: @@ -484,7 +488,7 @@ image: docker.io/alpine state: present command: sleep 20m - pod: "new:testidempod" + pod: "testidempod" - name: Check basic idempotency of pod container - run it again containers.podman.podman_container: diff --git a/tests/integration/targets/podman_container_api/tasks/main.yml b/tests/integration/targets/podman_container_api/tasks/main.yml new file mode 100644 index 000000000..e2ed42d8a --- /dev/null +++ b/tests/integration/targets/podman_container_api/tasks/main.yml @@ -0,0 +1,3 @@ +--- + +- include: ../../podman_containers/tasks/main.yml