Skip to content
This repository was archived by the owner on Mar 13, 2022. It is now read-only.

Add proper GCP config loader and refresher #22

Merged
merged 1 commit into from
Jul 25, 2017
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Add proper GCP config loader and refresher
mbohlool committed Jul 25, 2017

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
commit 824c03c7eee71dd5ac52fada8d7d36aecf81a781
80 changes: 80 additions & 0 deletions config/dateutil.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Copyright 2017 The Kubernetes Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import datetime
import math
import re


class TimezoneInfo(datetime.tzinfo):
def __init__(self, h, m):
self._name = "UTC"
if h != 0 and m != 0:
self._name += "%+03d:%2d" % (h, m)
self._delta = datetime.timedelta(hours=h, minutes=math.copysign(m, h))

def utcoffset(self, dt):
return self._delta

def tzname(self, dt):
return self._name

def dst(self, dt):
return datetime.timedelta(0)


UTC = TimezoneInfo(0, 0)

# ref https://www.ietf.org/rfc/rfc3339.txt
_re_rfc3339 = re.compile(r"(\d\d\d\d)-(\d\d)-(\d\d)" # full-date
r"[ Tt]" # Separator
r"(\d\d):(\d\d):(\d\d)([.,]\d+)?" # partial-time
r"([zZ ]|[-+]\d\d?:\d\d)?", # time-offset
re.VERBOSE + re.IGNORECASE)
_re_timezone = re.compile(r"([-+])(\d\d?):?(\d\d)?")


def parse_rfc3339(s):
if isinstance(s, datetime.datetime):
# no need to parse it, just make sure it has a timezone.
if not s.tzinfo:
return s.replace(tzinfo=UTC)
return s
groups = _re_rfc3339.search(s).groups()
dt = [0] * 7
for x in range(6):
dt[x] = int(groups[x])
if groups[6] is not None:
dt[6] = int(groups[6])
tz = UTC
if groups[7] is not None and groups[7] != 'Z' and groups[7] != 'z':
tz_groups = _re_timezone.search(groups[7]).groups()
hour = int(tz_groups[1])
minute = 0
if tz_groups[0] == "-":
hour *= -1
if tz_groups[2]:
minute = int(tz_groups[2])
tz = TimezoneInfo(hour, minute)
return datetime.datetime(
year=dt[0], month=dt[1], day=dt[2],
hour=dt[3], minute=dt[4], second=dt[5],
microsecond=dt[6], tzinfo=tz)


def format_rfc3339(date_time):
if date_time.tzinfo is None:
date_time = date_time.replace(tzinfo=UTC)
date_time = date_time.astimezone(UTC)
return date_time.strftime('%Y-%m-%dT%H:%M:%SZ')
53 changes: 53 additions & 0 deletions config/dateutil_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Copyright 2016 The Kubernetes Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import unittest
from datetime import datetime

from .dateutil import UTC, TimezoneInfo, format_rfc3339, parse_rfc3339


class DateUtilTest(unittest.TestCase):

def _parse_rfc3339_test(self, st, y, m, d, h, mn, s):
actual = parse_rfc3339(st)
expected = datetime(y, m, d, h, mn, s, 0, UTC)
self.assertEqual(expected, actual)

def test_parse_rfc3339(self):
self._parse_rfc3339_test("2017-07-25T04:44:21Z",
2017, 7, 25, 4, 44, 21)
self._parse_rfc3339_test("2017-07-25 04:44:21Z",
2017, 7, 25, 4, 44, 21)
self._parse_rfc3339_test("2017-07-25T04:44:21",
2017, 7, 25, 4, 44, 21)
self._parse_rfc3339_test("2017-07-25T04:44:21z",
2017, 7, 25, 4, 44, 21)
self._parse_rfc3339_test("2017-07-25T04:44:21+03:00",
2017, 7, 25, 1, 44, 21)
self._parse_rfc3339_test("2017-07-25T04:44:21-03:00",
2017, 7, 25, 7, 44, 21)

def test_format_rfc3339(self):
self.assertEqual(
format_rfc3339(datetime(2017, 7, 25, 4, 44, 21, 0, UTC)),
"2017-07-25T04:44:21Z")
self.assertEqual(
format_rfc3339(datetime(2017, 7, 25, 4, 44, 21, 0,
TimezoneInfo(2, 0))),
"2017-07-25T02:44:21Z")
self.assertEqual(
format_rfc3339(datetime(2017, 7, 25, 4, 44, 21, 0,
TimezoneInfo(-2, 30))),
"2017-07-25T07:14:21Z")
77 changes: 62 additions & 15 deletions config/kube_config.py
Original file line number Diff line number Diff line change
@@ -14,17 +14,21 @@

import atexit
import base64
import datetime
import os
import tempfile

import google.auth
import google.auth.transport.requests
import urllib3
import yaml
from google.oauth2.credentials import Credentials

from kubernetes.client import ApiClient, ConfigurationObject, configuration

from .config_exception import ConfigException
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')
_temp_files = {}

@@ -54,6 +58,11 @@ def _create_temp_file_with_content(content):
return name


def _is_expired(expiry):
return ((parse_rfc3339(expiry) + EXPIRY_SKEW_PREVENTION_DELAY) <=
datetime.datetime.utcnow().replace(tzinfo=UTC))


class FileOrData(object):
"""Utility class to read content of obj[%data_key_name] or file's
content of obj[%file_key_name] and represent it as file or data.
@@ -110,19 +119,26 @@ class KubeConfigLoader(object):
def __init__(self, config_dict, active_context=None,
get_google_credentials=None,
client_configuration=configuration,
config_base_path=""):
config_base_path="",
config_persister=None):
self._config = ConfigNode('kube-config', config_dict)
self._current_context = None
self._user = None
self._cluster = None
self.set_active_context(active_context)
self._config_base_path = config_base_path
self._config_persister = config_persister

def _refresh_credentials():
credentials, project_id = google.auth.default()
request = google.auth.transport.requests.Request()
credentials.refresh(request)
return credentials

if get_google_credentials:
self._get_google_credentials = get_google_credentials
else:
self._get_google_credentials = lambda: (
GoogleCredentials.get_application_default()
.get_access_token().access_token)
self._get_google_credentials = _refresh_credentials
self._client_configuration = client_configuration

def set_active_context(self, context_name=None):
@@ -166,16 +182,32 @@ def _load_authentication(self):
def _load_gcp_token(self):
if 'auth-provider' not in self._user:
return
if 'name' not in self._user['auth-provider']:
provider = self._user['auth-provider']
if 'name' not in provider:
return
if self._user['auth-provider']['name'] != 'gcp':
if provider['name'] != 'gcp':
return
# Ignore configs in auth-provider and rely on GoogleCredentials
# caching and refresh mechanism.
# TODO: support gcp command based token ("cmd-path" config).
self.token = "Bearer %s" % self._get_google_credentials()

if (('config' not in provider) or
('access-token' not in provider['config']) or
('expiry' in provider['config'] and
_is_expired(provider['config']['expiry']))):
# token is not available or expired, refresh it
self._refresh_gcp_token()

self.token = "Bearer %s" % provider['config']['access-token']
return self.token

def _refresh_gcp_token(self):
if 'config' not in self._user['auth-provider']:
self._user['auth-provider'].value['config'] = {}
provider = self._user['auth-provider']['config']
credentials = self._get_google_credentials()
provider.value['access-token'] = credentials.token
provider.value['expiry'] = format_rfc3339(credentials.expiry)
if self._config_persister:
self._config_persister(self._config.value)

def _load_user_token(self):
token = FileOrData(
self._user, 'tokenFile', 'token',
@@ -299,7 +331,8 @@ def list_kube_config_contexts(config_file=None):


def load_kube_config(config_file=None, context=None,
client_configuration=configuration):
client_configuration=configuration,
persist_config=True):
"""Loads authentication and cluster information from kube-config file
and stores them in kubernetes.client.configuration.
@@ -308,21 +341,35 @@ def load_kube_config(config_file=None, context=None,
from config file will be used.
:param client_configuration: The kubernetes.client.ConfigurationObject to
set configs to.
:param persist_config: If True, config file will be updated when changed
(e.g GCP token refresh).
"""

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

_get_kube_config_loader_for_yaml_file(
config_file, active_context=context,
client_configuration=client_configuration).load_and_set()
client_configuration=client_configuration,
config_persister=config_persister).load_and_set()


def new_client_from_config(config_file=None, context=None):
def new_client_from_config(
config_file=None,
context=None,
persist_config=True):
"""Loads configuration the same as load_kube_config but returns an ApiClient
to be used with any API object. This will allow the caller to concurrently
talk with multiple clusters."""
client_config = ConfigurationObject()
load_kube_config(config_file=config_file, context=context,
client_configuration=client_config)
client_configuration=client_config,
persist_config=persist_config)
return ApiClient(config=client_config)
59 changes: 52 additions & 7 deletions config/kube_config_test.py
Original file line number Diff line number Diff line change
@@ -13,6 +13,7 @@
# limitations under the License.

import base64
import datetime
import os
import shutil
import tempfile
@@ -22,6 +23,7 @@
from six import PY3

from .config_exception import ConfigException
from .dateutil import parse_rfc3339
from .kube_config import (ConfigNode, FileOrData, KubeConfigLoader,
_cleanup_temp_files, _create_temp_file_with_content,
list_kube_config_contexts, load_kube_config,
@@ -36,6 +38,10 @@ def _base64(string):
return base64.encodestring(string.encode()).decode()


def _raise_exception(st):
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not directly raising the exception?

Copy link
Contributor Author

@mbohlool mbohlool Jul 25, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you mean? I am passing this function as a function pointer to config loader and expect the function pointer (that suppose to update GCE token) never been called in the test. I was using lambda syntax to return a dummy token before, but you cannot raise exception in lambda syntax.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I didn't now that you can not raise inside lambda (always learning from codereview).

Forget about it.

raise Exception(st)


TEST_FILE_KEY = "file"
TEST_DATA_KEY = "data"
TEST_FILENAME = "test-filename"
@@ -304,6 +310,13 @@ class TestKubeConfigLoader(BaseTestCase):
"user": "gcp"
}
},
{
"name": "expired_gcp",
"context": {
"cluster": "default",
"user": "expired_gcp"
}
},
{
"name": "user_pass",
"context": {
@@ -397,7 +410,24 @@ class TestKubeConfigLoader(BaseTestCase):
"user": {
"auth-provider": {
"name": "gcp",
"access_token": "not_used",
"config": {
"access-token": TEST_DATA_BASE64,
}
},
"token": TEST_DATA_BASE64, # should be ignored
"username": TEST_USERNAME, # should be ignored
"password": TEST_PASSWORD, # should be ignored
}
},
{
"name": "expired_gcp",
"user": {
"auth-provider": {
"name": "gcp",
"config": {
"access-token": TEST_DATA_BASE64,
"expiry": "2000-01-01T12:00:00Z", # always in past
}
},
"token": TEST_DATA_BASE64, # should be ignored
"username": TEST_USERNAME, # should be ignored
@@ -464,24 +494,39 @@ def test_load_user_token(self):
self.assertTrue(loader._load_user_token())
self.assertEqual(BEARER_TOKEN_FORMAT % TEST_DATA_BASE64, loader.token)

def test_gcp(self):
def test_gcp_no_refresh(self):
expected = FakeConfig(
host=TEST_HOST,
token=BEARER_TOKEN_FORMAT % TEST_ANOTHER_DATA_BASE64)
token=BEARER_TOKEN_FORMAT % TEST_DATA_BASE64)
actual = FakeConfig()
KubeConfigLoader(
config_dict=self.TEST_KUBE_CONFIG,
active_context="gcp",
client_configuration=actual,
get_google_credentials=lambda: TEST_ANOTHER_DATA_BASE64) \
.load_and_set()
get_google_credentials=lambda: _raise_exception(
"SHOULD NOT BE CALLED")).load_and_set()
self.assertEqual(expected, actual)

def test_load_gcp_token(self):
def test_load_gcp_token_no_refresh(self):
loader = KubeConfigLoader(
config_dict=self.TEST_KUBE_CONFIG,
active_context="gcp",
get_google_credentials=lambda: TEST_ANOTHER_DATA_BASE64)
get_google_credentials=lambda: _raise_exception(
"SHOULD NOT BE CALLED"))
self.assertTrue(loader._load_gcp_token())
self.assertEqual(BEARER_TOKEN_FORMAT % TEST_DATA_BASE64,
loader.token)

def test_load_gcp_token_with_refresh(self):

def cred(): return None
cred.token = TEST_ANOTHER_DATA_BASE64
cred.expiry = datetime.datetime.now()

loader = KubeConfigLoader(
config_dict=self.TEST_KUBE_CONFIG,
active_context="expired_gcp",
get_google_credentials=lambda: cred)
self.assertTrue(loader._load_gcp_token())
self.assertEqual(BEARER_TOKEN_FORMAT % TEST_ANOTHER_DATA_BASE64,
loader.token)