mirror of
https://github.com/ansible-collections/kubernetes.core.git
synced 2026-05-13 13:02:01 +00:00
Initial work K8S client class (#276)
Initial work K8S client class SUMMARY Initial work on K8SClient Class. Reviewed-by: Mike Graves <mgraves@redhat.com> Reviewed-by: Alina Buzachis <None> Reviewed-by: None <None>
This commit is contained in:
committed by
Mike Graves
parent
42644ee26e
commit
7fb89a7b6f
253
plugins/module_utils/k8s/client.py
Normal file
253
plugins/module_utils/k8s/client.py
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# Copyright: (c) 2021, Red Hat | Ansible
|
||||||
|
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||||
|
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
from distutils.version import LooseVersion
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from ansible.module_utils.six import iteritems, string_types
|
||||||
|
|
||||||
|
from ansible_collections.kubernetes.core.plugins.module_utils.args_common import (
|
||||||
|
AUTH_ARG_MAP,
|
||||||
|
AUTH_ARG_SPEC,
|
||||||
|
AUTH_PROXY_HEADERS_SPEC,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ansible_collections.kubernetes.core.plugins.module_utils import (
|
||||||
|
k8sdynamicclient,
|
||||||
|
)
|
||||||
|
from ansible_collections.kubernetes.core.plugins.module_utils.client.discovery import (
|
||||||
|
LazyDiscoverer,
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
# Handled in module setup
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
import kubernetes
|
||||||
|
except ImportError:
|
||||||
|
# Handled in module setup
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
import urllib3
|
||||||
|
|
||||||
|
urllib3.disable_warnings()
|
||||||
|
except ImportError:
|
||||||
|
# Handled in module setup
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
module = None
|
||||||
|
_pool = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _requires_kubernetes_at_least(version: str):
|
||||||
|
if module:
|
||||||
|
module.requires("kubernetes", version)
|
||||||
|
else:
|
||||||
|
if LooseVersion(kubernetes.__version__) < LooseVersion(version):
|
||||||
|
raise Exception(
|
||||||
|
f"kubernetes >= {version} is required to use in-memory kubeconfig."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_auth_spec(module=None, **kwargs) -> Dict:
|
||||||
|
auth: Dict = {}
|
||||||
|
# If authorization variables aren't defined, look for them in environment variables
|
||||||
|
for true_name, arg_name in AUTH_ARG_MAP.items():
|
||||||
|
if module and module.params.get(arg_name) is not None:
|
||||||
|
auth[true_name] = module.params.get(arg_name)
|
||||||
|
elif arg_name in kwargs and kwargs.get(arg_name) is not None:
|
||||||
|
auth[true_name] = kwargs.get(arg_name)
|
||||||
|
elif arg_name == "proxy_headers":
|
||||||
|
# specific case for 'proxy_headers' which is a dictionary
|
||||||
|
proxy_headers = {}
|
||||||
|
for key in AUTH_PROXY_HEADERS_SPEC.keys():
|
||||||
|
env_value = os.getenv(
|
||||||
|
"K8S_AUTH_PROXY_HEADERS_{0}".format(key.upper()), None
|
||||||
|
)
|
||||||
|
if env_value is not None:
|
||||||
|
if AUTH_PROXY_HEADERS_SPEC[key].get("type") == "bool":
|
||||||
|
env_value = env_value.lower() not in ["0", "false", "no"]
|
||||||
|
proxy_headers[key] = env_value
|
||||||
|
if proxy_headers is not {}:
|
||||||
|
auth[true_name] = proxy_headers
|
||||||
|
else:
|
||||||
|
env_value = os.getenv(
|
||||||
|
"K8S_AUTH_{0}".format(arg_name.upper()), None
|
||||||
|
) or os.getenv("K8S_AUTH_{0}".format(true_name.upper()), None)
|
||||||
|
if env_value is not None:
|
||||||
|
if AUTH_ARG_SPEC[arg_name].get("type") == "bool":
|
||||||
|
env_value = env_value.lower() not in ["0", "false", "no"]
|
||||||
|
auth[true_name] = env_value
|
||||||
|
|
||||||
|
return auth
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config(auth: Dict) -> None:
|
||||||
|
kubeconfig = auth.get("kubeconfig")
|
||||||
|
optional_arg = {
|
||||||
|
"context": auth.get("context"),
|
||||||
|
"persist_config": auth.get("persist_config"),
|
||||||
|
}
|
||||||
|
if kubeconfig:
|
||||||
|
if isinstance(kubeconfig, string_types):
|
||||||
|
kubernetes.config.load_kube_config(config_file=kubeconfig, **optional_arg)
|
||||||
|
elif isinstance(kubeconfig, dict):
|
||||||
|
_requires_kubernetes_at_least("17.17.0")
|
||||||
|
kubernetes.config.load_kube_config_from_dict(
|
||||||
|
config_dict=kubeconfig, **optional_arg
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
kubernetes.config.load_kube_config(config_file=None, **optional_arg)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_configuration(auth: Dict):
|
||||||
|
def auth_set(*names: list) -> bool:
|
||||||
|
return all(auth.get(name) for name in names)
|
||||||
|
|
||||||
|
if auth_set("host"):
|
||||||
|
# Removing trailing slashes if any from hostname
|
||||||
|
auth["host"] = auth.get("host").rstrip("/")
|
||||||
|
|
||||||
|
if auth_set("username", "password", "host") or auth_set("api_key", "host"):
|
||||||
|
# We have enough in the parameters to authenticate, no need to load incluster or kubeconfig
|
||||||
|
pass
|
||||||
|
elif auth_set("kubeconfig") or auth_set("context"):
|
||||||
|
try:
|
||||||
|
_load_config(auth)
|
||||||
|
except Exception as err:
|
||||||
|
raise err
|
||||||
|
|
||||||
|
else:
|
||||||
|
# First try to do incluster config, then kubeconfig
|
||||||
|
try:
|
||||||
|
kubernetes.config.load_incluster_config()
|
||||||
|
except kubernetes.config.ConfigException:
|
||||||
|
try:
|
||||||
|
_load_config(auth)
|
||||||
|
except Exception as err:
|
||||||
|
raise err
|
||||||
|
|
||||||
|
# Override any values in the default configuration with Ansible parameters
|
||||||
|
# As of kubernetes-client v12.0.0, get_default_copy() is required here
|
||||||
|
try:
|
||||||
|
configuration = kubernetes.client.Configuration().get_default_copy()
|
||||||
|
except AttributeError:
|
||||||
|
configuration = kubernetes.client.Configuration()
|
||||||
|
|
||||||
|
for key, value in iteritems(auth):
|
||||||
|
if key in AUTH_ARG_MAP.keys() and value is not None:
|
||||||
|
if key == "api_key":
|
||||||
|
setattr(
|
||||||
|
configuration, key, {"authorization": "Bearer {0}".format(value)}
|
||||||
|
)
|
||||||
|
elif key == "proxy_headers":
|
||||||
|
headers = urllib3.util.make_headers(**value)
|
||||||
|
setattr(configuration, key, headers)
|
||||||
|
else:
|
||||||
|
setattr(configuration, key, value)
|
||||||
|
|
||||||
|
return configuration
|
||||||
|
|
||||||
|
|
||||||
|
def _configuration_digest(configuration) -> str:
|
||||||
|
m = hashlib.sha256()
|
||||||
|
for k in AUTH_ARG_MAP:
|
||||||
|
if not hasattr(configuration, k):
|
||||||
|
v = None
|
||||||
|
else:
|
||||||
|
v = getattr(configuration, k)
|
||||||
|
if v and k in ["ssl_ca_cert", "cert_file", "key_file"]:
|
||||||
|
with open(str(v), "r") as fd:
|
||||||
|
content = fd.read()
|
||||||
|
m.update(content.encode())
|
||||||
|
else:
|
||||||
|
m.update(str(v).encode())
|
||||||
|
digest = m.hexdigest()
|
||||||
|
|
||||||
|
return digest
|
||||||
|
|
||||||
|
|
||||||
|
def cache(func):
|
||||||
|
def wrapper(*args):
|
||||||
|
client = None
|
||||||
|
digest = _configuration_digest(*args)
|
||||||
|
if digest in _pool:
|
||||||
|
client = _pool[digest]
|
||||||
|
else:
|
||||||
|
client = func(*args)
|
||||||
|
_pool[digest] = client
|
||||||
|
|
||||||
|
return client
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def create_api_client(configuration):
|
||||||
|
return k8sdynamicclient.K8SDynamicClient(
|
||||||
|
kubernetes.client.ApiClient(configuration), discoverer=LazyDiscoverer
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class K8SClient:
|
||||||
|
"""A Client class for K8S modules.
|
||||||
|
|
||||||
|
This class has the primary purpose to proxy the kubernetes client and resource objects.
|
||||||
|
If there is a need for other methods or attributes to be proxied, they can be added here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, configuration, client, dry_run: bool = False) -> None:
|
||||||
|
self.configuration = configuration
|
||||||
|
self.client = client
|
||||||
|
self.dry_run = dry_run
|
||||||
|
|
||||||
|
@property
|
||||||
|
def resources(self) -> List[Any]:
|
||||||
|
return self.client.resources
|
||||||
|
|
||||||
|
def _ensure_dry_run(self, params: Dict) -> Dict:
|
||||||
|
if self.dry_run:
|
||||||
|
params["dry_run"] = True
|
||||||
|
return params
|
||||||
|
|
||||||
|
def validate(self, resource, **params):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get(self, resource, **params):
|
||||||
|
return resource.get(**params)
|
||||||
|
|
||||||
|
def delete(self, resource, **params):
|
||||||
|
return resource.delete(**self._ensure_dry_run(params))
|
||||||
|
|
||||||
|
def apply(self, resource, definition, namespace, **params):
|
||||||
|
return resource.apply(
|
||||||
|
definition, namespace=namespace, **self._ensure_dry_run(params)
|
||||||
|
)
|
||||||
|
|
||||||
|
def create(self, resource, definition, **params):
|
||||||
|
return resource.create(definition, **self._ensure_dry_run(params))
|
||||||
|
|
||||||
|
def replace(self, resource, definition, **params):
|
||||||
|
return resource.replace(definition, **self._ensure_dry_run(params))
|
||||||
|
|
||||||
|
def patch(self, resource, definition, **params):
|
||||||
|
return resource.patch(definition, **self._ensure_dry_run(params))
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_client(module=None, **kwargs: Optional[Any]) -> K8SClient:
|
||||||
|
auth_spec = _create_auth_spec(module, **kwargs)
|
||||||
|
configuration = _create_configuration(auth_spec)
|
||||||
|
client = create_api_client(configuration)
|
||||||
|
|
||||||
|
k8s_client = K8SClient(
|
||||||
|
configuration=configuration,
|
||||||
|
client=client,
|
||||||
|
dry_run=module.params.get("dry_run", False),
|
||||||
|
)
|
||||||
|
|
||||||
|
return k8s_client.client
|
||||||
159
tests/unit/module_utils/test_client.py
Normal file
159
tests/unit/module_utils/test_client.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import os
|
||||||
|
import base64
|
||||||
|
import tempfile
|
||||||
|
import yaml
|
||||||
|
import mock
|
||||||
|
from mock import MagicMock
|
||||||
|
|
||||||
|
from ansible_collections.kubernetes.core.plugins.module_utils.k8s.client import (
|
||||||
|
_create_auth_spec,
|
||||||
|
_create_configuration,
|
||||||
|
)
|
||||||
|
|
||||||
|
TEST_HOST = "test-host"
|
||||||
|
TEST_SSL_HOST = "https://test-host"
|
||||||
|
TEST_CLIENT_CERT = "/dev/null"
|
||||||
|
TEST_CLIENT_KEY = "/dev/null"
|
||||||
|
TEST_CERTIFICATE_AUTH = "/dev/null"
|
||||||
|
TEST_DATA = "test-data"
|
||||||
|
TEST_BEARER_TOKEN = "Bearer %s" % base64.standard_b64encode(TEST_DATA.encode()).decode()
|
||||||
|
TEST_KUBE_CONFIG = {
|
||||||
|
"current-context": "federal-context",
|
||||||
|
"contexts": [
|
||||||
|
{
|
||||||
|
"name": "simple_token",
|
||||||
|
"context": {"cluster": "default", "user": "simple_token"},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clusters": [{"name": "default", "cluster": {"server": TEST_HOST}}],
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"name": "ssl-no_file",
|
||||||
|
"user": {
|
||||||
|
"token": TEST_BEARER_TOKEN,
|
||||||
|
"client-certificate": TEST_CLIENT_CERT,
|
||||||
|
"client-key": TEST_CLIENT_KEY,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
_temp_files = []
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_temp_file():
|
||||||
|
for f in _temp_files:
|
||||||
|
os.remove(f)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_temp_file(content=""):
|
||||||
|
handler, name = tempfile.mkstemp()
|
||||||
|
_temp_files.append(name)
|
||||||
|
os.write(handler, str.encode(content))
|
||||||
|
os.close(handler)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_auth_spec_ssl_no_options():
|
||||||
|
module = MagicMock()
|
||||||
|
module.params = {}
|
||||||
|
actual_auth_spec = _create_auth_spec(module)
|
||||||
|
|
||||||
|
assert "proxy_headers" in actual_auth_spec
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_auth_spec_ssl_options():
|
||||||
|
ssl_options = {
|
||||||
|
"host": TEST_SSL_HOST,
|
||||||
|
"token": TEST_BEARER_TOKEN,
|
||||||
|
"client_cert": TEST_CLIENT_CERT,
|
||||||
|
"client_key": TEST_CLIENT_KEY,
|
||||||
|
"ca_cert": TEST_CERTIFICATE_AUTH,
|
||||||
|
"validate_certs": True,
|
||||||
|
}
|
||||||
|
expected_auth_spec = {
|
||||||
|
"host": TEST_SSL_HOST,
|
||||||
|
"cert_file": TEST_CLIENT_CERT,
|
||||||
|
"key_file": TEST_CLIENT_KEY,
|
||||||
|
"ssl_ca_cert": TEST_CERTIFICATE_AUTH,
|
||||||
|
"verify_ssl": True,
|
||||||
|
"proxy_headers": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
module = MagicMock()
|
||||||
|
module.params = ssl_options
|
||||||
|
actual_auth_spec = _create_auth_spec(module)
|
||||||
|
|
||||||
|
assert expected_auth_spec.items() <= actual_auth_spec.items()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_auth_spec_ssl_options_no_verify():
|
||||||
|
ssl_options = {
|
||||||
|
"host": TEST_SSL_HOST,
|
||||||
|
"token": TEST_BEARER_TOKEN,
|
||||||
|
"client_cert": TEST_CLIENT_CERT,
|
||||||
|
"client_key": TEST_CLIENT_KEY,
|
||||||
|
"validate_certs": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
expected_auth_spec = {
|
||||||
|
"host": TEST_SSL_HOST,
|
||||||
|
"cert_file": TEST_CLIENT_CERT,
|
||||||
|
"key_file": TEST_CLIENT_KEY,
|
||||||
|
"verify_ssl": False,
|
||||||
|
"proxy_headers": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
module = MagicMock()
|
||||||
|
module.params = ssl_options
|
||||||
|
actual_auth_spec = _create_auth_spec(module)
|
||||||
|
|
||||||
|
assert expected_auth_spec.items() <= actual_auth_spec.items()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch.dict(os.environ, {"K8S_AUTH_PROXY_HEADERS_PROXY_BASIC_AUTH": "foo:bar"})
|
||||||
|
@mock.patch.dict(os.environ, {"K8S_AUTH_PROXY_HEADERS_USER_AGENT": "foo/1.0"})
|
||||||
|
@mock.patch.dict(os.environ, {"K8S_AUTH_CERT_FILE": TEST_CLIENT_CERT})
|
||||||
|
def test_create_auth_spec_ssl_proxy():
|
||||||
|
expected_auth_spec = {
|
||||||
|
"kubeconfig": "~/.kube/customconfig",
|
||||||
|
"verify_ssl": True,
|
||||||
|
"cert_file": TEST_CLIENT_CERT,
|
||||||
|
"proxy_headers": {"proxy_basic_auth": "foo:bar", "user_agent": "foo/1.0"},
|
||||||
|
}
|
||||||
|
module = MagicMock()
|
||||||
|
options = {"validate_certs": True, "kubeconfig": "~/.kube/customconfig"}
|
||||||
|
|
||||||
|
module.params = options
|
||||||
|
actual_auth_spec = _create_auth_spec(module)
|
||||||
|
|
||||||
|
assert expected_auth_spec.items() <= actual_auth_spec.items()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_kube_config_from_file_path():
|
||||||
|
config_file = _create_temp_file(yaml.safe_dump(TEST_KUBE_CONFIG))
|
||||||
|
auth = {"kubeconfig": config_file, "context": "simple_token"}
|
||||||
|
actual_configuration = _create_configuration(auth)
|
||||||
|
|
||||||
|
expected_configuration = {
|
||||||
|
"host": TEST_HOST,
|
||||||
|
"kubeconfig": config_file,
|
||||||
|
"context": "simple_token",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert expected_configuration.items() <= actual_configuration.__dict__.items()
|
||||||
|
_remove_temp_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_kube_config_from_dict():
|
||||||
|
auth_spec = {"kubeconfig": TEST_KUBE_CONFIG, "context": "simple_token"}
|
||||||
|
actual_configuration = _create_configuration(auth_spec)
|
||||||
|
|
||||||
|
expected_configuration = {
|
||||||
|
"host": TEST_HOST,
|
||||||
|
"kubeconfig": TEST_KUBE_CONFIG,
|
||||||
|
"context": "simple_token",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert expected_configuration.items() <= actual_configuration.__dict__.items()
|
||||||
|
_remove_temp_file()
|
||||||
Reference in New Issue
Block a user