feat: merging kubeconfig files

This commit is contained in:
Tomasz Prus 2018-10-20 00:49:51 +02:00
parent bd9a8525e9
commit 328b2d1245
2 changed files with 274 additions and 25 deletions

View File

@ -14,10 +14,12 @@
import atexit
import base64
import copy
import datetime
import json
import logging
import os
import platform
import tempfile
import time
@ -38,6 +40,7 @@ from .dateutil import UTC, format_rfc3339, parse_rfc3339
EXPIRY_SKEW_PREVENTION_DELAY = datetime.timedelta(minutes=5)
KUBE_CONFIG_DEFAULT_LOCATION = os.environ.get('KUBECONFIG', '~/.kube/config')
ENV_KUBECONFIG_PATH_SEPARATOR = ';' if platform.system() == 'Windows' else ':'
_temp_files = {}
@ -132,7 +135,12 @@ class KubeConfigLoader(object):
get_google_credentials=None,
config_base_path="",
config_persister=None):
self._config = ConfigNode('kube-config', config_dict)
if isinstance(config_dict, ConfigNode):
self._config = config_dict
else:
self._config = ConfigNode('kube-config', config_dict)
self._current_context = None
self._user = None
self._cluster = None
@ -361,9 +369,10 @@ class KubeConfigLoader(object):
logging.error(str(e))
def _load_user_token(self):
base_path = self._get_base_path(self._user.path)
token = FileOrData(
self._user, 'tokenFile', 'token',
file_base_path=self._config_base_path,
file_base_path=base_path,
base64_file_content=False).as_data()
if token:
self.token = "Bearer %s" % token
@ -376,19 +385,27 @@ class KubeConfigLoader(object):
self._user['password'])).get('authorization')
return True
def _get_base_path(self, config_path):
if self._config_base_path is not None:
return self._config_base_path
if config_path is not None:
return os.path.abspath(os.path.dirname(config_path))
return ""
def _load_cluster_info(self):
if 'server' in self._cluster:
self.host = self._cluster['server'].rstrip('/')
if self.host.startswith("https"):
base_path = self._get_base_path(self._cluster.path)
self.ssl_ca_cert = FileOrData(
self._cluster, 'certificate-authority',
file_base_path=self._config_base_path).as_file()
file_base_path=base_path).as_file()
self.cert_file = FileOrData(
self._user, 'client-certificate',
file_base_path=self._config_base_path).as_file()
file_base_path=base_path).as_file()
self.key_file = FileOrData(
self._user, 'client-key',
file_base_path=self._config_base_path).as_file()
file_base_path=base_path).as_file()
if 'insecure-skip-tls-verify' in self._cluster:
self.verify_ssl = not self._cluster['insecure-skip-tls-verify']
@ -435,9 +452,10 @@ class ConfigNode(object):
message in case of missing keys. The assumption is all access keys are
present in a well-formed kube-config."""
def __init__(self, name, value):
def __init__(self, name, value, path=None):
self.name = name
self.value = value
self.path = path
def __contains__(self, key):
return key in self.value
@ -457,7 +475,7 @@ class ConfigNode(object):
'Invalid kube-config file. Expected key %s in %s'
% (key, self.name))
if isinstance(v, dict) or isinstance(v, list):
return ConfigNode('%s/%s' % (self.name, key), v)
return ConfigNode('%s/%s' % (self.name, key), v, self.path)
else:
return v
@ -482,7 +500,12 @@ class ConfigNode(object):
'Expected only one object with name %s in %s list'
% (name, self.name))
if result is not None:
return ConfigNode('%s[name=%s]' % (self.name, name), result)
if isinstance(result, ConfigNode):
return result
else:
return ConfigNode(
'%s[name=%s]' %
(self.name, name), result, self.path)
if safe:
return None
raise ConfigException(
@ -490,18 +513,87 @@ class ConfigNode(object):
'Expected object with name %s in %s list' % (name, self.name))
def _get_kube_config_loader_for_yaml_file(filename, **kwargs):
with open(filename) as f:
return KubeConfigLoader(
config_dict=yaml.safe_load(f),
config_base_path=os.path.abspath(os.path.dirname(filename)),
**kwargs)
class KubeConfigMerger:
"""Reads and merges configuration from one or more kube-config's.
The propery `config` can be passed to the KubeConfigLoader as config_dict.
It uses a path attribute from ConfigNode to store the path to kubeconfig.
This path is required to load certs from relative paths.
A method `save_changes` updates changed kubeconfig's (it compares current
state of dicts with).
"""
def __init__(self, paths):
self.paths = []
self.config_files = {}
self.config_merged = None
for path in paths.split(ENV_KUBECONFIG_PATH_SEPARATOR):
if path:
path = os.path.expanduser(path)
if os.path.exists(path):
self.paths.append(path)
self.load_config(path)
self.config_saved = copy.deepcopy(self.config_files)
@property
def config(self):
return self.config_merged
def load_config(self, path):
with open(path) as f:
config = yaml.safe_load(f)
if self.config_merged is None:
config_merged = copy.deepcopy(config)
for item in ('clusters', 'contexts', 'users'):
config_merged[item] = []
self.config_merged = ConfigNode(path, config_merged, path)
for item in ('clusters', 'contexts', 'users'):
self._merge(item, config[item], path)
self.config_files[path] = config
def _merge(self, item, add_cfg, path):
for new_item in add_cfg:
for exists in self.config_merged.value[item]:
if exists['name'] == new_item['name']:
break
else:
self.config_merged.value[item].append(ConfigNode(
'{}/{}'.format(path, new_item), new_item, path))
def save_changes(self):
for path in self.paths:
if self.config_saved[path] != self.config_files[path]:
self.save_config(path)
self.config_saved = copy.deepcopy(self.config_files)
def save_config(self, path):
with open(path, 'w') as f:
yaml.safe_dump(self.config_files[path], f,
default_flow_style=False)
def _get_kube_config_loader_for_yaml_file(
filename, persist_config=False, **kwargs):
kcfg = KubeConfigMerger(filename)
if persist_config and 'config_persister' not in kwargs:
kwargs['config_persister'] = kcfg.save_changes()
return KubeConfigLoader(
config_dict=kcfg.config,
config_base_path=None,
**kwargs)
def list_kube_config_contexts(config_file=None):
if config_file is None:
config_file = os.path.expanduser(KUBE_CONFIG_DEFAULT_LOCATION)
config_file = KUBE_CONFIG_DEFAULT_LOCATION
loader = _get_kube_config_loader_for_yaml_file(config_file)
return loader.list_contexts(), loader.current_context
@ -523,18 +615,12 @@ def load_kube_config(config_file=None, context=None,
"""
if config_file is None:
config_file = os.path.expanduser(KUBE_CONFIG_DEFAULT_LOCATION)
config_persister = None
if persist_config:
def _save_kube_config(config_map):
with open(config_file, 'w') as f:
yaml.safe_dump(config_map, f, default_flow_style=False)
config_persister = _save_kube_config
config_file = KUBE_CONFIG_DEFAULT_LOCATION
loader = _get_kube_config_loader_for_yaml_file(
config_file, active_context=context,
config_persister=config_persister)
persist_config=persist_config)
if client_configuration is None:
config = type.__call__(Configuration)
loader.load_and_set(config)

View File

@ -27,7 +27,8 @@ from six import PY3, next
from kubernetes.client import Configuration
from .config_exception import ConfigException
from .kube_config import (ConfigNode, FileOrData, KubeConfigLoader,
from .kube_config import (ENV_KUBECONFIG_PATH_SEPARATOR, ConfigNode,
FileOrData, KubeConfigLoader, KubeConfigMerger,
_cleanup_temp_files, _create_temp_file_with_content,
list_kube_config_contexts, load_kube_config,
new_client_from_config)
@ -987,5 +988,167 @@ class TestKubernetesClientConfiguration(BaseTestCase):
config.auth_settings()['BearerToken']['value'])
class TestKubeConfigMerger(BaseTestCase):
TEST_KUBE_CONFIG_PART1 = {
"current-context": "no_user",
"contexts": [
{
"name": "no_user",
"context": {
"cluster": "default"
}
},
],
"clusters": [
{
"name": "default",
"cluster": {
"server": TEST_HOST
}
},
],
"users": []
}
TEST_KUBE_CONFIG_PART2 = {
"current-context": "",
"contexts": [
{
"name": "ssl",
"context": {
"cluster": "ssl",
"user": "ssl"
}
},
{
"name": "simple_token",
"context": {
"cluster": "default",
"user": "simple_token"
}
},
],
"clusters": [
{
"name": "ssl",
"cluster": {
"server": TEST_SSL_HOST,
"certificate-authority-data":
TEST_CERTIFICATE_AUTH_BASE64,
}
},
],
"users": [
{
"name": "ssl",
"user": {
"token": TEST_DATA_BASE64,
"client-certificate-data": TEST_CLIENT_CERT_BASE64,
"client-key-data": TEST_CLIENT_KEY_BASE64,
}
},
]
}
TEST_KUBE_CONFIG_PART3 = {
"current-context": "no_user",
"contexts": [
{
"name": "expired_oidc",
"context": {
"cluster": "default",
"user": "expired_oidc"
}
},
{
"name": "ssl",
"context": {
"cluster": "skipped-part2-defined-this-context",
"user": "skipped"
}
},
],
"clusters": [
],
"users": [
{
"name": "expired_oidc",
"user": {
"auth-provider": {
"name": "oidc",
"config": {
"client-id": "tectonic-kubectl",
"client-secret": "FAKE_SECRET",
"id-token": TEST_OIDC_EXPIRED_LOGIN,
"idp-certificate-authority-data": TEST_OIDC_CA,
"idp-issuer-url": "https://example.org/identity",
"refresh-token":
"lucWJjEhlxZW01cXI3YmVlcYnpxNGhzk"
}
}
}
},
{
"name": "simple_token",
"user": {
"token": TEST_DATA_BASE64,
"username": TEST_USERNAME, # should be ignored
"password": TEST_PASSWORD, # should be ignored
}
},
]
}
def _create_multi_config(self):
files = []
for part in (
self.TEST_KUBE_CONFIG_PART1,
self.TEST_KUBE_CONFIG_PART2,
self.TEST_KUBE_CONFIG_PART3):
files.append(self._create_temp_file(yaml.safe_dump(part)))
return ENV_KUBECONFIG_PATH_SEPARATOR.join(files)
def test_list_kube_config_contexts(self):
kubeconfigs = self._create_multi_config()
expected_contexts = [
{'context': {'cluster': 'default'}, 'name': 'no_user'},
{'context': {'cluster': 'ssl', 'user': 'ssl'}, 'name': 'ssl'},
{'context': {'cluster': 'default', 'user': 'simple_token'},
'name': 'simple_token'},
{'context': {'cluster': 'default', 'user': 'expired_oidc'}, 'name': 'expired_oidc'}]
contexts, active_context = list_kube_config_contexts(
config_file=kubeconfigs)
self.assertEqual(contexts, expected_contexts)
self.assertEqual(active_context, expected_contexts[0])
def test_new_client_from_config(self):
kubeconfigs = self._create_multi_config()
client = new_client_from_config(
config_file=kubeconfigs, context="simple_token")
self.assertEqual(TEST_HOST, client.configuration.host)
self.assertEqual(BEARER_TOKEN_FORMAT % TEST_DATA_BASE64,
client.configuration.api_key['authorization'])
def test_save_changes(self):
kubeconfigs = self._create_multi_config()
# load configuration, update token, save config
kconf = KubeConfigMerger(kubeconfigs)
user = kconf.config['users'].get_with_name('expired_oidc')['user']
provider = user['auth-provider']['config']
provider.value['id-token'] = "token-changed"
kconf.save_changes()
# re-read configuration
kconf = KubeConfigMerger(kubeconfigs)
user = kconf.config['users'].get_with_name('expired_oidc')['user']
provider = user['auth-provider']['config']
# new token
self.assertEqual(provider.value['id-token'], "token-changed")
if __name__ == '__main__':
unittest.main()