mirror of
https://github.com/ansible-collections/kubernetes.core.git
synced 2026-03-27 05:43:02 +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