Skip to content

Commit 6d879a2

Browse files
authored
Merge pull request #108 from britive/develop
v1.6.0rc1
2 parents fb635b4 + a6b1b85 commit 6d879a2

14 files changed

Lines changed: 624 additions & 71 deletions

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,28 @@
22

33
* 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.
44

5+
## v1.6.0rc1 [2023-10-25]
6+
#### What's New
7+
* Initial support for Kubernetes - this functionality is not yet available publicly on the Britive Platform - this is a beta feature for internal use only
8+
9+
#### Enhancements
10+
* Add command `cache kubeconfig`
11+
* Update command `cache clear` to delete the kube config file if it exists
12+
* Add global config flag `auto-refresh-kube-config` set by `configure update global auto-refresh-kube-config true`
13+
* Add checkout mode `k8s-exec` for use exclusively inside an `exec` command of a kube config file
14+
* Add console helper script `pybritive-kube-exec` for use exclusively inside an `exec` command of a kube config file
15+
* Add the `pybritive` package version into the `User-Agent` string used by the Britive Python SDK (`britive` package)
16+
17+
#### Bug Fixes
18+
* None
19+
20+
#### Dependencies
21+
* None
22+
23+
#### Other
24+
* Documentation update on bash command to add the python `bin` path to your `PATH` environment variable.
25+
26+
527
## v1.5.0 [2023-10-20]
628
#### What's New
729
* None

docs/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ You will need to add this location to your path. The following command will do t
4242
this command into your `.bashrc, .zshrc, etc.` file, so it will always get executed on new terminal windows.
4343

4444
~~~
45-
export PATH=\"`python3 -m site --user-base`/bin:\$PATH\"
45+
export PATH=`python3 -m site --user-base`/bin:$PATH
4646
~~~
4747

4848
## Tenant Configuration

setup.cfg

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[metadata]
22
name = pybritive
3-
version = 1.5.0
3+
version = 1.6.0rc1
44
author = Britive Inc.
55
author_email = support@britive.com
66
description = A pure Python CLI for Britive
@@ -18,7 +18,6 @@ package_dir =
1818
packages = find:
1919
python_requires = >=3.7
2020

21-
# urllib3 version logic due to https://github.com/britive/python-cli/security/dependabot/6
2221
install_requires =
2322
click
2423
requests>=2.31.0
@@ -38,4 +37,5 @@ where = src
3837
[options.entry_points]
3938
console_scripts =
4039
pybritive = pybritive.cli_interface:safe_cli
41-
pybritive-aws-cred-process = pybritive.helpers.aws_credential_process:main
40+
pybritive-aws-cred-process = pybritive.helpers.aws_credential_process:main
41+
pybritive-kube-exec = pybritive.helpers.k8s_exec:main

src/pybritive/britive_cli.py

Lines changed: 127 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
from pathlib import Path
1010
import sys
1111
import uuid
12+
import pkg_resources
1213
import yaml
13-
1414
import click
1515
import jmespath
1616
from britive.britive import Britive
@@ -28,9 +28,10 @@
2828

2929

3030
class BritiveCli:
31-
def __init__(self, tenant_name: str = None, token: str = None, silent: bool = False,
32-
passphrase: str = None, federation_provider: str = None):
31+
def __init__(self, tenant_name: str = None, token: str = None, silent: bool = False,passphrase: str = None,
32+
federation_provider: str = None, from_helper_console_script: bool = False):
3333
self.silent = silent
34+
self.from_helper_console_script = from_helper_console_script
3435
self.output_format = None
3536
self.tenant_name = None
3637
self.tenant_alias = None
@@ -44,6 +45,16 @@ def __init__(self, tenant_name: str = None, token: str = None, silent: bool = Fa
4445
self.credential_manager = None
4546
self.verbose_checkout = False
4647
self.checkout_progress_previous_message = None
48+
self.cachable_modes = {
49+
'awscredentialprocess': {
50+
'app_type': 'AWS',
51+
'expiration_jmespath': 'expirationTime'
52+
},
53+
'kube-exec': {
54+
'app_type': 'Kubernetes',
55+
'expiration_jmespath': 'expirationTime'
56+
}
57+
}
4758
self.browser = None
4859

4960
def set_output_format(self, output_format: str):
@@ -102,10 +113,25 @@ def login(self, explicit: bool = False, browser: str = None):
102113
except exceptions.UnauthorizedRequest:
103114
self._cleanup_credentials()
104115

105-
# if user called `pybritive login` and we should refresh the profile cache...do so
106-
if explicit and self.config.auto_refresh_profile_cache():
107-
self._set_available_profiles()
108-
self.cache_profiles()
116+
self._update_sdk_user_agent()
117+
118+
# if user called `pybritive login` and we should get profiles...do so
119+
should_get_profiles = any([self.config.auto_refresh_profile_cache(), self.config.auto_refresh_kube_config()])
120+
if explicit and should_get_profiles:
121+
self._set_available_profiles() # will handle calling cache_profiles() and construct_kube_config()
122+
123+
def _update_sdk_user_agent(self):
124+
# update the user agent to include the pybritive cli version
125+
user_agent = self.b.session.headers.get('User-Agent')
126+
127+
try:
128+
version = pkg_resources.get_distribution('pybritive').version
129+
except Exception:
130+
version = 'unknown'
131+
132+
self.b.session.headers.update({
133+
'User-Agent': f'pybritive/{version} {user_agent}'
134+
})
109135

110136
def _cleanup_credentials(self):
111137
self.set_credential_manager()
@@ -307,7 +333,7 @@ def list_environments(self):
307333
data.append(row)
308334
self.print(data, ignore_silent=True)
309335

310-
def _set_available_profiles(self):
336+
def _set_available_profiles(self, from_cache_command=False):
311337
if not self.available_profiles:
312338
data = []
313339
for app in self.b.my_access.list_profiles():
@@ -327,12 +353,46 @@ def _set_available_profiles(self):
327353
'profile_allows_console': profile['consoleAccess'],
328354
'profile_allows_programmatic': profile['programmaticAccess'],
329355
'profile_description': profile['profileDescription'],
330-
'2_part_profile_format_allowed': app['requiresHierarchicalModel']
356+
'2_part_profile_format_allowed': app['requiresHierarchicalModel'],
357+
'env_properties': env.get('profileEnvironmentProperties', {})
331358
}
332359
data.append(row)
333360
self.available_profiles = data
334-
if self.config.auto_refresh_profile_cache():
335-
self.cache_profiles(load=False)
361+
362+
if not from_cache_command and self.config.auto_refresh_profile_cache():
363+
self.cache_profiles()
364+
if not from_cache_command and self.config.auto_refresh_kube_config():
365+
self.construct_kube_config()
366+
367+
def construct_kube_config(self, from_cache_command=False):
368+
if self.from_helper_console_script:
369+
return
370+
371+
if from_cache_command:
372+
self.login()
373+
self._set_available_profiles(from_cache_command=from_cache_command)
374+
375+
profiles = []
376+
for p in self.available_profiles:
377+
if p['app_type'].lower() == 'kubernetes':
378+
props = p['env_properties']
379+
url = props.get('apiServerUrl')
380+
cert = props.get('certificateAuthorityData')
381+
if props and all([url, cert]):
382+
profiles.append({
383+
'app': p['app_name'],
384+
'env': p['env_name'],
385+
'profile': p['profile_name'],
386+
'url': url,
387+
'cert': cert,
388+
})
389+
from .helpers.kube_config_builder import build_kube_config # lazy import as not everyone will want this
390+
build_kube_config(
391+
profiles=profiles,
392+
config=self.config,
393+
username=self.b.my_access.whoami()['username'],
394+
cli=self
395+
)
336396

337397
def _get_app_type(self, application_id):
338398
self._set_available_profiles()
@@ -342,7 +402,7 @@ def _get_app_type(self, application_id):
342402
raise click.ClickException(f'Application {application_id} not found')
343403

344404
def __get_cloud_credential_printer(self, app_type, console, mode, profile, silent, credentials,
345-
aws_credentials_file, gcloud_key_file):
405+
aws_credentials_file, gcloud_key_file, k8s_processor):
346406
if app_type in ['AWS', 'AWS Standalone']:
347407
return printer.AwsCloudCredentialPrinter(
348408
console=console,
@@ -372,14 +432,25 @@ def __get_cloud_credential_printer(self, app_type, console, mode, profile, silen
372432
cli=self,
373433
gcloud_key_file=gcloud_key_file
374434
)
375-
return printer.GenericCloudCredentialPrinter(
376-
console=console,
377-
mode=mode,
378-
profile=profile,
379-
credentials=credentials,
380-
silent=silent,
381-
cli=self
382-
)
435+
elif app_type in ['Kubernetes']:
436+
return printer.KubernetesCredentialPrinter(
437+
console=console,
438+
mode=mode,
439+
profile=profile,
440+
credentials=credentials,
441+
silent=silent,
442+
cli=self,
443+
k8s_processor=k8s_processor
444+
)
445+
else:
446+
return printer.GenericCloudCredentialPrinter(
447+
console=console,
448+
mode=mode,
449+
profile=profile,
450+
credentials=credentials,
451+
silent=silent,
452+
cli=self
453+
)
383454

384455
def checkin(self, profile, console):
385456
self.login()
@@ -474,9 +545,17 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime,
474545
force_renew, aws_credentials_file, gcloud_key_file, verbose):
475546
credentials = None
476547
app_type = None
477-
credential_process_creds_found = False
548+
cached_credentials_found = False
549+
k8s_processor = None
478550
self.verbose_checkout = verbose
479551

552+
# handle kube-exec since the profile is actually going to be passed in via another method
553+
# and perform some basic validation so we don't waste time performing a checkout when we
554+
# will not be able to return a response back to kubectl via the exec command
555+
if mode == 'kube-exec':
556+
from .helpers.k8s_exec_credential_builder import KubernetesExecCredentialProcessor
557+
k8s_processor = KubernetesExecCredentialProcessor()
558+
480559
# these 2 modes implicitly say that console access should be checked out without having to provide
481560
# the --console flag
482561
if mode and (mode == 'console' or mode.startswith('browser')):
@@ -488,22 +567,23 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime,
488567

489568
self._validate_justification(justification)
490569

491-
if mode == 'awscredentialprocess':
492-
self.silent = True # the aws credential process CANNOT output anything other than the expected JSON
493-
# we need to check the credential process cache for the credentials first
494-
# then check to see if they are expired
495-
# if not simply return those credentials
496-
# if they are expired
497-
app_type = 'AWS' # just hardcode as we know for sure this is for AWS
498-
credentials = Cache(passphrase=passphrase).get_awscredentialprocess(profile_name=alias or profile)
570+
if mode in self.cachable_modes:
571+
self.silent = True # CANNOT output anything other than the expected JSON
572+
# we need to check the cache for the credentials first and then check to see if they are expired
573+
# if not simply return those credentials, if they are expired, continue to do an actual checkout
574+
app_type = self.cachable_modes[mode]['app_type']
575+
credentials = Cache(passphrase=passphrase).get_credentials(profile_name=alias or profile, mode=mode)
499576
if credentials:
500-
expiration_timestamp_str = credentials['expirationTime'].replace('Z', '')
577+
expiration_timestamp_str = jmespath.search(
578+
expression=self.cachable_modes[mode]['expiration_jmespath'],
579+
data=credentials
580+
).replace('Z', '')
501581
expires = datetime.fromisoformat(expiration_timestamp_str)
502582
now = datetime.utcnow()
503583
if now >= expires: # check to ensure the credentials are still valid, if not, set to None and get new
504584
credentials = None
505585
else:
506-
credential_process_creds_found = True
586+
cached_credentials_found = True
507587

508588
parts = self._split_profile_into_parts(profile)
509589

@@ -518,7 +598,7 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime,
518598
'justification': justification
519599
}
520600

521-
if not credential_process_creds_found: # nothing found via aws cred process or not aws cred process mode
601+
if not cached_credentials_found: # nothing found in cache, cache is expired, or not a cachable mode
522602
response = self._checkout(**params)
523603
app_type = self._get_app_type(response['appContainerId'])
524604
credentials = response['credentials']
@@ -533,16 +613,17 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime,
533613
self.print('checking in the profile to get renewed credentials....standby')
534614
self.checkin(profile=profile)
535615
response = self._checkout(**params)
536-
credential_process_creds_found = False # need to write new creds to cache
616+
cached_credentials_found = False # need to write new creds to cache
537617
credentials = response['credentials']
538618

539619
if alias: # do this down here, so we know that the profile is valid and a checkout was successful
540620
self.config.save_profile_alias(alias=alias, profile=profile)
541621

542-
if mode == 'awscredentialprocess' and not credential_process_creds_found:
543-
Cache(passphrase=passphrase).save_awscredentialprocess(
622+
if mode in self.cachable_modes and not cached_credentials_found:
623+
Cache(passphrase=passphrase).save_credentials(
544624
profile_name=alias or profile,
545-
credentials=credentials
625+
credentials=credentials,
626+
mode=mode
546627
)
547628

548629
self.__get_cloud_credential_printer(
@@ -553,7 +634,8 @@ def checkout(self, alias, blocktime, console, justification, mode, maxpolltime,
553634
self.silent,
554635
credentials,
555636
aws_credentials_file,
556-
gcloud_key_file
637+
gcloud_key_file,
638+
k8s_processor
557639
).print()
558640

559641
def import_existing_npm_config(self):
@@ -659,11 +741,15 @@ def downloadsecret(self, path, blocktime, justification, maxpolltime, file):
659741
f.write(content)
660742
self.print(f'wrote contents of secret file to {path}')
661743

662-
def cache_profiles(self, load=True):
663-
if load:
664-
self.login()
665-
self._set_available_profiles()
744+
def cache_profiles(self, from_cache_command=False):
745+
if self.from_helper_console_script:
746+
return
666747
profiles = []
748+
749+
if from_cache_command:
750+
self.login()
751+
self._set_available_profiles(from_cache_command=from_cache_command)
752+
667753
for p in self.available_profiles:
668754
profile = self.escape_profile_element(p['app_name'])
669755
profile += '/'

src/pybritive/choices/mode.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
'browser-macosx',
2424
'browser-safari',
2525
'browser-chrome',
26-
'browser-chromium'
26+
'browser-chromium',
27+
'kube-exec', # bake into kubeconfig with oidc exec output and additional caching to make kubectl more performant
2728
],
2829
case_sensitive=False
2930
)

src/pybritive/commands/cache.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@ def cache():
1414
@britive_options(names='tenant,token,silent,passphrase,federation_provider')
1515
def profiles(ctx, tenant, token, silent, passphrase, federation_provider):
1616
"""Cache profiles locally to facilitate auto-completion of profile names on checkin/checkout."""
17-
ctx.obj.britive.cache_profiles()
17+
ctx.obj.britive.cache_profiles(from_cache_command=True)
18+
19+
20+
@cache.command()
21+
@build_britive
22+
@britive_options(names='tenant,token,silent,passphrase,federation_provider')
23+
def kubeconfig(ctx, tenant, token, silent, passphrase, federation_provider):
24+
"""Cache a Britive managed kube config file based on the profiles to which the caller has access."""
25+
ctx.obj.britive.construct_kube_config(from_cache_command=True)
1826

1927

2028
@cache.command()

src/pybritive/helpers/aws_credential_process.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,10 @@ def main():
6666
creds = None
6767
if not args['force_renew']: # if force renew let's defer to that the full package vs. this helper
6868
from .cache import Cache # lazy load
69-
creds = Cache(passphrase=args['passphrase']).get_awscredentialprocess(profile_name=args['profile'])
69+
creds = Cache(passphrase=args['passphrase']).get_credentials(
70+
profile_name=args['profile'],
71+
mode='awscredentialprocess'
72+
)
7073
if creds:
7174
from datetime import datetime # lazy load
7275
expiration = datetime.fromisoformat(creds['expirationTime'].replace('Z', ''))
@@ -90,7 +93,8 @@ def main():
9093
token=args['token'],
9194
passphrase=args['passphrase'],
9295
federation_provider=args['federation_provider'],
93-
silent=True
96+
silent=True,
97+
from_helper_console_script=True
9498
)
9599
b.config.get_tenant() # have to load the config here as that work is generally done
96100
b.checkout(

0 commit comments

Comments
 (0)