Skip to content
This repository has been 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
Show file tree
Hide file tree
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
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
Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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.

Expand All @@ -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)
Loading