Add typing information, 1/2 (#1176)

* Re-enable typing and improve config.

* Make mypy pass.

* Improve settings.

* First batch of types.

* Add more type hints.

* Fixes.

* Format.

* Fix split_port() without returning to previous type chaos.

* Continue with type hints (and ignores).
This commit is contained in:
Felix Fontein
2025-10-23 07:05:42 +02:00
committed by GitHub
parent 24f35644e3
commit 3350283bcc
92 changed files with 4366 additions and 2272 deletions
+12 -9
View File
@@ -12,8 +12,10 @@
from __future__ import annotations
import traceback
import typing as t
REQUESTS_IMPORT_ERROR: str | None # pylint: disable=invalid-name
try:
from requests import Session # noqa: F401, pylint: disable=unused-import
from requests.adapters import ( # noqa: F401, pylint: disable=unused-import
@@ -26,28 +28,29 @@ try:
except ImportError:
REQUESTS_IMPORT_ERROR = traceback.format_exc() # pylint: disable=invalid-name
class Session:
__attrs__ = []
class Session: # type: ignore
__attrs__: list[t.Never] = []
class HTTPAdapter:
__attrs__ = []
class HTTPAdapter: # type: ignore
__attrs__: list[t.Never] = []
class HTTPError(Exception):
class HTTPError(Exception): # type: ignore
pass
class InvalidSchema(Exception):
class InvalidSchema(Exception): # type: ignore
pass
else:
REQUESTS_IMPORT_ERROR = None # pylint: disable=invalid-name
URLLIB3_IMPORT_ERROR = None # pylint: disable=invalid-name
URLLIB3_IMPORT_ERROR: str | None = None # pylint: disable=invalid-name
try:
from requests.packages import urllib3 # pylint: disable=unused-import
# pylint: disable-next=unused-import
from requests.packages.urllib3 import connection as urllib3_connection
from requests.packages.urllib3 import ( # type: ignore # pylint: disable=unused-import # isort: skip
connection as urllib3_connection,
)
except ImportError:
try:
import urllib3 # pylint: disable=unused-import
+462 -86
View File
@@ -13,8 +13,9 @@ from __future__ import annotations
import json
import logging
import os
import struct
from functools import partial
import typing as t
from urllib.parse import quote
from .. import auth
@@ -47,16 +48,21 @@ from ..transport.sshconn import PARAMIKO_IMPORT_ERROR, SSHHTTPAdapter
from ..transport.ssladapter import SSLHTTPAdapter
from ..transport.unixconn import UnixHTTPAdapter
from ..utils import config, json_stream, utils
from ..utils.decorators import update_headers
from ..utils.decorators import minimum_version, update_headers
from ..utils.proxy import ProxyConfig
from ..utils.socket import consume_socket_output, demux_adaptor, frames_iter
from .daemon import DaemonApiMixin
if t.TYPE_CHECKING:
from requests import Response
from ..._socket_helper import SocketLike
log = logging.getLogger(__name__)
class APIClient(_Session, DaemonApiMixin):
class APIClient(_Session):
"""
A low-level client for the Docker Engine API.
@@ -105,16 +111,16 @@ class APIClient(_Session, DaemonApiMixin):
def __init__(
self,
base_url=None,
version=None,
timeout=DEFAULT_TIMEOUT_SECONDS,
tls=False,
user_agent=DEFAULT_USER_AGENT,
num_pools=None,
credstore_env=None,
use_ssh_client=False,
max_pool_size=DEFAULT_MAX_POOL_SIZE,
):
base_url: str | None = None,
version: str | None = None,
timeout: int | float = DEFAULT_TIMEOUT_SECONDS,
tls: bool | TLSConfig = False,
user_agent: str = DEFAULT_USER_AGENT,
num_pools: int | None = None,
credstore_env: dict[str, str] | None = None,
use_ssh_client: bool = False,
max_pool_size: int = DEFAULT_MAX_POOL_SIZE,
) -> None:
super().__init__()
fail_on_missing_imports()
@@ -124,7 +130,6 @@ class APIClient(_Session, DaemonApiMixin):
"If using TLS, the base_url argument must be provided."
)
self.base_url = base_url
self.timeout = timeout
self.headers["User-Agent"] = user_agent
@@ -145,6 +150,7 @@ class APIClient(_Session, DaemonApiMixin):
self.credstore_env = credstore_env
base_url = utils.parse_host(base_url, IS_WINDOWS_PLATFORM, tls=bool(tls))
self.base_url = base_url
# SSH has a different default for num_pools to all other adapters
num_pools = (
num_pools or DEFAULT_NUM_POOLS_SSH
@@ -152,6 +158,9 @@ class APIClient(_Session, DaemonApiMixin):
else DEFAULT_NUM_POOLS
)
self._custom_adapter: (
UnixHTTPAdapter | NpipeHTTPAdapter | SSHHTTPAdapter | SSLHTTPAdapter | None
) = None
if base_url.startswith("http+unix://"):
self._custom_adapter = UnixHTTPAdapter(
base_url,
@@ -223,7 +232,7 @@ class APIClient(_Session, DaemonApiMixin):
f"API versions below {MINIMUM_DOCKER_API_VERSION} are no longer supported by this library."
)
def _retrieve_server_version(self):
def _retrieve_server_version(self) -> str:
try:
version_result = self.version(api_version=False)
except Exception as e:
@@ -242,54 +251,87 @@ class APIClient(_Session, DaemonApiMixin):
f"Error while fetching server API version: {e}. Response seems to be broken."
) from e
def _set_request_timeout(self, kwargs):
def _set_request_timeout(self, kwargs: dict[str, t.Any]) -> dict[str, t.Any]:
"""Prepare the kwargs for an HTTP request by inserting the timeout
parameter, if not already present."""
kwargs.setdefault("timeout", self.timeout)
return kwargs
@update_headers
def _post(self, url, **kwargs):
def _post(self, url: str, **kwargs):
return self.post(url, **self._set_request_timeout(kwargs))
@update_headers
def _get(self, url, **kwargs):
def _get(self, url: str, **kwargs):
return self.get(url, **self._set_request_timeout(kwargs))
@update_headers
def _head(self, url, **kwargs):
def _head(self, url: str, **kwargs):
return self.head(url, **self._set_request_timeout(kwargs))
@update_headers
def _put(self, url, **kwargs):
def _put(self, url: str, **kwargs):
return self.put(url, **self._set_request_timeout(kwargs))
@update_headers
def _delete(self, url, **kwargs):
def _delete(self, url: str, **kwargs):
return self.delete(url, **self._set_request_timeout(kwargs))
def _url(self, pathfmt, *args, **kwargs):
def _url(self, pathfmt: str, *args: str, versioned_api: bool = True) -> str:
for arg in args:
if not isinstance(arg, str):
raise ValueError(
f"Expected a string but found {arg} ({type(arg)}) instead"
)
quote_f = partial(quote, safe="/:")
args = map(quote_f, args)
q_args = [quote(arg, safe="/:") for arg in args]
if kwargs.get("versioned_api", True):
return f"{self.base_url}/v{self._version}{pathfmt.format(*args)}"
return f"{self.base_url}{pathfmt.format(*args)}"
if versioned_api:
return f"{self.base_url}/v{self._version}{pathfmt.format(*q_args)}"
return f"{self.base_url}{pathfmt.format(*q_args)}"
def _raise_for_status(self, response):
def _raise_for_status(self, response: Response) -> None:
"""Raises stored :class:`APIError`, if one occurred."""
try:
response.raise_for_status()
except _HTTPError as e:
create_api_error_from_http_exception(e)
def _result(self, response, get_json=False, get_binary=False):
@t.overload
def _result(
self,
response: Response,
*,
get_json: t.Literal[False] = False,
get_binary: t.Literal[False] = False,
) -> str: ...
@t.overload
def _result(
self,
response: Response,
*,
get_json: t.Literal[True],
get_binary: t.Literal[False] = False,
) -> t.Any: ...
@t.overload
def _result(
self,
response: Response,
*,
get_json: t.Literal[False] = False,
get_binary: t.Literal[True],
) -> bytes: ...
@t.overload
def _result(
self, response: Response, *, get_json: bool = False, get_binary: bool = False
) -> t.Any | str | bytes: ...
def _result(
self, response: Response, *, get_json: bool = False, get_binary: bool = False
) -> t.Any | str | bytes:
if get_json and get_binary:
raise AssertionError("json and binary must not be both True")
self._raise_for_status(response)
@@ -300,10 +342,12 @@ class APIClient(_Session, DaemonApiMixin):
return response.content
return response.text
def _post_json(self, url, data, **kwargs):
def _post_json(
self, url: str, data: dict[str, str | None] | t.Any, **kwargs
) -> Response:
# Go <1.1 cannot unserialize null to a string
# so we do this disgusting thing here.
data2 = {}
data2: dict[str, t.Any] = {}
if data is not None and isinstance(data, dict):
for k, v in data.items():
if v is not None:
@@ -316,19 +360,19 @@ class APIClient(_Session, DaemonApiMixin):
kwargs["headers"]["Content-Type"] = "application/json"
return self._post(url, data=json.dumps(data2), **kwargs)
def _attach_params(self, override=None):
def _attach_params(self, override: dict[str, int] | None = None) -> dict[str, int]:
return override or {"stdout": 1, "stderr": 1, "stream": 1}
def _get_raw_response_socket(self, response):
def _get_raw_response_socket(self, response: Response) -> SocketLike:
self._raise_for_status(response)
if self.base_url == "http+docker://localnpipe":
sock = response.raw._fp.fp.raw.sock
sock = response.raw._fp.fp.raw.sock # type: ignore[union-attr]
elif self.base_url.startswith("http+docker://ssh"):
sock = response.raw._fp.fp.channel
sock = response.raw._fp.fp.channel # type: ignore[union-attr]
else:
sock = response.raw._fp.fp.raw
sock = response.raw._fp.fp.raw # type: ignore[union-attr]
if self.base_url.startswith("https://"):
sock = sock._sock
sock = sock._sock # type: ignore[union-attr]
try:
# Keep a reference to the response to stop it being garbage
# collected. If the response is garbage collected, it will
@@ -341,12 +385,26 @@ class APIClient(_Session, DaemonApiMixin):
return sock
def _stream_helper(self, response, decode=False):
@t.overload
def _stream_helper(
self, response: Response, *, decode: t.Literal[False] = False
) -> t.Generator[bytes]: ...
@t.overload
def _stream_helper(
self, response: Response, *, decode: t.Literal[True]
) -> t.Generator[t.Any]: ...
def _stream_helper(
self, response: Response, *, decode: bool = False
) -> t.Generator[t.Any]:
"""Generator for data coming from a chunked-encoded HTTP response."""
if response.raw._fp.chunked:
if response.raw._fp.chunked: # type: ignore[union-attr]
if decode:
yield from json_stream.json_stream(self._stream_helper(response, False))
yield from json_stream.json_stream(
self._stream_helper(response, decode=False)
)
else:
reader = response.raw
while not reader.closed:
@@ -354,15 +412,15 @@ class APIClient(_Session, DaemonApiMixin):
data = reader.read(1)
if not data:
break
if reader._fp.chunk_left:
data += reader.read(reader._fp.chunk_left)
if reader._fp.chunk_left: # type: ignore[union-attr]
data += reader.read(reader._fp.chunk_left) # type: ignore[union-attr]
yield data
else:
# Response is not chunked, meaning we probably
# encountered an error immediately
yield self._result(response, get_json=decode)
def _multiplexed_buffer_helper(self, response):
def _multiplexed_buffer_helper(self, response: Response) -> t.Generator[bytes]:
"""A generator of multiplexed data blocks read from a buffered
response."""
buf = self._result(response, get_binary=True)
@@ -378,7 +436,9 @@ class APIClient(_Session, DaemonApiMixin):
walker = end
yield buf[start:end]
def _multiplexed_response_stream_helper(self, response):
def _multiplexed_response_stream_helper(
self, response: Response
) -> t.Generator[bytes]:
"""A generator of multiplexed data blocks coming from a response
stream."""
@@ -399,7 +459,19 @@ class APIClient(_Session, DaemonApiMixin):
break
yield data
def _stream_raw_result(self, response, chunk_size=1, decode=True):
@t.overload
def _stream_raw_result(
self, response: Response, *, chunk_size: int = 1, decode: t.Literal[True] = True
) -> t.Generator[str]: ...
@t.overload
def _stream_raw_result(
self, response: Response, *, chunk_size: int = 1, decode: t.Literal[False]
) -> t.Generator[bytes]: ...
def _stream_raw_result(
self, response: Response, *, chunk_size: int = 1, decode: bool = True
) -> t.Generator[str | bytes]:
"""Stream result for TTY-enabled container and raw binary data"""
self._raise_for_status(response)
@@ -410,14 +482,81 @@ class APIClient(_Session, DaemonApiMixin):
yield from response.iter_content(chunk_size, decode)
def _read_from_socket(self, response, stream, tty=True, demux=False):
@t.overload
def _read_from_socket(
self,
response: Response,
*,
stream: t.Literal[True],
tty: bool = True,
demux: t.Literal[False] = False,
) -> t.Generator[bytes]: ...
@t.overload
def _read_from_socket(
self,
response: Response,
*,
stream: t.Literal[True],
tty: t.Literal[True] = True,
demux: t.Literal[True],
) -> t.Generator[tuple[bytes, None]]: ...
@t.overload
def _read_from_socket(
self,
response: Response,
*,
stream: t.Literal[True],
tty: t.Literal[False],
demux: t.Literal[True],
) -> t.Generator[tuple[bytes, None] | tuple[None, bytes]]: ...
@t.overload
def _read_from_socket(
self,
response: Response,
*,
stream: t.Literal[False],
tty: bool = True,
demux: t.Literal[False] = False,
) -> bytes: ...
@t.overload
def _read_from_socket(
self,
response: Response,
*,
stream: t.Literal[False],
tty: t.Literal[True] = True,
demux: t.Literal[True],
) -> tuple[bytes, None]: ...
@t.overload
def _read_from_socket(
self,
response: Response,
*,
stream: t.Literal[False],
tty: t.Literal[False],
demux: t.Literal[True],
) -> tuple[bytes, bytes]: ...
@t.overload
def _read_from_socket(
self, response: Response, *, stream: bool, tty: bool = True, demux: bool = False
) -> t.Any: ...
def _read_from_socket(
self, response: Response, *, stream: bool, tty: bool = True, demux: bool = False
) -> t.Any:
"""Consume all data from the socket, close the response and return the
data. If stream=True, then a generator is returned instead and the
caller is responsible for closing the response.
"""
socket = self._get_raw_response_socket(response)
gen = frames_iter(socket, tty)
gen: t.Generator = frames_iter(socket, tty)
if demux:
# The generator will output tuples (stdout, stderr)
@@ -434,7 +573,7 @@ class APIClient(_Session, DaemonApiMixin):
finally:
response.close()
def _disable_socket_timeout(self, socket):
def _disable_socket_timeout(self, socket: SocketLike) -> None:
"""Depending on the combination of python version and whether we are
connecting over http or https, we might need to access _sock, which
may or may not exist; or we may need to just settimeout on socket
@@ -451,18 +590,38 @@ class APIClient(_Session, DaemonApiMixin):
if not hasattr(s, "settimeout"):
continue
timeout = -1
timeout: int | float | None = -1
if hasattr(s, "gettimeout"):
timeout = s.gettimeout()
timeout = s.gettimeout() # type: ignore[union-attr]
# Do not change the timeout if it is already disabled.
if timeout is None or timeout == 0.0:
continue
s.settimeout(None)
s.settimeout(None) # type: ignore[union-attr]
def _get_result_tty(self, stream, res, is_tty):
@t.overload
def _get_result_tty(
self, stream: t.Literal[True], res: Response, is_tty: t.Literal[True]
) -> t.Generator[str]: ...
@t.overload
def _get_result_tty(
self, stream: t.Literal[True], res: Response, is_tty: t.Literal[False]
) -> t.Generator[bytes]: ...
@t.overload
def _get_result_tty(
self, stream: t.Literal[False], res: Response, is_tty: t.Literal[True]
) -> bytes: ...
@t.overload
def _get_result_tty(
self, stream: t.Literal[False], res: Response, is_tty: t.Literal[False]
) -> bytes: ...
def _get_result_tty(self, stream: bool, res: Response, is_tty: bool) -> t.Any:
# We should also use raw streaming (without keep-alive)
# if we are dealing with a tty-enabled container.
if is_tty:
@@ -478,11 +637,11 @@ class APIClient(_Session, DaemonApiMixin):
return self._multiplexed_response_stream_helper(res)
return sep.join(list(self._multiplexed_buffer_helper(res)))
def _unmount(self, *args):
def _unmount(self, *args) -> None:
for proto in args:
self.adapters.pop(proto)
def get_adapter(self, url):
def get_adapter(self, url: str):
try:
return super().get_adapter(url)
except _InvalidSchema as e:
@@ -491,10 +650,10 @@ class APIClient(_Session, DaemonApiMixin):
raise e
@property
def api_version(self):
def api_version(self) -> str:
return self._version
def reload_config(self, dockercfg_path=None):
def reload_config(self, dockercfg_path: str | None = None) -> None:
"""
Force a reload of the auth configuration
@@ -510,7 +669,7 @@ class APIClient(_Session, DaemonApiMixin):
dockercfg_path, credstore_env=self.credstore_env
)
def _set_auth_headers(self, headers):
def _set_auth_headers(self, headers: dict[str, str | bytes]) -> None:
log.debug("Looking for auth config")
# If we do not have any auth data so far, try reloading the config
@@ -537,57 +696,62 @@ class APIClient(_Session, DaemonApiMixin):
else:
log.debug("No auth config found")
def get_binary(self, pathfmt, *args, **kwargs):
def get_binary(self, pathfmt: str, *args: str, **kwargs) -> bytes:
return self._result(
self._get(self._url(pathfmt, *args, versioned_api=True), **kwargs),
get_binary=True,
)
def get_json(self, pathfmt, *args, **kwargs):
def get_json(self, pathfmt: str, *args: str, **kwargs) -> t.Any:
return self._result(
self._get(self._url(pathfmt, *args, versioned_api=True), **kwargs),
get_json=True,
)
def get_text(self, pathfmt, *args, **kwargs):
def get_text(self, pathfmt: str, *args: str, **kwargs) -> str:
return self._result(
self._get(self._url(pathfmt, *args, versioned_api=True), **kwargs)
)
def get_raw_stream(self, pathfmt, *args, **kwargs):
chunk_size = kwargs.pop("chunk_size", DEFAULT_DATA_CHUNK_SIZE)
def get_raw_stream(
self,
pathfmt: str,
*args: str,
chunk_size: int = DEFAULT_DATA_CHUNK_SIZE,
**kwargs,
) -> t.Generator[bytes]:
res = self._get(
self._url(pathfmt, *args, versioned_api=True), stream=True, **kwargs
)
self._raise_for_status(res)
return self._stream_raw_result(res, chunk_size, False)
return self._stream_raw_result(res, chunk_size=chunk_size, decode=False)
def delete_call(self, pathfmt, *args, **kwargs):
def delete_call(self, pathfmt: str, *args: str, **kwargs) -> None:
self._raise_for_status(
self._delete(self._url(pathfmt, *args, versioned_api=True), **kwargs)
)
def delete_json(self, pathfmt, *args, **kwargs):
def delete_json(self, pathfmt: str, *args: str, **kwargs) -> t.Any:
return self._result(
self._delete(self._url(pathfmt, *args, versioned_api=True), **kwargs),
get_json=True,
)
def post_call(self, pathfmt, *args, **kwargs):
def post_call(self, pathfmt: str, *args: str, **kwargs) -> None:
self._raise_for_status(
self._post(self._url(pathfmt, *args, versioned_api=True), **kwargs)
)
def post_json(self, pathfmt, *args, **kwargs):
data = kwargs.pop("data", None)
def post_json(self, pathfmt: str, *args: str, data: t.Any = None, **kwargs) -> None:
self._raise_for_status(
self._post_json(
self._url(pathfmt, *args, versioned_api=True), data, **kwargs
)
)
def post_json_to_binary(self, pathfmt, *args, **kwargs):
data = kwargs.pop("data", None)
def post_json_to_binary(
self, pathfmt: str, *args: str, data: t.Any = None, **kwargs
) -> bytes:
return self._result(
self._post_json(
self._url(pathfmt, *args, versioned_api=True), data, **kwargs
@@ -595,8 +759,9 @@ class APIClient(_Session, DaemonApiMixin):
get_binary=True,
)
def post_json_to_json(self, pathfmt, *args, **kwargs):
data = kwargs.pop("data", None)
def post_json_to_json(
self, pathfmt: str, *args: str, data: t.Any = None, **kwargs
) -> t.Any:
return self._result(
self._post_json(
self._url(pathfmt, *args, versioned_api=True), data, **kwargs
@@ -604,17 +769,24 @@ class APIClient(_Session, DaemonApiMixin):
get_json=True,
)
def post_json_to_text(self, pathfmt, *args, **kwargs):
data = kwargs.pop("data", None)
def post_json_to_text(
self, pathfmt: str, *args: str, data: t.Any = None, **kwargs
) -> str:
return self._result(
self._post_json(
self._url(pathfmt, *args, versioned_api=True), data, **kwargs
),
)
def post_json_to_stream_socket(self, pathfmt, *args, **kwargs):
data = kwargs.pop("data", None)
headers = (kwargs.pop("headers", None) or {}).copy()
def post_json_to_stream_socket(
self,
pathfmt: str,
*args: str,
data: t.Any = None,
headers: dict[str, str] | None = None,
**kwargs,
) -> SocketLike:
headers = headers.copy() if headers else {}
headers.update(
{
"Connection": "Upgrade",
@@ -631,18 +803,102 @@ class APIClient(_Session, DaemonApiMixin):
)
)
def post_json_to_stream(self, pathfmt, *args, **kwargs):
data = kwargs.pop("data", None)
headers = (kwargs.pop("headers", None) or {}).copy()
@t.overload
def post_json_to_stream(
self,
pathfmt: str,
*args: str,
data: t.Any = None,
headers: dict[str, str] | None = None,
stream: t.Literal[True],
tty: bool = True,
demux: t.Literal[False] = False,
**kwargs,
) -> t.Generator[bytes]: ...
@t.overload
def post_json_to_stream(
self,
pathfmt: str,
*args: str,
data: t.Any = None,
headers: dict[str, str] | None = None,
stream: t.Literal[True],
tty: t.Literal[True] = True,
demux: t.Literal[True],
**kwargs,
) -> t.Generator[tuple[bytes, None]]: ...
@t.overload
def post_json_to_stream(
self,
pathfmt: str,
*args: str,
data: t.Any = None,
headers: dict[str, str] | None = None,
stream: t.Literal[True],
tty: t.Literal[False],
demux: t.Literal[True],
**kwargs,
) -> t.Generator[tuple[bytes, None] | tuple[None, bytes]]: ...
@t.overload
def post_json_to_stream(
self,
pathfmt: str,
*args: str,
data: t.Any = None,
headers: dict[str, str] | None = None,
stream: t.Literal[False],
tty: bool = True,
demux: t.Literal[False] = False,
**kwargs,
) -> bytes: ...
@t.overload
def post_json_to_stream(
self,
pathfmt: str,
*args: str,
data: t.Any = None,
headers: dict[str, str] | None = None,
stream: t.Literal[False],
tty: t.Literal[True] = True,
demux: t.Literal[True],
**kwargs,
) -> tuple[bytes, None]: ...
@t.overload
def post_json_to_stream(
self,
pathfmt: str,
*args: str,
data: t.Any = None,
headers: dict[str, str] | None = None,
stream: t.Literal[False],
tty: t.Literal[False],
demux: t.Literal[True],
**kwargs,
) -> tuple[bytes, bytes]: ...
def post_json_to_stream(
self,
pathfmt: str,
*args: str,
data: t.Any = None,
headers: dict[str, str] | None = None,
stream: bool = False,
demux: bool = False,
tty: bool = False,
**kwargs,
) -> t.Any:
headers = headers.copy() if headers else {}
headers.update(
{
"Connection": "Upgrade",
"Upgrade": "tcp",
}
)
stream = kwargs.pop("stream", False)
demux = kwargs.pop("demux", False)
tty = kwargs.pop("tty", False)
return self._read_from_socket(
self._post_json(
self._url(pathfmt, *args, versioned_api=True),
@@ -651,13 +907,133 @@ class APIClient(_Session, DaemonApiMixin):
stream=True,
**kwargs,
),
stream,
stream=stream,
tty=tty,
demux=demux,
)
def post_to_json(self, pathfmt, *args, **kwargs):
def post_to_json(self, pathfmt: str, *args: str, **kwargs) -> t.Any:
return self._result(
self._post(self._url(pathfmt, *args, versioned_api=True), **kwargs),
get_json=True,
)
@minimum_version("1.25")
def df(self) -> dict[str, t.Any]:
"""
Get data usage information.
Returns:
(dict): A dictionary representing different resource categories
and their respective data usage.
Raises:
:py:class:`docker.errors.APIError`
If the server returns an error.
"""
url = self._url("/system/df")
return self._result(self._get(url), get_json=True)
def info(self) -> dict[str, t.Any]:
"""
Display system-wide information. Identical to the ``docker info``
command.
Returns:
(dict): The info as a dict
Raises:
:py:class:`docker.errors.APIError`
If the server returns an error.
"""
return self._result(self._get(self._url("/info")), get_json=True)
def login(
self,
username: str,
password: str | None = None,
email: str | None = None,
registry: str | None = None,
reauth: bool = False,
dockercfg_path: str | None = None,
) -> dict[str, t.Any]:
"""
Authenticate with a registry. Similar to the ``docker login`` command.
Args:
username (str): The registry username
password (str): The plaintext password
email (str): The email for the registry account
registry (str): URL to the registry. E.g.
``https://index.docker.io/v1/``
reauth (bool): Whether or not to refresh existing authentication on
the Docker server.
dockercfg_path (str): Use a custom path for the Docker config file
(default ``$HOME/.docker/config.json`` if present,
otherwise ``$HOME/.dockercfg``)
Returns:
(dict): The response from the login request
Raises:
:py:class:`docker.errors.APIError`
If the server returns an error.
"""
# If we do not have any auth data so far, try reloading the config file
# one more time in case anything showed up in there.
# If dockercfg_path is passed check to see if the config file exists,
# if so load that config.
if dockercfg_path and os.path.exists(dockercfg_path):
self._auth_configs = auth.load_config(
dockercfg_path, credstore_env=self.credstore_env
)
elif not self._auth_configs or self._auth_configs.is_empty:
self._auth_configs = auth.load_config(credstore_env=self.credstore_env)
authcfg = self._auth_configs.resolve_authconfig(registry)
# If we found an existing auth config for this registry and username
# combination, we can return it immediately unless reauth is requested.
if authcfg and authcfg.get("username", None) == username and not reauth:
return authcfg
req_data = {
"username": username,
"password": password,
"email": email,
"serveraddress": registry,
}
response = self._post_json(self._url("/auth"), data=req_data)
if response.status_code == 200:
self._auth_configs.add_auth(registry or auth.INDEX_NAME, req_data)
return self._result(response, get_json=True)
def ping(self) -> bool:
"""
Checks the server is responsive. An exception will be raised if it
is not responding.
Returns:
(bool) The response from the server.
Raises:
:py:class:`docker.errors.APIError`
If the server returns an error.
"""
return self._result(self._get(self._url("/_ping"))) == "OK"
def version(self, api_version: bool = True) -> dict[str, t.Any]:
"""
Returns version information from the server. Similar to the ``docker
version`` command.
Returns:
(dict): The server version information
Raises:
:py:class:`docker.errors.APIError`
If the server returns an error.
"""
url = self._url("/version", versioned_api=api_version)
return self._result(self._get(url), get_json=True)
-139
View File
@@ -1,139 +0,0 @@
# This code is part of the Ansible collection community.docker, but is an independent component.
# This particular file, and this file only, is based on the Docker SDK for Python (https://github.com/docker/docker-py/)
#
# Copyright (c) 2016-2022 Docker, Inc.
#
# It is licensed under the Apache 2.0 license (see LICENSES/Apache-2.0.txt in this collection)
# SPDX-License-Identifier: Apache-2.0
# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time.
# Do not use this from other collections or standalone plugins/modules!
from __future__ import annotations
import os
from .. import auth
from ..utils.decorators import minimum_version
class DaemonApiMixin:
@minimum_version("1.25")
def df(self):
"""
Get data usage information.
Returns:
(dict): A dictionary representing different resource categories
and their respective data usage.
Raises:
:py:class:`docker.errors.APIError`
If the server returns an error.
"""
url = self._url("/system/df")
return self._result(self._get(url), get_json=True)
def info(self):
"""
Display system-wide information. Identical to the ``docker info``
command.
Returns:
(dict): The info as a dict
Raises:
:py:class:`docker.errors.APIError`
If the server returns an error.
"""
return self._result(self._get(self._url("/info")), get_json=True)
def login(
self,
username,
password=None,
email=None,
registry=None,
reauth=False,
dockercfg_path=None,
):
"""
Authenticate with a registry. Similar to the ``docker login`` command.
Args:
username (str): The registry username
password (str): The plaintext password
email (str): The email for the registry account
registry (str): URL to the registry. E.g.
``https://index.docker.io/v1/``
reauth (bool): Whether or not to refresh existing authentication on
the Docker server.
dockercfg_path (str): Use a custom path for the Docker config file
(default ``$HOME/.docker/config.json`` if present,
otherwise ``$HOME/.dockercfg``)
Returns:
(dict): The response from the login request
Raises:
:py:class:`docker.errors.APIError`
If the server returns an error.
"""
# If we do not have any auth data so far, try reloading the config file
# one more time in case anything showed up in there.
# If dockercfg_path is passed check to see if the config file exists,
# if so load that config.
if dockercfg_path and os.path.exists(dockercfg_path):
self._auth_configs = auth.load_config(
dockercfg_path, credstore_env=self.credstore_env
)
elif not self._auth_configs or self._auth_configs.is_empty:
self._auth_configs = auth.load_config(credstore_env=self.credstore_env)
authcfg = self._auth_configs.resolve_authconfig(registry)
# If we found an existing auth config for this registry and username
# combination, we can return it immediately unless reauth is requested.
if authcfg and authcfg.get("username", None) == username and not reauth:
return authcfg
req_data = {
"username": username,
"password": password,
"email": email,
"serveraddress": registry,
}
response = self._post_json(self._url("/auth"), data=req_data)
if response.status_code == 200:
self._auth_configs.add_auth(registry or auth.INDEX_NAME, req_data)
return self._result(response, get_json=True)
def ping(self):
"""
Checks the server is responsive. An exception will be raised if it
is not responding.
Returns:
(bool) The response from the server.
Raises:
:py:class:`docker.errors.APIError`
If the server returns an error.
"""
return self._result(self._get(self._url("/_ping"))) == "OK"
def version(self, api_version=True):
"""
Returns version information from the server. Similar to the ``docker
version`` command.
Returns:
(dict): The server version information
Raises:
:py:class:`docker.errors.APIError`
If the server returns an error.
"""
url = self._url("/version", versioned_api=api_version)
return self._result(self._get(url), get_json=True)
+60 -30
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import base64
import json
import logging
import typing as t
from . import errors
from .credentials.errors import CredentialsNotFound, StoreError
@@ -21,6 +22,12 @@ from .credentials.store import Store
from .utils import config
if t.TYPE_CHECKING:
from ansible_collections.community.docker.plugins.module_utils._api.api.client import (
APIClient,
)
INDEX_NAME = "docker.io"
INDEX_URL = f"https://index.{INDEX_NAME}/v1/"
TOKEN_USERNAME = "<token>"
@@ -28,7 +35,7 @@ TOKEN_USERNAME = "<token>"
log = logging.getLogger(__name__)
def resolve_repository_name(repo_name):
def resolve_repository_name(repo_name: str) -> tuple[str, str]:
if "://" in repo_name:
raise errors.InvalidRepository(
f"Repository name cannot contain a scheme ({repo_name})"
@@ -42,14 +49,14 @@ def resolve_repository_name(repo_name):
return resolve_index_name(index_name), remote_name
def resolve_index_name(index_name):
def resolve_index_name(index_name: str) -> str:
index_name = convert_to_hostname(index_name)
if index_name == "index." + INDEX_NAME:
index_name = INDEX_NAME
return index_name
def get_config_header(client, registry):
def get_config_header(client: APIClient, registry: str) -> bytes | None:
log.debug("Looking for auth config")
if not client._auth_configs or client._auth_configs.is_empty:
log.debug("No auth config in memory - loading from filesystem")
@@ -69,32 +76,38 @@ def get_config_header(client, registry):
return None
def split_repo_name(repo_name):
def split_repo_name(repo_name: str) -> tuple[str, str]:
parts = repo_name.split("/", 1)
if len(parts) == 1 or (
"." not in parts[0] and ":" not in parts[0] and parts[0] != "localhost"
):
# This is a docker index repo (ex: username/foobar or ubuntu)
return INDEX_NAME, repo_name
return tuple(parts)
return tuple(parts) # type: ignore
def get_credential_store(authconfig, registry):
def get_credential_store(
authconfig: dict[str, t.Any] | AuthConfig, registry: str
) -> str | None:
if not isinstance(authconfig, AuthConfig):
authconfig = AuthConfig(authconfig)
return authconfig.get_credential_store(registry)
class AuthConfig(dict):
def __init__(self, dct, credstore_env=None):
def __init__(
self, dct: dict[str, t.Any], credstore_env: dict[str, str] | None = None
):
if "auths" not in dct:
dct["auths"] = {}
self.update(dct)
self._credstore_env = credstore_env
self._stores = {}
self._stores: dict[str, Store] = {}
@classmethod
def parse_auth(cls, entries, raise_on_error=False):
def parse_auth(
cls, entries: dict[str, dict[str, t.Any]], raise_on_error=False
) -> dict[str, dict[str, t.Any]]:
"""
Parses authentication entries
@@ -107,10 +120,10 @@ class AuthConfig(dict):
Authentication registry.
"""
conf = {}
conf: dict[str, dict[str, t.Any]] = {}
for registry, entry in entries.items():
if not isinstance(entry, dict):
log.debug("Config entry for key %s is not auth config", registry)
log.debug("Config entry for key %s is not auth config", registry) # type: ignore
# We sometimes fall back to parsing the whole config as if it
# was the auth config by itself, for legacy purposes. In that
# case, we fail silently and return an empty conf if any of the
@@ -150,7 +163,12 @@ class AuthConfig(dict):
return conf
@classmethod
def load_config(cls, config_path, config_dict, credstore_env=None):
def load_config(
cls,
config_path: str | None,
config_dict: dict[str, t.Any] | None,
credstore_env: dict[str, str] | None = None,
) -> t.Self:
"""
Loads authentication data from a Docker configuration file in the given
root directory or if config_path is passed use given path.
@@ -196,22 +214,24 @@ class AuthConfig(dict):
return cls({"auths": cls.parse_auth(config_dict)}, credstore_env)
@property
def auths(self):
def auths(self) -> dict[str, dict[str, t.Any]]:
return self.get("auths", {})
@property
def creds_store(self):
def creds_store(self) -> str | None:
return self.get("credsStore", None)
@property
def cred_helpers(self):
def cred_helpers(self) -> dict[str, t.Any]:
return self.get("credHelpers", {})
@property
def is_empty(self):
def is_empty(self) -> bool:
return not self.auths and not self.creds_store and not self.cred_helpers
def resolve_authconfig(self, registry=None):
def resolve_authconfig(
self, registry: str | None = None
) -> dict[str, t.Any] | None:
"""
Returns the authentication data from the given auth configuration for a
specific registry. As with the Docker client, legacy entries in the
@@ -244,7 +264,9 @@ class AuthConfig(dict):
log.debug("No entry found")
return None
def _resolve_authconfig_credstore(self, registry, credstore_name):
def _resolve_authconfig_credstore(
self, registry: str | None, credstore_name: str
) -> dict[str, t.Any] | None:
if not registry or registry == INDEX_NAME:
# The ecosystem is a little schizophrenic with index.docker.io VS
# docker.io - in that case, it seems the full URL is necessary.
@@ -272,19 +294,19 @@ class AuthConfig(dict):
except StoreError as e:
raise errors.DockerException(f"Credentials store error: {e}")
def _get_store_instance(self, name):
def _get_store_instance(self, name: str):
if name not in self._stores:
self._stores[name] = Store(name, environment=self._credstore_env)
return self._stores[name]
def get_credential_store(self, registry):
def get_credential_store(self, registry: str | None) -> str | None:
if not registry or registry == INDEX_NAME:
registry = INDEX_URL
return self.cred_helpers.get(registry) or self.creds_store
def get_all_credentials(self):
auth_data = self.auths.copy()
def get_all_credentials(self) -> dict[str, dict[str, t.Any] | None]:
auth_data: dict[str, dict[str, t.Any] | None] = self.auths.copy() # type: ignore
if self.creds_store:
# Retrieve all credentials from the default store
store = self._get_store_instance(self.creds_store)
@@ -299,21 +321,23 @@ class AuthConfig(dict):
return auth_data
def add_auth(self, reg, data):
def add_auth(self, reg: str, data: dict[str, t.Any]) -> None:
self["auths"][reg] = data
def resolve_authconfig(authconfig, registry=None, credstore_env=None):
def resolve_authconfig(
authconfig, registry: str | None = None, credstore_env: dict[str, str] | None = None
):
if not isinstance(authconfig, AuthConfig):
authconfig = AuthConfig(authconfig, credstore_env)
return authconfig.resolve_authconfig(registry)
def convert_to_hostname(url):
def convert_to_hostname(url: str) -> str:
return url.replace("http://", "").replace("https://", "").split("/", 1)[0]
def decode_auth(auth):
def decode_auth(auth: str | bytes) -> tuple[str, str]:
if isinstance(auth, str):
auth = auth.encode("ascii")
s = base64.b64decode(auth)
@@ -321,12 +345,14 @@ def decode_auth(auth):
return login.decode("utf8"), pwd.decode("utf8")
def encode_header(auth):
def encode_header(auth: dict[str, t.Any]) -> bytes:
auth_json = json.dumps(auth).encode("ascii")
return base64.urlsafe_b64encode(auth_json)
def parse_auth(entries, raise_on_error=False):
def parse_auth(
entries: dict[str, dict[str, t.Any]], raise_on_error: bool = False
) -> dict[str, dict[str, t.Any]]:
"""
Parses authentication entries
@@ -342,11 +368,15 @@ def parse_auth(entries, raise_on_error=False):
return AuthConfig.parse_auth(entries, raise_on_error)
def load_config(config_path=None, config_dict=None, credstore_env=None):
def load_config(
config_path: str | None = None,
config_dict: dict[str, t.Any] | None = None,
credstore_env: dict[str, str] | None = None,
) -> AuthConfig:
return AuthConfig.load_config(config_path, config_dict, credstore_env)
def _load_legacy_config(config_file):
def _load_legacy_config(config_file: str) -> dict[str, dict[str, t.Any]]:
log.debug("Attempting to parse legacy auth file format")
try:
data = []
+20 -15
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import json
import os
import typing as t
from .. import errors
from .config import (
@@ -24,7 +25,11 @@ from .config import (
from .context import Context
def create_default_context():
if t.TYPE_CHECKING:
from ..tls import TLSConfig
def create_default_context() -> Context:
host = None
if os.environ.get("DOCKER_HOST"):
host = os.environ.get("DOCKER_HOST")
@@ -42,7 +47,7 @@ class ContextAPI:
DEFAULT_CONTEXT = None
@classmethod
def get_default_context(cls):
def get_default_context(cls) -> Context:
context = cls.DEFAULT_CONTEXT
if context is None:
context = create_default_context()
@@ -52,13 +57,13 @@ class ContextAPI:
@classmethod
def create_context(
cls,
name,
orchestrator=None,
host=None,
tls_cfg=None,
default_namespace=None,
skip_tls_verify=False,
):
name: str,
orchestrator: str | None = None,
host: str | None = None,
tls_cfg: TLSConfig | None = None,
default_namespace: str | None = None,
skip_tls_verify: bool = False,
) -> Context:
"""Creates a new context.
Returns:
(Context): a Context object.
@@ -108,7 +113,7 @@ class ContextAPI:
return ctx
@classmethod
def get_context(cls, name=None):
def get_context(cls, name: str | None = None) -> Context | None:
"""Retrieves a context object.
Args:
name (str): The name of the context
@@ -136,7 +141,7 @@ class ContextAPI:
return Context.load_context(name)
@classmethod
def contexts(cls):
def contexts(cls) -> list[Context]:
"""Context list.
Returns:
(Context): List of context objects.
@@ -170,7 +175,7 @@ class ContextAPI:
return contexts
@classmethod
def get_current_context(cls):
def get_current_context(cls) -> Context | None:
"""Get current context.
Returns:
(Context): current context object.
@@ -178,7 +183,7 @@ class ContextAPI:
return cls.get_context()
@classmethod
def set_current_context(cls, name="default"):
def set_current_context(cls, name: str = "default") -> None:
ctx = cls.get_context(name)
if not ctx:
raise errors.ContextNotFound(name)
@@ -188,7 +193,7 @@ class ContextAPI:
raise errors.ContextException(f"Failed to set current context: {err}")
@classmethod
def remove_context(cls, name):
def remove_context(cls, name: str) -> None:
"""Remove a context. Similar to the ``docker context rm`` command.
Args:
@@ -220,7 +225,7 @@ class ContextAPI:
ctx.remove()
@classmethod
def inspect_context(cls, name="default"):
def inspect_context(cls, name: str = "default") -> dict[str, t.Any]:
"""Inspect a context. Similar to the ``docker context inspect`` command.
Args:
+11 -10
View File
@@ -23,7 +23,7 @@ from ..utils.utils import parse_host
METAFILE = "meta.json"
def get_current_context_name_with_source():
def get_current_context_name_with_source() -> tuple[str, str]:
if os.environ.get("DOCKER_HOST"):
return "default", "DOCKER_HOST environment variable set"
if os.environ.get("DOCKER_CONTEXT"):
@@ -41,11 +41,11 @@ def get_current_context_name_with_source():
return "default", "fallback value"
def get_current_context_name():
def get_current_context_name() -> str:
return get_current_context_name_with_source()[0]
def write_context_name_to_docker_config(name=None):
def write_context_name_to_docker_config(name: str | None = None) -> Exception | None:
if name == "default":
name = None
docker_cfg_path = find_config_file()
@@ -62,44 +62,45 @@ def write_context_name_to_docker_config(name=None):
elif name:
config["currentContext"] = name
else:
return
return None
if not docker_cfg_path:
docker_cfg_path = get_default_config_file()
try:
with open(docker_cfg_path, "wt", encoding="utf-8") as f:
json.dump(config, f, indent=4)
return None
except Exception as e: # pylint: disable=broad-exception-caught
return e
def get_context_id(name):
def get_context_id(name: str) -> str:
return hashlib.sha256(name.encode("utf-8")).hexdigest()
def get_context_dir():
def get_context_dir() -> str:
docker_cfg_path = find_config_file() or get_default_config_file()
return os.path.join(os.path.dirname(docker_cfg_path), "contexts")
def get_meta_dir(name=None):
def get_meta_dir(name: str | None = None) -> str:
meta_dir = os.path.join(get_context_dir(), "meta")
if name:
return os.path.join(meta_dir, get_context_id(name))
return meta_dir
def get_meta_file(name):
def get_meta_file(name) -> str:
return os.path.join(get_meta_dir(name), METAFILE)
def get_tls_dir(name=None, endpoint=""):
def get_tls_dir(name: str | None = None, endpoint: str = "") -> str:
context_dir = get_context_dir()
if name:
return os.path.join(context_dir, "tls", get_context_id(name), endpoint)
return os.path.join(context_dir, "tls")
def get_context_host(path=None, tls=False):
def get_context_host(path: str | None = None, tls: bool = False) -> str:
host = parse_host(path, IS_WINDOWS_PLATFORM, tls)
if host == DEFAULT_UNIX_SOCKET:
# remove http+ from default docker socket url
+42 -39
View File
@@ -13,6 +13,7 @@ from __future__ import annotations
import json
import os
import typing as t
from shutil import copyfile, rmtree
from ..errors import ContextException
@@ -33,21 +34,21 @@ class Context:
def __init__(
self,
name,
orchestrator=None,
host=None,
endpoints=None,
skip_tls_verify=False,
tls=False,
description=None,
):
name: str,
orchestrator: str | None = None,
host: str | None = None,
endpoints: dict[str, dict[str, t.Any]] | None = None,
skip_tls_verify: bool = False,
tls: bool = False,
description: str | None = None,
) -> None:
if not name:
raise ValueError("Name not provided")
self.name = name
self.context_type = None
self.orchestrator = orchestrator
self.endpoints = {}
self.tls_cfg = {}
self.tls_cfg: dict[str, TLSConfig] = {}
self.meta_path = IN_MEMORY
self.tls_path = IN_MEMORY
self.description = description
@@ -89,12 +90,12 @@ class Context:
def set_endpoint(
self,
name="docker",
host=None,
tls_cfg=None,
skip_tls_verify=False,
def_namespace=None,
):
name: str = "docker",
host: str | None = None,
tls_cfg: TLSConfig | None = None,
skip_tls_verify: bool = False,
def_namespace: str | None = None,
) -> None:
self.endpoints[name] = {
"Host": get_context_host(host, not skip_tls_verify or tls_cfg is not None),
"SkipTLSVerify": skip_tls_verify,
@@ -105,11 +106,11 @@ class Context:
if tls_cfg:
self.tls_cfg[name] = tls_cfg
def inspect(self):
def inspect(self) -> dict[str, t.Any]:
return self()
@classmethod
def load_context(cls, name):
def load_context(cls, name: str) -> t.Self | None:
meta = Context._load_meta(name)
if meta:
instance = cls(
@@ -125,12 +126,12 @@ class Context:
return None
@classmethod
def _load_meta(cls, name):
def _load_meta(cls, name: str) -> dict[str, t.Any] | None:
meta_file = get_meta_file(name)
if not os.path.isfile(meta_file):
return None
metadata = {}
metadata: dict[str, t.Any] = {}
try:
with open(meta_file, "rt", encoding="utf-8") as f:
metadata = json.load(f)
@@ -154,7 +155,7 @@ class Context:
return metadata
def _load_certs(self):
def _load_certs(self) -> None:
certs = {}
tls_dir = get_tls_dir(self.name)
for endpoint in self.endpoints:
@@ -184,7 +185,7 @@ class Context:
self.tls_cfg = certs
self.tls_path = tls_dir
def save(self):
def save(self) -> None:
meta_dir = get_meta_dir(self.name)
if not os.path.isdir(meta_dir):
os.makedirs(meta_dir)
@@ -216,54 +217,54 @@ class Context:
self.meta_path = get_meta_dir(self.name)
self.tls_path = get_tls_dir(self.name)
def remove(self):
def remove(self) -> None:
if os.path.isdir(self.meta_path):
rmtree(self.meta_path)
if os.path.isdir(self.tls_path):
rmtree(self.tls_path)
def __repr__(self):
def __repr__(self) -> str:
return f"<{self.__class__.__name__}: '{self.name}'>"
def __str__(self):
def __str__(self) -> str:
return json.dumps(self.__call__(), indent=2)
def __call__(self):
def __call__(self) -> dict[str, t.Any]:
result = self.Metadata
result.update(self.TLSMaterial)
result.update(self.Storage)
return result
def is_docker_host(self):
def is_docker_host(self) -> bool:
return self.context_type is None
@property
def Name(self): # pylint: disable=invalid-name
def Name(self) -> str: # pylint: disable=invalid-name
return self.name
@property
def Host(self): # pylint: disable=invalid-name
def Host(self) -> str | None: # pylint: disable=invalid-name
if not self.orchestrator or self.orchestrator == "swarm":
endpoint = self.endpoints.get("docker", None)
if endpoint:
return endpoint.get("Host", None)
return endpoint.get("Host", None) # type: ignore
return None
return self.endpoints[self.orchestrator].get("Host", None)
return self.endpoints[self.orchestrator].get("Host", None) # type: ignore
@property
def Orchestrator(self): # pylint: disable=invalid-name
def Orchestrator(self) -> str | None: # pylint: disable=invalid-name
return self.orchestrator
@property
def Metadata(self): # pylint: disable=invalid-name
meta = {}
def Metadata(self) -> dict[str, t.Any]: # pylint: disable=invalid-name
meta: dict[str, t.Any] = {}
if self.orchestrator:
meta = {"StackOrchestrator": self.orchestrator}
return {"Name": self.name, "Metadata": meta, "Endpoints": self.endpoints}
@property
def TLSConfig(self): # pylint: disable=invalid-name
def TLSConfig(self) -> TLSConfig | None: # pylint: disable=invalid-name
key = self.orchestrator
if not key or key == "swarm":
key = "docker"
@@ -272,13 +273,15 @@ class Context:
return None
@property
def TLSMaterial(self): # pylint: disable=invalid-name
certs = {}
def TLSMaterial(self) -> dict[str, t.Any]: # pylint: disable=invalid-name
certs: dict[str, t.Any] = {}
for endpoint, tls in self.tls_cfg.items():
cert, key = tls.cert
certs[endpoint] = list(map(os.path.basename, [tls.ca_cert, cert, key]))
paths = [tls.ca_cert, *tls.cert] if tls.cert else [tls.ca_cert]
certs[endpoint] = [
os.path.basename(path) if path else None for path in paths
]
return {"TLSMaterial": certs}
@property
def Storage(self): # pylint: disable=invalid-name
def Storage(self) -> dict[str, t.Any]: # pylint: disable=invalid-name
return {"Storage": {"MetadataPath": self.meta_path, "TLSPath": self.tls_path}}
@@ -11,6 +11,12 @@
from __future__ import annotations
import typing as t
if t.TYPE_CHECKING:
from subprocess import CalledProcessError
class StoreError(RuntimeError):
pass
@@ -24,7 +30,7 @@ class InitializationError(StoreError):
pass
def process_store_error(cpe, program):
def process_store_error(cpe: CalledProcessError, program: str) -> StoreError:
message = cpe.output.decode("utf-8")
if "credentials not found in native keychain" in message:
return CredentialsNotFound(f"No matching credentials in {program}")
+11 -6
View File
@@ -14,13 +14,14 @@ from __future__ import annotations
import errno
import json
import subprocess
import typing as t
from . import constants, errors
from .utils import create_environment_dict, find_executable
class Store:
def __init__(self, program, environment=None):
def __init__(self, program: str, environment: dict[str, str] | None = None) -> None:
"""Create a store object that acts as an interface to
perform the basic operations for storing, retrieving
and erasing credentials using `program`.
@@ -33,7 +34,7 @@ class Store:
f"{self.program} not installed or not available in PATH"
)
def get(self, server):
def get(self, server: str | bytes) -> dict[str, t.Any]:
"""Retrieve credentials for `server`. If no credentials are found,
a `StoreError` will be raised.
"""
@@ -53,7 +54,7 @@ class Store:
return result
def store(self, server, username, secret):
def store(self, server: str, username: str, secret: str) -> bytes:
"""Store credentials for `server`. Raises a `StoreError` if an error
occurs.
"""
@@ -62,7 +63,7 @@ class Store:
).encode("utf-8")
return self._execute("store", data_input)
def erase(self, server):
def erase(self, server: str | bytes) -> None:
"""Erase credentials for `server`. Raises a `StoreError` if an error
occurs.
"""
@@ -70,12 +71,16 @@ class Store:
server = server.encode("utf-8")
self._execute("erase", server)
def list(self):
def list(self) -> t.Any:
"""List stored credentials. Requires v0.4.0+ of the helper."""
data = self._execute("list", None)
return json.loads(data.decode("utf-8"))
def _execute(self, subcmd, data_input):
def _execute(self, subcmd: str, data_input: bytes | None) -> bytes:
if self.exe is None:
raise errors.StoreError(
f"{self.program} not installed or not available in PATH"
)
output = None
env = create_environment_dict(self.environment)
try:
@@ -15,7 +15,7 @@ import os
from shutil import which
def find_executable(executable, path=None):
def find_executable(executable: str, path: str | None = None) -> str | None:
"""
As distutils.spawn.find_executable, but on Windows, look up
every extension declared in PATHEXT instead of just `.exe`
@@ -26,7 +26,7 @@ def find_executable(executable, path=None):
return which(executable, path=path)
def create_environment_dict(overrides):
def create_environment_dict(overrides: dict[str, str] | None) -> dict[str, str]:
"""
Create and return a copy of os.environ with the specified overrides
"""
+38 -24
View File
@@ -11,6 +11,8 @@
from __future__ import annotations
import typing as t
from ansible.module_utils.common.text.converters import to_native
from ._import_helper import HTTPError as _HTTPError
@@ -25,7 +27,7 @@ class DockerException(Exception):
"""
def create_api_error_from_http_exception(e):
def create_api_error_from_http_exception(e: _HTTPError) -> t.NoReturn:
"""
Create a suitable APIError from requests.exceptions.HTTPError.
"""
@@ -52,14 +54,16 @@ class APIError(_HTTPError, DockerException):
An HTTP error from the API.
"""
def __init__(self, message, response=None, explanation=None):
def __init__(
self, message: str | Exception, response=None, explanation: str | None = None
) -> None:
# requests 1.2 supports response as a keyword argument, but
# requests 1.1 does not
super().__init__(message)
self.response = response
self.explanation = explanation
self.explanation = explanation or ""
def __str__(self):
def __str__(self) -> str:
message = super().__str__()
if self.is_client_error():
@@ -74,19 +78,20 @@ class APIError(_HTTPError, DockerException):
return message
@property
def status_code(self):
def status_code(self) -> int | None:
if self.response is not None:
return self.response.status_code
return None
def is_error(self):
def is_error(self) -> bool:
return self.is_client_error() or self.is_server_error()
def is_client_error(self):
def is_client_error(self) -> bool:
if self.status_code is None:
return False
return 400 <= self.status_code < 500
def is_server_error(self):
def is_server_error(self) -> bool:
if self.status_code is None:
return False
return 500 <= self.status_code < 600
@@ -121,10 +126,10 @@ class DeprecatedMethod(DockerException):
class TLSParameterError(DockerException):
def __init__(self, msg):
def __init__(self, msg: str) -> None:
self.msg = msg
def __str__(self):
def __str__(self) -> str:
return self.msg + (
". TLS configurations should map the Docker CLI "
"client configurations. See "
@@ -142,7 +147,14 @@ class ContainerError(DockerException):
Represents a container that has exited with a non-zero exit code.
"""
def __init__(self, container, exit_status, command, image, stderr):
def __init__(
self,
container: str,
exit_status: int,
command: list[str],
image: str,
stderr: str | None,
):
self.container = container
self.exit_status = exit_status
self.command = command
@@ -156,12 +168,12 @@ class ContainerError(DockerException):
class StreamParseError(RuntimeError):
def __init__(self, reason):
def __init__(self, reason: Exception) -> None:
self.msg = reason
class BuildError(DockerException):
def __init__(self, reason, build_log):
def __init__(self, reason: str, build_log: str) -> None:
super().__init__(reason)
self.msg = reason
self.build_log = build_log
@@ -171,7 +183,7 @@ class ImageLoadError(DockerException):
pass
def create_unexpected_kwargs_error(name, kwargs):
def create_unexpected_kwargs_error(name: str, kwargs: dict[str, t.Any]) -> TypeError:
quoted_kwargs = [f"'{k}'" for k in sorted(kwargs)]
text = [f"{name}() "]
if len(quoted_kwargs) == 1:
@@ -183,42 +195,44 @@ def create_unexpected_kwargs_error(name, kwargs):
class MissingContextParameter(DockerException):
def __init__(self, param):
def __init__(self, param: str) -> None:
self.param = param
def __str__(self):
def __str__(self) -> str:
return f"missing parameter: {self.param}"
class ContextAlreadyExists(DockerException):
def __init__(self, name):
def __init__(self, name: str) -> None:
self.name = name
def __str__(self):
def __str__(self) -> str:
return f"context {self.name} already exists"
class ContextException(DockerException):
def __init__(self, msg):
def __init__(self, msg: str) -> None:
self.msg = msg
def __str__(self):
def __str__(self) -> str:
return self.msg
class ContextNotFound(DockerException):
def __init__(self, name):
def __init__(self, name: str) -> None:
self.name = name
def __str__(self):
def __str__(self) -> str:
return f"context '{self.name}' not found"
class MissingRequirementException(DockerException):
def __init__(self, msg, requirement, import_exception):
def __init__(
self, msg: str, requirement: str, import_exception: ImportError | str
) -> None:
self.msg = msg
self.requirement = requirement
self.import_exception = import_exception
def __str__(self):
def __str__(self) -> str:
return self.msg
+15 -20
View File
@@ -12,12 +12,18 @@
from __future__ import annotations
import os
import ssl
import typing as t
from . import errors
from .transport.ssladapter import SSLHTTPAdapter
if t.TYPE_CHECKING:
from ansible_collections.community.docker.plugins.module_utils._api.api.client import (
APIClient,
)
class TLSConfig:
"""
TLS configuration.
@@ -27,25 +33,22 @@ class TLSConfig:
ca_cert (str): Path to CA cert file.
verify (bool or str): This can be ``False`` or a path to a CA cert
file.
ssl_version (int): A valid `SSL version`_.
assert_hostname (bool): Verify the hostname of the server.
.. _`SSL version`:
https://docs.python.org/3.5/library/ssl.html#ssl.PROTOCOL_TLSv1
"""
cert = None
ca_cert = None
verify = None
ssl_version = None
cert: tuple[str, str] | None = None
ca_cert: str | None = None
verify: bool | None = None
def __init__(
self,
client_cert=None,
ca_cert=None,
verify=None,
ssl_version=None,
assert_hostname=None,
client_cert: tuple[str, str] | None = None,
ca_cert: str | None = None,
verify: bool | None = None,
assert_hostname: bool | None = None,
):
# Argument compatibility/mapping with
# https://docs.docker.com/engine/articles/https/
@@ -55,12 +58,6 @@ class TLSConfig:
self.assert_hostname = assert_hostname
# If the user provides an SSL version, we should use their preference
if ssl_version:
self.ssl_version = ssl_version
else:
self.ssl_version = ssl.PROTOCOL_TLS_CLIENT
# "client_cert" must have both or neither cert/key files. In
# either case, Alert the user when both are expected, but any are
# missing.
@@ -90,11 +87,10 @@ class TLSConfig:
"Invalid CA certificate provided for `ca_cert`."
)
def configure_client(self, client):
def configure_client(self, client: APIClient) -> None:
"""
Configure a client with these TLS options.
"""
client.ssl_version = self.ssl_version
if self.verify and self.ca_cert:
client.verify = self.ca_cert
@@ -107,7 +103,6 @@ class TLSConfig:
client.mount(
"https://",
SSLHTTPAdapter(
ssl_version=self.ssl_version,
assert_hostname=self.assert_hostname,
),
)
@@ -15,7 +15,7 @@ from .._import_helper import HTTPAdapter as _HTTPAdapter
class BaseHTTPAdapter(_HTTPAdapter):
def close(self):
def close(self) -> None:
super().close()
if hasattr(self, "pools"):
self.pools.clear()
@@ -24,10 +24,10 @@ class BaseHTTPAdapter(_HTTPAdapter):
# https://github.com/psf/requests/commit/c0813a2d910ea6b4f8438b91d315b8d181302356
# changes requests.adapters.HTTPAdapter to no longer call get_connection() from
# send(), but instead call _get_connection().
def _get_connection(self, request, *args, **kwargs):
def _get_connection(self, request, *args, **kwargs): # type: ignore
return self.get_connection(request.url, kwargs.get("proxies"))
# Fix for requests 2.32.2+:
# https://github.com/psf/requests/commit/c98e4d133ef29c46a9b68cd783087218a8075e05
def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None):
def get_connection_with_tls_context(self, request, verify, proxies=None, cert=None): # type: ignore
return self.get_connection(request.url, proxies)
@@ -23,12 +23,12 @@ RecentlyUsedContainer = urllib3._collections.RecentlyUsedContainer
class NpipeHTTPConnection(urllib3_connection.HTTPConnection):
def __init__(self, npipe_path, timeout=60):
def __init__(self, npipe_path: str, timeout: int | float = 60) -> None:
super().__init__("localhost", timeout=timeout)
self.npipe_path = npipe_path
self.timeout = timeout
def connect(self):
def connect(self) -> None:
sock = NpipeSocket()
sock.settimeout(self.timeout)
sock.connect(self.npipe_path)
@@ -36,18 +36,20 @@ class NpipeHTTPConnection(urllib3_connection.HTTPConnection):
class NpipeHTTPConnectionPool(urllib3.connectionpool.HTTPConnectionPool):
def __init__(self, npipe_path, timeout=60, maxsize=10):
def __init__(
self, npipe_path: str, timeout: int | float = 60, maxsize: int = 10
) -> None:
super().__init__("localhost", timeout=timeout, maxsize=maxsize)
self.npipe_path = npipe_path
self.timeout = timeout
def _new_conn(self):
def _new_conn(self) -> NpipeHTTPConnection:
return NpipeHTTPConnection(self.npipe_path, self.timeout)
# When re-using connections, urllib3 tries to call select() on our
# NpipeSocket instance, causing a crash. To circumvent this, we override
# _get_conn, where that check happens.
def _get_conn(self, timeout):
def _get_conn(self, timeout: int | float) -> NpipeHTTPConnection:
conn = None
try:
conn = self.pool.get(block=self.block, timeout=timeout)
@@ -67,7 +69,6 @@ class NpipeHTTPConnectionPool(urllib3.connectionpool.HTTPConnectionPool):
class NpipeHTTPAdapter(BaseHTTPAdapter):
__attrs__ = HTTPAdapter.__attrs__ + [
"npipe_path",
"pools",
@@ -77,11 +78,11 @@ class NpipeHTTPAdapter(BaseHTTPAdapter):
def __init__(
self,
base_url,
timeout=60,
pool_connections=constants.DEFAULT_NUM_POOLS,
max_pool_size=constants.DEFAULT_MAX_POOL_SIZE,
):
base_url: str,
timeout: int | float = 60,
pool_connections: int = constants.DEFAULT_NUM_POOLS,
max_pool_size: int = constants.DEFAULT_MAX_POOL_SIZE,
) -> None:
self.npipe_path = base_url.replace("npipe://", "")
self.timeout = timeout
self.max_pool_size = max_pool_size
@@ -90,7 +91,7 @@ class NpipeHTTPAdapter(BaseHTTPAdapter):
)
super().__init__()
def get_connection(self, url, proxies=None):
def get_connection(self, url: str | bytes, proxies=None) -> NpipeHTTPConnectionPool:
with self.pools.lock:
pool = self.pools.get(url)
if pool:
@@ -103,7 +104,7 @@ class NpipeHTTPAdapter(BaseHTTPAdapter):
return pool
def request_url(self, request, proxies):
def request_url(self, request, proxies) -> str:
# The select_proxy utility in requests errors out when the provided URL
# does not have a hostname, like is the case when using a UNIX socket.
# Since proxies are an irrelevant notion in the case of UNIX sockets
@@ -15,8 +15,10 @@ import functools
import io
import time
import traceback
import typing as t
PYWIN32_IMPORT_ERROR: str | None # pylint: disable=invalid-name
try:
import pywintypes
import win32api
@@ -28,6 +30,13 @@ except ImportError:
else:
PYWIN32_IMPORT_ERROR = None # pylint: disable=invalid-name
if t.TYPE_CHECKING:
from collections.abc import Buffer, Callable
_Self = t.TypeVar("_Self")
_P = t.ParamSpec("_P")
_R = t.TypeVar("_R")
ERROR_PIPE_BUSY = 0xE7
SECURITY_SQOS_PRESENT = 0x100000
@@ -36,10 +45,12 @@ SECURITY_ANONYMOUS = 0
MAXIMUM_RETRY_COUNT = 10
def check_closed(f):
def check_closed(
f: Callable[t.Concatenate[_Self, _P], _R],
) -> Callable[t.Concatenate[_Self, _P], _R]:
@functools.wraps(f)
def wrapped(self, *args, **kwargs):
if self._closed:
def wrapped(self: _Self, *args: _P.args, **kwargs: _P.kwargs) -> _R:
if self._closed: # type: ignore
raise RuntimeError("Can not reuse socket after connection was closed.")
return f(self, *args, **kwargs)
@@ -53,25 +64,25 @@ class NpipeSocket:
implemented.
"""
def __init__(self, handle=None):
def __init__(self, handle=None) -> None:
self._timeout = win32pipe.NMPWAIT_USE_DEFAULT_WAIT
self._handle = handle
self._address = None
self._address: str | None = None
self._closed = False
self.flags = None
self.flags: int | None = None
def accept(self):
def accept(self) -> t.NoReturn:
raise NotImplementedError()
def bind(self, address):
def bind(self, address) -> t.NoReturn:
raise NotImplementedError()
def close(self):
def close(self) -> None:
self._handle.Close()
self._closed = True
@check_closed
def connect(self, address, retry_count=0):
def connect(self, address, retry_count: int = 0) -> None:
try:
handle = win32file.CreateFile(
address,
@@ -99,14 +110,14 @@ class NpipeSocket:
return self.connect(address, retry_count)
raise e
self.flags = win32pipe.GetNamedPipeInfo(handle)[0]
self.flags = win32pipe.GetNamedPipeInfo(handle)[0] # type: ignore
self._handle = handle
self._address = address
@check_closed
def connect_ex(self, address):
return self.connect(address)
def connect_ex(self, address) -> None:
self.connect(address)
@check_closed
def detach(self):
@@ -114,25 +125,25 @@ class NpipeSocket:
return self._handle
@check_closed
def dup(self):
def dup(self) -> NpipeSocket:
return NpipeSocket(self._handle)
def getpeername(self):
def getpeername(self) -> str | None:
return self._address
def getsockname(self):
def getsockname(self) -> str | None:
return self._address
def getsockopt(self, level, optname, buflen=None):
def getsockopt(self, level, optname, buflen=None) -> t.NoReturn:
raise NotImplementedError()
def ioctl(self, control, option):
def ioctl(self, control, option) -> t.NoReturn:
raise NotImplementedError()
def listen(self, backlog):
def listen(self, backlog) -> t.NoReturn:
raise NotImplementedError()
def makefile(self, mode=None, bufsize=None):
def makefile(self, mode: str, bufsize: int | None = None):
if mode.strip("b") != "r":
raise NotImplementedError()
rawio = NpipeFileIOBase(self)
@@ -141,30 +152,30 @@ class NpipeSocket:
return io.BufferedReader(rawio, buffer_size=bufsize)
@check_closed
def recv(self, bufsize, flags=0):
def recv(self, bufsize: int, flags: int = 0) -> str:
dummy_err, data = win32file.ReadFile(self._handle, bufsize)
return data
@check_closed
def recvfrom(self, bufsize, flags=0):
def recvfrom(self, bufsize: int, flags: int = 0) -> tuple[str, str | None]:
data = self.recv(bufsize, flags)
return (data, self._address)
@check_closed
def recvfrom_into(self, buf, nbytes=0, flags=0):
return self.recv_into(buf, nbytes, flags), self._address
def recvfrom_into(
self, buf: Buffer, nbytes: int = 0, flags: int = 0
) -> tuple[int, str | None]:
return self.recv_into(buf, nbytes), self._address
@check_closed
def recv_into(self, buf, nbytes=0):
readbuf = buf
if not isinstance(buf, memoryview):
readbuf = memoryview(buf)
def recv_into(self, buf: Buffer, nbytes: int = 0) -> int:
readbuf = buf if isinstance(buf, memoryview) else memoryview(buf)
event = win32event.CreateEvent(None, True, True, None)
try:
overlapped = pywintypes.OVERLAPPED()
overlapped.hEvent = event
dummy_err, dummy_data = win32file.ReadFile(
dummy_err, dummy_data = win32file.ReadFile( # type: ignore
self._handle, readbuf[:nbytes] if nbytes else readbuf, overlapped
)
wait_result = win32event.WaitForSingleObject(event, self._timeout)
@@ -176,12 +187,12 @@ class NpipeSocket:
win32api.CloseHandle(event)
@check_closed
def send(self, string, flags=0):
def send(self, string: Buffer, flags: int = 0) -> int:
event = win32event.CreateEvent(None, True, True, None)
try:
overlapped = pywintypes.OVERLAPPED()
overlapped.hEvent = event
win32file.WriteFile(self._handle, string, overlapped)
win32file.WriteFile(self._handle, string, overlapped) # type: ignore
wait_result = win32event.WaitForSingleObject(event, self._timeout)
if wait_result == win32event.WAIT_TIMEOUT:
win32file.CancelIo(self._handle)
@@ -191,20 +202,20 @@ class NpipeSocket:
win32api.CloseHandle(event)
@check_closed
def sendall(self, string, flags=0):
def sendall(self, string: Buffer, flags: int = 0) -> int:
return self.send(string, flags)
@check_closed
def sendto(self, string, address):
def sendto(self, string: Buffer, address: str) -> int:
self.connect(address)
return self.send(string)
def setblocking(self, flag):
def setblocking(self, flag: bool):
if flag:
return self.settimeout(None)
return self.settimeout(0)
def settimeout(self, value):
def settimeout(self, value: int | float | None) -> None:
if value is None:
# Blocking mode
self._timeout = win32event.INFINITE
@@ -214,39 +225,39 @@ class NpipeSocket:
# Timeout mode - Value converted to milliseconds
self._timeout = int(value * 1000)
def gettimeout(self):
def gettimeout(self) -> int | float | None:
return self._timeout
def setsockopt(self, level, optname, value):
def setsockopt(self, level, optname, value) -> t.NoReturn:
raise NotImplementedError()
@check_closed
def shutdown(self, how):
def shutdown(self, how) -> None:
return self.close()
class NpipeFileIOBase(io.RawIOBase):
def __init__(self, npipe_socket):
def __init__(self, npipe_socket) -> None:
self.sock = npipe_socket
def close(self):
def close(self) -> None:
super().close()
self.sock = None
def fileno(self):
def fileno(self) -> int:
return self.sock.fileno()
def isatty(self):
def isatty(self) -> bool:
return False
def readable(self):
def readable(self) -> bool:
return True
def readinto(self, buf):
def readinto(self, buf: Buffer) -> int:
return self.sock.recv_into(buf)
def seekable(self):
def seekable(self) -> bool:
return False
def writable(self):
def writable(self) -> bool:
return False
+71 -38
View File
@@ -17,6 +17,7 @@ import signal
import socket
import subprocess
import traceback
import typing as t
from queue import Empty
from urllib.parse import urlparse
@@ -25,6 +26,7 @@ from .._import_helper import HTTPAdapter, urllib3, urllib3_connection
from .basehttpadapter import BaseHTTPAdapter
PARAMIKO_IMPORT_ERROR: str | None # pylint: disable=invalid-name
try:
import paramiko
except ImportError:
@@ -32,12 +34,15 @@ except ImportError:
else:
PARAMIKO_IMPORT_ERROR = None # pylint: disable=invalid-name
if t.TYPE_CHECKING:
from collections.abc import Buffer
RecentlyUsedContainer = urllib3._collections.RecentlyUsedContainer
class SSHSocket(socket.socket):
def __init__(self, host):
def __init__(self, host: str) -> None:
super().__init__(socket.AF_INET, socket.SOCK_STREAM)
self.host = host
self.port = None
@@ -47,9 +52,9 @@ class SSHSocket(socket.socket):
if "@" in self.host:
self.user, self.host = self.host.split("@")
self.proc = None
self.proc: subprocess.Popen | None = None
def connect(self, **kwargs):
def connect(self, *args_: t.Any, **kwargs: t.Any) -> None:
args = ["ssh"]
if self.user:
args = args + ["-l", self.user]
@@ -81,37 +86,48 @@ class SSHSocket(socket.socket):
preexec_fn=preexec_func,
)
def _write(self, data):
if not self.proc or self.proc.stdin.closed:
def _write(self, data: Buffer) -> int:
if not self.proc:
raise RuntimeError(
"SSH subprocess not initiated. connect() must be called first."
)
assert self.proc.stdin is not None
if self.proc.stdin.closed:
raise RuntimeError(
"SSH subprocess not initiated. connect() must be called first after close()."
)
written = self.proc.stdin.write(data)
self.proc.stdin.flush()
return written
def sendall(self, data):
def sendall(self, data: Buffer, *args, **kwargs) -> None:
self._write(data)
def send(self, data):
def send(self, data: Buffer, *args, **kwargs) -> int:
return self._write(data)
def recv(self, n):
def recv(self, n: int, *args, **kwargs) -> bytes:
if not self.proc:
raise RuntimeError(
"SSH subprocess not initiated. connect() must be called first."
)
assert self.proc.stdout is not None
return self.proc.stdout.read(n)
def makefile(self, mode):
def makefile(self, mode: str, *args, **kwargs) -> t.IO: # type: ignore
if not self.proc:
self.connect()
self.proc.stdout.channel = self
assert self.proc is not None
assert self.proc.stdout is not None
self.proc.stdout.channel = self # type: ignore
return self.proc.stdout
def close(self):
if not self.proc or self.proc.stdin.closed:
def close(self) -> None:
if not self.proc:
return
assert self.proc.stdin is not None
if self.proc.stdin.closed:
return
self.proc.stdin.write(b"\n\n")
self.proc.stdin.flush()
@@ -119,13 +135,19 @@ class SSHSocket(socket.socket):
class SSHConnection(urllib3_connection.HTTPConnection):
def __init__(self, ssh_transport=None, timeout=60, host=None):
def __init__(
self,
*,
ssh_transport=None,
timeout: int | float = 60,
host: str,
) -> None:
super().__init__("localhost", timeout=timeout)
self.ssh_transport = ssh_transport
self.timeout = timeout
self.ssh_host = host
def connect(self):
def connect(self) -> None:
if self.ssh_transport:
sock = self.ssh_transport.open_session()
sock.settimeout(self.timeout)
@@ -141,7 +163,14 @@ class SSHConnection(urllib3_connection.HTTPConnection):
class SSHConnectionPool(urllib3.connectionpool.HTTPConnectionPool):
scheme = "ssh"
def __init__(self, ssh_client=None, timeout=60, maxsize=10, host=None):
def __init__(
self,
*,
ssh_client: paramiko.SSHClient | None = None,
timeout: int | float = 60,
maxsize: int = 10,
host: str,
) -> None:
super().__init__("localhost", timeout=timeout, maxsize=maxsize)
self.ssh_transport = None
self.timeout = timeout
@@ -149,13 +178,17 @@ class SSHConnectionPool(urllib3.connectionpool.HTTPConnectionPool):
self.ssh_transport = ssh_client.get_transport()
self.ssh_host = host
def _new_conn(self):
return SSHConnection(self.ssh_transport, self.timeout, self.ssh_host)
def _new_conn(self) -> SSHConnection:
return SSHConnection(
ssh_transport=self.ssh_transport,
timeout=self.timeout,
host=self.ssh_host,
)
# When re-using connections, urllib3 calls fileno() on our
# SSH channel instance, quickly overloading our fd limit. To avoid this,
# we override _get_conn
def _get_conn(self, timeout):
def _get_conn(self, timeout: int | float) -> SSHConnection:
conn = None
try:
conn = self.pool.get(block=self.block, timeout=timeout)
@@ -175,7 +208,6 @@ class SSHConnectionPool(urllib3.connectionpool.HTTPConnectionPool):
class SSHHTTPAdapter(BaseHTTPAdapter):
__attrs__ = HTTPAdapter.__attrs__ + [
"pools",
"timeout",
@@ -186,13 +218,13 @@ class SSHHTTPAdapter(BaseHTTPAdapter):
def __init__(
self,
base_url,
timeout=60,
pool_connections=constants.DEFAULT_NUM_POOLS,
max_pool_size=constants.DEFAULT_MAX_POOL_SIZE,
shell_out=False,
):
self.ssh_client = None
base_url: str,
timeout: int | float = 60,
pool_connections: int = constants.DEFAULT_NUM_POOLS,
max_pool_size: int = constants.DEFAULT_MAX_POOL_SIZE,
shell_out: bool = False,
) -> None:
self.ssh_client: paramiko.SSHClient | None = None
if not shell_out:
self._create_paramiko_client(base_url)
self._connect()
@@ -208,30 +240,31 @@ class SSHHTTPAdapter(BaseHTTPAdapter):
)
super().__init__()
def _create_paramiko_client(self, base_url):
def _create_paramiko_client(self, base_url: str) -> None:
logging.getLogger("paramiko").setLevel(logging.WARNING)
self.ssh_client = paramiko.SSHClient()
base_url = urlparse(base_url)
self.ssh_params = {
"hostname": base_url.hostname,
"port": base_url.port,
"username": base_url.username,
base_url_p = urlparse(base_url)
assert base_url_p.hostname is not None
self.ssh_params: dict[str, t.Any] = {
"hostname": base_url_p.hostname,
"port": base_url_p.port,
"username": base_url_p.username,
}
ssh_config_file = os.path.expanduser("~/.ssh/config")
if os.path.exists(ssh_config_file):
conf = paramiko.SSHConfig()
with open(ssh_config_file, "rt", encoding="utf-8") as f:
conf.parse(f)
host_config = conf.lookup(base_url.hostname)
host_config = conf.lookup(base_url_p.hostname)
if "proxycommand" in host_config:
self.ssh_params["sock"] = paramiko.ProxyCommand(
host_config["proxycommand"]
)
if "hostname" in host_config:
self.ssh_params["hostname"] = host_config["hostname"]
if base_url.port is None and "port" in host_config:
if base_url_p.port is None and "port" in host_config:
self.ssh_params["port"] = host_config["port"]
if base_url.username is None and "user" in host_config:
if base_url_p.username is None and "user" in host_config:
self.ssh_params["username"] = host_config["user"]
if "identityfile" in host_config:
self.ssh_params["key_filename"] = host_config["identityfile"]
@@ -239,11 +272,11 @@ class SSHHTTPAdapter(BaseHTTPAdapter):
self.ssh_client.load_system_host_keys()
self.ssh_client.set_missing_host_key_policy(paramiko.RejectPolicy())
def _connect(self):
def _connect(self) -> None:
if self.ssh_client:
self.ssh_client.connect(**self.ssh_params)
def get_connection(self, url, proxies=None):
def get_connection(self, url: str | bytes, proxies=None) -> SSHConnectionPool:
if not self.ssh_client:
return SSHConnectionPool(
ssh_client=self.ssh_client,
@@ -270,7 +303,7 @@ class SSHHTTPAdapter(BaseHTTPAdapter):
return pool
def close(self):
def close(self) -> None:
super().close()
if self.ssh_client:
self.ssh_client.close()
@@ -11,9 +11,7 @@
from __future__ import annotations
from ansible_collections.community.docker.plugins.module_utils._version import (
LooseVersion,
)
import typing as t
from .._import_helper import HTTPAdapter, urllib3
from .basehttpadapter import BaseHTTPAdapter
@@ -30,14 +28,19 @@ PoolManager = urllib3.poolmanager.PoolManager
class SSLHTTPAdapter(BaseHTTPAdapter):
"""An HTTPS Transport Adapter that uses an arbitrary SSL version."""
__attrs__ = HTTPAdapter.__attrs__ + ["assert_hostname", "ssl_version"]
__attrs__ = HTTPAdapter.__attrs__ + ["assert_hostname"]
def __init__(self, ssl_version=None, assert_hostname=None, **kwargs):
self.ssl_version = ssl_version
def __init__(
self,
assert_hostname: bool | None = None,
**kwargs,
) -> None:
self.assert_hostname = assert_hostname
super().__init__(**kwargs)
def init_poolmanager(self, connections, maxsize, block=False):
def init_poolmanager(
self, connections: int, maxsize: int, block: bool = False, **kwargs: t.Any
) -> None:
kwargs = {
"num_pools": connections,
"maxsize": maxsize,
@@ -45,12 +48,10 @@ class SSLHTTPAdapter(BaseHTTPAdapter):
}
if self.assert_hostname is not None:
kwargs["assert_hostname"] = self.assert_hostname
if self.ssl_version and self.can_override_ssl_version():
kwargs["ssl_version"] = self.ssl_version
self.poolmanager = PoolManager(**kwargs)
def get_connection(self, *args, **kwargs):
def get_connection(self, *args, **kwargs) -> urllib3.ConnectionPool:
"""
Ensure assert_hostname is set correctly on our pool
@@ -61,15 +62,7 @@ class SSLHTTPAdapter(BaseHTTPAdapter):
conn = super().get_connection(*args, **kwargs)
if (
self.assert_hostname is not None
and conn.assert_hostname != self.assert_hostname
and conn.assert_hostname != self.assert_hostname # type: ignore
):
conn.assert_hostname = self.assert_hostname
conn.assert_hostname = self.assert_hostname # type: ignore
return conn
def can_override_ssl_version(self):
urllib_ver = urllib3.__version__.split("-")[0]
if urllib_ver is None:
return False
if urllib_ver == "dev":
return True
return LooseVersion(urllib_ver) > LooseVersion("1.5")
+22 -15
View File
@@ -12,6 +12,7 @@
from __future__ import annotations
import socket
import typing as t
from .. import constants
from .._import_helper import HTTPAdapter, urllib3, urllib3_connection
@@ -22,26 +23,27 @@ RecentlyUsedContainer = urllib3._collections.RecentlyUsedContainer
class UnixHTTPConnection(urllib3_connection.HTTPConnection):
def __init__(self, base_url, unix_socket, timeout=60):
def __init__(
self, base_url: str | bytes, unix_socket, timeout: int | float = 60
) -> None:
super().__init__("localhost", timeout=timeout)
self.base_url = base_url
self.unix_socket = unix_socket
self.timeout = timeout
self.disable_buffering = False
def connect(self):
def connect(self) -> None:
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(self.timeout)
sock.connect(self.unix_socket)
self.sock = sock
def putheader(self, header, *values):
def putheader(self, header: str, *values: str) -> None:
super().putheader(header, *values)
if header == "Connection" and "Upgrade" in values:
self.disable_buffering = True
def response_class(self, sock, *args, **kwargs):
def response_class(self, sock, *args, **kwargs) -> t.Any:
# FIXME: We may need to disable buffering on Py3,
# but there's no clear way to do it at the moment. See:
# https://github.com/docker/docker-py/issues/1799
@@ -49,18 +51,23 @@ class UnixHTTPConnection(urllib3_connection.HTTPConnection):
class UnixHTTPConnectionPool(urllib3.connectionpool.HTTPConnectionPool):
def __init__(self, base_url, socket_path, timeout=60, maxsize=10):
def __init__(
self,
base_url: str | bytes,
socket_path: str,
timeout: int | float = 60,
maxsize: int = 10,
) -> None:
super().__init__("localhost", timeout=timeout, maxsize=maxsize)
self.base_url = base_url
self.socket_path = socket_path
self.timeout = timeout
def _new_conn(self):
def _new_conn(self) -> UnixHTTPConnection:
return UnixHTTPConnection(self.base_url, self.socket_path, self.timeout)
class UnixHTTPAdapter(BaseHTTPAdapter):
__attrs__ = HTTPAdapter.__attrs__ + [
"pools",
"socket_path",
@@ -70,11 +77,11 @@ class UnixHTTPAdapter(BaseHTTPAdapter):
def __init__(
self,
socket_url,
timeout=60,
pool_connections=constants.DEFAULT_NUM_POOLS,
max_pool_size=constants.DEFAULT_MAX_POOL_SIZE,
):
socket_url: str,
timeout: int | float = 60,
pool_connections: int = constants.DEFAULT_NUM_POOLS,
max_pool_size: int = constants.DEFAULT_MAX_POOL_SIZE,
) -> None:
socket_path = socket_url.replace("http+unix://", "")
if not socket_path.startswith("/"):
socket_path = "/" + socket_path
@@ -86,7 +93,7 @@ class UnixHTTPAdapter(BaseHTTPAdapter):
)
super().__init__()
def get_connection(self, url, proxies=None):
def get_connection(self, url: str | bytes, proxies=None) -> UnixHTTPConnectionPool:
with self.pools.lock:
pool = self.pools.get(url)
if pool:
@@ -99,7 +106,7 @@ class UnixHTTPAdapter(BaseHTTPAdapter):
return pool
def request_url(self, request, proxies):
def request_url(self, request, proxies) -> str:
# The select_proxy utility in requests errors out when the provided URL
# does not have a hostname, like is the case when using a UNIX socket.
# Since proxies are an irrelevant notion in the case of UNIX sockets
+4 -3
View File
@@ -12,6 +12,7 @@
from __future__ import annotations
import socket
import typing as t
from .._import_helper import urllib3
from ..errors import DockerException
@@ -29,11 +30,11 @@ class CancellableStream:
>>> events.close()
"""
def __init__(self, stream, response):
def __init__(self, stream, response) -> None:
self._stream = stream
self._response = response
def __iter__(self):
def __iter__(self) -> t.Self:
return self
def __next__(self):
@@ -46,7 +47,7 @@ class CancellableStream:
next = __next__
def close(self):
def close(self) -> None:
"""
Closes the event streaming.
"""
+43 -22
View File
@@ -17,26 +17,38 @@ import random
import re
import tarfile
import tempfile
import typing as t
from ..constants import IS_WINDOWS_PLATFORM, WINDOWS_LONGPATH_PREFIX
from . import fnmatch
if t.TYPE_CHECKING:
from collections.abc import Sequence
_SEP = re.compile("/|\\\\") if IS_WINDOWS_PLATFORM else re.compile("/")
def tar(path, exclude=None, dockerfile=None, fileobj=None, gzip=False):
def tar(
path: str,
exclude: list[str] | None = None,
dockerfile: tuple[str, str | None] | tuple[None, None] | None = None,
fileobj: t.IO[bytes] | None = None,
gzip: bool = False,
) -> t.IO[bytes]:
root = os.path.abspath(path)
exclude = exclude or []
dockerfile = dockerfile or (None, None)
extra_files = []
extra_files: list[tuple[str, str]] = []
if dockerfile[1] is not None:
assert dockerfile[0] is not None
dockerignore_contents = "\n".join(
(exclude or [".dockerignore"]) + [dockerfile[0]]
)
extra_files = [
(".dockerignore", dockerignore_contents),
dockerfile,
dockerfile, # type: ignore
]
return create_archive(
files=sorted(exclude_paths(root, exclude, dockerfile=dockerfile[0])),
@@ -47,7 +59,9 @@ def tar(path, exclude=None, dockerfile=None, fileobj=None, gzip=False):
)
def exclude_paths(root, patterns, dockerfile=None):
def exclude_paths(
root: str, patterns: list[str], dockerfile: str | None = None
) -> set[str]:
"""
Given a root directory path and a list of .dockerignore patterns, return
an iterator of all paths (both regular files and directories) in the root
@@ -64,7 +78,7 @@ def exclude_paths(root, patterns, dockerfile=None):
return set(pm.walk(root))
def build_file_list(root):
def build_file_list(root: str) -> list[str]:
files = []
for dirname, dirnames, fnames in os.walk(root):
for filename in fnames + dirnames:
@@ -74,7 +88,13 @@ def build_file_list(root):
return files
def create_archive(root, files=None, fileobj=None, gzip=False, extra_files=None):
def create_archive(
root: str,
files: Sequence[str] | None = None,
fileobj: t.IO[bytes] | None = None,
gzip: bool = False,
extra_files: Sequence[tuple[str, str]] | None = None,
) -> t.IO[bytes]:
extra_files = extra_files or []
if not fileobj:
fileobj = tempfile.NamedTemporaryFile()
@@ -92,7 +112,7 @@ def create_archive(root, files=None, fileobj=None, gzip=False, extra_files=None)
if i is None:
# This happens when we encounter a socket file. We can safely
# ignore it and proceed.
continue
continue # type: ignore
# Workaround https://bugs.python.org/issue32713
if i.mtime < 0 or i.mtime > 8**11 - 1:
@@ -124,11 +144,11 @@ def create_archive(root, files=None, fileobj=None, gzip=False, extra_files=None)
return fileobj
def mkbuildcontext(dockerfile):
def mkbuildcontext(dockerfile: io.BytesIO | t.IO[bytes]) -> t.IO[bytes]:
f = tempfile.NamedTemporaryFile() # pylint: disable=consider-using-with
try:
with tarfile.open(mode="w", fileobj=f) as t:
if isinstance(dockerfile, io.StringIO):
if isinstance(dockerfile, io.StringIO): # type: ignore
raise TypeError("Please use io.BytesIO to create in-memory Dockerfiles")
if isinstance(dockerfile, io.BytesIO):
dfinfo = tarfile.TarInfo("Dockerfile")
@@ -144,17 +164,17 @@ def mkbuildcontext(dockerfile):
return f
def split_path(p):
def split_path(p: str) -> list[str]:
return [pt for pt in re.split(_SEP, p) if pt and pt != "."]
def normalize_slashes(p):
def normalize_slashes(p: str) -> str:
if IS_WINDOWS_PLATFORM:
return "/".join(split_path(p))
return p
def walk(root, patterns, default=True):
def walk(root: str, patterns: Sequence[str], default: bool = True) -> t.Generator[str]:
pm = PatternMatcher(patterns)
return pm.walk(root)
@@ -162,11 +182,11 @@ def walk(root, patterns, default=True):
# Heavily based on
# https://github.com/moby/moby/blob/master/pkg/fileutils/fileutils.go
class PatternMatcher:
def __init__(self, patterns):
def __init__(self, patterns: Sequence[str]) -> None:
self.patterns = list(filter(lambda p: p.dirs, [Pattern(p) for p in patterns]))
self.patterns.append(Pattern("!.dockerignore"))
def matches(self, filepath):
def matches(self, filepath: str) -> bool:
matched = False
parent_path = os.path.dirname(filepath)
parent_path_dirs = split_path(parent_path)
@@ -185,8 +205,8 @@ class PatternMatcher:
return matched
def walk(self, root):
def rec_walk(current_dir):
def walk(self, root: str) -> t.Generator[str]:
def rec_walk(current_dir: str) -> t.Generator[str]:
for f in os.listdir(current_dir):
fpath = os.path.join(os.path.relpath(current_dir, root), f)
if fpath.startswith("." + os.path.sep):
@@ -220,7 +240,7 @@ class PatternMatcher:
class Pattern:
def __init__(self, pattern_str):
def __init__(self, pattern_str: str) -> None:
self.exclusion = False
if pattern_str.startswith("!"):
self.exclusion = True
@@ -230,8 +250,7 @@ class Pattern:
self.cleaned_pattern = "/".join(self.dirs)
@classmethod
def normalize(cls, p):
def normalize(cls, p: str) -> list[str]:
# Remove trailing spaces
p = p.strip()
@@ -256,11 +275,13 @@ class Pattern:
i += 1
return split
def match(self, filepath):
def match(self, filepath: str) -> bool:
return fnmatch.fnmatch(normalize_slashes(filepath), self.cleaned_pattern)
def process_dockerfile(dockerfile, path):
def process_dockerfile(
dockerfile: str | None, path: str
) -> tuple[str, str | None] | tuple[None, None]:
if not dockerfile:
return (None, None)
@@ -268,7 +289,7 @@ def process_dockerfile(dockerfile, path):
if not os.path.isabs(dockerfile):
abs_dockerfile = os.path.join(path, dockerfile)
if IS_WINDOWS_PLATFORM and path.startswith(WINDOWS_LONGPATH_PREFIX):
abs_dockerfile = f"{WINDOWS_LONGPATH_PREFIX}{os.path.normpath(abs_dockerfile[len(WINDOWS_LONGPATH_PREFIX):])}"
abs_dockerfile = f"{WINDOWS_LONGPATH_PREFIX}{os.path.normpath(abs_dockerfile[len(WINDOWS_LONGPATH_PREFIX) :])}"
if os.path.splitdrive(path)[0] != os.path.splitdrive(abs_dockerfile)[
0
] or os.path.relpath(abs_dockerfile, path).startswith(".."):
+6 -5
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import json
import logging
import os
import typing as t
from ..constants import IS_WINDOWS_PLATFORM
@@ -24,11 +25,11 @@ LEGACY_DOCKER_CONFIG_FILENAME = ".dockercfg"
log = logging.getLogger(__name__)
def get_default_config_file():
def get_default_config_file() -> str:
return os.path.join(home_dir(), DOCKER_CONFIG_FILENAME)
def find_config_file(config_path=None):
def find_config_file(config_path: str | None = None) -> str | None:
homedir = home_dir()
paths = list(
filter(
@@ -54,14 +55,14 @@ def find_config_file(config_path=None):
return None
def config_path_from_environment():
def config_path_from_environment() -> str | None:
config_dir = os.environ.get("DOCKER_CONFIG")
if not config_dir:
return None
return os.path.join(config_dir, os.path.basename(DOCKER_CONFIG_FILENAME))
def home_dir():
def home_dir() -> str:
"""
Get the user's home directory, using the same logic as the Docker Engine
client - use %USERPROFILE% on Windows, $HOME/getuid on POSIX.
@@ -71,7 +72,7 @@ def home_dir():
return os.path.expanduser("~")
def load_general_config(config_path=None):
def load_general_config(config_path: str | None = None) -> dict[str, t.Any]:
config_file = find_config_file(config_path)
if not config_file:
+31 -7
View File
@@ -12,16 +12,37 @@
from __future__ import annotations
import functools
import typing as t
from .. import errors
from . import utils
def minimum_version(version):
def decorator(f):
if t.TYPE_CHECKING:
from collections.abc import Callable
from ..api.client import APIClient
_Self = t.TypeVar("_Self")
_P = t.ParamSpec("_P")
_R = t.TypeVar("_R")
def minimum_version(
version: str,
) -> Callable[
[Callable[t.Concatenate[_Self, _P], _R]],
Callable[t.Concatenate[_Self, _P], _R],
]:
def decorator(
f: Callable[t.Concatenate[_Self, _P], _R],
) -> Callable[t.Concatenate[_Self, _P], _R]:
@functools.wraps(f)
def wrapper(self, *args, **kwargs):
if utils.version_lt(self._version, version):
def wrapper(self: _Self, *args: _P.args, **kwargs: _P.kwargs) -> _R:
# We use _Self instead of APIClient since this is used for mixins for APIClient.
# This unfortunately means that self._version does not exist in the mixin,
# it only exists after mixing in. This is why we ignore types here.
if utils.version_lt(self._version, version): # type: ignore
raise errors.InvalidVersion(
f"{f.__name__} is not available for version < {version}"
)
@@ -32,13 +53,16 @@ def minimum_version(version):
return decorator
def update_headers(f):
def inner(self, *args, **kwargs):
def update_headers(
f: Callable[t.Concatenate[APIClient, _P], _R],
) -> Callable[t.Concatenate[APIClient, _P], _R]:
def inner(self: APIClient, *args: _P.args, **kwargs: _P.kwargs) -> _R:
if "HttpHeaders" in self._general_configs:
if not kwargs.get("headers"):
kwargs["headers"] = self._general_configs["HttpHeaders"]
else:
kwargs["headers"].update(self._general_configs["HttpHeaders"])
# We cannot (yet) model that kwargs["headers"] should be a dictionary
kwargs["headers"].update(self._general_configs["HttpHeaders"]) # type: ignore
return f(self, *args, **kwargs)
return inner
+5 -5
View File
@@ -28,16 +28,16 @@ import re
__all__ = ["fnmatch", "fnmatchcase", "translate"]
_cache = {}
_cache: dict[str, re.Pattern] = {}
_MAXCACHE = 100
def _purge():
def _purge() -> None:
"""Clear the pattern cache"""
_cache.clear()
def fnmatch(name, pat):
def fnmatch(name: str, pat: str):
"""Test whether FILENAME matches PATTERN.
Patterns are Unix shell style:
@@ -58,7 +58,7 @@ def fnmatch(name, pat):
return fnmatchcase(name, pat)
def fnmatchcase(name, pat):
def fnmatchcase(name: str, pat: str) -> bool:
"""Test whether FILENAME matches PATTERN, including case.
This is a version of fnmatch() which does not case-normalize
its arguments.
@@ -74,7 +74,7 @@ def fnmatchcase(name, pat):
return re_pat.match(name) is not None
def translate(pat):
def translate(pat: str) -> str:
"""Translate a shell PATTERN to a regular expression.
There is no way to quote meta-characters.
+20 -7
View File
@@ -13,14 +13,22 @@ from __future__ import annotations
import json
import json.decoder
import typing as t
from ..errors import StreamParseError
if t.TYPE_CHECKING:
import re
from collections.abc import Callable
_T = t.TypeVar("_T")
json_decoder = json.JSONDecoder()
def stream_as_text(stream):
def stream_as_text(stream: t.Generator[bytes | str]) -> t.Generator[str]:
"""
Given a stream of bytes or text, if any of the items in the stream
are bytes convert them to text.
@@ -33,20 +41,22 @@ def stream_as_text(stream):
yield data
def json_splitter(buffer):
def json_splitter(buffer: str) -> tuple[t.Any, str] | None:
"""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() :]
ws: re.Pattern = json.decoder.WHITESPACE # type: ignore[attr-defined]
m = ws.match(buffer, index)
rest = buffer[m.end() :] if m else buffer[index:]
return obj, rest
except ValueError:
return None
def json_stream(stream):
def json_stream(stream: t.Generator[str | bytes]) -> t.Generator[t.Any]:
"""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).
@@ -54,21 +64,24 @@ def json_stream(stream):
return split_buffer(stream, json_splitter, json_decoder.decode)
def line_splitter(buffer, separator="\n"):
def line_splitter(buffer: str, separator: str = "\n") -> tuple[str, str] | None:
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):
def split_buffer(
stream: t.Generator[str | bytes],
splitter: Callable[[str], tuple[_T, str] | None],
decoder: Callable[[str], _T],
) -> t.Generator[_T | str]:
"""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):
+66 -26
View File
@@ -12,6 +12,11 @@
from __future__ import annotations
import re
import typing as t
if t.TYPE_CHECKING:
from collections.abc import Collection, Sequence
PORT_SPEC = re.compile(
@@ -26,32 +31,42 @@ PORT_SPEC = re.compile(
)
def add_port_mapping(port_bindings, internal_port, external):
def add_port_mapping(
port_bindings: dict[str, list[str | tuple[str, str | None] | None]],
internal_port: str,
external: str | tuple[str, str | None] | None,
) -> None:
if internal_port in port_bindings:
port_bindings[internal_port].append(external)
else:
port_bindings[internal_port] = [external]
def add_port(port_bindings, internal_port_range, external_range):
def add_port(
port_bindings: dict[str, list[str | tuple[str, str | None] | None]],
internal_port_range: list[str],
external_range: list[str] | list[tuple[str, str | None]] | None,
) -> None:
if external_range is None:
for internal_port in internal_port_range:
add_port_mapping(port_bindings, internal_port, None)
else:
ports = zip(internal_port_range, external_range)
for internal_port, external_port in ports:
add_port_mapping(port_bindings, internal_port, external_port)
for internal_port, external_port in zip(internal_port_range, external_range):
# mypy loses the exact type of eternal_port elements for some reason...
add_port_mapping(port_bindings, internal_port, external_port) # type: ignore
def build_port_bindings(ports):
port_bindings = {}
def build_port_bindings(
ports: Collection[str],
) -> dict[str, list[str | tuple[str, str | None] | None]]:
port_bindings: dict[str, list[str | tuple[str, str | None] | None]] = {}
for port in ports:
internal_port_range, external_range = split_port(port)
add_port(port_bindings, internal_port_range, external_range)
return port_bindings
def _raise_invalid_port(port):
def _raise_invalid_port(port: str) -> t.NoReturn:
raise ValueError(
f'Invalid port "{port}", should be '
"[[remote_ip:]remote_port[-remote_port]:]"
@@ -59,39 +74,64 @@ def _raise_invalid_port(port):
)
def port_range(start, end, proto, randomly_available_port=False):
if not start:
@t.overload
def port_range(
start: str,
end: str | None,
proto: str,
randomly_available_port: bool = False,
) -> list[str]: ...
@t.overload
def port_range(
start: str | None,
end: str | None,
proto: str,
randomly_available_port: bool = False,
) -> list[str] | None: ...
def port_range(
start: str | None,
end: str | None,
proto: str,
randomly_available_port: bool = False,
) -> list[str] | None:
if start is None:
return start
if not end:
if end is None:
return [f"{start}{proto}"]
if randomly_available_port:
return [f"{start}-{end}{proto}"]
return [f"{port}{proto}" for port in range(int(start), int(end) + 1)]
def split_port(port):
if hasattr(port, "legacy_repr"):
# This is the worst hack, but it prevents a bug in Compose 1.14.0
# https://github.com/docker/docker-py/issues/1668
# TODO: remove once fixed in Compose stable
port = port.legacy_repr()
def split_port(
port: str,
) -> tuple[list[str], list[str] | list[tuple[str, str | None]] | None]:
port = str(port)
match = PORT_SPEC.match(port)
if match is None:
_raise_invalid_port(port)
parts = match.groupdict()
host = parts["host"]
proto = parts["proto"] or ""
internal = port_range(parts["int"], parts["int_end"], proto)
external = port_range(parts["ext"], parts["ext_end"], "", len(internal) == 1)
host: str | None = parts["host"]
proto: str = parts["proto"] or ""
int_p: str = parts["int"]
ext_p: str = parts["ext"]
internal: list[str] = port_range(int_p, parts["int_end"], proto) # type: ignore
external = port_range(ext_p or None, parts["ext_end"], "", len(internal) == 1)
if host is None:
if external is not None and len(internal) != len(external):
if (external is not None and len(internal) != len(external)) or ext_p == "":
raise ValueError("Port ranges don't match in length")
return internal, external
external_or_none: Sequence[str | None]
if not external:
external = [None] * len(internal)
elif len(internal) != len(external):
raise ValueError("Port ranges don't match in length")
return internal, [(host, ext_port) for ext_port in external]
external_or_none = [None] * len(internal)
else:
external_or_none = external
if len(internal) != len(external_or_none):
raise ValueError("Port ranges don't match in length")
return internal, [(host, ext_port) for ext_port in external_or_none]
+8 -8
View File
@@ -20,23 +20,23 @@ class ProxyConfig(dict):
"""
@property
def http(self):
def http(self) -> str | None:
return self.get("http")
@property
def https(self):
def https(self) -> str | None:
return self.get("https")
@property
def ftp(self):
def ftp(self) -> str | None:
return self.get("ftp")
@property
def no_proxy(self):
def no_proxy(self) -> str | None:
return self.get("no_proxy")
@staticmethod
def from_dict(config):
def from_dict(config: dict[str, str]) -> ProxyConfig:
"""
Instantiate a new ProxyConfig from a dictionary that represents a
client configuration, as described in `the documentation`_.
@@ -51,7 +51,7 @@ class ProxyConfig(dict):
no_proxy=config.get("noProxy"),
)
def get_environment(self):
def get_environment(self) -> dict[str, str]:
"""
Return a dictionary representing the environment variables used to
set the proxy settings.
@@ -67,7 +67,7 @@ class ProxyConfig(dict):
env["no_proxy"] = env["NO_PROXY"] = self.no_proxy
return env
def inject_proxy_environment(self, environment):
def inject_proxy_environment(self, environment: list[str]) -> list[str]:
"""
Given a list of strings representing environment variables, prepend the
environment variables corresponding to the proxy settings.
@@ -82,5 +82,5 @@ class ProxyConfig(dict):
# variables defined in "environment" to take precedence.
return proxy_env + environment
def __str__(self):
def __str__(self) -> str:
return f"ProxyConfig(http={self.http}, https={self.https}, ftp={self.ftp}, no_proxy={self.no_proxy})"
+32 -12
View File
@@ -16,10 +16,15 @@ import os
import select
import socket as pysocket
import struct
import typing as t
from ..transport.npipesocket import NpipeSocket
if t.TYPE_CHECKING:
from collections.abc import Iterable
STDOUT = 1
STDERR = 2
@@ -33,7 +38,7 @@ class SocketError(Exception):
NPIPE_ENDED = 109
def read(socket, n=4096):
def read(socket, n: int = 4096) -> bytes | None:
"""
Reads at most n bytes from socket
"""
@@ -58,6 +63,7 @@ def read(socket, n=4096):
except EnvironmentError as e:
if e.errno not in recoverable_errors:
raise
return None # TODO ???
except Exception as e:
is_pipe_ended = (
isinstance(socket, NpipeSocket)
@@ -67,11 +73,11 @@ def read(socket, n=4096):
if is_pipe_ended:
# npipes do not support duplex sockets, so we interpret
# a PIPE_ENDED error as a close operation (0-length read).
return ""
return b""
raise
def read_exactly(socket, n):
def read_exactly(socket, n: int) -> bytes:
"""
Reads exactly n bytes from socket
Raises SocketError if there is not enough data
@@ -85,7 +91,7 @@ def read_exactly(socket, n):
return data
def next_frame_header(socket):
def next_frame_header(socket) -> tuple[int, int]:
"""
Returns the stream and size of the next frame of data waiting to be read
from socket, according to the protocol defined here:
@@ -101,7 +107,7 @@ def next_frame_header(socket):
return (stream, actual)
def frames_iter(socket, tty):
def frames_iter(socket, tty: bool) -> t.Generator[tuple[int, bytes]]:
"""
Return a generator of frames read from socket. A frame is a tuple where
the first item is the stream number and the second item is a chunk of data.
@@ -114,7 +120,7 @@ def frames_iter(socket, tty):
return frames_iter_no_tty(socket)
def frames_iter_no_tty(socket):
def frames_iter_no_tty(socket) -> t.Generator[tuple[int, bytes]]:
"""
Returns a generator of data read from the socket when the tty setting is
not enabled.
@@ -135,20 +141,34 @@ def frames_iter_no_tty(socket):
yield (stream, result)
def frames_iter_tty(socket):
def frames_iter_tty(socket) -> t.Generator[bytes]:
"""
Return a generator of data read from the socket when the tty setting is
enabled.
"""
while True:
result = read(socket)
if len(result) == 0:
if not result:
# We have reached EOF
return
yield result
def consume_socket_output(frames, demux=False):
@t.overload
def consume_socket_output(frames, demux: t.Literal[False] = False) -> bytes: ...
@t.overload
def consume_socket_output(frames, demux: t.Literal[True]) -> tuple[bytes, bytes]: ...
@t.overload
def consume_socket_output(
frames, demux: bool = False
) -> bytes | tuple[bytes, bytes]: ...
def consume_socket_output(frames, demux: bool = False) -> bytes | tuple[bytes, bytes]:
"""
Iterate through frames read from the socket and return the result.
@@ -167,7 +187,7 @@ def consume_socket_output(frames, demux=False):
# If the streams are demultiplexed, the generator yields tuples
# (stdout, stderr)
out = [None, None]
out: list[bytes | None] = [None, None]
for frame in frames:
# It is guaranteed that for each frame, one and only one stream
# is not None.
@@ -183,10 +203,10 @@ def consume_socket_output(frames, demux=False):
out[1] = frame[1]
else:
out[1] += frame[1]
return tuple(out)
return tuple(out) # type: ignore
def demux_adaptor(stream_id, data):
def demux_adaptor(stream_id: int, data: bytes) -> tuple[bytes | None, bytes | None]:
"""
Utility to demultiplex stdout and stderr when reading frames from the
socket.
+78 -65
View File
@@ -18,6 +18,7 @@ import os
import os.path
import shlex
import string
import typing as t
from urllib.parse import urlparse, urlunparse
from ansible_collections.community.docker.plugins.module_utils._version import (
@@ -34,32 +35,23 @@ from ..constants import (
from ..tls import TLSConfig
if t.TYPE_CHECKING:
import ssl
from collections.abc import Mapping, Sequence
URLComponents = collections.namedtuple(
"URLComponents",
"scheme netloc url params query fragment",
)
def create_ipam_pool(*args, **kwargs):
raise errors.DeprecatedMethod(
"utils.create_ipam_pool has been removed. Please use a "
"docker.types.IPAMPool object instead."
)
def create_ipam_config(*args, **kwargs):
raise errors.DeprecatedMethod(
"utils.create_ipam_config has been removed. Please use a "
"docker.types.IPAMConfig object instead."
)
def decode_json_header(header):
def decode_json_header(header: str) -> dict[str, t.Any]:
data = base64.b64decode(header).decode("utf-8")
return json.loads(data)
def compare_version(v1, v2):
def compare_version(v1: str, v2: str) -> t.Literal[-1, 0, 1]:
"""Compare docker versions
>>> v1 = '1.9'
@@ -80,43 +72,64 @@ def compare_version(v1, v2):
return 1
def version_lt(v1, v2):
def version_lt(v1: str, v2: str) -> bool:
return compare_version(v1, v2) > 0
def version_gte(v1, v2):
def version_gte(v1: str, v2: str) -> bool:
return not version_lt(v1, v2)
def _convert_port_binding(binding):
def _convert_port_binding(
binding: (
tuple[str, str | int | None]
| tuple[str | int | None]
| dict[str, str]
| str
| int
),
) -> dict[str, str]:
result = {"HostIp": "", "HostPort": ""}
host_port: str | int | None = ""
if isinstance(binding, tuple):
if len(binding) == 2:
result["HostPort"] = binding[1]
host_port = binding[1] # type: ignore
result["HostIp"] = binding[0]
elif isinstance(binding[0], str):
result["HostIp"] = binding[0]
else:
result["HostPort"] = binding[0]
host_port = binding[0]
elif isinstance(binding, dict):
if "HostPort" in binding:
result["HostPort"] = binding["HostPort"]
host_port = binding["HostPort"]
if "HostIp" in binding:
result["HostIp"] = binding["HostIp"]
else:
raise ValueError(binding)
else:
result["HostPort"] = binding
if result["HostPort"] is None:
result["HostPort"] = ""
else:
result["HostPort"] = str(result["HostPort"])
host_port = binding
result["HostPort"] = str(host_port) if host_port is not None else ""
return result
def convert_port_bindings(port_bindings):
def convert_port_bindings(
port_bindings: dict[
str | int,
tuple[str, str | int | None]
| tuple[str | int | None]
| dict[str, str]
| str
| int
| list[
tuple[str, str | int | None]
| tuple[str | int | None]
| dict[str, str]
| str
| int
],
],
) -> dict[str, list[dict[str, str]]]:
result = {}
for k, v in port_bindings.items():
key = str(k)
@@ -129,9 +142,11 @@ def convert_port_bindings(port_bindings):
return result
def convert_volume_binds(binds):
def convert_volume_binds(
binds: list[str] | Mapping[str | bytes, dict[str, str | bytes] | bytes | str | int],
) -> list[str]:
if isinstance(binds, list):
return binds
return binds # type: ignore
result = []
for k, v in binds.items():
@@ -149,7 +164,7 @@ def convert_volume_binds(binds):
if "ro" in v:
mode = "ro" if v["ro"] else "rw"
elif "mode" in v:
mode = v["mode"]
mode = v["mode"] # type: ignore # TODO
else:
mode = "rw"
@@ -165,9 +180,9 @@ def convert_volume_binds(binds):
]
if "propagation" in v and v["propagation"] in propagation_modes:
if mode:
mode = ",".join([mode, v["propagation"]])
mode = ",".join([mode, v["propagation"]]) # type: ignore # TODO
else:
mode = v["propagation"]
mode = v["propagation"] # type: ignore # TODO
result.append(f"{k}:{bind}:{mode}")
else:
@@ -177,7 +192,7 @@ def convert_volume_binds(binds):
return result
def convert_tmpfs_mounts(tmpfs):
def convert_tmpfs_mounts(tmpfs: dict[str, str] | list[str]) -> dict[str, str]:
if isinstance(tmpfs, dict):
return tmpfs
@@ -204,9 +219,11 @@ def convert_tmpfs_mounts(tmpfs):
return result
def convert_service_networks(networks):
def convert_service_networks(
networks: list[str | dict[str, str]],
) -> list[dict[str, str]]:
if not networks:
return networks
return networks # type: ignore
if not isinstance(networks, list):
raise TypeError("networks parameter must be a list.")
@@ -218,17 +235,17 @@ def convert_service_networks(networks):
return result
def parse_repository_tag(repo_name):
def parse_repository_tag(repo_name: str) -> tuple[str, str | None]:
parts = repo_name.rsplit("@", 1)
if len(parts) == 2:
return tuple(parts)
return tuple(parts) # type: ignore
parts = repo_name.rsplit(":", 1)
if len(parts) == 2 and "/" not in parts[1]:
return tuple(parts)
return tuple(parts) # type: ignore
return repo_name, None
def parse_host(addr, is_win32=False, tls=False):
def parse_host(addr: str | None, is_win32: bool = False, tls: bool = False) -> str:
# Sensible defaults
if not addr and is_win32:
return DEFAULT_NPIPE
@@ -308,7 +325,7 @@ def parse_host(addr, is_win32=False, tls=False):
).rstrip("/")
def parse_devices(devices):
def parse_devices(devices: Sequence[dict[str, str] | str]) -> list[dict[str, str]]:
device_list = []
for device in devices:
if isinstance(device, dict):
@@ -337,7 +354,10 @@ def parse_devices(devices):
return device_list
def kwargs_from_env(ssl_version=None, assert_hostname=None, environment=None):
def kwargs_from_env(
assert_hostname: bool | None = None,
environment: Mapping[str, str] | None = None,
) -> dict[str, t.Any]:
if not environment:
environment = os.environ
host = environment.get("DOCKER_HOST")
@@ -347,14 +367,14 @@ def kwargs_from_env(ssl_version=None, assert_hostname=None, environment=None):
# empty string for tls verify counts as "false".
# Any value or 'unset' counts as true.
tls_verify = environment.get("DOCKER_TLS_VERIFY")
if tls_verify == "":
tls_verify_str = environment.get("DOCKER_TLS_VERIFY")
if tls_verify_str == "":
tls_verify = False
else:
tls_verify = tls_verify is not None
tls_verify = tls_verify_str is not None
enable_tls = cert_path or tls_verify
params = {}
params: dict[str, t.Any] = {}
if host:
params["base_url"] = host
@@ -377,14 +397,13 @@ def kwargs_from_env(ssl_version=None, assert_hostname=None, environment=None):
),
ca_cert=os.path.join(cert_path, "ca.pem"),
verify=tls_verify,
ssl_version=ssl_version,
assert_hostname=assert_hostname,
)
return params
def convert_filters(filters):
def convert_filters(filters: Mapping[str, bool | str | list[str]]) -> str:
result = {}
for k, v in filters.items():
if isinstance(v, bool):
@@ -397,7 +416,7 @@ def convert_filters(filters):
return json.dumps(result)
def parse_bytes(s):
def parse_bytes(s: int | float | str) -> int | float:
if isinstance(s, (int, float)):
return s
if len(s) == 0:
@@ -435,14 +454,16 @@ def parse_bytes(s):
return s
def normalize_links(links):
def normalize_links(links: dict[str, str] | Sequence[tuple[str, str]]) -> list[str]:
if isinstance(links, dict):
links = links.items()
sorted_links = sorted(links.items())
else:
sorted_links = sorted(links)
return [f"{k}:{v}" if v else k for k, v in sorted(links)]
return [f"{k}:{v}" if v else k for k, v in sorted_links]
def parse_env_file(env_file):
def parse_env_file(env_file: str | os.PathLike) -> dict[str, str]:
"""
Reads a line-separated environment file.
The format of each line should be "key=value".
@@ -451,7 +472,6 @@ def parse_env_file(env_file):
with open(env_file, "rt", encoding="utf-8") as f:
for line in f:
if line[0] == "#":
continue
@@ -471,11 +491,11 @@ def parse_env_file(env_file):
return environment
def split_command(command):
def split_command(command: str) -> list[str]:
return shlex.split(command)
def format_environment(environment):
def format_environment(environment: Mapping[str, str | bytes]) -> list[str]:
def format_env(key, value):
if value is None:
return key
@@ -487,16 +507,9 @@ def format_environment(environment):
return [format_env(*var) for var in environment.items()]
def format_extra_hosts(extra_hosts, task=False):
def format_extra_hosts(extra_hosts: Mapping[str, str], task: bool = False) -> list[str]:
# Use format dictated by Swarm API if container is part of a task
if task:
return [f"{v} {k}" for k, v in sorted(extra_hosts.items())]
return [f"{k}:{v}" for k, v in sorted(extra_hosts.items())]
def create_host_config(self, *args, **kwargs):
raise errors.DeprecatedMethod(
"utils.create_host_config has been removed. Please use a "
"docker.types.HostConfig object instead."
)
+99 -50
View File
@@ -13,6 +13,7 @@ import platform
import re
import sys
import traceback
import typing as t
from collections.abc import Mapping, Sequence
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
@@ -36,6 +37,9 @@ from ansible_collections.community.docker.plugins.module_utils._version import (
HAS_DOCKER_PY_2 = False # pylint: disable=invalid-name
HAS_DOCKER_PY_3 = False # pylint: disable=invalid-name
HAS_DOCKER_ERROR: None | str # pylint: disable=invalid-name
HAS_DOCKER_TRACEBACK: None | str # pylint: disable=invalid-name
docker_version: str | None # pylint: disable=invalid-name
try:
from docker import __version__ as docker_version
@@ -51,12 +55,13 @@ try:
HAS_DOCKER_PY_2 = True # pylint: disable=invalid-name
from docker import APIClient as Client
else:
from docker import Client
from docker import Client # type: ignore
except ImportError as exc:
HAS_DOCKER_ERROR = str(exc) # pylint: disable=invalid-name
HAS_DOCKER_TRACEBACK = traceback.format_exc() # pylint: disable=invalid-name
HAS_DOCKER_PY = False # pylint: disable=invalid-name
docker_version = None # pylint: disable=invalid-name
else:
HAS_DOCKER_PY = True # pylint: disable=invalid-name
HAS_DOCKER_ERROR = None # pylint: disable=invalid-name
@@ -71,30 +76,34 @@ except ImportError:
# Either Docker SDK for Python is no longer using requests, or Docker SDK for Python is not around either,
# or Docker SDK for Python's dependency requests is missing. In any case, define an exception
# class RequestException so that our code does not break.
class RequestException(Exception):
class RequestException(Exception): # type: ignore
pass
if t.TYPE_CHECKING:
from collections.abc import Callable
MIN_DOCKER_VERSION = "2.0.0"
if not HAS_DOCKER_PY:
docker_version = None # pylint: disable=invalid-name
# No Docker SDK for Python. Create a place holder client to allow
# instantiation of AnsibleModule and proper error handing
class Client: # noqa: F811, pylint: disable=function-redefined
class Client: # type: ignore # noqa: F811, pylint: disable=function-redefined
def __init__(self, **kwargs):
pass
class APIError(Exception): # noqa: F811, pylint: disable=function-redefined
class APIError(Exception): # type: ignore # noqa: F811, pylint: disable=function-redefined
pass
class NotFound(Exception): # noqa: F811, pylint: disable=function-redefined
class NotFound(Exception): # type: ignore # noqa: F811, pylint: disable=function-redefined
pass
def _get_tls_config(fail_function, **kwargs):
def _get_tls_config(
fail_function: Callable[[str], t.NoReturn], **kwargs: t.Any
) -> TLSConfig:
if "assert_hostname" in kwargs and LooseVersion(docker_version) >= LooseVersion(
"7.0.0b1"
):
@@ -109,17 +118,18 @@ def _get_tls_config(fail_function, **kwargs):
# Filter out all None parameters
kwargs = dict((k, v) for k, v in kwargs.items() if v is not None)
try:
tls_config = TLSConfig(**kwargs)
return tls_config
return TLSConfig(**kwargs)
except TLSParameterError as exc:
fail_function(f"TLS config error: {exc}")
def is_using_tls(auth_data):
def is_using_tls(auth_data: dict[str, t.Any]) -> bool:
return auth_data["tls_verify"] or auth_data["tls"]
def get_connect_params(auth_data, fail_function):
def get_connect_params(
auth_data: dict[str, t.Any], fail_function: Callable[[str], t.NoReturn]
) -> dict[str, t.Any]:
if is_using_tls(auth_data):
auth_data["docker_host"] = auth_data["docker_host"].replace(
"tcp://", "https://"
@@ -171,7 +181,11 @@ DOCKERPYUPGRADE_UPGRADE_DOCKER = "Use `pip install --upgrade docker` to upgrade.
class AnsibleDockerClientBase(Client):
def __init__(self, min_docker_version=None, min_docker_api_version=None):
def __init__(
self,
min_docker_version: str | None = None,
min_docker_api_version: str | None = None,
) -> None:
if min_docker_version is None:
min_docker_version = MIN_DOCKER_VERSION
@@ -212,23 +226,34 @@ class AnsibleDockerClientBase(Client):
f"Docker API version is {self.docker_api_version_str}. Minimum version required is {min_docker_api_version}."
)
def log(self, msg, pretty_print=False):
def log(self, msg: t.Any, pretty_print: bool = False):
pass
# if self.debug:
# from .util import log_debug
# log_debug(msg, pretty_print=pretty_print)
@abc.abstractmethod
def fail(self, msg, **kwargs):
def fail(self, msg: str, **kwargs: t.Any) -> t.NoReturn:
pass
def deprecate(self, msg, version=None, date=None, collection_name=None):
@abc.abstractmethod
def deprecate(
self,
msg: str,
version: str | None = None,
date: str | None = None,
collection_name: str | None = None,
) -> None:
pass
@staticmethod
def _get_value(
param_name, param_value, env_variable, default_value, value_type="str"
):
param_name: str,
param_value: t.Any,
env_variable: str | None,
default_value: t.Any | None,
value_type: t.Literal["str", "bool", "int"] = "str",
) -> t.Any:
if param_value is not None:
# take module parameter value
if value_type == "bool":
@@ -265,11 +290,11 @@ class AnsibleDockerClientBase(Client):
return default_value
@abc.abstractmethod
def _get_params(self):
def _get_params(self) -> dict[str, t.Any]:
pass
@property
def auth_params(self):
def auth_params(self) -> dict[str, t.Any]:
# Get authentication credentials.
# Precedence: module parameters-> environment variables-> defaults.
@@ -354,7 +379,7 @@ class AnsibleDockerClientBase(Client):
return result
def _handle_ssl_error(self, error):
def _handle_ssl_error(self, error: Exception) -> t.NoReturn:
match = re.match(r"hostname.*doesn\'t match (\'.*\')", str(error))
if match:
hostname = self.auth_params["tls_hostname"]
@@ -366,7 +391,7 @@ class AnsibleDockerClientBase(Client):
)
self.fail(f"SSL Exception: {error}")
def get_container_by_id(self, container_id):
def get_container_by_id(self, container_id: str) -> dict[str, t.Any] | None:
try:
self.log(f"Inspecting container Id {container_id}")
result = self.inspect_container(container=container_id)
@@ -377,7 +402,7 @@ class AnsibleDockerClientBase(Client):
except Exception as exc: # pylint: disable=broad-exception-caught
self.fail(f"Error inspecting container: {exc}")
def get_container(self, name=None):
def get_container(self, name: str | None) -> dict[str, t.Any] | None:
"""
Lookup a container and return the inspection results.
"""
@@ -414,7 +439,9 @@ class AnsibleDockerClientBase(Client):
return self.get_container_by_id(result["Id"])
def get_network(self, name=None, network_id=None):
def get_network(
self, name: str | None = None, network_id: str | None = None
) -> dict[str, t.Any] | None:
"""
Lookup a network and return the inspection results.
"""
@@ -453,7 +480,7 @@ class AnsibleDockerClientBase(Client):
return result
def find_image(self, name, tag):
def find_image(self, name: str, tag: str) -> dict[str, t.Any] | None:
"""
Lookup an image (by name and tag) and return the inspection results.
"""
@@ -505,7 +532,9 @@ class AnsibleDockerClientBase(Client):
self.log(f"Image {name}:{tag} not found.")
return None
def find_image_by_id(self, image_id, accept_missing_image=False):
def find_image_by_id(
self, image_id: str, accept_missing_image: bool = False
) -> dict[str, t.Any] | None:
"""
Lookup an image (by ID) and return the inspection results.
"""
@@ -524,7 +553,7 @@ class AnsibleDockerClientBase(Client):
self.fail(f"Error inspecting image ID {image_id} - {exc}")
return inspection
def _image_lookup(self, name, tag):
def _image_lookup(self, name: str, tag: str) -> list[dict[str, t.Any]]:
"""
Including a tag in the name parameter sent to the Docker SDK for Python images method
does not work consistently. Instead, get the result set for name and manually check
@@ -547,7 +576,9 @@ class AnsibleDockerClientBase(Client):
break
return images
def pull_image(self, name, tag="latest", image_platform=None):
def pull_image(
self, name: str, tag: str = "latest", image_platform: str | None = None
) -> tuple[dict[str, t.Any] | None, bool]:
"""
Pull an image
"""
@@ -578,7 +609,7 @@ class AnsibleDockerClientBase(Client):
return new_tag, old_tag == new_tag
def inspect_distribution(self, image, **kwargs):
def inspect_distribution(self, image: str, **kwargs) -> dict[str, t.Any]:
"""
Get image digest by directly calling the Docker API when running Docker SDK < 4.0.0
since prior versions did not support accessing private repositories.
@@ -592,7 +623,7 @@ class AnsibleDockerClientBase(Client):
self._url("/distribution/{0}/json", image),
headers={"X-Registry-Auth": header},
),
get_json=True,
json=True,
)
return super().inspect_distribution(image, **kwargs)
@@ -601,18 +632,24 @@ class AnsibleDockerClient(AnsibleDockerClientBase):
def __init__(
self,
argument_spec=None,
supports_check_mode=False,
mutually_exclusive=None,
required_together=None,
required_if=None,
required_one_of=None,
required_by=None,
min_docker_version=None,
min_docker_api_version=None,
option_minimal_versions=None,
option_minimal_versions_ignore_params=None,
fail_results=None,
argument_spec: dict[str, t.Any] | None = None,
supports_check_mode: bool = False,
mutually_exclusive: Sequence[Sequence[str]] | None = None,
required_together: Sequence[Sequence[str]] | None = None,
required_if: (
Sequence[
tuple[str, t.Any, Sequence[str]]
| tuple[str, t.Any, Sequence[str], bool]
]
| None
) = None,
required_one_of: Sequence[Sequence[str]] | None = None,
required_by: dict[str, Sequence[str]] | None = None,
min_docker_version: str | None = None,
min_docker_api_version: str | None = None,
option_minimal_versions: dict[str, t.Any] | None = None,
option_minimal_versions_ignore_params: Sequence[str] | None = None,
fail_results: dict[str, t.Any] | None = None,
):
# Modules can put information in here which will always be returned
@@ -625,12 +662,12 @@ class AnsibleDockerClient(AnsibleDockerClientBase):
merged_arg_spec.update(argument_spec)
self.arg_spec = merged_arg_spec
mutually_exclusive_params = []
mutually_exclusive_params: list[Sequence[str]] = []
mutually_exclusive_params += DOCKER_MUTUALLY_EXCLUSIVE
if mutually_exclusive:
mutually_exclusive_params += mutually_exclusive
required_together_params = []
required_together_params: list[Sequence[str]] = []
required_together_params += DOCKER_REQUIRED_TOGETHER
if required_together:
required_together_params += required_together
@@ -658,20 +695,30 @@ class AnsibleDockerClient(AnsibleDockerClientBase):
option_minimal_versions, option_minimal_versions_ignore_params
)
def fail(self, msg, **kwargs):
def fail(self, msg: str, **kwargs: t.Any) -> t.NoReturn:
self.fail_results.update(kwargs)
self.module.fail_json(msg=msg, **sanitize_result(self.fail_results))
def deprecate(self, msg, version=None, date=None, collection_name=None):
def deprecate(
self,
msg: str,
version: str | None = None,
date: str | None = None,
collection_name: str | None = None,
) -> None:
self.module.deprecate(
msg, version=version, date=date, collection_name=collection_name
)
def _get_params(self):
def _get_params(self) -> dict[str, t.Any]:
return self.module.params
def _get_minimal_versions(self, option_minimal_versions, ignore_params=None):
self.option_minimal_versions = {}
def _get_minimal_versions(
self,
option_minimal_versions: dict[str, t.Any],
ignore_params: Sequence[str] | None = None,
) -> None:
self.option_minimal_versions: dict[str, dict[str, t.Any]] = {}
for option in self.module.argument_spec:
if ignore_params is not None:
if option in ignore_params:
@@ -722,7 +769,9 @@ class AnsibleDockerClient(AnsibleDockerClientBase):
msg = f"Cannot {usg} with your configuration."
self.fail(msg)
def report_warnings(self, result, warnings_key=None):
def report_warnings(
self, result: t.Any, warnings_key: Sequence[str] | None = None
) -> None:
"""
Checks result of client operation for warnings, and if present, outputs them.
+85 -44
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import abc
import os
import re
import typing as t
from collections.abc import Mapping, Sequence
from ansible.module_utils.basic import AnsibleModule, missing_required_lib
@@ -28,7 +29,7 @@ try:
)
except ImportError:
# Define an exception class RequestException so that our code does not break.
class RequestException(Exception):
class RequestException(Exception): # type: ignore
pass
@@ -60,19 +61,26 @@ from ansible_collections.community.docker.plugins.module_utils._util import (
)
def _get_tls_config(fail_function, **kwargs):
if t.TYPE_CHECKING:
from collections.abc import Callable
def _get_tls_config(
fail_function: Callable[[str], t.NoReturn], **kwargs: t.Any
) -> TLSConfig:
try:
tls_config = TLSConfig(**kwargs)
return tls_config
return TLSConfig(**kwargs)
except TLSParameterError as exc:
fail_function(f"TLS config error: {exc}")
def is_using_tls(auth_data):
def is_using_tls(auth_data: dict[str, t.Any]) -> bool:
return auth_data["tls_verify"] or auth_data["tls"]
def get_connect_params(auth_data, fail_function):
def get_connect_params(
auth_data: dict[str, t.Any], fail_function: Callable[[str], t.NoReturn]
) -> dict[str, t.Any]:
if is_using_tls(auth_data):
auth_data["docker_host"] = auth_data["docker_host"].replace(
"tcp://", "https://"
@@ -114,7 +122,7 @@ def get_connect_params(auth_data, fail_function):
class AnsibleDockerClientBase(Client):
def __init__(self, min_docker_api_version=None):
def __init__(self, min_docker_api_version: str | None = None) -> None:
self._connect_params = get_connect_params(
self.auth_params, fail_function=self.fail
)
@@ -138,23 +146,34 @@ class AnsibleDockerClientBase(Client):
f"Docker API version is {self.docker_api_version_str}. Minimum version required is {min_docker_api_version}."
)
def log(self, msg, pretty_print=False):
def log(self, msg: t.Any, pretty_print: bool = False):
pass
# if self.debug:
# from .util import log_debug
# log_debug(msg, pretty_print=pretty_print)
@abc.abstractmethod
def fail(self, msg, **kwargs):
def fail(self, msg: str, **kwargs: t.Any) -> t.NoReturn:
pass
def deprecate(self, msg, version=None, date=None, collection_name=None):
@abc.abstractmethod
def deprecate(
self,
msg: str,
version: str | None = None,
date: str | None = None,
collection_name: str | None = None,
) -> None:
pass
@staticmethod
def _get_value(
param_name, param_value, env_variable, default_value, value_type="str"
):
param_name: str,
param_value: t.Any,
env_variable: str | None,
default_value: t.Any | None,
value_type: t.Literal["str", "bool", "int"] = "str",
) -> t.Any:
if param_value is not None:
# take module parameter value
if value_type == "bool":
@@ -191,11 +210,11 @@ class AnsibleDockerClientBase(Client):
return default_value
@abc.abstractmethod
def _get_params(self):
def _get_params(self) -> dict[str, t.Any]:
pass
@property
def auth_params(self):
def auth_params(self) -> dict[str, t.Any]:
# Get authentication credentials.
# Precedence: module parameters-> environment variables-> defaults.
@@ -288,7 +307,7 @@ class AnsibleDockerClientBase(Client):
return result
def _handle_ssl_error(self, error):
def _handle_ssl_error(self, error: Exception) -> t.NoReturn:
match = re.match(r"hostname.*doesn\'t match (\'.*\')", str(error))
if match:
hostname = self.auth_params["tls_hostname"]
@@ -300,7 +319,7 @@ class AnsibleDockerClientBase(Client):
)
self.fail(f"SSL Exception: {error}")
def get_container_by_id(self, container_id):
def get_container_by_id(self, container_id: str) -> dict[str, t.Any] | None:
try:
self.log(f"Inspecting container Id {container_id}")
result = self.get_json("/containers/{0}/json", container_id)
@@ -311,7 +330,7 @@ class AnsibleDockerClientBase(Client):
except Exception as exc: # pylint: disable=broad-exception-caught
self.fail(f"Error inspecting container: {exc}")
def get_container(self, name=None):
def get_container(self, name: str | None) -> dict[str, t.Any] | None:
"""
Lookup a container and return the inspection results.
"""
@@ -355,7 +374,9 @@ class AnsibleDockerClientBase(Client):
return self.get_container_by_id(result["Id"])
def get_network(self, name=None, network_id=None):
def get_network(
self, name: str | None = None, network_id: str | None = None
) -> dict[str, t.Any] | None:
"""
Lookup a network and return the inspection results.
"""
@@ -395,14 +416,14 @@ class AnsibleDockerClientBase(Client):
return result
def _image_lookup(self, name, tag):
def _image_lookup(self, name: str, tag: str | None) -> list[dict[str, t.Any]]:
"""
Including a tag in the name parameter sent to the Docker SDK for Python images method
does not work consistently. Instead, get the result set for name and manually check
if the tag exists.
"""
try:
params = {
params: dict[str, t.Any] = {
"only_ids": 0,
"all": 0,
}
@@ -427,7 +448,7 @@ class AnsibleDockerClientBase(Client):
break
return images
def find_image(self, name, tag):
def find_image(self, name: str, tag: str | None) -> dict[str, t.Any] | None:
"""
Lookup an image (by name and tag) and return the inspection results.
"""
@@ -478,7 +499,9 @@ class AnsibleDockerClientBase(Client):
self.log(f"Image {name}:{tag} not found.")
return None
def find_image_by_id(self, image_id, accept_missing_image=False):
def find_image_by_id(
self, image_id: str, accept_missing_image: bool = False
) -> dict[str, t.Any] | None:
"""
Lookup an image (by ID) and return the inspection results.
"""
@@ -496,7 +519,9 @@ class AnsibleDockerClientBase(Client):
except Exception as exc: # pylint: disable=broad-exception-caught
self.fail(f"Error inspecting image ID {image_id} - {exc}")
def pull_image(self, name, tag="latest", image_platform=None):
def pull_image(
self, name: str, tag: str = "latest", image_platform: str | None = None
) -> tuple[dict[str, t.Any] | None, bool]:
"""
Pull an image
"""
@@ -544,22 +569,26 @@ class AnsibleDockerClientBase(Client):
class AnsibleDockerClient(AnsibleDockerClientBase):
def __init__(
self,
argument_spec=None,
supports_check_mode=False,
mutually_exclusive=None,
required_together=None,
required_if=None,
required_one_of=None,
required_by=None,
min_docker_api_version=None,
option_minimal_versions=None,
option_minimal_versions_ignore_params=None,
fail_results=None,
argument_spec: dict[str, t.Any] | None = None,
supports_check_mode: bool = False,
mutually_exclusive: Sequence[Sequence[str]] | None = None,
required_together: Sequence[Sequence[str]] | None = None,
required_if: (
Sequence[
tuple[str, t.Any, Sequence[str]]
| tuple[str, t.Any, Sequence[str], bool]
]
| None
) = None,
required_one_of: Sequence[Sequence[str]] | None = None,
required_by: dict[str, Sequence[str]] | None = None,
min_docker_api_version: str | None = None,
option_minimal_versions: dict[str, t.Any] | None = None,
option_minimal_versions_ignore_params: Sequence[str] | None = None,
fail_results: dict[str, t.Any] | None = None,
):
# Modules can put information in here which will always be returned
# in case client.fail() is called.
self.fail_results = fail_results or {}
@@ -570,12 +599,12 @@ class AnsibleDockerClient(AnsibleDockerClientBase):
merged_arg_spec.update(argument_spec)
self.arg_spec = merged_arg_spec
mutually_exclusive_params = []
mutually_exclusive_params: list[Sequence[str]] = []
mutually_exclusive_params += DOCKER_MUTUALLY_EXCLUSIVE
if mutually_exclusive:
mutually_exclusive_params += mutually_exclusive
required_together_params = []
required_together_params: list[Sequence[str]] = []
required_together_params += DOCKER_REQUIRED_TOGETHER
if required_together:
required_together_params += required_together
@@ -600,20 +629,30 @@ class AnsibleDockerClient(AnsibleDockerClientBase):
option_minimal_versions, option_minimal_versions_ignore_params
)
def fail(self, msg, **kwargs):
def fail(self, msg: str, **kwargs: t.Any) -> t.NoReturn:
self.fail_results.update(kwargs)
self.module.fail_json(msg=msg, **sanitize_result(self.fail_results))
def deprecate(self, msg, version=None, date=None, collection_name=None):
def deprecate(
self,
msg: str,
version: str | None = None,
date: str | None = None,
collection_name: str | None = None,
) -> None:
self.module.deprecate(
msg, version=version, date=date, collection_name=collection_name
)
def _get_params(self):
def _get_params(self) -> dict[str, t.Any]:
return self.module.params
def _get_minimal_versions(self, option_minimal_versions, ignore_params=None):
self.option_minimal_versions = {}
def _get_minimal_versions(
self,
option_minimal_versions: dict[str, t.Any],
ignore_params: Sequence[str] | None = None,
) -> None:
self.option_minimal_versions: dict[str, dict[str, t.Any]] = {}
for option in self.module.argument_spec:
if ignore_params is not None:
if option in ignore_params:
@@ -654,7 +693,9 @@ class AnsibleDockerClient(AnsibleDockerClientBase):
msg = f"Cannot {usg} with your configuration."
self.fail(msg)
def report_warnings(self, result, warnings_key=None):
def report_warnings(
self, result: t.Any, warnings_key: Sequence[str] | None = None
) -> None:
"""
Checks result of client operation for warnings, and if present, outputs them.
+125 -46
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import abc
import json
import shlex
import typing as t
from ansible.module_utils.basic import AnsibleModule, env_fallback
from ansible.module_utils.common.process import get_bin_path
@@ -31,6 +32,10 @@ from ansible_collections.community.docker.plugins.module_utils._version import (
)
if t.TYPE_CHECKING:
from collections.abc import Mapping, Sequence
DOCKER_COMMON_ARGS = {
"docker_cli": {"type": "path"},
"docker_host": {
@@ -72,10 +77,16 @@ class DockerException(Exception):
class AnsibleDockerClientBase:
docker_api_version_str: str | None
docker_api_version: LooseVersion | None
def __init__(
self, common_args, min_docker_api_version=None, needs_api_version=True
):
self._environment = {}
self,
common_args,
min_docker_api_version: str | None = None,
needs_api_version: bool = True,
) -> None:
self._environment: dict[str, str] = {}
if common_args["tls_hostname"]:
self._environment["DOCKER_TLS_HOSTNAME"] = common_args["tls_hostname"]
if common_args["api_version"] and common_args["api_version"] != "auto":
@@ -109,10 +120,10 @@ class AnsibleDockerClientBase:
self._cli_base.extend(["--context", common_args["cli_context"]])
# `--format json` was only added as a shorthand for `--format {{ json . }}` in Docker 23.0
dummy, self._version, dummy = self.call_cli_json(
dummy, self._version, dummy2 = self.call_cli_json(
"version", "--format", "{{ json . }}", check_rc=True
)
self._info = None
self._info: dict[str, t.Any] | None = None
if needs_api_version:
if not isinstance(self._version.get("Server"), dict) or not isinstance(
@@ -138,32 +149,47 @@ class AnsibleDockerClientBase:
"Internal error: cannot have needs_api_version=False with min_docker_api_version not None"
)
def log(self, msg, pretty_print=False):
def log(self, msg: str, pretty_print: bool = False):
pass
# if self.debug:
# from .util import log_debug
# log_debug(msg, pretty_print=pretty_print)
def get_cli(self):
def get_cli(self) -> str:
return self._cli
def get_version_info(self):
def get_version_info(self) -> str:
return self._version
def _compose_cmd(self, args):
def _compose_cmd(self, args: t.Sequence[str]) -> list[str]:
return self._cli_base + list(args)
def _compose_cmd_str(self, args):
def _compose_cmd_str(self, args: t.Sequence[str]) -> str:
return " ".join(shlex.quote(a) for a in self._compose_cmd(args))
@abc.abstractmethod
def call_cli(self, *args, check_rc=False, data=None, cwd=None, environ_update=None):
def call_cli(
self,
*args: str,
check_rc: bool = False,
data: bytes | None = None,
cwd: str | None = None,
environ_update: dict[str, str] | None = None,
) -> tuple[int, bytes, bytes]:
pass
# def call_cli_json(self, *args, check_rc=False, data=None, cwd=None, environ_update=None, warn_on_stderr=False):
def call_cli_json(self, *args, **kwargs):
warn_on_stderr = kwargs.pop("warn_on_stderr", False)
rc, stdout, stderr = self.call_cli(*args, **kwargs)
def call_cli_json(
self,
*args: str,
check_rc: bool = False,
data: bytes | None = None,
cwd: str | None = None,
environ_update: dict[str, str] | None = None,
warn_on_stderr: bool = False,
) -> tuple[int, t.Any, bytes]:
rc, stdout, stderr = self.call_cli(
*args, check_rc=check_rc, data=data, cwd=cwd, environ_update=environ_update
)
if warn_on_stderr and stderr:
self.warn(to_native(stderr))
try:
@@ -174,10 +200,18 @@ class AnsibleDockerClientBase:
)
return rc, data, stderr
# def call_cli_json_stream(self, *args, check_rc=False, data=None, cwd=None, environ_update=None, warn_on_stderr=False):
def call_cli_json_stream(self, *args, **kwargs):
warn_on_stderr = kwargs.pop("warn_on_stderr", False)
rc, stdout, stderr = self.call_cli(*args, **kwargs)
def call_cli_json_stream(
self,
*args: str,
check_rc: bool = False,
data: bytes | None = None,
cwd: str | None = None,
environ_update: dict[str, str] | None = None,
warn_on_stderr: bool = False,
) -> tuple[int, list[t.Any], bytes]:
rc, stdout, stderr = self.call_cli(
*args, check_rc=check_rc, data=data, cwd=cwd, environ_update=environ_update
)
if warn_on_stderr and stderr:
self.warn(to_native(stderr))
result = []
@@ -193,25 +227,31 @@ class AnsibleDockerClientBase:
return rc, result, stderr
@abc.abstractmethod
def fail(self, msg, **kwargs):
def fail(self, msg: str, **kwargs) -> t.NoReturn:
pass
@abc.abstractmethod
def warn(self, msg):
def warn(self, msg: str) -> None:
pass
@abc.abstractmethod
def deprecate(self, msg, version=None, date=None, collection_name=None):
def deprecate(
self,
msg: str,
version: str | None = None,
date: str | None = None,
collection_name: str | None = None,
) -> None:
pass
def get_cli_info(self):
def get_cli_info(self) -> dict[str, t.Any]:
if self._info is None:
dummy, self._info, dummy = self.call_cli_json(
dummy, self._info, dummy2 = self.call_cli_json(
"info", "--format", "{{ json . }}", check_rc=True
)
return self._info
def get_client_plugin_info(self, component):
def get_client_plugin_info(self, component: str) -> dict[str, t.Any] | None:
cli_info = self.get_cli_info()
if not isinstance(cli_info.get("ClientInfo"), dict):
self.fail(
@@ -222,13 +262,13 @@ class AnsibleDockerClientBase:
return plugin
return None
def _image_lookup(self, name, tag):
def _image_lookup(self, name: str, tag: str) -> list[dict[str, t.Any]]:
"""
Including a tag in the name parameter sent to the Docker SDK for Python images method
does not work consistently. Instead, get the result set for name and manually check
if the tag exists.
"""
dummy, images, dummy = self.call_cli_json_stream(
dummy, images, dummy2 = self.call_cli_json_stream(
"image",
"ls",
"--format",
@@ -247,7 +287,13 @@ class AnsibleDockerClientBase:
break
return images
def find_image(self, name, tag):
@t.overload
def find_image(self, name: None, tag: str) -> None: ...
@t.overload
def find_image(self, name: str, tag: str) -> dict[str, t.Any] | None: ...
def find_image(self, name: str | None, tag: str) -> dict[str, t.Any] | None:
"""
Lookup an image (by name and tag) and return the inspection results.
"""
@@ -298,7 +344,19 @@ class AnsibleDockerClientBase:
self.log(f"Image {name}:{tag} not found.")
return None
def find_image_by_id(self, image_id, accept_missing_image=False):
@t.overload
def find_image_by_id(
self, image_id: None, accept_missing_image: bool = False
) -> None: ...
@t.overload
def find_image_by_id(
self, image_id: str | None, accept_missing_image: bool = False
) -> dict[str, t.Any] | None: ...
def find_image_by_id(
self, image_id: str | None, accept_missing_image: bool = False
) -> dict[str, t.Any] | None:
"""
Lookup an image (by ID) and return the inspection results.
"""
@@ -320,17 +378,23 @@ class AnsibleDockerClientBase:
class AnsibleModuleDockerClient(AnsibleDockerClientBase):
def __init__(
self,
argument_spec=None,
supports_check_mode=False,
mutually_exclusive=None,
required_together=None,
required_if=None,
required_one_of=None,
required_by=None,
min_docker_api_version=None,
fail_results=None,
needs_api_version=True,
):
argument_spec: dict[str, t.Any] | None = None,
supports_check_mode: bool = False,
mutually_exclusive: Sequence[Sequence[str]] | None = None,
required_together: Sequence[Sequence[str]] | None = None,
required_if: (
Sequence[
tuple[str, t.Any, Sequence[str]]
| tuple[str, t.Any, Sequence[str], bool]
]
| None
) = None,
required_one_of: Sequence[Sequence[str]] | None = None,
required_by: Mapping[str, Sequence[str]] | None = None,
min_docker_api_version: str | None = None,
fail_results: dict[str, t.Any] | None = None,
needs_api_version: bool = True,
) -> None:
# Modules can put information in here which will always be returned
# in case client.fail() is called.
@@ -342,12 +406,14 @@ class AnsibleModuleDockerClient(AnsibleDockerClientBase):
merged_arg_spec.update(argument_spec)
self.arg_spec = merged_arg_spec
mutually_exclusive_params = [("docker_host", "cli_context")]
mutually_exclusive_params: list[Sequence[str]] = [
("docker_host", "cli_context")
]
mutually_exclusive_params += DOCKER_MUTUALLY_EXCLUSIVE
if mutually_exclusive:
mutually_exclusive_params += mutually_exclusive
required_together_params = []
required_together_params: list[Sequence[str]] = []
required_together_params += DOCKER_REQUIRED_TOGETHER
if required_together:
required_together_params += required_together
@@ -373,7 +439,14 @@ class AnsibleModuleDockerClient(AnsibleDockerClientBase):
needs_api_version=needs_api_version,
)
def call_cli(self, *args, check_rc=False, data=None, cwd=None, environ_update=None):
def call_cli(
self,
*args: str,
check_rc: bool = False,
data: bytes | None = None,
cwd: str | None = None,
environ_update: dict[str, str] | None = None,
) -> tuple[int, bytes, bytes]:
environment = self._environment.copy()
if environ_update:
environment.update(environ_update)
@@ -390,14 +463,20 @@ class AnsibleModuleDockerClient(AnsibleDockerClientBase):
)
return rc, stdout, stderr
def fail(self, msg, **kwargs):
def fail(self, msg: str, **kwargs) -> t.NoReturn:
self.fail_results.update(kwargs)
self.module.fail_json(msg=msg, **sanitize_result(self.fail_results))
def warn(self, msg):
def warn(self, msg: str) -> None:
self.module.warn(msg)
def deprecate(self, msg, version=None, date=None, collection_name=None):
def deprecate(
self,
msg: str,
version: str | None = None,
date: str | None = None,
collection_name: str | None = None,
) -> None:
self.module.deprecate(
msg, version=version, date=date, collection_name=collection_name
)
+107 -48
View File
@@ -14,6 +14,7 @@ import re
import shutil
import tempfile
import traceback
import typing as t
from collections import namedtuple
from shlex import quote
@@ -34,6 +35,7 @@ from ansible_collections.community.docker.plugins.module_utils._version import (
)
PYYAML_IMPORT_ERROR: None | str # pylint: disable=invalid-name
try:
import yaml
@@ -41,7 +43,7 @@ try:
# use C version if possible for speedup
from yaml import CSafeDumper as _SafeDumper
except ImportError:
from yaml import SafeDumper as _SafeDumper
from yaml import SafeDumper as _SafeDumper # type: ignore
except ImportError:
HAS_PYYAML = False
PYYAML_IMPORT_ERROR = traceback.format_exc() # pylint: disable=invalid-name
@@ -49,6 +51,13 @@ else:
HAS_PYYAML = True
PYYAML_IMPORT_ERROR = None # pylint: disable=invalid-name
if t.TYPE_CHECKING:
from collections.abc import Callable, Sequence
from ansible_collections.community.docker.plugins.module_utils._common_cli import (
AnsibleModuleDockerClient as _Client,
)
DOCKER_COMPOSE_FILES = (
"compose.yaml",
@@ -144,8 +153,7 @@ class ResourceType:
SERVICE = "service"
@classmethod
def from_docker_compose_event(cls, resource_type):
# type: (Type[ResourceType], Text) -> Any
def from_docker_compose_event(cls, resource_type: str) -> t.Any:
return {
"Network": cls.NETWORK,
"Image": cls.IMAGE,
@@ -240,7 +248,9 @@ _RE_BUILD_PROGRESS_EVENT = re.compile(r"^\s*==>\s+(?P<msg>.*)$")
MINIMUM_COMPOSE_VERSION = "2.18.0"
def _extract_event(line, warn_function=None):
def _extract_event(
line: str, warn_function: Callable[[str], None] | None = None
) -> tuple[Event | None, bool]:
match = _RE_RESOURCE_EVENT.match(line)
if match is not None:
status = match.group("status")
@@ -323,7 +333,9 @@ def _extract_event(line, warn_function=None):
return None, False
def _extract_logfmt_event(line, warn_function=None):
def _extract_logfmt_event(
line: str, warn_function: Callable[[str], None] | None = None
) -> tuple[Event | None, bool]:
try:
result = _parse_logfmt_line(line, logrus_mode=True)
except _InvalidLogFmt:
@@ -338,7 +350,11 @@ def _extract_logfmt_event(line, warn_function=None):
return None, False
def _warn_missing_dry_run_prefix(line, warn_missing_dry_run_prefix, warn_function):
def _warn_missing_dry_run_prefix(
line: str,
warn_missing_dry_run_prefix: bool,
warn_function: Callable[[str], None] | None,
) -> None:
if warn_missing_dry_run_prefix and warn_function:
# This could be a bug, a change of docker compose's output format, ...
# Tell the user to report it to us :-)
@@ -349,7 +365,9 @@ def _warn_missing_dry_run_prefix(line, warn_missing_dry_run_prefix, warn_functio
)
def _warn_unparsable_line(line, warn_function):
def _warn_unparsable_line(
line: str, warn_function: Callable[[str], None] | None
) -> None:
# This could be a bug, a change of docker compose's output format, ...
# Tell the user to report it to us :-)
if warn_function:
@@ -360,14 +378,16 @@ def _warn_unparsable_line(line, warn_function):
)
def _find_last_event_for(events, resource_id):
def _find_last_event_for(
events: list[Event], resource_id: str
) -> tuple[int, Event] | None:
for index, event in enumerate(reversed(events)):
if event.resource_id == resource_id:
return len(events) - 1 - index, event
return None
def _concat_event_msg(event, append_msg):
def _concat_event_msg(event: Event, append_msg: str) -> Event:
return Event(
event.resource_type,
event.resource_id,
@@ -382,7 +402,9 @@ _JSON_LEVEL_TO_STATUS_MAP = {
}
def parse_json_events(stderr, warn_function=None):
def parse_json_events(
stderr: bytes, warn_function: Callable[[str], None] | None = None
) -> list[Event]:
events = []
stderr_lines = stderr.splitlines()
if stderr_lines and stderr_lines[-1] == b"":
@@ -523,7 +545,12 @@ def parse_json_events(stderr, warn_function=None):
return events
def parse_events(stderr, dry_run=False, warn_function=None, nonzero_rc=False):
def parse_events(
stderr: bytes,
dry_run: bool = False,
warn_function: Callable[[str], None] | None = None,
nonzero_rc: bool = False,
) -> list[Event]:
events = []
error_event = None
stderr_lines = stderr.splitlines()
@@ -597,7 +624,11 @@ def parse_events(stderr, dry_run=False, warn_function=None, nonzero_rc=False):
return events
def has_changes(events, ignore_service_pull_events=False, ignore_build_events=False):
def has_changes(
events: Sequence[Event],
ignore_service_pull_events: bool = False,
ignore_build_events: bool = False,
) -> bool:
for event in events:
if event.status in DOCKER_STATUS_WORKING:
if ignore_service_pull_events and event.status in DOCKER_STATUS_PULL:
@@ -613,7 +644,7 @@ def has_changes(events, ignore_service_pull_events=False, ignore_build_events=Fa
return False
def extract_actions(events):
def extract_actions(events: Sequence[Event]) -> list[dict[str, t.Any]]:
actions = []
pull_actions = set()
for event in events:
@@ -645,7 +676,9 @@ def extract_actions(events):
return actions
def emit_warnings(events, warn_function):
def emit_warnings(
events: Sequence[Event], warn_function: Callable[[str], None]
) -> None:
for event in events:
# If a message is present, assume it is a warning
if (
@@ -656,13 +689,21 @@ def emit_warnings(events, warn_function):
)
def is_failed(events, rc):
def is_failed(events: Sequence[Event], rc: int) -> bool:
if rc:
return True
return False
def update_failed(result, events, args, stdout, stderr, rc, cli):
def update_failed(
result: dict[str, t.Any],
events: Sequence[Event],
args: list[str],
stdout: str | bytes,
stderr: str | bytes,
rc: int,
cli: str,
) -> bool:
if not rc:
return False
errors = []
@@ -696,7 +737,7 @@ def update_failed(result, events, args, stdout, stderr, rc, cli):
return True
def common_compose_argspec():
def common_compose_argspec() -> dict[str, t.Any]:
return {
"project_src": {"type": "path"},
"project_name": {"type": "str"},
@@ -708,7 +749,7 @@ def common_compose_argspec():
}
def common_compose_argspec_ex():
def common_compose_argspec_ex() -> dict[str, t.Any]:
return {
"argspec": common_compose_argspec(),
"mutually_exclusive": [("definition", "project_src"), ("definition", "files")],
@@ -721,16 +762,18 @@ def common_compose_argspec_ex():
}
def combine_binary_output(*outputs):
def combine_binary_output(*outputs: bytes | None) -> bytes:
return b"\n".join(out for out in outputs if out)
def combine_text_output(*outputs):
def combine_text_output(*outputs: str | None) -> str:
return "\n".join(out for out in outputs if out)
class BaseComposeManager(DockerBaseClass):
def __init__(self, client, min_version=MINIMUM_COMPOSE_VERSION):
def __init__(
self, client: _Client, min_version: str = MINIMUM_COMPOSE_VERSION
) -> None:
super().__init__()
self.client = client
self.check_mode = self.client.check_mode
@@ -794,12 +837,12 @@ class BaseComposeManager(DockerBaseClass):
# more precisely in https://github.com/docker/compose/pull/11478
self.use_json_events = self.compose_version >= LooseVersion("2.29.0")
def get_compose_version(self):
def get_compose_version(self) -> str:
return (
self.get_compose_version_from_cli() or self.get_compose_version_from_api()
)
def get_compose_version_from_cli(self):
def get_compose_version_from_cli(self) -> str | None:
rc, version_info, dummy_stderr = self.client.call_cli(
"compose", "version", "--format", "json"
)
@@ -813,7 +856,7 @@ class BaseComposeManager(DockerBaseClass):
except Exception: # pylint: disable=broad-exception-caught
return None
def get_compose_version_from_api(self):
def get_compose_version_from_api(self) -> str:
compose = self.client.get_client_plugin_info("compose")
if compose is None:
self.fail(
@@ -826,11 +869,11 @@ class BaseComposeManager(DockerBaseClass):
)
return compose["Version"].lstrip("v")
def fail(self, msg, **kwargs):
def fail(self, msg: str, **kwargs: t.Any) -> t.NoReturn:
self.cleanup()
self.client.fail(msg, **kwargs)
def get_base_args(self, plain_progress=False):
def get_base_args(self, plain_progress: bool = False) -> list[str]:
args = ["compose", "--ansi", "never"]
if self.use_json_events and not plain_progress:
args.extend(["--progress", "json"])
@@ -848,28 +891,33 @@ class BaseComposeManager(DockerBaseClass):
args.extend(["--profile", profile])
return args
def _handle_failed_cli_call(self, args, rc, stdout, stderr):
def _handle_failed_cli_call(
self, args: list[str], rc: int, stdout: str | bytes, stderr: bytes
) -> t.NoReturn:
events = parse_json_events(stderr, warn_function=self.client.warn)
result = {}
result: dict[str, t.Any] = {}
self.update_failed(result, events, args, stdout, stderr, rc)
self.client.module.exit_json(**result)
def list_containers_raw(self):
def list_containers_raw(self) -> list[dict[str, t.Any]]:
args = self.get_base_args() + ["ps", "--format", "json", "--all"]
if self.compose_version >= LooseVersion("2.23.0"):
# https://github.com/docker/compose/pull/11038
args.append("--no-trunc")
kwargs = {"cwd": self.project_src, "check_rc": not self.use_json_events}
if self.compose_version >= LooseVersion("2.21.0"):
# Breaking change in 2.21.0: https://github.com/docker/compose/pull/10918
rc, containers, stderr = self.client.call_cli_json_stream(*args, **kwargs)
rc, containers, stderr = self.client.call_cli_json_stream(
*args, cwd=self.project_src, check_rc=not self.use_json_events
)
else:
rc, containers, stderr = self.client.call_cli_json(*args, **kwargs)
rc, containers, stderr = self.client.call_cli_json(
*args, cwd=self.project_src, check_rc=not self.use_json_events
)
if self.use_json_events and rc != 0:
self._handle_failed_cli_call(args, rc, containers, stderr)
self._handle_failed_cli_call(args, rc, json.dumps(containers), stderr)
return containers
def list_containers(self):
def list_containers(self) -> list[dict[str, t.Any]]:
result = []
for container in self.list_containers_raw():
labels = {}
@@ -886,10 +934,11 @@ class BaseComposeManager(DockerBaseClass):
result.append(container)
return result
def list_images(self):
def list_images(self) -> list[str]:
args = self.get_base_args() + ["images", "--format", "json"]
kwargs = {"cwd": self.project_src, "check_rc": not self.use_json_events}
rc, images, stderr = self.client.call_cli_json(*args, **kwargs)
rc, images, stderr = self.client.call_cli_json(
*args, cwd=self.project_src, check_rc=not self.use_json_events
)
if self.use_json_events and rc != 0:
self._handle_failed_cli_call(args, rc, images, stderr)
if isinstance(images, dict):
@@ -899,7 +948,9 @@ class BaseComposeManager(DockerBaseClass):
images = list(images.values())
return images
def parse_events(self, stderr, dry_run=False, nonzero_rc=False):
def parse_events(
self, stderr: bytes, dry_run: bool = False, nonzero_rc: bool = False
) -> list[Event]:
if self.use_json_events:
return parse_json_events(stderr, warn_function=self.client.warn)
return parse_events(
@@ -909,17 +960,17 @@ class BaseComposeManager(DockerBaseClass):
nonzero_rc=nonzero_rc,
)
def emit_warnings(self, events):
def emit_warnings(self, events: Sequence[Event]) -> None:
emit_warnings(events, warn_function=self.client.warn)
def update_result(
self,
result,
events,
stdout,
stderr,
ignore_service_pull_events=False,
ignore_build_events=False,
result: dict[str, t.Any],
events: Sequence[Event],
stdout: str | bytes,
stderr: str | bytes,
ignore_service_pull_events: bool = False,
ignore_build_events: bool = False,
):
result["changed"] = result.get("changed", False) or has_changes(
events,
@@ -930,7 +981,15 @@ class BaseComposeManager(DockerBaseClass):
result["stdout"] = combine_text_output(result.get("stdout"), to_native(stdout))
result["stderr"] = combine_text_output(result.get("stderr"), to_native(stderr))
def update_failed(self, result, events, args, stdout, stderr, rc):
def update_failed(
self,
result: dict[str, t.Any],
events: Sequence[Event],
args: list[str],
stdout: str | bytes,
stderr: bytes,
rc: int,
):
return update_failed(
result,
events,
@@ -941,14 +1000,14 @@ class BaseComposeManager(DockerBaseClass):
cli=self.client.get_cli(),
)
def cleanup_result(self, result):
def cleanup_result(self, result: dict[str, t.Any]) -> None:
if not result.get("failed"):
# Only return stdout and stderr if it is not empty
for res in ("stdout", "stderr"):
if result.get(res) == "":
result.pop(res)
def cleanup(self):
def cleanup(self) -> None:
for directory in self.cleanup_dirs:
try:
shutil.rmtree(directory, True)
+130 -59
View File
@@ -16,6 +16,7 @@ import os.path
import shutil
import stat
import tarfile
import typing as t
from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text
@@ -25,6 +26,16 @@ from ansible_collections.community.docker.plugins.module_utils._api.errors impor
)
if t.TYPE_CHECKING:
from collections.abc import Callable
from _typeshed import WriteableBuffer
from ansible_collections.community.docker.plugins.module_utils._api.api.client import (
APIClient,
)
class DockerFileCopyError(Exception):
pass
@@ -37,7 +48,9 @@ class DockerFileNotFound(DockerFileCopyError):
pass
def _put_archive(client, container, path, data):
def _put_archive(
client: APIClient, container: str, path: str, data: bytes | t.Generator[bytes]
) -> bool:
# data can also be file object for streaming. This is because _put uses requests's put().
# See https://requests.readthedocs.io/en/latest/user/advanced/#streaming-uploads
url = client._url("/containers/{0}/archive", container)
@@ -47,8 +60,14 @@ def _put_archive(client, container, path, data):
def _symlink_tar_creator(
b_in_path, file_stat, out_file, user_id, group_id, mode=None, user_name=None
):
b_in_path: bytes,
file_stat: os.stat_result,
out_file: str | bytes,
user_id: int,
group_id: int,
mode: int | None = None,
user_name: str | None = None,
) -> bytes:
if not stat.S_ISLNK(file_stat.st_mode):
raise DockerUnexpectedError("stat information is not for a symlink")
bio = io.BytesIO()
@@ -75,16 +94,28 @@ def _symlink_tar_creator(
def _symlink_tar_generator(
b_in_path, file_stat, out_file, user_id, group_id, mode=None, user_name=None
):
b_in_path: bytes,
file_stat: os.stat_result,
out_file: str | bytes,
user_id: int,
group_id: int,
mode: int | None = None,
user_name: str | None = None,
) -> t.Generator[bytes]:
yield _symlink_tar_creator(
b_in_path, file_stat, out_file, user_id, group_id, mode, user_name
)
def _regular_file_tar_generator(
b_in_path, file_stat, out_file, user_id, group_id, mode=None, user_name=None
):
b_in_path: bytes,
file_stat: os.stat_result,
out_file: str | bytes,
user_id: int,
group_id: int,
mode: int | None = None,
user_name: str | None = None,
) -> t.Generator[bytes]:
if not stat.S_ISREG(file_stat.st_mode):
raise DockerUnexpectedError("stat information is not for a regular file")
tarinfo = tarfile.TarInfo()
@@ -136,8 +167,13 @@ def _regular_file_tar_generator(
def _regular_content_tar_generator(
content, out_file, user_id, group_id, mode, user_name=None
):
content: bytes,
out_file: str | bytes,
user_id: int,
group_id: int,
mode: int,
user_name: str | None = None,
) -> t.Generator[bytes]:
tarinfo = tarfile.TarInfo()
tarinfo.name = (
os.path.splitdrive(to_text(out_file))[1].replace(os.sep, "/").lstrip("/")
@@ -175,16 +211,16 @@ def _regular_content_tar_generator(
def put_file(
client,
container,
in_path,
out_path,
user_id,
group_id,
mode=None,
user_name=None,
follow_links=False,
):
client: APIClient,
container: str,
in_path: str,
out_path: str,
user_id: int,
group_id: int,
mode: int | None = None,
user_name: str | None = None,
follow_links: bool = False,
) -> None:
"""Transfer a file from local to Docker container."""
if not os.path.exists(to_bytes(in_path, errors="surrogate_or_strict")):
raise DockerFileNotFound(f"file or module does not exist: {to_native(in_path)}")
@@ -232,8 +268,15 @@ def put_file(
def put_file_content(
client, container, content, out_path, user_id, group_id, mode, user_name=None
):
client: APIClient,
container: str,
content: bytes,
out_path: str,
user_id: int,
group_id: int,
mode: int,
user_name: str | None = None,
) -> None:
"""Transfer a file from local to Docker container."""
out_dir, out_file = os.path.split(out_path)
@@ -248,7 +291,13 @@ def put_file_content(
)
def stat_file(client, container, in_path, follow_links=False, log=None):
def stat_file(
client: APIClient,
container: str,
in_path: str,
follow_links: bool = False,
log: Callable[[str], None] | None = None,
) -> tuple[str, dict[str, t.Any] | None, str | None]:
"""Fetch information on a file from a Docker container to local.
Return a tuple ``(path, stat_data, link_target)`` where:
@@ -265,12 +314,12 @@ def stat_file(client, container, in_path, follow_links=False, log=None):
while True:
if in_path in considered_in_paths:
raise DockerFileCopyError(
f'Found infinite symbolic link loop when trying to stating "{in_path}"'
f"Found infinite symbolic link loop when trying to stating {in_path!r}"
)
considered_in_paths.add(in_path)
if log:
log(f'FETCH: Stating "{in_path}"')
log(f"FETCH: Stating {in_path!r}")
response = client._head(
client._url("/containers/{0}/archive", container),
@@ -299,24 +348,24 @@ def stat_file(client, container, in_path, follow_links=False, log=None):
class _RawGeneratorFileobj(io.RawIOBase):
def __init__(self, stream):
def __init__(self, stream: t.Generator[bytes]):
self._stream = stream
self._buf = b""
def readable(self):
def readable(self) -> bool:
return True
def _readinto_from_buf(self, b, index, length):
def _readinto_from_buf(self, b: WriteableBuffer, index: int, length: int) -> int:
cpy = min(length - index, len(self._buf))
if cpy:
b[index : index + cpy] = self._buf[:cpy]
b[index : index + cpy] = self._buf[:cpy] # type: ignore # TODO!
self._buf = self._buf[cpy:]
index += cpy
return index
def readinto(self, b):
def readinto(self, b: WriteableBuffer) -> int:
index = 0
length = len(b)
length = len(b) # type: ignore # TODO!
index = self._readinto_from_buf(b, index, length)
if index == length:
@@ -330,25 +379,28 @@ class _RawGeneratorFileobj(io.RawIOBase):
return self._readinto_from_buf(b, index, length)
def _stream_generator_to_fileobj(stream):
def _stream_generator_to_fileobj(stream: t.Generator[bytes]) -> io.BufferedReader:
"""Given a generator that generates chunks of bytes, create a readable buffered stream."""
raw = _RawGeneratorFileobj(stream)
return io.BufferedReader(raw)
_T = t.TypeVar("_T")
def fetch_file_ex(
client,
container,
in_path,
process_none,
process_regular,
process_symlink,
process_other,
follow_links=False,
log=None,
):
client: APIClient,
container: str,
in_path: str,
process_none: Callable[[str], _T],
process_regular: Callable[[str, tarfile.TarFile, tarfile.TarInfo], _T],
process_symlink: Callable[[str, tarfile.TarInfo], _T],
process_other: Callable[[str, tarfile.TarInfo], _T],
follow_links: bool = False,
log: Callable[[str], None] | None = None,
) -> _T:
"""Fetch a file (as a tar file entry) from a Docker container to local."""
considered_in_paths = set()
considered_in_paths: set[str] = set()
while True:
if in_path in considered_in_paths:
@@ -372,8 +424,8 @@ def fetch_file_ex(
with tarfile.open(
fileobj=_stream_generator_to_fileobj(stream), mode="r|"
) as tar:
symlink_member = None
result = None
symlink_member: tarfile.TarInfo | None = None
result: _T | None = None
found = False
for member in tar:
if found:
@@ -398,35 +450,46 @@ def fetch_file_ex(
log(f'FETCH: Following symbolic link to "{in_path}"')
continue
if found:
return result
return result # type: ignore
raise DockerUnexpectedError("Received tarfile is empty!")
def fetch_file(client, container, in_path, out_path, follow_links=False, log=None):
def fetch_file(
client: APIClient,
container: str,
in_path: str,
out_path: str,
follow_links: bool = False,
log: Callable[[str], None] | None = None,
) -> str:
b_out_path = to_bytes(out_path, errors="surrogate_or_strict")
def process_none(in_path):
def process_none(in_path: str) -> str:
raise DockerFileNotFound(
f"File {in_path} does not exist in container {container}"
)
def process_regular(in_path, tar, member):
def process_regular(
in_path: str, tar: tarfile.TarFile, member: tarfile.TarInfo
) -> str:
if not follow_links and os.path.exists(b_out_path):
os.unlink(b_out_path)
with tar.extractfile(member) as in_f:
with open(b_out_path, "wb") as out_f:
shutil.copyfileobj(in_f, out_f)
reader = tar.extractfile(member)
if reader:
with reader as in_f:
with open(b_out_path, "wb") as out_f:
shutil.copyfileobj(in_f, out_f)
return in_path
def process_symlink(in_path, member):
def process_symlink(in_path, member) -> str:
if os.path.exists(b_out_path):
os.unlink(b_out_path)
os.symlink(member.linkname, b_out_path)
return in_path
def process_other(in_path, member):
def process_other(in_path, member) -> str:
raise DockerFileCopyError(
f'Remote file "{in_path}" is not a regular file or a symbolic link'
)
@@ -444,7 +507,13 @@ def fetch_file(client, container, in_path, out_path, follow_links=False, log=Non
)
def _execute_command(client, container, command, log=None, check_rc=False):
def _execute_command(
client: APIClient,
container: str,
command: list[str],
log: Callable[[str], None] | None = None,
check_rc: bool = False,
) -> tuple[int, bytes, bytes]:
if log:
log(f"Executing {command} in {container}")
@@ -483,7 +552,7 @@ def _execute_command(client, container, command, log=None, check_rc=False):
result = client.get_json("/exec/{0}/json", exec_id)
rc = result.get("ExitCode") or 0
rc: int = result.get("ExitCode") or 0
stdout = stdout or b""
stderr = stderr or b""
@@ -493,13 +562,15 @@ def _execute_command(client, container, command, log=None, check_rc=False):
if check_rc and rc != 0:
command_str = " ".join(command)
raise DockerUnexpectedError(
f'Obtained unexpected exit code {rc} when running "{command_str}" in {container}.\nSTDOUT: {stdout}\nSTDERR: {stderr}'
f'Obtained unexpected exit code {rc} when running "{command_str}" in {container}.\nSTDOUT: {stdout!r}\nSTDERR: {stderr!r}'
)
return rc, stdout, stderr
def determine_user_group(client, container, log=None):
def determine_user_group(
client: APIClient, container: str, log: Callable[[str], None] | None = None
) -> tuple[int, int]:
dummy_rc, stdout, dummy_stderr = _execute_command(
client, container, ["/bin/sh", "-c", "id -u && id -g"], check_rc=True, log=log
)
@@ -507,7 +578,7 @@ def determine_user_group(client, container, log=None):
stdout_lines = stdout.splitlines()
if len(stdout_lines) != 2:
raise DockerUnexpectedError(
f"Expected two-line output to obtain user and group ID for container {container}, but got {len(stdout_lines)} lines:\n{stdout}"
f"Expected two-line output to obtain user and group ID for container {container}, but got {len(stdout_lines)} lines:\n{stdout!r}"
)
user_id, group_id = stdout_lines
@@ -515,5 +586,5 @@ def determine_user_group(client, container, log=None):
return int(user_id), int(group_id)
except ValueError as exc:
raise DockerUnexpectedError(
f'Expected two-line output with numeric IDs to obtain user and group ID for container {container}, but got "{user_id}" and "{group_id}" instead'
f"Expected two-line output with numeric IDs to obtain user and group ID for container {container}, but got {user_id!r} and {group_id!r} instead"
) from exc
+14 -16
View File
@@ -18,12 +18,10 @@ class ImageArchiveManifestSummary:
"docker image save some:tag > some.tar" command.
"""
def __init__(self, image_id, repo_tags):
def __init__(self, image_id: str, repo_tags: list[str]) -> None:
"""
:param image_id: File name portion of Config entry, e.g. abcde12345 from abcde12345.json
:type image_id: str
:param repo_tags Docker image names, e.g. ["hello-world:latest"]
:type repo_tags: list[str]
"""
self.image_id = image_id
@@ -34,22 +32,21 @@ class ImageArchiveInvalidException(Exception):
pass
def api_image_id(archive_image_id):
def api_image_id(archive_image_id: str) -> str:
"""
Accepts an image hash in the format stored in manifest.json, and returns an equivalent identifier
that represents the same image hash, but in the format presented by the Docker Engine API.
:param archive_image_id: plain image hash
:type archive_image_id: str
:returns: Prefixed hash used by REST api
:rtype: str
"""
return f"sha256:{archive_image_id}"
def load_archived_image_manifest(archive_path):
def load_archived_image_manifest(
archive_path: str,
) -> list[ImageArchiveManifestSummary] | None:
"""
Attempts to get image IDs and image names from metadata stored in the image
archive tar file.
@@ -62,10 +59,7 @@ def load_archived_image_manifest(archive_path):
ImageArchiveInvalidException: A file already exists at archive_path, but could not extract an image ID from it.
:param archive_path: Tar file to read
:type archive_path: str
:return: None, if no file at archive_path, or a list of ImageArchiveManifestSummary objects.
:rtype: ImageArchiveManifestSummary
"""
try:
@@ -76,8 +70,15 @@ def load_archived_image_manifest(archive_path):
with tarfile.open(archive_path, "r") as tf:
try:
try:
with tf.extractfile("manifest.json") as ef:
reader = tf.extractfile("manifest.json")
if reader is None:
raise ImageArchiveInvalidException(
"Failed to read manifest.json"
)
with reader as ef:
manifest = json.load(ef)
except ImageArchiveInvalidException:
raise
except Exception as exc:
raise ImageArchiveInvalidException(
f"Failed to decode and deserialize manifest.json: {exc}"
@@ -139,7 +140,7 @@ def load_archived_image_manifest(archive_path):
) from exc
def archived_image_manifest(archive_path):
def archived_image_manifest(archive_path: str) -> ImageArchiveManifestSummary | None:
"""
Attempts to get Image.Id and image name from metadata stored in the image
archive tar file.
@@ -152,10 +153,7 @@ def archived_image_manifest(archive_path):
ImageArchiveInvalidException: A file already exists at archive_path, but could not extract an image ID from it.
:param archive_path: Tar file to read
:type archive_path: str
:return: None, if no file at archive_path, or the extracted image ID, which will not have a sha256: prefix.
:rtype: ImageArchiveManifestSummary
"""
results = load_archived_image_manifest(archive_path)
+21 -18
View File
@@ -13,6 +13,9 @@ See https://pkg.go.dev/github.com/kr/logfmt?utm_source=godoc for information on
from __future__ import annotations
import typing as t
from enum import Enum
# The format is defined in https://pkg.go.dev/github.com/kr/logfmt?utm_source=godoc
# (look for "EBNFish")
@@ -22,7 +25,7 @@ class InvalidLogFmt(Exception):
pass
class _Mode:
class _Mode(Enum):
GARBAGE = 0
KEY = 1
EQUAL = 2
@@ -68,29 +71,29 @@ _HEX_DICT = {
}
def _is_ident(cur):
def _is_ident(cur: str) -> bool:
return cur > " " and cur not in ('"', "=")
class _Parser:
def __init__(self, line):
def __init__(self, line: str) -> None:
self.line = line
self.index = 0
self.length = len(line)
def done(self):
def done(self) -> bool:
return self.index >= self.length
def cur(self):
def cur(self) -> str:
return self.line[self.index]
def next(self):
def next(self) -> None:
self.index += 1
def prev(self):
def prev(self) -> None:
self.index -= 1
def parse_unicode_sequence(self):
def parse_unicode_sequence(self) -> str:
if self.index + 6 > self.length:
raise InvalidLogFmt("Not enough space for unicode escape")
if self.line[self.index : self.index + 2] != "\\u":
@@ -108,27 +111,27 @@ class _Parser:
return chr(v)
def parse_line(line, logrus_mode=False):
result = {}
def parse_line(line: str, logrus_mode: bool = False) -> dict[str, t.Any]:
result: dict[str, t.Any] = {}
parser = _Parser(line)
key = []
value = []
key: list[str] = []
value: list[str] = []
mode = _Mode.GARBAGE
def handle_kv(has_no_value=False):
def handle_kv(has_no_value: bool = False) -> None:
k = "".join(key)
v = None if has_no_value else "".join(value)
result[k] = v
del key[:]
del value[:]
def parse_garbage(cur):
def parse_garbage(cur: str) -> _Mode:
if _is_ident(cur):
return _Mode.KEY
parser.next()
return _Mode.GARBAGE
def parse_key(cur):
def parse_key(cur: str) -> _Mode:
if _is_ident(cur):
key.append(cur)
parser.next()
@@ -142,7 +145,7 @@ def parse_line(line, logrus_mode=False):
parser.next()
return _Mode.GARBAGE
def parse_equal(cur):
def parse_equal(cur: str) -> _Mode:
if _is_ident(cur):
value.append(cur)
parser.next()
@@ -154,7 +157,7 @@ def parse_line(line, logrus_mode=False):
parser.next()
return _Mode.GARBAGE
def parse_ident_value(cur):
def parse_ident_value(cur: str) -> _Mode:
if _is_ident(cur):
value.append(cur)
parser.next()
@@ -163,7 +166,7 @@ def parse_line(line, logrus_mode=False):
parser.next()
return _Mode.GARBAGE
def parse_quoted_value(cur):
def parse_quoted_value(cur: str) -> _Mode:
if cur == "\\":
parser.next()
if parser.done():
+286 -129
View File
@@ -12,6 +12,7 @@ import abc
import os
import re
import shlex
import typing as t
from functools import partial
from ansible.module_utils.common.text.converters import to_text
@@ -32,6 +33,23 @@ from ansible_collections.community.docker.plugins.module_utils._util import (
)
if t.TYPE_CHECKING:
from collections.abc import Callable, Sequence
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.docker.plugins.module_utils._version import (
LooseVersion,
)
ValueType = t.Literal["set", "list", "dict", "bool", "int", "float", "str"]
AnsibleType = t.Literal["list", "dict", "bool", "int", "float", "str"]
ComparisonMode = t.Literal["ignore", "strict", "allow_more_present"]
ComparisonType = t.Literal["set", "set(dict)", "list", "dict", "value"]
Client = t.TypeVar("Client")
_DEFAULT_IP_REPLACEMENT_STRING = (
"[[DEFAULT_IP:iewahhaeB4Sae6Aen8IeShairoh4zeph7xaekoh8Geingunaesaeweiy3ooleiwi]]"
)
@@ -54,7 +72,9 @@ _MOUNT_OPTION_TYPES = {
}
def _get_ansible_type(value_type):
def _get_ansible_type(
value_type: ValueType,
) -> AnsibleType:
if value_type == "set":
return "list"
if value_type not in ("list", "dict", "bool", "int", "float", "str"):
@@ -65,21 +85,22 @@ def _get_ansible_type(value_type):
class Option:
def __init__(
self,
name,
value_type,
owner,
ansible_type=None,
elements=None,
ansible_elements=None,
ansible_suboptions=None,
ansible_aliases=None,
ansible_choices=None,
needs_no_suboptions=False,
default_comparison=None,
not_a_container_option=False,
not_an_ansible_option=False,
copy_comparison_from=None,
compare=None,
name: str,
*,
value_type: ValueType,
owner: OptionGroup,
ansible_type: AnsibleType | None = None,
elements: ValueType | None = None,
ansible_elements: AnsibleType | None = None,
ansible_suboptions: dict[str, t.Any] | None = None,
ansible_aliases: Sequence[str] | None = None,
ansible_choices: Sequence[str] | None = None,
needs_no_suboptions: bool = False,
default_comparison: ComparisonMode | None = None,
not_a_container_option: bool = False,
not_an_ansible_option: bool = False,
copy_comparison_from: str | None = None,
compare: Callable[[Option, t.Any, t.Any], bool] | None = None,
):
self.name = name
self.value_type = value_type
@@ -95,8 +116,8 @@ class Option:
if (elements is None and ansible_elements is None) and needs_ansible_elements:
raise ValueError("Ansible elements required for Ansible lists")
self.elements = elements if needs_elements else None
self.ansible_elements = (
(ansible_elements or _get_ansible_type(elements))
self.ansible_elements: AnsibleType | None = (
(ansible_elements or _get_ansible_type(elements or "str"))
if needs_ansible_elements
else None
)
@@ -119,10 +140,12 @@ class Option:
self.ansible_suboptions = ansible_suboptions if needs_suboptions else None
self.ansible_aliases = ansible_aliases or []
self.ansible_choices = ansible_choices
comparison_type = self.value_type
if comparison_type == "set" and self.elements == "dict":
comparison_type: ComparisonType
if self.value_type == "set" and self.elements == "dict":
comparison_type = "set(dict)"
elif comparison_type not in ("set", "list", "dict"):
elif self.value_type in ("set", "list", "dict"):
comparison_type = self.value_type # type: ignore
else:
comparison_type = "value"
self.comparison_type = comparison_type
if default_comparison is not None:
@@ -152,36 +175,45 @@ class Option:
class OptionGroup:
def __init__(
self,
preprocess=None,
ansible_mutually_exclusive=None,
ansible_required_together=None,
ansible_required_one_of=None,
ansible_required_if=None,
ansible_required_by=None,
):
*,
preprocess: (
Callable[[AnsibleModule, dict[str, t.Any]], dict[str, t.Any]] | None
) = None,
ansible_mutually_exclusive: Sequence[Sequence[str]] | None = None,
ansible_required_together: Sequence[Sequence[str]] | None = None,
ansible_required_one_of: Sequence[Sequence[str]] | None = None,
ansible_required_if: (
Sequence[
tuple[str, t.Any, Sequence[str]]
| tuple[str, t.Any, Sequence[str], bool]
]
| None
) = None,
ansible_required_by: dict[str, Sequence[str]] | None = None,
) -> None:
if preprocess is None:
def preprocess(module, values):
return values
self.preprocess = preprocess
self.options = []
self.all_options = []
self.engines = {}
self.options: list[Option] = []
self.all_options: list[Option] = []
self.engines: dict[str, Engine] = {}
self.ansible_mutually_exclusive = ansible_mutually_exclusive or []
self.ansible_required_together = ansible_required_together or []
self.ansible_required_one_of = ansible_required_one_of or []
self.ansible_required_if = ansible_required_if or []
self.ansible_required_by = ansible_required_by or {}
self.argument_spec = {}
self.argument_spec: dict[str, t.Any] = {}
def add_option(self, *args, **kwargs):
def add_option(self, *args, **kwargs) -> OptionGroup:
option = Option(*args, owner=self, **kwargs)
if not option.not_a_container_option:
self.options.append(option)
self.all_options.append(option)
if not option.not_an_ansible_option:
ansible_option = {
ansible_option: dict[str, t.Any] = {
"type": option.ansible_type,
}
if option.ansible_elements is not None:
@@ -195,213 +227,297 @@ class OptionGroup:
self.argument_spec[option.name] = ansible_option
return self
def supports_engine(self, engine_name):
def supports_engine(self, engine_name: str) -> bool:
return engine_name in self.engines
def get_engine(self, engine_name):
def get_engine(self, engine_name: str) -> Engine:
return self.engines[engine_name]
def add_engine(self, engine_name, engine):
def add_engine(self, engine_name: str, engine: Engine) -> OptionGroup:
self.engines[engine_name] = engine
return self
class Engine:
min_api_version = None # string or None
min_api_version_obj = None # LooseVersion object or None
extra_option_minimal_versions = None # dict[str, dict[str, Any]] or None
class Engine(t.Generic[Client]):
min_api_version: str | None = None
min_api_version_obj: LooseVersion | None = None
extra_option_minimal_versions: dict[str, dict[str, t.Any]] | None = None
@abc.abstractmethod
def get_value(self, module, container, api_version, options, image, host_info):
def get_value(
self,
module: AnsibleModule,
container: dict[str, t.Any],
api_version: LooseVersion,
options: list[Option],
image: dict[str, t.Any] | None,
host_info: dict[str, t.Any] | None,
) -> dict[str, t.Any]:
pass
def compare_value(self, option, param_value, container_value):
def compare_value(
self, option: Option, param_value: t.Any, container_value: t.Any
) -> bool:
return option.compare(param_value, container_value)
@abc.abstractmethod
def set_value(self, module, data, api_version, options, values):
def set_value(
self,
module: AnsibleModule,
data: dict[str, t.Any],
api_version: LooseVersion,
options: list[Option],
values: dict[str, t.Any],
) -> None:
pass
@abc.abstractmethod
def get_expected_values(
self, module, client, api_version, options, image, values, host_info
):
self,
module: AnsibleModule,
client: Client,
api_version: LooseVersion,
options: list[Option],
image: dict[str, t.Any] | None,
values: dict[str, t.Any],
host_info: dict[str, t.Any] | None,
) -> dict[str, t.Any]:
pass
@abc.abstractmethod
def ignore_mismatching_result(
self,
module,
client,
api_version,
option,
image,
container_value,
expected_value,
host_info,
):
module: AnsibleModule,
client: Client,
api_version: LooseVersion,
option: Option,
image: dict[str, t.Any] | None,
container_value: t.Any,
expected_value: t.Any,
host_info: dict[str, t.Any] | None,
) -> bool:
pass
@abc.abstractmethod
def preprocess_value(self, module, client, api_version, options, values):
def preprocess_value(
self,
module: AnsibleModule,
client: Client,
api_version: LooseVersion,
options: list[Option],
values: dict[str, t.Any],
) -> dict[str, t.Any]:
pass
@abc.abstractmethod
def update_value(self, module, data, api_version, options, values):
def update_value(
self,
module: AnsibleModule,
data: dict[str, t.Any],
api_version: LooseVersion,
options: list[Option],
values: dict[str, t.Any],
) -> None:
pass
@abc.abstractmethod
def can_set_value(self, api_version):
def can_set_value(self, api_version: LooseVersion) -> bool:
pass
@abc.abstractmethod
def can_update_value(self, api_version):
def can_update_value(self, api_version: LooseVersion) -> bool:
pass
@abc.abstractmethod
def needs_container_image(self, values):
def needs_container_image(self, values: dict[str, t.Any]) -> bool:
pass
@abc.abstractmethod
def needs_host_info(self, values):
def needs_host_info(self, values: dict[str, t.Any]) -> bool:
pass
class EngineDriver:
name = None # string
class EngineDriver(t.Generic[Client]):
name: str
@abc.abstractmethod
def setup(
self,
argument_spec,
mutually_exclusive=None,
required_together=None,
required_one_of=None,
required_if=None,
required_by=None,
):
# Return (module, active_options, client)
argument_spec: dict[str, t.Any],
mutually_exclusive: Sequence[Sequence[str]] | None = None,
required_together: Sequence[Sequence[str]] | None = None,
required_one_of: Sequence[Sequence[str]] | None = None,
required_if: (
Sequence[
tuple[str, t.Any, Sequence[str]]
| tuple[str, t.Any, Sequence[str], bool]
]
| None
) = None,
required_by: dict[str, Sequence[str]] | None = None,
) -> tuple[AnsibleModule, list[OptionGroup], Client]:
pass
@abc.abstractmethod
def get_host_info(self, client):
def get_host_info(self, client: Client) -> dict[str, t.Any]:
pass
@abc.abstractmethod
def get_api_version(self, client):
def get_api_version(self, client: Client) -> LooseVersion:
pass
@abc.abstractmethod
def get_container_id(self, container):
def get_container_id(self, container: dict[str, t.Any]) -> str:
pass
@abc.abstractmethod
def get_image_from_container(self, container):
def get_image_from_container(self, container: dict[str, t.Any]) -> str:
pass
@abc.abstractmethod
def get_image_name_from_container(self, container):
def get_image_name_from_container(self, container: dict[str, t.Any]) -> str | None:
pass
@abc.abstractmethod
def is_container_removing(self, container):
def is_container_removing(self, container: dict[str, t.Any]) -> bool:
pass
@abc.abstractmethod
def is_container_running(self, container):
def is_container_running(self, container: dict[str, t.Any]) -> bool:
pass
@abc.abstractmethod
def is_container_paused(self, container):
def is_container_paused(self, container: dict[str, t.Any]) -> bool:
pass
@abc.abstractmethod
def inspect_container_by_name(self, client, container_name):
def inspect_container_by_name(
self, client: Client, container_name: str
) -> dict[str, t.Any] | None:
pass
@abc.abstractmethod
def inspect_container_by_id(self, client, container_id):
def inspect_container_by_id(
self, client: Client, container_id: str
) -> dict[str, t.Any] | None:
pass
@abc.abstractmethod
def inspect_image_by_id(self, client, image_id):
def inspect_image_by_id(
self, client: Client, image_id: str
) -> dict[str, t.Any] | None:
pass
@abc.abstractmethod
def inspect_image_by_name(self, client, repository, tag):
def inspect_image_by_name(
self, client: Client, repository: str, tag: str
) -> dict[str, t.Any] | None:
pass
@abc.abstractmethod
def pull_image(self, client, repository, tag, image_platform=None):
def pull_image(
self,
client: Client,
repository: str,
tag: str,
image_platform: str | None = None,
) -> tuple[dict[str, t.Any] | None, bool]:
pass
@abc.abstractmethod
def pause_container(self, client, container_id):
def pause_container(self, client: Client, container_id: str) -> None:
pass
@abc.abstractmethod
def unpause_container(self, client, container_id):
def unpause_container(self, client: Client, container_id: str) -> None:
pass
@abc.abstractmethod
def disconnect_container_from_network(self, client, container_id, network_id):
def disconnect_container_from_network(
self, client: Client, container_id: str, network_id: str
) -> None:
pass
@abc.abstractmethod
def connect_container_to_network(
self, client, container_id, network_id, parameters=None
):
self,
client: Client,
container_id: str,
network_id: str,
parameters: dict[str, t.Any] | None = None,
) -> None:
pass
def create_container_supports_more_than_one_network(self, client):
def create_container_supports_more_than_one_network(self, client: Client) -> bool:
return False
@abc.abstractmethod
def create_container(
self, client, container_name, create_parameters, networks=None
):
self,
client: Client,
container_name: str,
create_parameters: dict[str, t.Any],
networks: dict[str, dict[str, t.Any]] | None = None,
) -> str:
pass
@abc.abstractmethod
def start_container(self, client, container_id):
def start_container(self, client: Client, container_id: str) -> None:
pass
@abc.abstractmethod
def wait_for_container(self, client, container_id, timeout=None):
def wait_for_container(
self, client: Client, container_id: str, timeout: int | float | None = None
) -> int | None:
pass
@abc.abstractmethod
def get_container_output(self, client, container_id):
def get_container_output(
self, client: Client, container_id: str
) -> tuple[bytes, t.Literal[True]] | tuple[str, t.Literal[False]]:
pass
@abc.abstractmethod
def update_container(self, client, container_id, update_parameters):
def update_container(
self, client: Client, container_id: str, update_parameters: dict[str, t.Any]
) -> None:
pass
@abc.abstractmethod
def restart_container(self, client, container_id, timeout=None):
def restart_container(
self, client: Client, container_id: str, timeout: int | float | None = None
) -> None:
pass
@abc.abstractmethod
def kill_container(self, client, container_id, kill_signal=None):
def kill_container(
self, client: Client, container_id: str, kill_signal: str | None = None
) -> None:
pass
@abc.abstractmethod
def stop_container(self, client, container_id, timeout=None):
def stop_container(
self, client: Client, container_id: str, timeout: int | float | None = None
) -> None:
pass
@abc.abstractmethod
def remove_container(
self, client, container_id, remove_volumes=False, link=False, force=False
):
self,
client: Client,
container_id: str,
remove_volumes: bool = False,
link: bool = False,
force: bool = False,
) -> None:
pass
@abc.abstractmethod
def run(self, runner, client):
def run(self, runner: Callable[[], None], client: Client) -> None:
pass
def _is_volume_permissions(mode):
def _is_volume_permissions(mode: str) -> bool:
for part in mode.split(","):
if part not in (
"rw",
@@ -423,7 +539,7 @@ def _is_volume_permissions(mode):
return True
def _parse_port_range(range_or_port, module):
def _parse_port_range(range_or_port: str, module: AnsibleModule) -> list[int]:
"""
Parses a string containing either a single port or a range of ports.
@@ -443,7 +559,7 @@ def _parse_port_range(range_or_port, module):
module.fail_json(msg=f'Invalid port: "{range_or_port}"')
def _split_colon_ipv6(text, module):
def _split_colon_ipv6(text: str, module: AnsibleModule) -> list[str]:
"""
Split string by ':', while keeping IPv6 addresses in square brackets in one component.
"""
@@ -475,7 +591,9 @@ def _split_colon_ipv6(text, module):
return result
def _preprocess_command(module, values):
def _preprocess_command(
module: AnsibleModule, values: dict[str, t.Any]
) -> dict[str, t.Any]:
if "command" not in values:
return values
value = values["command"]
@@ -502,7 +620,9 @@ def _preprocess_command(module, values):
}
def _preprocess_entrypoint(module, values):
def _preprocess_entrypoint(
module: AnsibleModule, values: dict[str, t.Any]
) -> dict[str, t.Any]:
if "entrypoint" not in values:
return values
value = values["entrypoint"]
@@ -522,7 +642,9 @@ def _preprocess_entrypoint(module, values):
}
def _preprocess_env(module, values):
def _preprocess_env(
module: AnsibleModule, values: dict[str, t.Any]
) -> dict[str, t.Any]:
if not values:
return {}
final_env = {}
@@ -546,7 +668,9 @@ def _preprocess_env(module, values):
}
def _preprocess_healthcheck(module, values):
def _preprocess_healthcheck(
module: AnsibleModule, values: dict[str, t.Any]
) -> dict[str, t.Any]:
if not values:
return {}
return {
@@ -556,7 +680,12 @@ def _preprocess_healthcheck(module, values):
}
def _preprocess_convert_to_bytes(module, values, name, unlimited_value=None):
def _preprocess_convert_to_bytes(
module: AnsibleModule,
values: dict[str, t.Any],
name: str,
unlimited_value: int | None = None,
) -> dict[str, t.Any]:
if name not in values:
return values
try:
@@ -571,7 +700,9 @@ def _preprocess_convert_to_bytes(module, values, name, unlimited_value=None):
module.fail_json(msg=f"Failed to convert {name} to bytes: {exc}")
def _preprocess_mac_address(module, values):
def _preprocess_mac_address(
module: AnsibleModule, values: dict[str, t.Any]
) -> dict[str, t.Any]:
if "mac_address" not in values:
return values
return {
@@ -579,7 +710,9 @@ def _preprocess_mac_address(module, values):
}
def _preprocess_networks(module, values):
def _preprocess_networks(
module: AnsibleModule, values: dict[str, t.Any]
) -> dict[str, t.Any]:
if (
module.params["networks_cli_compatible"] is True
and values.get("networks")
@@ -605,14 +738,18 @@ def _preprocess_networks(module, values):
return values
def _preprocess_sysctls(module, values):
def _preprocess_sysctls(
module: AnsibleModule, values: dict[str, t.Any]
) -> dict[str, t.Any]:
if "sysctls" in values:
for key, value in values["sysctls"].items():
values["sysctls"][key] = to_text(value, errors="surrogate_or_strict")
return values
def _preprocess_tmpfs(module, values):
def _preprocess_tmpfs(
module: AnsibleModule, values: dict[str, t.Any]
) -> dict[str, t.Any]:
if "tmpfs" not in values:
return values
result = {}
@@ -625,7 +762,9 @@ def _preprocess_tmpfs(module, values):
return {"tmpfs": result}
def _preprocess_ulimits(module, values):
def _preprocess_ulimits(
module: AnsibleModule, values: dict[str, t.Any]
) -> dict[str, t.Any]:
if "ulimits" not in values:
return values
result = []
@@ -644,8 +783,10 @@ def _preprocess_ulimits(module, values):
}
def _preprocess_mounts(module, values):
last = {}
def _preprocess_mounts(
module: AnsibleModule, values: dict[str, t.Any]
) -> dict[str, t.Any]:
last: dict[str, str] = {}
def check_collision(t, name):
if t in last:
@@ -776,7 +917,9 @@ def _preprocess_mounts(module, values):
return values
def _preprocess_labels(module, values):
def _preprocess_labels(
module: AnsibleModule, values: dict[str, t.Any]
) -> dict[str, t.Any]:
result = {}
if "labels" in values:
labels = values["labels"]
@@ -787,13 +930,15 @@ def _preprocess_labels(module, values):
return result
def _preprocess_log(module, values):
result = {}
def _preprocess_log(
module: AnsibleModule, values: dict[str, t.Any]
) -> dict[str, t.Any]:
result: dict[str, t.Any] = {}
if "log_driver" not in values:
return result
result["log_driver"] = values["log_driver"]
if "log_options" in values:
options = {}
options: dict[str, str] = {}
for k, v in values["log_options"].items():
if not isinstance(v, str):
value = to_text(v, errors="surrogate_or_strict")
@@ -807,7 +952,9 @@ def _preprocess_log(module, values):
return result
def _preprocess_ports(module, values):
def _preprocess_ports(
module: AnsibleModule, values: dict[str, t.Any]
) -> dict[str, t.Any]:
if "published_ports" in values:
if "all" in values["published_ports"]:
module.fail_json(
@@ -815,7 +962,12 @@ def _preprocess_ports(module, values):
"to randomly assign port mappings for those not specified by published_ports."
)
binds = {}
binds: dict[
str | int,
tuple[str]
| tuple[str, str | int]
| list[tuple[str] | tuple[str, str | int]],
] = {}
for port in values["published_ports"]:
parts = _split_colon_ipv6(
to_text(port, errors="surrogate_or_strict"), module
@@ -827,6 +979,7 @@ def _preprocess_ports(module, values):
container_ports = _parse_port_range(container_port, module)
p_len = len(parts)
port_binds: Sequence[tuple[str] | tuple[str, str | int]]
if p_len == 1:
port_binds = len(container_ports) * [(_DEFAULT_IP_REPLACEMENT_STRING,)]
elif p_len == 2:
@@ -865,8 +1018,12 @@ def _preprocess_ports(module, values):
"Maybe you forgot to use square brackets ([...]) around an IPv6 address?"
)
for bind, container_port in zip(port_binds, container_ports):
idx = f"{container_port}/{protocol}" if protocol else container_port
for bind, container_port_val in zip(port_binds, container_ports):
idx = (
f"{container_port_val}/{protocol}"
if protocol
else container_port_val
)
if idx in binds:
old_bind = binds[idx]
if isinstance(old_bind, list):
@@ -882,9 +1039,9 @@ def _preprocess_ports(module, values):
for port in values["exposed_ports"]:
port = to_text(port, errors="surrogate_or_strict").strip()
protocol = "tcp"
match = re.search(r"(/.+$)", port)
if match:
protocol = match.group(1).replace("/", "")
matcher = re.search(r"(/.+$)", port)
if matcher:
protocol = matcher.group(1).replace("/", "")
port = re.sub(r"/.+$", "", port)
exposed.append((port, protocol))
if "published_ports" in values:
@@ -912,7 +1069,7 @@ def _preprocess_ports(module, values):
return values
def _compare_platform(option, param_value, container_value):
def _compare_platform(option: Option, param_value: t.Any, container_value: t.Any):
if option.comparison == "ignore":
return True
try:
File diff suppressed because it is too large Load Diff
+246 -136
View File
@@ -9,6 +9,7 @@
from __future__ import annotations
import re
import typing as t
from time import sleep
from ansible.module_utils.common.text.converters import to_text
@@ -16,6 +17,9 @@ from ansible.module_utils.common.text.converters import to_text
from ansible_collections.community.docker.plugins.module_utils._api.utils.utils import (
parse_repository_tag,
)
from ansible_collections.community.docker.plugins.module_utils._module_container.base import (
Client,
)
from ansible_collections.community.docker.plugins.module_utils._util import (
DifferenceTracker,
DockerBaseClass,
@@ -25,13 +29,23 @@ from ansible_collections.community.docker.plugins.module_utils._util import (
)
if t.TYPE_CHECKING:
from collections.abc import Sequence
from ansible.module_utils.basic import AnsibleModule
from .base import Engine, EngineDriver, Option, OptionGroup
class Container(DockerBaseClass):
def __init__(self, container, engine_driver):
def __init__(
self, container: dict[str, t.Any] | None, engine_driver: EngineDriver
) -> None:
super().__init__()
self.raw = container
self.id = None
self.image = None
self.image_name = None
self.id: str | None = None
self.image: str | None = None
self.image_name: str | None = None
self.container = container
self.engine_driver = engine_driver
if container:
@@ -41,11 +55,11 @@ class Container(DockerBaseClass):
self.log(self.container, pretty_print=True)
@property
def exists(self):
def exists(self) -> bool:
return bool(self.container)
@property
def removing(self):
def removing(self) -> bool:
return (
self.engine_driver.is_container_removing(self.container)
if self.container
@@ -53,7 +67,7 @@ class Container(DockerBaseClass):
)
@property
def running(self):
def running(self) -> bool:
return (
self.engine_driver.is_container_running(self.container)
if self.container
@@ -61,7 +75,7 @@ class Container(DockerBaseClass):
)
@property
def paused(self):
def paused(self) -> bool:
return (
self.engine_driver.is_container_paused(self.container)
if self.container
@@ -69,8 +83,14 @@ class Container(DockerBaseClass):
)
class ContainerManager(DockerBaseClass):
def __init__(self, module, engine_driver, client, active_options):
class ContainerManager(DockerBaseClass, t.Generic[Client]):
def __init__(
self,
module: AnsibleModule,
engine_driver: EngineDriver,
client: Client,
active_options: list[OptionGroup],
) -> None:
super().__init__()
self.module = module
self.engine_driver = engine_driver
@@ -78,46 +98,64 @@ class ContainerManager(DockerBaseClass):
self.options = active_options
self.all_options = self._collect_all_options(active_options)
self.check_mode = self.module.check_mode
self.param_cleanup = self.module.params["cleanup"]
self.param_container_default_behavior = self.module.params[
"container_default_behavior"
]
self.param_default_host_ip = self.module.params["default_host_ip"]
self.param_debug = self.module.params["debug"]
self.param_force_kill = self.module.params["force_kill"]
self.param_image = self.module.params["image"]
self.param_image_comparison = self.module.params["image_comparison"]
self.param_image_label_mismatch = self.module.params["image_label_mismatch"]
self.param_image_name_mismatch = self.module.params["image_name_mismatch"]
self.param_keep_volumes = self.module.params["keep_volumes"]
self.param_kill_signal = self.module.params["kill_signal"]
self.param_name = self.module.params["name"]
self.param_networks_cli_compatible = self.module.params[
self.param_cleanup: bool = self.module.params["cleanup"]
self.param_container_default_behavior: t.Literal[
"compatibility", "no_defaults"
] = self.module.params["container_default_behavior"]
self.param_default_host_ip: str | None = self.module.params["default_host_ip"]
self.param_debug: bool = self.module.params["debug"]
self.param_force_kill: bool = self.module.params["force_kill"]
self.param_image: str | None = self.module.params["image"]
self.param_image_comparison: t.Literal["desired-image", "current-image"] = (
self.module.params["image_comparison"]
)
self.param_image_label_mismatch: t.Literal["ignore", "fail"] = (
self.module.params["image_label_mismatch"]
)
self.param_image_name_mismatch: t.Literal["ignore", "recreate"] = (
self.module.params["image_name_mismatch"]
)
self.param_keep_volumes: bool = self.module.params["keep_volumes"]
self.param_kill_signal: str | None = self.module.params["kill_signal"]
self.param_name: str = self.module.params["name"]
self.param_networks_cli_compatible: bool = self.module.params[
"networks_cli_compatible"
]
self.param_output_logs = self.module.params["output_logs"]
self.param_paused = self.module.params["paused"]
self.param_pull = self.module.params["pull"]
if self.param_pull is True:
self.param_pull = "always"
if self.param_pull is False:
self.param_pull = "missing"
self.param_pull_check_mode_behavior = self.module.params[
"pull_check_mode_behavior"
self.param_output_logs: bool = self.module.params["output_logs"]
self.param_paused: bool | None = self.module.params["paused"]
param_pull: t.Literal["never", "missing", "always", True, False] = (
self.module.params["pull"]
)
if param_pull is True:
param_pull = "always"
if param_pull is False:
param_pull = "missing"
self.param_pull: t.Literal["never", "missing", "always"] = param_pull
self.param_pull_check_mode_behavior: t.Literal[
"image_not_present", "always"
] = self.module.params["pull_check_mode_behavior"]
self.param_recreate: bool = self.module.params["recreate"]
self.param_removal_wait_timeout: int | float | None = self.module.params[
"removal_wait_timeout"
]
self.param_recreate = self.module.params["recreate"]
self.param_removal_wait_timeout = self.module.params["removal_wait_timeout"]
self.param_healthy_wait_timeout = self.module.params["healthy_wait_timeout"]
if self.param_healthy_wait_timeout <= 0:
self.param_healthy_wait_timeout: int | float | None = self.module.params[
"healthy_wait_timeout"
]
if (
self.param_healthy_wait_timeout is not None
and self.param_healthy_wait_timeout <= 0
):
self.param_healthy_wait_timeout = None
self.param_restart = self.module.params["restart"]
self.param_state = self.module.params["state"]
self.param_restart: bool = self.module.params["restart"]
self.param_state: t.Literal[
"absent", "present", "healthy", "started", "stopped"
] = self.module.params["state"]
self._parse_comparisons()
self._update_params()
self.results = {"changed": False, "actions": []}
self.diff = {}
self.diff: dict[str, t.Any] = {}
self.diff_tracker = DifferenceTracker()
self.facts = {}
self.facts: dict[str, t.Any] | None = {}
if self.param_default_host_ip:
valid_ip = False
if re.match(
@@ -134,16 +172,22 @@ class ContainerManager(DockerBaseClass):
"The value of default_host_ip must be an empty string, an IPv4 address, "
f'or an IPv6 address. Got "{self.param_default_host_ip}" instead.'
)
self.parameters = None
self.parameters: list[tuple[OptionGroup, dict[str, t.Any]]] | None = None
def _collect_all_options(self, active_options):
def _add_action(self, action: dict[str, t.Any]) -> None:
actions: list[dict[str, t.Any]] = self.results["actions"] # type: ignore
actions.append(action)
def _collect_all_options(
self, active_options: list[OptionGroup]
) -> dict[str, Option]:
all_options = {}
for options in active_options:
for option in options.options:
all_options[option.name] = option
return all_options
def _collect_all_module_params(self):
def _collect_all_module_params(self) -> set[str]:
all_module_options = set()
for option, data in self.module.argument_spec.items():
all_module_options.add(option)
@@ -152,7 +196,7 @@ class ContainerManager(DockerBaseClass):
all_module_options.add(alias)
return all_module_options
def _parse_comparisons(self):
def _parse_comparisons(self) -> None:
# Keep track of all module params and all option aliases
all_module_options = self._collect_all_module_params()
comp_aliases = {}
@@ -163,10 +207,11 @@ class ContainerManager(DockerBaseClass):
for alias in option.ansible_aliases:
comp_aliases[alias] = option_name
# Process comparisons specified by user
if self.module.params.get("comparisons"):
comparisons: dict[str, t.Any] | None = self.module.params.get("comparisons")
if comparisons:
# If '*' appears in comparisons, process it first
if "*" in self.module.params["comparisons"]:
value = self.module.params["comparisons"]["*"]
if "*" in comparisons:
value = comparisons["*"]
if value not in ("strict", "ignore"):
self.fail(
"The wildcard can only be used with comparison modes 'strict' and 'ignore'!"
@@ -179,8 +224,8 @@ class ContainerManager(DockerBaseClass):
continue
option.comparison = value
# Now process all other comparisons.
comp_aliases_used = {}
for key, value in self.module.params["comparisons"].items():
comp_aliases_used: dict[str, str] = {}
for key, value in comparisons.items():
if key == "*":
continue
# Find main key
@@ -220,7 +265,7 @@ class ContainerManager(DockerBaseClass):
option.copy_comparison_from
].comparison
def _update_params(self):
def _update_params(self) -> None:
if (
self.param_networks_cli_compatible is True
and self.module.params["networks"]
@@ -247,12 +292,14 @@ class ContainerManager(DockerBaseClass):
if self.module.params[param] is None:
self.module.params[param] = value
def fail(self, *args, **kwargs):
self.client.fail(*args, **kwargs)
def fail(self, *args, **kwargs) -> t.NoReturn:
# mypy doesn't know that Client has fail() method
raise self.client.fail(*args, **kwargs) # type: ignore
def run(self):
def run(self) -> None:
if self.param_state in ("stopped", "started", "present", "healthy"):
self.present(self.param_state)
# mypy doesn't get that self.param_state has only one of the above values
self.present(self.param_state) # type: ignore
elif self.param_state == "absent":
self.absent()
@@ -270,15 +317,16 @@ class ContainerManager(DockerBaseClass):
def wait_for_state(
self,
container_id,
complete_states=None,
wait_states=None,
accept_removal=False,
max_wait=None,
health_state=False,
):
container_id: str,
*,
complete_states: Sequence[str | None] | None = None,
wait_states: Sequence[str | None] | None = None,
accept_removal: bool = False,
max_wait: int | float | None = None,
health_state: bool = False,
) -> dict[str, t.Any] | None:
delay = 1.0
total_wait = 0
total_wait = 0.0
while True:
# Inspect container
result = self.engine_driver.inspect_container_by_id(
@@ -314,7 +362,9 @@ class ContainerManager(DockerBaseClass):
# code will have slept for ~1.5 minutes.)
delay = min(delay * 1.1, 10)
def _collect_params(self, active_options):
def _collect_params(
self, active_options: list[OptionGroup]
) -> list[tuple[OptionGroup, dict[str, t.Any]]]:
parameters = []
for options in active_options:
values = {}
@@ -336,21 +386,25 @@ class ContainerManager(DockerBaseClass):
parameters.append((options, values))
return parameters
def _needs_container_image(self):
def _needs_container_image(self) -> bool:
assert self.parameters is not None
for options, values in self.parameters:
engine = options.get_engine(self.engine_driver.name)
if engine.needs_container_image(values):
return True
return False
def _needs_host_info(self):
def _needs_host_info(self) -> bool:
assert self.parameters is not None
for options, values in self.parameters:
engine = options.get_engine(self.engine_driver.name)
if engine.needs_host_info(values):
return True
return False
def present(self, state):
def present(
self, state: t.Literal["stopped", "started", "present", "healthy"]
) -> None:
self.parameters = self._collect_params(self.options)
container = self._get_container(self.param_name)
was_running = container.running
@@ -382,6 +436,7 @@ class ContainerManager(DockerBaseClass):
self.diff_tracker.add("exists", parameter=True, active=False)
if container.removing and not self.check_mode:
# Wait for container to be removed before trying to create it
assert container.id is not None
self.wait_for_state(
container.id,
wait_states=["removing"],
@@ -394,6 +449,7 @@ class ContainerManager(DockerBaseClass):
container_created = True
else:
# Existing container
assert container.id is not None
different, differences = self.has_different_configuration(
container, container_image, comparison_image, host_info
)
@@ -453,13 +509,16 @@ class ContainerManager(DockerBaseClass):
if state in ("started", "healthy") and not container.running:
self.diff_tracker.add("running", parameter=True, active=was_running)
assert container.id is not None
container = self.container_start(container.id)
elif state in ("started", "healthy") and self.param_restart:
self.diff_tracker.add("running", parameter=True, active=was_running)
self.diff_tracker.add("restarted", parameter=True, active=False)
assert container.id is not None
container = self.container_restart(container.id)
elif state == "stopped" and container.running:
self.diff_tracker.add("running", parameter=False, active=was_running)
assert container.id is not None
self.container_stop(container.id)
container = self._get_container(container.id)
@@ -472,6 +531,7 @@ class ContainerManager(DockerBaseClass):
"paused", parameter=self.param_paused, active=was_paused
)
if not self.check_mode:
assert container.id is not None
try:
if self.param_paused:
self.engine_driver.pause_container(
@@ -487,12 +547,13 @@ class ContainerManager(DockerBaseClass):
)
container = self._get_container(container.id)
self.results["changed"] = True
self.results["actions"].append({"set_paused": self.param_paused})
self._add_action({"set_paused": self.param_paused})
self.facts = container.raw
if state == "healthy" and not self.check_mode:
# `None` means that no health check enabled; simply treat this as 'healthy'
assert container.id is not None
inspect_result = self.wait_for_state(
container.id,
wait_states=["starting", "unhealthy"],
@@ -504,41 +565,51 @@ class ContainerManager(DockerBaseClass):
# Return the latest inspection results retrieved
self.facts = inspect_result
def absent(self):
def absent(self) -> None:
container = self._get_container(self.param_name)
if container.exists:
assert container.id is not None
if container.running:
self.diff_tracker.add("running", parameter=False, active=True)
self.container_stop(container.id)
self.diff_tracker.add("exists", parameter=False, active=True)
self.container_remove(container.id)
def _output_logs(self, msg):
def _output_logs(self, msg: str | bytes) -> None:
self.module.log(msg=msg)
def _get_container(self, container):
def _get_container(self, container: str) -> Container:
"""
Expects container ID or Name. Returns a container object
"""
container = self.engine_driver.inspect_container_by_name(self.client, container)
return Container(container, self.engine_driver)
container_data = self.engine_driver.inspect_container_by_name(
self.client, container
)
return Container(container_data, self.engine_driver)
def _get_container_image(self, container, fallback=None):
def _get_container_image(
self, container: Container, fallback: dict[str, t.Any] | None = None
) -> dict[str, t.Any] | None:
if not container.exists or container.removing:
return fallback
image = container.image
assert image is not None
if is_image_name_id(image):
image = self.engine_driver.inspect_image_by_id(self.client, image)
image_data = self.engine_driver.inspect_image_by_id(self.client, image)
else:
repository, tag = parse_repository_tag(image)
if not tag:
tag = "latest"
image = self.engine_driver.inspect_image_by_name(
image_data = self.engine_driver.inspect_image_by_name(
self.client, repository, tag
)
return image or fallback
return image_data or fallback
def _get_image(self, container, needs_container_image=False):
def _get_image(
self, container: Container, needs_container_image: bool = False
) -> tuple[
dict[str, t.Any] | None, dict[str, t.Any] | None, dict[str, t.Any] | None
]:
image_parameter = self.param_image
get_container_image = needs_container_image or not image_parameter
container_image = (
@@ -553,7 +624,7 @@ class ContainerManager(DockerBaseClass):
if is_image_name_id(image_parameter):
image = self.engine_driver.inspect_image_by_id(self.client, image_parameter)
if image is None:
self.client.fail(f"Cannot find image with ID {image_parameter}")
self.fail(f"Cannot find image with ID {image_parameter}")
else:
repository, tag = parse_repository_tag(image_parameter)
if not tag:
@@ -562,7 +633,7 @@ class ContainerManager(DockerBaseClass):
self.client, repository, tag
)
if not image and self.param_pull == "never":
self.client.fail(
self.fail(
f"Cannot find image with name {repository}:{tag}, and pull=never"
)
if not image or self.param_pull == "always":
@@ -576,12 +647,12 @@ class ContainerManager(DockerBaseClass):
)
if already_to_latest:
self.results["changed"] = False
self.results["actions"].append(
self._add_action(
{"pulled_image": f"{repository}:{tag}", "changed": False}
)
else:
self.results["changed"] = True
self.results["actions"].append(
self._add_action(
{"pulled_image": f"{repository}:{tag}", "changed": True}
)
elif not image or self.param_pull_check_mode_behavior == "always":
@@ -589,10 +660,10 @@ class ContainerManager(DockerBaseClass):
# pull. (Implicitly: if the image is there, claim it already was latest unless
# pull_check_mode_behavior == 'always'.)
self.results["changed"] = True
action = {"pulled_image": f"{repository}:{tag}"}
action: dict[str, t.Any] = {"pulled_image": f"{repository}:{tag}"}
if not image:
action["changed"] = True
self.results["actions"].append(action)
self._add_action(action)
self.log("image")
self.log(image, pretty_print=True)
@@ -605,7 +676,9 @@ class ContainerManager(DockerBaseClass):
return image, container_image, comparison_image
def _image_is_different(self, image, container):
def _image_is_different(
self, image: dict[str, t.Any] | None, container: Container
) -> bool:
if image and image.get("Id"):
if container and container.image:
if image.get("Id") != container.image:
@@ -615,8 +688,9 @@ class ContainerManager(DockerBaseClass):
return True
return False
def _compose_create_parameters(self, image):
params = {}
def _compose_create_parameters(self, image: str) -> dict[str, t.Any]:
params: dict[str, t.Any] = {}
assert self.parameters is not None
for options, values in self.parameters:
engine = options.get_engine(self.engine_driver.name)
if engine.can_set_value(self.engine_driver.get_api_version(self.client)):
@@ -632,15 +706,16 @@ class ContainerManager(DockerBaseClass):
def _record_differences(
self,
differences,
options,
param_values,
engine,
container,
container_image,
image,
host_info,
differences: DifferenceTracker,
options: OptionGroup,
param_values: dict[str, t.Any],
engine: Engine,
container: Container,
container_image: dict[str, t.Any] | None,
image: dict[str, t.Any] | None,
host_info: dict[str, t.Any] | None,
):
assert container.raw is not None
container_values = engine.get_value(
self.module,
container.raw,
@@ -709,9 +784,16 @@ class ContainerManager(DockerBaseClass):
c = sorted(c, key=sort_key_fn)
differences.add(option.name, parameter=p, active=c)
def has_different_configuration(self, container, container_image, image, host_info):
def has_different_configuration(
self,
container: Container,
container_image: dict[str, t.Any] | None,
image: dict[str, t.Any] | None,
host_info: dict[str, t.Any] | None,
) -> tuple[bool, DifferenceTracker]:
differences = DifferenceTracker()
update_differences = DifferenceTracker()
assert self.parameters is not None
for options, param_values in self.parameters:
engine = options.get_engine(self.engine_driver.name)
if engine.can_update_value(self.engine_driver.get_api_version(self.client)):
@@ -743,9 +825,14 @@ class ContainerManager(DockerBaseClass):
return has_differences, differences
def has_different_resource_limits(
self, container, container_image, image, host_info
):
self,
container: Container,
container_image: dict[str, t.Any] | None,
image: dict[str, t.Any] | None,
host_info: dict[str, t.Any] | None,
) -> tuple[bool, DifferenceTracker]:
differences = DifferenceTracker()
assert self.parameters is not None
for options, param_values in self.parameters:
engine = options.get_engine(self.engine_driver.name)
if not engine.can_update_value(
@@ -765,8 +852,9 @@ class ContainerManager(DockerBaseClass):
has_differences = not differences.empty
return has_differences, differences
def _compose_update_parameters(self):
result = {}
def _compose_update_parameters(self) -> dict[str, t.Any]:
result: dict[str, t.Any] = {}
assert self.parameters is not None
for options, values in self.parameters:
engine = options.get_engine(self.engine_driver.name)
if not engine.can_update_value(
@@ -782,7 +870,13 @@ class ContainerManager(DockerBaseClass):
)
return result
def update_limits(self, container, container_image, image, host_info):
def update_limits(
self,
container: Container,
container_image: dict[str, t.Any] | None,
image: dict[str, t.Any] | None,
host_info: dict[str, t.Any] | None,
) -> Container:
limits_differ, different_limits = self.has_different_resource_limits(
container, container_image, image, host_info
)
@@ -793,20 +887,24 @@ class ContainerManager(DockerBaseClass):
)
self.diff_tracker.merge(different_limits)
if limits_differ and not self.check_mode:
assert container.id is not None
self.container_update(container.id, self._compose_update_parameters())
return self._get_container(container.id)
return container
def has_network_differences(self, container):
def has_network_differences(
self, container: Container
) -> tuple[bool, list[dict[str, t.Any]]]:
"""
Check if the container is connected to requested networks with expected options: links, aliases, ipv4, ipv6
"""
different = False
differences = []
differences: list[dict[str, t.Any]] = []
if not self.module.params["networks"]:
return different, differences
assert container.container is not None
if not container.container.get("NetworkSettings"):
self.fail(
"has_missing_networks: Error parsing container properties. NetworkSettings missing."
@@ -869,13 +967,16 @@ class ContainerManager(DockerBaseClass):
)
return different, differences
def has_extra_networks(self, container):
def has_extra_networks(
self, container: Container
) -> tuple[bool, list[dict[str, t.Any]]]:
"""
Check if the container is connected to non-requested networks
"""
extra_networks = []
extra_networks: list[dict[str, t.Any]] = []
extra = False
assert container.container is not None
if not container.container.get("NetworkSettings"):
self.fail(
"has_extra_networks: Error parsing container properties. NetworkSettings missing."
@@ -896,7 +997,9 @@ class ContainerManager(DockerBaseClass):
)
return extra, extra_networks
def update_networks(self, container, container_created):
def update_networks(
self, container: Container, container_created: bool
) -> Container:
updated_container = container
if self.all_options["networks"].comparison != "ignore" or container_created:
has_network_differences, network_differences = self.has_network_differences(
@@ -939,13 +1042,14 @@ class ContainerManager(DockerBaseClass):
updated_container = self._purge_networks(container, extra_networks)
return updated_container
def _add_networks(self, container, differences):
def _add_networks(
self, container: Container, differences: list[dict[str, t.Any]]
) -> Container:
assert container.id is not None
for diff in differences:
# remove the container from the network, if connected
if diff.get("container"):
self.results["actions"].append(
{"removed_from_network": diff["parameter"]["name"]}
)
self._add_action({"removed_from_network": diff["parameter"]["name"]})
if not self.check_mode:
try:
self.engine_driver.disconnect_container_from_network(
@@ -956,7 +1060,7 @@ class ContainerManager(DockerBaseClass):
f"Error disconnecting container from network {diff['parameter']['name']} - {exc}"
)
# connect to the network
self.results["actions"].append(
self._add_action(
{
"added_to_network": diff["parameter"]["name"],
"network_parameters": diff["parameter"],
@@ -982,9 +1086,12 @@ class ContainerManager(DockerBaseClass):
)
return self._get_container(container.id)
def _purge_networks(self, container, networks):
def _purge_networks(
self, container: Container, networks: list[dict[str, t.Any]]
) -> Container:
assert container.id is not None
for network in networks:
self.results["actions"].append({"removed_from_network": network["name"]})
self._add_action({"removed_from_network": network["name"]})
if not self.check_mode:
try:
self.engine_driver.disconnect_container_from_network(
@@ -996,7 +1103,7 @@ class ContainerManager(DockerBaseClass):
)
return self._get_container(container.id)
def container_create(self, image):
def container_create(self, image: str) -> Container | None:
create_parameters = self._compose_create_parameters(image)
self.log("create container")
self.log(f"image: {image} parameters:")
@@ -1014,7 +1121,7 @@ class ContainerManager(DockerBaseClass):
for key, value in network.items()
if key not in ("name", "id")
}
self.results["actions"].append(
self._add_action(
{
"created": "Created container",
"create_parameters": create_parameters,
@@ -1022,7 +1129,6 @@ class ContainerManager(DockerBaseClass):
}
)
self.results["changed"] = True
new_container = None
if not self.check_mode:
try:
container_id = self.engine_driver.create_container(
@@ -1031,11 +1137,11 @@ class ContainerManager(DockerBaseClass):
except Exception as exc: # pylint: disable=broad-exception-caught
self.fail(f"Error creating container: {exc}")
return self._get_container(container_id)
return new_container
return None
def container_start(self, container_id):
def container_start(self, container_id: str) -> Container:
self.log(f"start container {container_id}")
self.results["actions"].append({"started": container_id})
self._add_action({"started": container_id})
self.results["changed"] = True
if not self.check_mode:
try:
@@ -1047,9 +1153,11 @@ class ContainerManager(DockerBaseClass):
status = self.engine_driver.wait_for_container(
self.client, container_id
)
self.client.fail_results["status"] = status
# mypy doesn't know that Client has fail_results property
self.client.fail_results["status"] = status # type: ignore
self.results["status"] = status
output: str | bytes
if self.module.params["auto_remove"]:
output = "Cannot retrieve result as auto_remove is enabled"
if self.param_output_logs:
@@ -1077,12 +1185,14 @@ class ContainerManager(DockerBaseClass):
return insp
return self._get_container(container_id)
def container_remove(self, container_id, link=False, force=False):
def container_remove(
self, container_id: str, link: bool = False, force: bool = False
) -> None:
volume_state = not self.param_keep_volumes
self.log(
f"remove container container:{container_id} v:{volume_state} link:{link} force{force}"
)
self.results["actions"].append(
self._add_action(
{
"removed": container_id,
"volume_state": volume_state,
@@ -1101,13 +1211,15 @@ class ContainerManager(DockerBaseClass):
force=force,
)
except Exception as exc: # pylint: disable=broad-exception-caught
self.client.fail(f"Error removing container {container_id}: {exc}")
self.fail(f"Error removing container {container_id}: {exc}")
def container_update(self, container_id, update_parameters):
def container_update(
self, container_id: str, update_parameters: dict[str, t.Any]
) -> Container:
if update_parameters:
self.log(f"update container {container_id}")
self.log(update_parameters, pretty_print=True)
self.results["actions"].append(
self._add_action(
{"updated": container_id, "update_parameters": update_parameters}
)
self.results["changed"] = True
@@ -1120,10 +1232,8 @@ class ContainerManager(DockerBaseClass):
self.fail(f"Error updating container {container_id}: {exc}")
return self._get_container(container_id)
def container_kill(self, container_id):
self.results["actions"].append(
{"killed": container_id, "signal": self.param_kill_signal}
)
def container_kill(self, container_id: str) -> None:
self._add_action({"killed": container_id, "signal": self.param_kill_signal})
self.results["changed"] = True
if not self.check_mode:
try:
@@ -1133,8 +1243,8 @@ class ContainerManager(DockerBaseClass):
except Exception as exc: # pylint: disable=broad-exception-caught
self.fail(f"Error killing container {container_id}: {exc}")
def container_restart(self, container_id):
self.results["actions"].append(
def container_restart(self, container_id: str) -> Container:
self._add_action(
{"restarted": container_id, "timeout": self.module.params["stop_timeout"]}
)
self.results["changed"] = True
@@ -1147,11 +1257,11 @@ class ContainerManager(DockerBaseClass):
self.fail(f"Error restarting container {container_id}: {exc}")
return self._get_container(container_id)
def container_stop(self, container_id):
def container_stop(self, container_id: str) -> None:
if self.param_force_kill:
self.container_kill(container_id)
return
self.results["actions"].append(
self._add_action(
{"stopped": container_id, "timeout": self.module.params["stop_timeout"]}
)
self.results["changed"] = True
@@ -1164,7 +1274,7 @@ class ContainerManager(DockerBaseClass):
self.fail(f"Error stopping container {container_id}: {exc}")
def run_module(engine_driver):
def run_module(engine_driver: EngineDriver) -> None:
module, active_options, client = engine_driver.setup(
argument_spec={
"cleanup": {"type": "bool", "default": False},
@@ -1228,7 +1338,7 @@ def run_module(engine_driver):
],
)
def execute():
def execute() -> t.NoReturn:
cm = ContainerManager(module, engine_driver, client, active_options)
cm.run()
module.exit_json(**sanitize_result(cm.results))
+37 -18
View File
@@ -14,12 +14,13 @@
from __future__ import annotations
import re
import typing as t
_VALID_STR = re.compile("^[A-Za-z0-9_-]+$")
def _validate_part(string, part, part_name):
def _validate_part(string: str, part: str, part_name: str) -> str:
if not part:
raise ValueError(f'Invalid platform string "{string}": {part_name} is empty')
if not _VALID_STR.match(part):
@@ -79,7 +80,7 @@ _KNOWN_ARCH = (
)
def _normalize_os(os_str):
def _normalize_os(os_str: str) -> str:
# See normalizeOS() in https://github.com/containerd/containerd/blob/main/platforms/database.go
os_str = os_str.lower()
if os_str == "macos":
@@ -112,7 +113,7 @@ _NORMALIZE_ARCH = {
}
def _normalize_arch(arch_str, variant_str):
def _normalize_arch(arch_str: str, variant_str: str) -> tuple[str, str]:
# See normalizeArch() in https://github.com/containerd/containerd/blob/main/platforms/database.go
arch_str = arch_str.lower()
variant_str = variant_str.lower()
@@ -121,15 +122,16 @@ def _normalize_arch(arch_str, variant_str):
res = _NORMALIZE_ARCH.get((arch_str, None))
if res is None:
return arch_str, variant_str
if res is not None:
arch_str = res[0]
if res[1] is not None:
variant_str = res[1]
return arch_str, variant_str
arch_str = res[0]
if res[1] is not None:
variant_str = res[1]
return arch_str, variant_str
class _Platform:
def __init__(self, os=None, arch=None, variant=None):
def __init__(
self, os: str | None = None, arch: str | None = None, variant: str | None = None
) -> None:
self.os = os
self.arch = arch
self.variant = variant
@@ -140,7 +142,12 @@ class _Platform:
raise ValueError("If variant is given, os must be given too")
@classmethod
def parse_platform_string(cls, string, daemon_os=None, daemon_arch=None):
def parse_platform_string(
cls,
string: str | None,
daemon_os: str | None = None,
daemon_arch: str | None = None,
) -> t.Self:
# See Parse() in https://github.com/containerd/containerd/blob/main/platforms/platforms.go
if string is None:
return cls()
@@ -182,6 +189,7 @@ class _Platform:
)
if variant is not None and not variant:
raise ValueError(f'Invalid platform string "{string}": variant is empty')
assert arch is not None # otherwise variant would be None as well
arch, variant = _normalize_arch(arch, variant or "")
if len(parts) == 2 and arch == "arm" and variant == "v7":
variant = None
@@ -189,9 +197,12 @@ class _Platform:
variant = "v8"
return cls(os=_normalize_os(os), arch=arch, variant=variant or None)
def __str__(self):
def __str__(self) -> str:
if self.variant:
parts = [self.os, self.arch, self.variant]
assert (
self.os is not None and self.arch is not None
) # ensured in constructor
parts: list[str] = [self.os, self.arch, self.variant]
elif self.os:
if self.arch:
parts = [self.os, self.arch]
@@ -203,12 +214,14 @@ class _Platform:
parts = []
return "/".join(parts)
def __repr__(self):
def __repr__(self) -> str:
return (
f"_Platform(os={self.os!r}, arch={self.arch!r}, variant={self.variant!r})"
)
def __eq__(self, other):
def __eq__(self, other: object) -> bool:
if not isinstance(other, _Platform):
return NotImplemented
return (
self.os == other.os
and self.arch == other.arch
@@ -216,7 +229,9 @@ class _Platform:
)
def normalize_platform_string(string, daemon_os=None, daemon_arch=None):
def normalize_platform_string(
string: str, daemon_os: str | None = None, daemon_arch: str | None = None
) -> str:
return str(
_Platform.parse_platform_string(
string, daemon_os=daemon_os, daemon_arch=daemon_arch
@@ -225,8 +240,12 @@ def normalize_platform_string(string, daemon_os=None, daemon_arch=None):
def compose_platform_string(
os=None, arch=None, variant=None, daemon_os=None, daemon_arch=None
):
os: str | None = None,
arch: str | None = None,
variant: str | None = None,
daemon_os: str | None = None,
daemon_arch: str | None = None,
) -> str:
if os is None and daemon_os is not None:
os = _normalize_os(daemon_os)
if arch is None and daemon_arch is not None:
@@ -235,7 +254,7 @@ def compose_platform_string(
return str(_Platform(os=os, arch=arch, variant=variant or None))
def compare_platform_strings(string1, string2):
def compare_platform_strings(string1: str, string2: str) -> bool:
return _Platform.parse_platform_string(string1) == _Platform.parse_platform_string(
string2
)
+9 -9
View File
@@ -13,7 +13,7 @@ import random
from ansible.module_utils.common.text.converters import to_bytes, to_native, to_text
def generate_insecure_key():
def generate_insecure_key() -> bytes:
"""Do NOT use this for cryptographic purposes!"""
while True:
# Generate a one-byte key. Right now the functions below do not use more
@@ -24,23 +24,23 @@ def generate_insecure_key():
return key
def scramble(value, key):
def scramble(value: str, key: bytes) -> str:
"""Do NOT use this for cryptographic purposes!"""
if len(key) < 1:
raise ValueError("Key must be at least one byte")
value = to_bytes(value)
b_value = to_bytes(value)
k = key[0]
value = bytes([k ^ b for b in value])
return "=S=" + to_native(base64.b64encode(value))
b_value = bytes([k ^ b for b in b_value])
return f"=S={to_native(base64.b64encode(b_value))}"
def unscramble(value, key):
def unscramble(value: str, key: bytes) -> str:
"""Do NOT use this for cryptographic purposes!"""
if len(key) < 1:
raise ValueError("Key must be at least one byte")
if not value.startswith("=S="):
raise ValueError("Value does not start with indicator")
value = base64.b64decode(value[3:])
b_value = base64.b64decode(value[3:])
k = key[0]
value = bytes([k ^ b for b in value])
return to_text(value)
b_value = bytes([k ^ b for b in b_value])
return to_text(b_value)
+51 -29
View File
@@ -12,6 +12,7 @@ import os.path
import selectors
import socket as pysocket
import struct
import typing as t
from ansible_collections.community.docker.plugins.module_utils._api.utils import (
socket as docker_socket,
@@ -23,58 +24,74 @@ from ansible_collections.community.docker.plugins.module_utils._socket_helper im
)
if t.TYPE_CHECKING:
from collections.abc import Callable
from ansible.module_utils.basic import AnsibleModule
from ansible_collections.community.docker.plugins.module_utils._socket_helper import (
SocketLike,
)
PARAMIKO_POLL_TIMEOUT = 0.01 # 10 milliseconds
def _empty_writer(msg: str) -> None:
pass
class DockerSocketHandlerBase:
def __init__(self, sock, log=None):
def __init__(
self, sock: SocketLike, log: Callable[[str], None] | None = None
) -> None:
make_unblocking(sock)
if log is not None:
self._log = log
else:
self._log = lambda msg: True
self._log = log or _empty_writer
self._paramiko_read_workaround = hasattr(
sock, "send_ready"
) and "paramiko" in str(type(sock))
self._sock = sock
self._block_done_callback = None
self._block_buffer = []
self._block_done_callback: Callable[[int, bytes], None] | None = None
self._block_buffer: list[tuple[int, bytes]] = []
self._eof = False
self._read_buffer = b""
self._write_buffer = b""
self._end_of_writing = False
self._current_stream = None
self._current_stream: int | None = None
self._current_missing = 0
self._current_buffer = b""
self._selector = selectors.DefaultSelector()
self._selector.register(self._sock, selectors.EVENT_READ)
def __enter__(self):
def __enter__(self) -> t.Self:
return self
def __exit__(self, type_, value, tb):
def __exit__(self, type_, value, tb) -> None:
self._selector.close()
def set_block_done_callback(self, block_done_callback):
def set_block_done_callback(
self, block_done_callback: Callable[[int, bytes], None]
) -> None:
self._block_done_callback = block_done_callback
if self._block_done_callback is not None:
while self._block_buffer:
elt = self._block_buffer.pop(0)
self._block_done_callback(*elt)
def _add_block(self, stream_id, data):
def _add_block(self, stream_id: int, data: bytes) -> None:
if self._block_done_callback is not None:
self._block_done_callback(stream_id, data)
else:
self._block_buffer.append((stream_id, data))
def _read(self):
def _read(self) -> None:
if self._eof:
return
data: bytes | None
if hasattr(self._sock, "recv"):
try:
data = self._sock.recv(262144)
@@ -86,13 +103,13 @@ class DockerSocketHandlerBase:
self._eof = True
return
raise
elif isinstance(self._sock, getattr(pysocket, "SocketIO")):
data = self._sock.read()
elif isinstance(self._sock, pysocket.SocketIO): # type: ignore[unreachable]
data = self._sock.read() # type: ignore
else:
data = os.read(self._sock.fileno())
data = os.read(self._sock.fileno()) # type: ignore # TODO does this really work?!
if data is None:
# no data available
return
return # type: ignore[unreachable]
self._log(f"read {len(data)} bytes")
if len(data) == 0:
# Stream EOF
@@ -106,6 +123,7 @@ class DockerSocketHandlerBase:
self._read_buffer = self._read_buffer[n:]
self._current_missing -= n
if self._current_missing == 0:
assert self._current_stream is not None
self._add_block(self._current_stream, self._current_buffer)
self._current_buffer = b""
if len(self._read_buffer) < 8:
@@ -119,13 +137,13 @@ class DockerSocketHandlerBase:
self._eof = True
break
def _handle_end_of_writing(self):
def _handle_end_of_writing(self) -> None:
if self._end_of_writing and len(self._write_buffer) == 0:
self._end_of_writing = False
self._log("Shutting socket down for writing")
shutdown_writing(self._sock, self._log)
def _write(self):
def _write(self) -> None:
if len(self._write_buffer) > 0:
written = write_to_socket(self._sock, self._write_buffer)
self._write_buffer = self._write_buffer[written:]
@@ -138,7 +156,9 @@ class DockerSocketHandlerBase:
self._selector.modify(self._sock, selectors.EVENT_READ)
self._handle_end_of_writing()
def select(self, timeout=None, _internal_recursion=False):
def select(
self, timeout: int | float | None = None, _internal_recursion: bool = False
) -> bool:
if (
not _internal_recursion
and self._paramiko_read_workaround
@@ -147,12 +167,14 @@ class DockerSocketHandlerBase:
# When the SSH transport is used, Docker SDK for Python internally uses Paramiko, whose
# Channel object supports select(), but only for reading
# (https://github.com/paramiko/paramiko/issues/695).
if self._sock.send_ready():
if self._sock.send_ready(): # type: ignore
self._write()
return True
while timeout is None or timeout > PARAMIKO_POLL_TIMEOUT:
result = self.select(PARAMIKO_POLL_TIMEOUT, _internal_recursion=True)
if self._sock.send_ready():
result = int(
self.select(PARAMIKO_POLL_TIMEOUT, _internal_recursion=True)
)
if self._sock.send_ready(): # type: ignore
self._read()
result += 1
if result > 0:
@@ -172,19 +194,19 @@ class DockerSocketHandlerBase:
self._write()
result = len(events)
if self._paramiko_read_workaround and len(self._write_buffer) > 0:
if self._sock.send_ready():
if self._sock.send_ready(): # type: ignore
self._write()
result += 1
return result > 0
def is_eof(self):
def is_eof(self) -> bool:
return self._eof
def end_of_writing(self):
def end_of_writing(self) -> None:
self._end_of_writing = True
self._handle_end_of_writing()
def consume(self):
def consume(self) -> tuple[bytes, bytes]:
stdout = []
stderr = []
@@ -203,12 +225,12 @@ class DockerSocketHandlerBase:
self.select()
return b"".join(stdout), b"".join(stderr)
def write(self, str_to_write):
def write(self, str_to_write: bytes) -> None:
self._write_buffer += str_to_write
if len(self._write_buffer) == len(str_to_write):
self._write()
class DockerSocketHandlerModule(DockerSocketHandlerBase):
def __init__(self, sock, module):
def __init__(self, sock: SocketLike, module: AnsibleModule) -> None:
super().__init__(sock, module.debug)
+14 -7
View File
@@ -12,9 +12,14 @@ import os
import os.path
import socket as pysocket
import typing as t
from collections.abc import Callable
def make_file_unblocking(file) -> None:
if t.TYPE_CHECKING:
SocketLike = pysocket.socket
def make_file_unblocking(file: SocketLike) -> None:
fcntl.fcntl(
file.fileno(),
fcntl.F_SETFL,
@@ -22,7 +27,7 @@ def make_file_unblocking(file) -> None:
)
def make_file_blocking(file) -> None:
def make_file_blocking(file: SocketLike) -> None:
fcntl.fcntl(
file.fileno(),
fcntl.F_SETFL,
@@ -30,11 +35,11 @@ def make_file_blocking(file) -> None:
)
def make_unblocking(sock) -> None:
def make_unblocking(sock: SocketLike) -> None:
if hasattr(sock, "_sock"):
sock._sock.setblocking(0)
elif hasattr(sock, "setblocking"):
sock.setblocking(0)
sock.setblocking(0) # type: ignore # TODO: CHECK!
else:
make_file_unblocking(sock)
@@ -43,7 +48,9 @@ def _empty_writer(msg: str) -> None:
pass
def shutdown_writing(sock, log: t.Callable[[str], None] = _empty_writer) -> None:
def shutdown_writing(
sock: SocketLike, log: Callable[[str], None] = _empty_writer
) -> None:
# FIXME: This does **not work with SSLSocket**! Apparently SSLSocket does not allow to send
# a close_notify TLS alert without completely shutting down the connection.
# Calling sock.shutdown(pysocket.SHUT_WR) simply turns of TLS encryption and from that
@@ -56,14 +63,14 @@ def shutdown_writing(sock, log: t.Callable[[str], None] = _empty_writer) -> None
except TypeError as e:
# probably: "TypeError: shutdown() takes 1 positional argument but 2 were given"
log(f"Shutting down for writing not possible; trying shutdown instead: {e}")
sock.shutdown()
sock.shutdown() # type: ignore
elif isinstance(sock, getattr(pysocket, "SocketIO")):
sock._sock.shutdown(pysocket.SHUT_WR)
else:
log("No idea how to signal end of writing")
def write_to_socket(sock, data: bytes) -> None:
def write_to_socket(sock: SocketLike, data: bytes) -> int:
if hasattr(sock, "_send_until_done"):
# WrappedSocket (urllib3/contrib/pyopenssl) does not have `send`, but
# only `sendall`, which uses `_send_until_done` under the hood.
+58 -38
View File
@@ -9,6 +9,7 @@
from __future__ import annotations
import json
import typing as t
from time import sleep
@@ -28,10 +29,7 @@ from ansible_collections.community.docker.plugins.module_utils._version import (
class AnsibleDockerSwarmClient(AnsibleDockerClient):
def __init__(self, **kwargs):
super().__init__(**kwargs)
def get_swarm_node_id(self):
def get_swarm_node_id(self) -> str | None:
"""
Get the 'NodeID' of the Swarm node or 'None' if host is not in Swarm. It returns the NodeID
of Docker host the module is executed on
@@ -51,7 +49,7 @@ class AnsibleDockerSwarmClient(AnsibleDockerClient):
return swarm_info["Swarm"]["NodeID"]
return None
def check_if_swarm_node(self, node_id=None):
def check_if_swarm_node(self, node_id: str | None = None) -> bool | None:
"""
Checking if host is part of Docker Swarm. If 'node_id' is not provided it reads the Docker host
system information looking if specific key in output exists. If 'node_id' is provided then it tries to
@@ -83,11 +81,11 @@ class AnsibleDockerSwarmClient(AnsibleDockerClient):
try:
node_info = self.get_node_inspect(node_id=node_id)
except APIError:
return
return None
return node_info["ID"] is not None
def check_if_swarm_manager(self):
def check_if_swarm_manager(self) -> bool:
"""
Checks if node role is set as Manager in Swarm. The node is the docker host on which module action
is performed. The inspect_swarm() will fail if node is not a manager
@@ -101,7 +99,7 @@ class AnsibleDockerSwarmClient(AnsibleDockerClient):
except APIError:
return False
def fail_task_if_not_swarm_manager(self):
def fail_task_if_not_swarm_manager(self) -> None:
"""
If host is not a swarm manager then Ansible task on this host should end with 'failed' state
"""
@@ -110,7 +108,7 @@ class AnsibleDockerSwarmClient(AnsibleDockerClient):
"Error running docker swarm module: must run on swarm manager node"
)
def check_if_swarm_worker(self):
def check_if_swarm_worker(self) -> bool:
"""
Checks if node role is set as Worker in Swarm. The node is the docker host on which module action
is performed. Will fail if run on host that is not part of Swarm via check_if_swarm_node()
@@ -122,7 +120,9 @@ class AnsibleDockerSwarmClient(AnsibleDockerClient):
return True
return False
def check_if_swarm_node_is_down(self, node_id=None, repeat_check=1):
def check_if_swarm_node_is_down(
self, node_id: str | None = None, repeat_check: int = 1
) -> bool:
"""
Checks if node status on Swarm manager is 'down'. If node_id is provided it query manager about
node specified in parameter, otherwise it query manager itself. If run on Swarm Worker node or
@@ -147,7 +147,19 @@ class AnsibleDockerSwarmClient(AnsibleDockerClient):
return True
return False
def get_node_inspect(self, node_id=None, skip_missing=False):
@t.overload
def get_node_inspect(
self, node_id: str | None = None, skip_missing: t.Literal[False] = False
) -> dict[str, t.Any]: ...
@t.overload
def get_node_inspect(
self, node_id: str | None = None, skip_missing: bool = False
) -> dict[str, t.Any] | None: ...
def get_node_inspect(
self, node_id: str | None = None, skip_missing: bool = False
) -> dict[str, t.Any] | None:
"""
Returns Swarm node info as in 'docker node inspect' command about single node
@@ -195,7 +207,7 @@ class AnsibleDockerSwarmClient(AnsibleDockerClient):
node_info["Status"]["Addr"] = swarm_leader_ip
return node_info
def get_all_nodes_inspect(self):
def get_all_nodes_inspect(self) -> list[dict[str, t.Any]]:
"""
Returns Swarm node info as in 'docker node inspect' command about all registered nodes
@@ -217,7 +229,17 @@ class AnsibleDockerSwarmClient(AnsibleDockerClient):
node_info = json.loads(json_str)
return node_info
def get_all_nodes_list(self, output="short"):
@t.overload
def get_all_nodes_list(self, output: t.Literal["short"] = "short") -> list[str]: ...
@t.overload
def get_all_nodes_list(
self, output: t.Literal["long"]
) -> list[dict[str, t.Any]]: ...
def get_all_nodes_list(
self, output: t.Literal["short", "long"] = "short"
) -> list[str] | list[dict[str, t.Any]]:
"""
Returns list of nodes registered in Swarm
@@ -227,48 +249,46 @@ class AnsibleDockerSwarmClient(AnsibleDockerClient):
if 'output' is 'long' then returns data is list of dict containing the attributes as in
output of command 'docker node ls'
"""
nodes_list = []
nodes_inspect = self.get_all_nodes_inspect()
if nodes_inspect is None:
return None
if output == "short":
nodes_list = []
for node in nodes_inspect:
nodes_list.append(node["Description"]["Hostname"])
elif output == "long":
return nodes_list
if output == "long":
nodes_info_list = []
for node in nodes_inspect:
node_property = {}
node_property: dict[str, t.Any] = {}
node_property.update({"ID": node["ID"]})
node_property.update({"Hostname": node["Description"]["Hostname"]})
node_property.update({"Status": node["Status"]["State"]})
node_property.update({"Availability": node["Spec"]["Availability"]})
node_property["ID"] = node["ID"]
node_property["Hostname"] = node["Description"]["Hostname"]
node_property["Status"] = node["Status"]["State"]
node_property["Availability"] = node["Spec"]["Availability"]
if "ManagerStatus" in node:
if node["ManagerStatus"]["Leader"] is True:
node_property.update({"Leader": True})
node_property.update(
{"ManagerStatus": node["ManagerStatus"]["Reachability"]}
)
node_property.update(
{"EngineVersion": node["Description"]["Engine"]["EngineVersion"]}
)
node_property["Leader"] = True
node_property["ManagerStatus"] = node["ManagerStatus"][
"Reachability"
]
node_property["EngineVersion"] = node["Description"]["Engine"][
"EngineVersion"
]
nodes_list.append(node_property)
else:
return None
nodes_info_list.append(node_property)
return nodes_info_list
return nodes_list
def get_node_name_by_id(self, nodeid):
def get_node_name_by_id(self, nodeid: str) -> str:
return self.get_node_inspect(nodeid)["Description"]["Hostname"]
def get_unlock_key(self):
def get_unlock_key(self) -> str | None:
if self.docker_py_version < LooseVersion("2.7.0"):
return None
return super().get_unlock_key()
def get_service_inspect(self, service_id, skip_missing=False):
def get_service_inspect(
self, service_id: str, skip_missing: bool = False
) -> dict[str, t.Any] | None:
"""
Returns Swarm service info as in 'docker service inspect' command about single service
+72 -32
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import json
import re
import typing as t
from datetime import timedelta
from urllib.parse import urlparse
@@ -17,6 +18,12 @@ from ansible.module_utils.common.collections import is_sequence
from ansible.module_utils.common.text.converters import to_text
if t.TYPE_CHECKING:
from collections.abc import Callable
from ansible.module_utils.basic import AnsibleModule
DEFAULT_DOCKER_HOST = "unix:///var/run/docker.sock"
DEFAULT_TLS = False
DEFAULT_TLS_VERIFY = False
@@ -69,22 +76,24 @@ DOCKER_COMMON_ARGS_VARS = {
if option_name != "debug"
}
DOCKER_MUTUALLY_EXCLUSIVE = []
DOCKER_MUTUALLY_EXCLUSIVE: list[tuple[str, ...] | list[str]] = []
DOCKER_REQUIRED_TOGETHER = [["client_cert", "client_key"]]
DOCKER_REQUIRED_TOGETHER: list[tuple[str, ...] | list[str]] = [
["client_cert", "client_key"]
]
DEFAULT_DOCKER_REGISTRY = "https://index.docker.io/v1/"
BYTE_SUFFIXES = ["B", "KB", "MB", "GB", "TB", "PB"]
def is_image_name_id(name):
def is_image_name_id(name: str) -> bool:
"""Check whether the given image name is in fact an image ID (hash)."""
if re.match("^sha256:[0-9a-fA-F]{64}$", name):
return True
return False
def is_valid_tag(tag, allow_empty=False):
def is_valid_tag(tag: str, allow_empty: bool = False) -> bool:
"""Check whether the given string is a valid docker tag name."""
if not tag:
return allow_empty
@@ -93,7 +102,7 @@ def is_valid_tag(tag, allow_empty=False):
return bool(re.match("^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$", tag))
def sanitize_result(data):
def sanitize_result(data: t.Any) -> t.Any:
"""Sanitize data object for return to Ansible.
When the data object contains types such as docker.types.containers.HostConfig,
@@ -110,7 +119,7 @@ def sanitize_result(data):
return data
def log_debug(msg, pretty_print=False):
def log_debug(msg: t.Any, pretty_print: bool = False):
"""Write a log message to docker.log.
If ``pretty_print=True``, the message will be pretty-printed as JSON.
@@ -126,25 +135,28 @@ def log_debug(msg, pretty_print=False):
class DockerBaseClass:
def __init__(self):
def __init__(self) -> None:
self.debug = False
def log(self, msg, pretty_print=False):
def log(self, msg: t.Any, pretty_print: bool = False) -> None:
pass
# if self.debug:
# log_debug(msg, pretty_print=pretty_print)
def update_tls_hostname(
result, old_behavior=False, deprecate_function=None, uses_tls=True
):
result: dict[str, t.Any],
old_behavior: bool = False,
deprecate_function: Callable[[str], None] | None = None,
uses_tls: bool = True,
) -> None:
if result["tls_hostname"] is None:
# get default machine name from the url
parsed_url = urlparse(result["docker_host"])
result["tls_hostname"] = parsed_url.netloc.rsplit(":", 1)[0]
def compare_dict_allow_more_present(av, bv):
def compare_dict_allow_more_present(av: dict, bv: dict) -> bool:
"""
Compare two dictionaries for whether every entry of the first is in the second.
"""
@@ -156,7 +168,12 @@ def compare_dict_allow_more_present(av, bv):
return True
def compare_generic(a, b, method, datatype):
def compare_generic(
a: t.Any,
b: t.Any,
method: t.Literal["ignore", "strict", "allow_more_present"],
datatype: t.Literal["value", "list", "set", "set(dict)", "dict"],
) -> bool:
"""
Compare values a and b as described by method and datatype.
@@ -247,10 +264,10 @@ def compare_generic(a, b, method, datatype):
class DifferenceTracker:
def __init__(self):
self._diff = []
def __init__(self) -> None:
self._diff: list[dict[str, t.Any]] = []
def add(self, name, parameter=None, active=None):
def add(self, name: str, parameter: t.Any = None, active: t.Any = None) -> None:
self._diff.append(
{
"name": name,
@@ -259,14 +276,14 @@ class DifferenceTracker:
}
)
def merge(self, other_tracker):
def merge(self, other_tracker: DifferenceTracker) -> None:
self._diff.extend(other_tracker._diff)
@property
def empty(self):
def empty(self) -> bool:
return len(self._diff) == 0
def get_before_after(self):
def get_before_after(self) -> tuple[dict[str, t.Any], dict[str, t.Any]]:
"""
Return texts ``before`` and ``after``.
"""
@@ -277,13 +294,13 @@ class DifferenceTracker:
after[item["name"]] = item["parameter"]
return before, after
def has_difference_for(self, name):
def has_difference_for(self, name: str) -> bool:
"""
Returns a boolean if a difference exists for name
"""
return any(diff for diff in self._diff if diff["name"] == name)
def get_legacy_docker_container_diffs(self):
def get_legacy_docker_container_diffs(self) -> list[dict[str, t.Any]]:
"""
Return differences in the docker_container legacy format.
"""
@@ -297,7 +314,7 @@ class DifferenceTracker:
result.append(item)
return result
def get_legacy_docker_diffs(self):
def get_legacy_docker_diffs(self) -> list[str]:
"""
Return differences in the docker_container legacy format.
"""
@@ -305,8 +322,13 @@ class DifferenceTracker:
return result
def sanitize_labels(labels, labels_field, client=None, module=None):
def fail(msg):
def sanitize_labels(
labels: dict[str, t.Any] | None,
labels_field: str,
client=None,
module: AnsibleModule | None = None,
) -> None:
def fail(msg: str) -> t.NoReturn:
if client is not None:
client.fail(msg)
if module is not None:
@@ -325,7 +347,21 @@ def sanitize_labels(labels, labels_field, client=None, module=None):
labels[k] = to_text(v)
def clean_dict_booleans_for_docker_api(data, allow_sequences=False):
@t.overload
def clean_dict_booleans_for_docker_api(
data: dict[str, t.Any], *, allow_sequences: t.Literal[False] = False
) -> dict[str, str]: ...
@t.overload
def clean_dict_booleans_for_docker_api(
data: dict[str, t.Any], *, allow_sequences: bool
) -> dict[str, str | list[str]]: ...
def clean_dict_booleans_for_docker_api(
data: dict[str, t.Any] | None, *, allow_sequences: bool = False
) -> dict[str, str] | dict[str, str | list[str]]:
"""
Go does not like Python booleans 'True' or 'False', while Ansible is just
fine with them in YAML. As such, they need to be converted in cases where
@@ -353,7 +389,7 @@ def clean_dict_booleans_for_docker_api(data, allow_sequences=False):
return result
def convert_duration_to_nanosecond(time_str):
def convert_duration_to_nanosecond(time_str: str) -> int:
"""
Return time duration in nanosecond.
"""
@@ -372,9 +408,9 @@ def convert_duration_to_nanosecond(time_str):
if not parts:
raise ValueError(f"Invalid time duration - {time_str}")
parts = parts.groupdict()
parts_dict = parts.groupdict()
time_params = {}
for name, value in parts.items():
for name, value in parts_dict.items():
if value:
time_params[name] = int(value)
@@ -386,13 +422,15 @@ def convert_duration_to_nanosecond(time_str):
return time_in_nanoseconds
def normalize_healthcheck_test(test):
def normalize_healthcheck_test(test: t.Any) -> list[str]:
if isinstance(test, (tuple, list)):
return [str(e) for e in test]
return ["CMD-SHELL", str(test)]
def normalize_healthcheck(healthcheck, normalize_test=False):
def normalize_healthcheck(
healthcheck: dict[str, t.Any], normalize_test: bool = False
) -> dict[str, t.Any]:
"""
Return dictionary of healthcheck parameters.
"""
@@ -438,7 +476,9 @@ def normalize_healthcheck(healthcheck, normalize_test=False):
return result
def parse_healthcheck(healthcheck):
def parse_healthcheck(
healthcheck: dict[str, t.Any] | None,
) -> tuple[dict[str, t.Any] | None, bool | None]:
"""
Return dictionary of healthcheck parameters and boolean if
healthcheck defined in image was requested to be disabled.
@@ -456,8 +496,8 @@ def parse_healthcheck(healthcheck):
return result, False
def omit_none_from_dict(d):
def omit_none_from_dict(d: dict[str, t.Any]) -> dict[str, t.Any]:
"""
Return a copy of the dictionary with all keys with value None omitted.
"""
return dict((k, v) for (k, v) in d.items() if v is not None)
return {k: v for (k, v) in d.items() if v is not None}