diff --git a/CHANGELOG.md b/CHANGELOG.md index ccffef7..8cff8db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ * As of v1.4.0 release candidates will be published in an effort to get new features out faster while still allowing time for full QA testing before moving the release candidate to a full release. +## v1.6.0rc1 [2023-10-25] +#### What's New +* Initial support for Kubernetes - this functionality is not yet available publicly on the Britive Platform - this is a beta feature for internal use only + +#### Enhancements +* Add command `cache kubeconfig` +* Update command `cache clear` to delete the kube config file if it exists +* Add global config flag `auto-refresh-kube-config` set by `configure update global auto-refresh-kube-config true` +* Add checkout mode `k8s-exec` for use exclusively inside an `exec` command of a kube config file +* Add console helper script `pybritive-kube-exec` for use exclusively inside an `exec` command of a kube config file +* Add the `pybritive` package version into the `User-Agent` string used by the Britive Python SDK (`britive` package) + +#### Bug Fixes +* None + +#### Dependencies +* None + +#### Other +* Documentation update on bash command to add the python `bin` path to your `PATH` environment variable. + + ## v1.5.0 [2023-10-20] #### What's New * None diff --git a/docs/index.md b/docs/index.md index e45fb14..493c43e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -42,7 +42,7 @@ You will need to add this location to your path. The following command will do t this command into your `.bashrc, .zshrc, etc.` file, so it will always get executed on new terminal windows. ~~~ -export PATH=\"`python3 -m site --user-base`/bin:\$PATH\" +export PATH=`python3 -m site --user-base`/bin:$PATH ~~~ ## Tenant Configuration diff --git a/setup.cfg b/setup.cfg index 7a8d2ac..23387a3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = pybritive -version = 1.5.0 +version = 1.6.0rc1 author = Britive Inc. author_email = support@britive.com description = A pure Python CLI for Britive @@ -18,7 +18,6 @@ package_dir = packages = find: python_requires = >=3.7 -# urllib3 version logic due to https://github.com/britive/python-cli/security/dependabot/6 install_requires = click requests>=2.31.0 @@ -38,4 +37,5 @@ where = src [options.entry_points] console_scripts = pybritive = pybritive.cli_interface:safe_cli - pybritive-aws-cred-process = pybritive.helpers.aws_credential_process:main \ No newline at end of file + pybritive-aws-cred-process = pybritive.helpers.aws_credential_process:main + pybritive-kube-exec = pybritive.helpers.k8s_exec:main \ No newline at end of file diff --git a/src/pybritive/britive_cli.py b/src/pybritive/britive_cli.py index 015366c..7a1134f 100644 --- a/src/pybritive/britive_cli.py +++ b/src/pybritive/britive_cli.py @@ -9,8 +9,8 @@ from pathlib import Path import sys import uuid +import pkg_resources import yaml - import click import jmespath from britive.britive import Britive @@ -28,9 +28,10 @@ class BritiveCli: - def __init__(self, tenant_name: str = None, token: str = None, silent: bool = False, - passphrase: str = None, federation_provider: str = None): + def __init__(self, tenant_name: str = None, token: str = None, silent: bool = False,passphrase: str = None, + federation_provider: str = None, from_helper_console_script: bool = False): self.silent = silent + self.from_helper_console_script = from_helper_console_script self.output_format = None self.tenant_name = None self.tenant_alias = None @@ -44,6 +45,16 @@ def __init__(self, tenant_name: str = None, token: str = None, silent: bool = Fa self.credential_manager = None self.verbose_checkout = False self.checkout_progress_previous_message = None + self.cachable_modes = { + 'awscredentialprocess': { + 'app_type': 'AWS', + 'expiration_jmespath': 'expirationTime' + }, + 'kube-exec': { + 'app_type': 'Kubernetes', + 'expiration_jmespath': 'expirationTime' + } + } self.browser = None def set_output_format(self, output_format: str): @@ -102,10 +113,25 @@ def login(self, explicit: bool = False, browser: str = None): except exceptions.UnauthorizedRequest: self._cleanup_credentials() - # if user called `pybritive login` and we should refresh the profile cache...do so - if explicit and self.config.auto_refresh_profile_cache(): - self._set_available_profiles() - self.cache_profiles() + self._update_sdk_user_agent() + + # if user called `pybritive login` and we should get profiles...do so + should_get_profiles = any([self.config.auto_refresh_profile_cache(), self.config.auto_refresh_kube_config()]) + if explicit and should_get_profiles: + self._set_available_profiles() # will handle calling cache_profiles() and construct_kube_config() + + def _update_sdk_user_agent(self): + # update the user agent to include the pybritive cli version + user_agent = self.b.session.headers.get('User-Agent') + + try: + version = pkg_resources.get_distribution('pybritive').version + except Exception: + version = 'unknown' + + self.b.session.headers.update({ + 'User-Agent': f'pybritive/{version} {user_agent}' + }) def _cleanup_credentials(self): self.set_credential_manager() @@ -307,7 +333,7 @@ def list_environments(self): data.append(row) self.print(data, ignore_silent=True) - def _set_available_profiles(self): + def _set_available_profiles(self, from_cache_command=False): if not self.available_profiles: data = [] for app in self.b.my_access.list_profiles(): @@ -327,12 +353,46 @@ def _set_available_profiles(self): 'profile_allows_console': profile['consoleAccess'], 'profile_allows_programmatic': profile['programmaticAccess'], 'profile_description': profile['profileDescription'], - '2_part_profile_format_allowed': app['requiresHierarchicalModel'] + '2_part_profile_format_allowed': app['requiresHierarchicalModel'], + 'env_properties': env.get('profileEnvironmentProperties', {}) } data.append(row) self.available_profiles = data - if self.config.auto_refresh_profile_cache(): - self.cache_profiles(load=False) + + if not from_cache_command and self.config.auto_refresh_profile_cache(): + self.cache_profiles() + if not from_cache_command and self.config.auto_refresh_kube_config(): + self.construct_kube_config() + + def construct_kube_config(self, from_cache_command=False): + if self.from_helper_console_script: + return + + if from_cache_command: + self.login() + self._set_available_profiles(from_cache_command=from_cache_command) + + profiles = [] + for p in self.available_profiles: + if p['app_type'].lower() == 'kubernetes': + props = p['env_properties'] + url = props.get('apiServerUrl') + cert = props.get('certificateAuthorityData') + if props and all([url, cert]): + profiles.append({ + 'app': p['app_name'], + 'env': p['env_name'], + 'profile': p['profile_name'], + 'url': url, + 'cert': cert, + }) + from .helpers.kube_config_builder import build_kube_config # lazy import as not everyone will want this + build_kube_config( + profiles=profiles, + config=self.config, + username=self.b.my_access.whoami()['username'], + cli=self + ) def _get_app_type(self, application_id): self._set_available_profiles() @@ -342,7 +402,7 @@ def _get_app_type(self, application_id): raise click.ClickException(f'Application {application_id} not found') def __get_cloud_credential_printer(self, app_type, console, mode, profile, silent, credentials, - aws_credentials_file, gcloud_key_file): + aws_credentials_file, gcloud_key_file, k8s_processor): if app_type in ['AWS', 'AWS Standalone']: return printer.AwsCloudCredentialPrinter( console=console, @@ -372,14 +432,25 @@ def __get_cloud_credential_printer(self, app_type, console, mode, profile, silen cli=self, gcloud_key_file=gcloud_key_file ) - return printer.GenericCloudCredentialPrinter( - console=console, - mode=mode, - profile=profile, - credentials=credentials, - silent=silent, - cli=self - ) + elif app_type in ['Kubernetes']: + return printer.KubernetesCredentialPrinter( + console=console, + mode=mode, + profile=profile, + credentials=credentials, + silent=silent, + cli=self, + k8s_processor=k8s_processor + ) + else: + return printer.GenericCloudCredentialPrinter( + console=console, + mode=mode, + profile=profile, + credentials=credentials, + silent=silent, + cli=self + ) def checkin(self, profile, console): self.login() @@ -474,9 +545,17 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime, force_renew, aws_credentials_file, gcloud_key_file, verbose): credentials = None app_type = None - credential_process_creds_found = False + cached_credentials_found = False + k8s_processor = None self.verbose_checkout = verbose + # handle kube-exec since the profile is actually going to be passed in via another method + # and perform some basic validation so we don't waste time performing a checkout when we + # will not be able to return a response back to kubectl via the exec command + if mode == 'kube-exec': + from .helpers.k8s_exec_credential_builder import KubernetesExecCredentialProcessor + k8s_processor = KubernetesExecCredentialProcessor() + # these 2 modes implicitly say that console access should be checked out without having to provide # the --console flag if mode and (mode == 'console' or mode.startswith('browser')): @@ -488,22 +567,23 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime, self._validate_justification(justification) - if mode == 'awscredentialprocess': - self.silent = True # the aws credential process CANNOT output anything other than the expected JSON - # we need to check the credential process cache for the credentials first - # then check to see if they are expired - # if not simply return those credentials - # if they are expired - app_type = 'AWS' # just hardcode as we know for sure this is for AWS - credentials = Cache(passphrase=passphrase).get_awscredentialprocess(profile_name=alias or profile) + if mode in self.cachable_modes: + self.silent = True # CANNOT output anything other than the expected JSON + # we need to check the cache for the credentials first and then check to see if they are expired + # if not simply return those credentials, if they are expired, continue to do an actual checkout + app_type = self.cachable_modes[mode]['app_type'] + credentials = Cache(passphrase=passphrase).get_credentials(profile_name=alias or profile, mode=mode) if credentials: - expiration_timestamp_str = credentials['expirationTime'].replace('Z', '') + expiration_timestamp_str = jmespath.search( + expression=self.cachable_modes[mode]['expiration_jmespath'], + data=credentials + ).replace('Z', '') expires = datetime.fromisoformat(expiration_timestamp_str) now = datetime.utcnow() if now >= expires: # check to ensure the credentials are still valid, if not, set to None and get new credentials = None else: - credential_process_creds_found = True + cached_credentials_found = True parts = self._split_profile_into_parts(profile) @@ -518,7 +598,7 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime, 'justification': justification } - if not credential_process_creds_found: # nothing found via aws cred process or not aws cred process mode + if not cached_credentials_found: # nothing found in cache, cache is expired, or not a cachable mode response = self._checkout(**params) app_type = self._get_app_type(response['appContainerId']) credentials = response['credentials'] @@ -533,16 +613,17 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime, self.print('checking in the profile to get renewed credentials....standby') self.checkin(profile=profile) response = self._checkout(**params) - credential_process_creds_found = False # need to write new creds to cache + cached_credentials_found = False # need to write new creds to cache credentials = response['credentials'] if alias: # do this down here, so we know that the profile is valid and a checkout was successful self.config.save_profile_alias(alias=alias, profile=profile) - if mode == 'awscredentialprocess' and not credential_process_creds_found: - Cache(passphrase=passphrase).save_awscredentialprocess( + if mode in self.cachable_modes and not cached_credentials_found: + Cache(passphrase=passphrase).save_credentials( profile_name=alias or profile, - credentials=credentials + credentials=credentials, + mode=mode ) self.__get_cloud_credential_printer( @@ -553,7 +634,8 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime, self.silent, credentials, aws_credentials_file, - gcloud_key_file + gcloud_key_file, + k8s_processor ).print() def import_existing_npm_config(self): @@ -659,11 +741,15 @@ def downloadsecret(self, path, blocktime, justification, maxpolltime, file): f.write(content) self.print(f'wrote contents of secret file to {path}') - def cache_profiles(self, load=True): - if load: - self.login() - self._set_available_profiles() + def cache_profiles(self, from_cache_command=False): + if self.from_helper_console_script: + return profiles = [] + + if from_cache_command: + self.login() + self._set_available_profiles(from_cache_command=from_cache_command) + for p in self.available_profiles: profile = self.escape_profile_element(p['app_name']) profile += '/' diff --git a/src/pybritive/choices/mode.py b/src/pybritive/choices/mode.py index 30befda..d48bab3 100644 --- a/src/pybritive/choices/mode.py +++ b/src/pybritive/choices/mode.py @@ -23,7 +23,8 @@ 'browser-macosx', 'browser-safari', 'browser-chrome', - 'browser-chromium' + 'browser-chromium', + 'kube-exec', # bake into kubeconfig with oidc exec output and additional caching to make kubectl more performant ], case_sensitive=False ) diff --git a/src/pybritive/commands/cache.py b/src/pybritive/commands/cache.py index 31fda3d..2cb14fc 100644 --- a/src/pybritive/commands/cache.py +++ b/src/pybritive/commands/cache.py @@ -14,7 +14,15 @@ def cache(): @britive_options(names='tenant,token,silent,passphrase,federation_provider') def profiles(ctx, tenant, token, silent, passphrase, federation_provider): """Cache profiles locally to facilitate auto-completion of profile names on checkin/checkout.""" - ctx.obj.britive.cache_profiles() + ctx.obj.britive.cache_profiles(from_cache_command=True) + + +@cache.command() +@build_britive +@britive_options(names='tenant,token,silent,passphrase,federation_provider') +def kubeconfig(ctx, tenant, token, silent, passphrase, federation_provider): + """Cache a Britive managed kube config file based on the profiles to which the caller has access.""" + ctx.obj.britive.construct_kube_config(from_cache_command=True) @cache.command() diff --git a/src/pybritive/helpers/aws_credential_process.py b/src/pybritive/helpers/aws_credential_process.py index 94bf6bc..bcfae2b 100644 --- a/src/pybritive/helpers/aws_credential_process.py +++ b/src/pybritive/helpers/aws_credential_process.py @@ -66,7 +66,10 @@ def main(): creds = None if not args['force_renew']: # if force renew let's defer to that the full package vs. this helper from .cache import Cache # lazy load - creds = Cache(passphrase=args['passphrase']).get_awscredentialprocess(profile_name=args['profile']) + creds = Cache(passphrase=args['passphrase']).get_credentials( + profile_name=args['profile'], + mode='awscredentialprocess' + ) if creds: from datetime import datetime # lazy load expiration = datetime.fromisoformat(creds['expirationTime'].replace('Z', '')) @@ -90,7 +93,8 @@ def main(): token=args['token'], passphrase=args['passphrase'], federation_provider=args['federation_provider'], - silent=True + silent=True, + from_helper_console_script=True ) b.config.get_tenant() # have to load the config here as that work is generally done b.checkout( diff --git a/src/pybritive/helpers/cache.py b/src/pybritive/helpers/cache.py index 244ceda..8c773eb 100644 --- a/src/pybritive/helpers/cache.py +++ b/src/pybritive/helpers/cache.py @@ -1,7 +1,6 @@ import json import os from pathlib import Path - from .encryption import StringEncryption, InvalidPassphraseException @@ -10,8 +9,14 @@ def __init__(self, passphrase: str = None): self.passphrase = passphrase self.string_encryptor = StringEncryption(passphrase=self.passphrase) home = os.getenv('PYBRITIVE_HOME_DIR', str(Path.home())) - self.path = str(Path(home) / '.britive' / 'pybritive.cache') # handle os specific separators properly + self.base_path = str(Path(home) / '.britive') + self.path = str(Path(self.base_path) / 'pybritive.cache') # handle os specific separators properly self.cache = {} + self.default_key_values = { + 'profiles': [], + 'awscredentialprocess': {}, + 'kube-exec': {} + } self.load() def load(self): @@ -29,10 +34,7 @@ def load(self): except json.decoder.JSONDecodeError: self.cache = {} - if 'profiles' not in self.cache.keys(): - self.cache['profiles'] = [] - if 'awscredentialprocess' not in self.cache.keys(): - self.cache['awscredentialprocess'] = {} + self.cache = {**self.default_key_values, **self.cache} def write(self): # write the new cache file @@ -49,24 +51,28 @@ def save_profiles(self, profiles: list): self.write() def clear(self): - self.cache['profiles'] = [] - self.cache['awscredentialprocess'] = {} + # write empty cache file + self.cache = self.default_key_values self.write() - def get_awscredentialprocess(self, profile_name: str): + # delete kube config if it exists + kubeconfig = Path(self.base_path) / 'kube' / 'config' + kubeconfig.unlink(missing_ok=True) + + def get_credentials(self, profile_name: str, mode: str = 'awscredentialprocess'): try: - ciphertext = self.cache['awscredentialprocess'].get(profile_name.lower()) + ciphertext = self.cache[mode].get(profile_name.lower()) if not ciphertext: return None return json.loads(self.string_encryptor.decrypt(ciphertext)) except InvalidPassphraseException: # if we cannot decrypt don't error - just make the API call to get the creds return None - def save_awscredentialprocess(self, profile_name: str, credentials: dict): + def save_credentials(self, profile_name: str, credentials: dict, mode: str = 'awscredentialprocess'): ciphertext = self.string_encryptor.encrypt(json.dumps(credentials, default=str)) - self.cache['awscredentialprocess'][profile_name.lower()] = ciphertext + self.cache[mode][profile_name.lower()] = ciphertext self.write() - def clear_awscredentialprocess(self, profile_name: str): - self.cache['awscredentialprocess'].pop(profile_name.lower(), None) + def clear_credentials(self, profile_name: str, mode: str = 'awscredentialprocess'): + self.cache[mode].pop(profile_name.lower(), None) self.write() diff --git a/src/pybritive/helpers/cloud_credential_printer.py b/src/pybritive/helpers/cloud_credential_printer.py index 372d839..1770e2a 100644 --- a/src/pybritive/helpers/cloud_credential_printer.py +++ b/src/pybritive/helpers/cloud_credential_printer.py @@ -62,6 +62,8 @@ def print(self): self.print_azps() if self.mode == 'gcloudauth': self.print_gcloudauth() + if self.mode == 'kube': + self.print_kube() def print_console(self): url = self.credentials.get('url', self.credentials) @@ -95,6 +97,9 @@ def print_azps(self): def print_gcloudauth(self): self._not_implemented() + def print_kube(self): + self._not_implemented() + def _not_implemented(self): raise click.ClickException(f'Application type {self.app_type} does not support the specified mode.') @@ -253,3 +258,21 @@ def print_gcloudauth(self): f"gcloud auth activate-service-account {self.credentials['client_email']} --key-file {str(path)}", ignore_silent=True ) + + +class KubernetesCredentialPrinter(CloudCredentialPrinter): + def __init__(self, console, mode, profile, silent, credentials, cli, k8s_processor): + self.k8s_processor = k8s_processor + super().__init__('Kubernetes', console, mode, profile, silent, credentials, cli) + + def print_json(self): + try: + self.cli.print(json.dumps(self.credentials, indent=2), ignore_silent=True) + except json.JSONDecodeError: + self.cli.print(self.credentials, ignore_silent=True) + + def print_kube(self): + if self.mode_modifier == 'exec': + self.cli.print(self.k8s_processor.construct_exec_credential(self.credentials), ignore_silent=True) + else: + raise ValueError(f'--mode modifier {self.mode_modifier} for mode {self.mode} not supported') diff --git a/src/pybritive/helpers/config.py b/src/pybritive/helpers/config.py index 6f97df4..047b8a9 100644 --- a/src/pybritive/helpers/config.py +++ b/src/pybritive/helpers/config.py @@ -46,7 +46,8 @@ def coalesce(*arg): 'default_tenant', 'output_format', 'credential_backend', - 'auto-refresh-profile-cache' + 'auto-refresh-profile-cache', + 'auto-refresh-kube-config' ] tenant_fields = [ @@ -64,11 +65,14 @@ class ConfigManager: def __init__(self, cli: object, tenant_name: str = None): self.tenant_name = tenant_name self.home = os.getenv('PYBRITIVE_HOME_DIR', str(Path.home())) - self.path = str(Path(self.home) / '.britive' / 'pybritive.config') # handle os specific separators properly + self.base_path = str(Path(self.home) / '.britive') + self.path = str(Path(self.base_path) / 'pybritive.config') self.config = None self.alias = None self.default_tenant = None self.tenants = None + self.tenants_by_name = None + self.aliases_and_names = None self.profile_aliases = {} self.cli = cli self.loaded = False @@ -104,10 +108,17 @@ def load(self, force=False): self.alias = None # will be set in self.get_tenant() self.default_tenant = self.config.get('global', {}).get('default_tenant') self.tenants = {} + self.tenants_by_name = {} for key in self.config: if key.startswith('tenant-'): alias = extract_tenant(key) - self.tenants[alias] = self.config[key] + item = {**self.config[key], **{'alias': alias}} + self.tenants[alias] = item + + name = self.config[key].get('name', alias) + if name != alias: + self.tenants_by_name[name] = item + self.aliases_and_names = {**self.tenants, **self.tenants_by_name} self.profile_aliases = self.config.get('profile-aliases', {}) self.loaded = True @@ -135,12 +146,12 @@ def get_tenant(self): provided_tenant_name = list(self.tenants)[0] # if we get here then we now have a tenant name we can check to ensure exists - if provided_tenant_name not in self.tenants and not name: + if provided_tenant_name not in self.aliases_and_names and not name: raise click.ClickException(f'Tenant name "{provided_tenant_name}" not found in {self.path}') self.alias = provided_tenant_name or name # return details about the requested tenant - return self.tenants.get(provided_tenant_name, {'name': name}) + return self.aliases_and_names.get(provided_tenant_name, {'name': name, 'alias': name}) def save(self): self.validate() # ensure we are actually writing a valid config @@ -177,6 +188,14 @@ def save_global(self, default_tenant_name: str = None, output_format: str = None self.config['global']['credential_backend'] = backend self.save() + def get_profile_aliases(self, reverse_keys: bool = False): + self.load() + aliases = self.config.get('profile-aliases', {}) + if reverse_keys: + return {v: k for k, v in aliases.items()} + else: + return aliases + def save_profile_alias(self, alias, profile): self.profile_aliases[alias] = profile self.config['profile-aliases'] = self.profile_aliases @@ -267,6 +286,9 @@ def validate_global(self, section, fields): if field == 'auto-refresh-profile-cache' and value not in ['true', 'false']: error = f'Invalid {section} field {field} value {value} provided. Invalid value choice.' self.validation_error_messages.append(error) + if field == 'auto-refresh-kube-config' and value not in ['true', 'false']: + error = f'Invalid {section} field {field} value {value} provided. Invalid value choice.' + self.validation_error_messages.append(error) if field == 'default_tenant': tenant_aliases_from_sections = [ extract_tenant(t) for t in self.config if t.startswith('tenant-') @@ -309,3 +331,10 @@ def auto_refresh_profile_cache(self): if value == 'true': return True return False + + def auto_refresh_kube_config(self): + self.load() + value = self.config.get('global', {}).get('auto-refresh-kube-config', 'false') + if value == 'true': + return True + return False diff --git a/src/pybritive/helpers/k8s_exec.py b/src/pybritive/helpers/k8s_exec.py new file mode 100644 index 0000000..35c7169 --- /dev/null +++ b/src/pybritive/helpers/k8s_exec.py @@ -0,0 +1,101 @@ +def get_args(): + from getopt import getopt # lazy load + from sys import argv # lazy load + options = getopt(argv[1:], 't:T:p:F:hv', [ + 'tenant=', + 'token=', + 'passphrase=', + 'federation-provider=', + 'help', + 'version' + ])[0] + + args = { + 'tenant': None, + 'token': None, + 'passphrase': None, + 'federation_provider': None + } + + for opt, arg in options: + if opt in ('-t', '--tenant'): + args['tenant'] = arg + if opt in ('-T', '--token'): + args['token'] = arg + if opt in ('-p', '--passphrase'): + args['passphrase'] = arg + if opt in ('-F', '--federation-provider'): + args['federation_provider'] = arg + if opt in ('-h', '--help'): + usage() + if opt in ('-v', '--version'): + from platform import platform, python_version # lazy load + from pkg_resources import get_distribution # lazy load + cli_version = get_distribution('pybritive').version + print( + f'pybritive: {cli_version} / platform: {platform()} / python: {python_version()}' + ) + exit() + + return args + + +def usage(): + from sys import argv # lazy load + print(f"Usage : {argv[0]} [-t/--tenant, -T/--token, -t/--passphrase, -F/--federation-provider]") + exit() + + +def main(): + args = get_args() + + from .k8s_exec_credential_builder import KubernetesExecCredentialProcessor + + k8s_processor = KubernetesExecCredentialProcessor() + + from .cache import Cache # lazy load + creds = Cache(passphrase=args['passphrase']).get_credentials( + profile_name=k8s_processor.profile, + mode='kube-exec' + ) + if creds: + from datetime import datetime # lazy load + expiration = datetime.fromisoformat(creds['expirationTime'].replace('Z', '')) + now = datetime.utcnow() + if now > expiration: # creds have expired so set to none so new one get checked out + creds = None + else: + print(k8s_processor.construct_exec_credential(creds)) + exit() + + if not creds: + from ..britive_cli import BritiveCli # lazy load for performance purposes + + b = BritiveCli( + tenant_name=args['tenant'], + token=args['token'], + passphrase=args['passphrase'], + federation_provider=args['federation_provider'], + silent=True, + from_helper_console_script=True + ) + b.config.get_tenant() # have to load the config here as that work is generally done elsewhere + b.checkout( + alias=None, + blocktime=None, + console=False, + justification=None, + mode='kube-exec', + maxpolltime=None, + profile=k8s_processor.profile, + passphrase=args['passphrase'], + force_renew=None, + aws_credentials_file=None, + gcloud_key_file=None, + verbose=None + ) + exit() + + +if __name__ == '__main__': + main() diff --git a/src/pybritive/helpers/k8s_exec_credential_builder.py b/src/pybritive/helpers/k8s_exec_credential_builder.py new file mode 100644 index 0000000..61b743f --- /dev/null +++ b/src/pybritive/helpers/k8s_exec_credential_builder.py @@ -0,0 +1,50 @@ +import json +import os + + +class KubernetesExecCredentialProcessor: + def __init__(self): + self.api_version = None + self.profile = None + self.exec_data = None + self._parse() + + def _parse(self): + # parse the information provided by kube exec + self.exec_data = json.loads(os.environ.get('KUBERNETES_EXEC_INFO', 'null')) + + if not self.exec_data: # this env var HAS to exist if we are being invoked by k8s kubeconfig exec command + raise Exception( + 'could not find environment variable KUBERNETES_EXEC_INFO - is this command being run ' + 'within a kubeconfig context?' + ) + + self.api_version = self.exec_data.get('apiVersion') + + if not self.api_version: + raise ValueError('apiVersion not found. Cannot continue.') + + if self.api_version == 'client.authentication.k8s.io/v1alpha1': + raise ValueError(f'apiVersion {self.api_version} is not supported.') + + if self.api_version not in ['client.authentication.k8s.io/v1', 'client.authentication.k8s.io/v1beta1']: + raise Exception(f'apiVersion {self.api_version} not accounted for.') + + self.profile = self.exec_data.get('spec', {}).get('cluster', {}).get('config', {}).get('britive-profile') + + if not self.profile: + raise ValueError( + 'kubeconfig cluster extension named britive-profile not found or no profile value specified' + ) + + def construct_exec_credential(self, credentials: dict): + response = { + 'kind': 'ExecCredential', + 'apiVersion': self.api_version, + 'spec': {}, + 'status': { + 'expirationTimestamp': credentials['expirationTime'], + 'token': credentials['jwt'] + } + } + return json.dumps(response) diff --git a/src/pybritive/helpers/kube_config_builder.py b/src/pybritive/helpers/kube_config_builder.py new file mode 100644 index 0000000..9bcce77 --- /dev/null +++ b/src/pybritive/helpers/kube_config_builder.py @@ -0,0 +1,198 @@ +import yaml +from pathlib import Path +from .config import ConfigManager +from ..britive_cli import BritiveCli +import os + + +def sanitize(name: str): + name = name.lower() + # name = name.replace(' ', '_').replace('/', "_").replace('\\', '_') + return name + + +def check_env_var(filename, cli: BritiveCli): + kubeconfig = os.getenv('KUBECONFIG') + + # no env var present + if not kubeconfig: + command = f'export KUBECONFIG=~/.kube/config:{filename}' + cli.print(f'Please ensure your KUBECONFIG environment variable includes the Britive managed kube config file.') + cli.print(command) + else: + for configfile in kubeconfig.split(':'): + full_path = str(Path(configfile).expanduser()).lower() + if filename.lower() == full_path: + return # we found what we came for - silently continue + + # if we get here we need to instruct the user to add the britive managed kube config file + cli.print(f'Please modify your KUBECONFIG environment variable to include the ' + f'Britive managed kube config file.') + command = f'export KUBECONFIG="${{KUBECONFIG}}:{filename}"' + cli.print(command) + + +def merge_new_with_existing(clusters, contexts, users, filename, tenant, assigned_aliases): + # get the existing config, so we can pop out all + # items related to this tenant as we will be replacing + # them with the above created items + existing_kubeconfig = {} + if Path(filename).exists(): + with open(filename, 'r') as f: + existing_kubeconfig = yaml.safe_load(f) or {} + + prefix = f'{tenant}-' + for cluster in existing_kubeconfig.get('clusters', []): + if not cluster.get('name', '').startswith(prefix): + clusters.append(cluster) + + for context in existing_kubeconfig.get('contexts', []): + name = context.get('name', '') + if not name.startswith(prefix) and name not in assigned_aliases: + contexts.append(context) + + for user in existing_kubeconfig.get('users', []): + if not user.get('name', '').startswith(prefix): + users.append(user) + + kubeconfig = { + 'apiVersion': 'v1', + 'clusters': clusters, + 'contexts': contexts, + 'users': users, + 'kind': 'Config' + } + + # write out the config file + with open(filename, 'w') as f: + yaml.safe_dump(kubeconfig, f, default_flow_style=False, encoding='utf-8') + + +def parse_profiles(profiles, aliases): + cluster_names = {} + assigned_aliases = [] + for profile in profiles: + env_profile = f"{sanitize(profile['env'])}-{sanitize(profile['profile'].lower())}" + if env_profile not in cluster_names: + app = BritiveCli.escape_profile_element(profile['app']) + env = BritiveCli.escape_profile_element(profile['env']) + pro = BritiveCli.escape_profile_element(profile['profile']) + + escaped_profile_str = f"{app}/{env}/{pro}".lower() + alias = aliases.get(escaped_profile_str, None) + assigned_aliases.append(alias) + + cluster_names[env_profile] = { + 'apps': [], + 'url': profile['url'], + 'cert': profile['cert'], + 'escaped_profile': escaped_profile_str, + 'profile': f"{profile['app']}/{profile['env']}/{profile['profile']}".lower(), + 'alias': alias + } + cluster_names[env_profile]['apps'].append(sanitize(profile['app'])) + return [cluster_names, assigned_aliases] + + +def build_tenant_config(tenant, cluster_names, username): + users = [ + { + 'name': username, + 'user': { + 'exec': { + 'apiVersion': 'client.authentication.k8s.io/v1beta1', + 'command': 'pybritive-kube-exec', + 'args': [ + '-t', + tenant + ], + 'env': None, + 'interactiveMode': 'Never', + 'provideClusterInfo': True + } + } + } + ] if len(cluster_names.keys()) > 0 else [] + contexts = [] + clusters = [] + + for env_profile, details in cluster_names.items(): + if len(details['apps']) == 1: + names = [env_profile] + else: + names = [f"{sanitize(a)}-{env_profile}" for a in details['apps']] + + cert = details['cert'] + url = details['url'] + + for name in names: + clusters.append( + { + 'name': f'{tenant}-{name}', + 'cluster': { + 'certificate-authority-data': cert, + 'server': url, + 'extensions': [ + { + 'name': 'client.authentication.k8s.io/exec', + 'extension': { + 'britive-profile': details.get('alias', details['escaped_profile']) + } + } + ] + } + } + ) + + contexts.append( + { + 'name': details.get('alias', f'{tenant}-{name}'), + 'context': { + 'cluster': f'{tenant}-{name}', + 'user': username + } + } + ) + return [clusters, contexts, users] + + +def build_kube_config(profiles: list, config: ConfigManager, username: str, cli: BritiveCli): + tenant = config.get_tenant()['alias'].lower() # must be run first to set the tenant alias in the config + + # something unique that is not likely to clash with any other username that may be present in a kube config file + # add the tenant details which will mean 1 user per tenant + username = f'{tenant}-{username}' + + # grab the aliases + aliases = config.get_profile_aliases(reverse_keys=True) + + # parse all the profiles + cluster_names, assigned_aliases = parse_profiles(profiles, aliases) + + # establish the 3 elements of the config + clusters, contexts, users = build_tenant_config( + tenant=tenant, + cluster_names=cluster_names, + username=username + ) + + # calculate the path for the config + kube_dir = Path(config.base_path) / 'kube' + kube_dir.mkdir(exist_ok=True) + filename = str(kube_dir / 'config') + + # merge any existing config with the new config + # and write it to disk + merge_new_with_existing( + clusters=clusters, + contexts=contexts, + users=users, + tenant=tenant, + filename=filename, + assigned_aliases=assigned_aliases + ) + + # if required ensure we tell the user they need to modify their KUBECONFIG env var + # in order to pick up the Britive managed kube config file + if len(clusters) > 0: + check_env_var(filename=filename, cli=cli) diff --git a/src/pybritive/helpers/profile_argument_decorator.py b/src/pybritive/helpers/profile_argument_decorator.py index ed5fa5d..a84d295 100644 --- a/src/pybritive/helpers/profile_argument_decorator.py +++ b/src/pybritive/helpers/profile_argument_decorator.py @@ -1,13 +1,38 @@ import click import pkg_resources from ..completers.profile import profile_completer +import os +import json + click_major_version = int(pkg_resources.get_distribution('click').version.split('.')[0]) +def validate_profile(ctx, param, value): + if 'KUBERNETES_EXEC_INFO' in os.environ: + try: + return json.loads(os.getenv('KUBERNETES_EXEC_INFO'))['spec']['cluster']['config']['britive-profile'] + except: + raise ValueError('unable to find britive profile via cluster exec-extension with name britive-profile') + return value + + +def is_required(): + return 'KUBERNETES_EXEC_INFO' not in os.environ + + def click_smart_profile_argument(func): + required = is_required() + kwargs = { + 'required': required + } + if not required: + kwargs['callback'] = validate_profile if click_major_version >= 8: - dec = click.argument('profile', shell_complete=profile_completer) - else: - dec = click.argument('profile') + kwargs['shell_complete'] = profile_completer + + dec = click.argument( + 'profile', + **kwargs + ) return dec(func)