Skip to content

Commit

Permalink
Fall back to rudimentary login handlers if the clients are absent
Browse files Browse the repository at this point in the history
This will make Kopf runnable in the majority of cases even with no `pykube-ng` and `kubernetes` client libraries installed. This was not a problem till recently, but then `pykube-ng` was removed from the implicit dependencies, so users get frustrated by "unauthenticated" or "ran out of credentials" errors.

To avoid running too many login handlers at startup, the rudimentary login handlers are added strictly when the client libraries are absent, not always. This can be overridden with custom login handlers calling these functions (as documented).

Signed-off-by: Sergey Vasilyev <nolar@nolar.info>
  • Loading branch information
nolar committed May 14, 2021
1 parent 2e963ac commit dcf351c
Show file tree
Hide file tree
Showing 7 changed files with 570 additions and 4 deletions.
23 changes: 21 additions & 2 deletions docs/authentication.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,18 @@ To access a Kubernetes cluster, an endpoint and some credentials are needed.
They are usually taken either from the environment (environment variables),
or from the ``~/.kube/config`` file, or from external authentication services.

Kopf provides rudimentary authentication out of the box: it can authenticate
with the Kubernetes API either via the service account or raw kubeconfig data
(with no additional interpretation or parsing of those).

But this can be not enough in some setups and environments.
Kopf does not try to maintain all the authentication methods possible.
Instead, it allows the operator developers to implement their custom
authentication methods and piggybacks the existing Kubernetes clients.
authentication methods and "piggybacks" the existing Kubernetes clients.

The latter ones can implement some advanced authentication techniques,
such as the temporary token retrieval via the authentication services,
token rotation, etc.


Custom authentication
Expand Down Expand Up @@ -129,8 +138,18 @@ until succeeded, returned nothing, or explicitly failed)::
def login_fn(**kwargs):
return kopf.login_via_pykube(**kwargs)

Similarly, if the libraries are installed and needed, but their credentials
are not desired, the rudimentary login functions can be used directly::

import kopf

@kopf.on.login()
def login_fn(**kwargs):
return kopf.login_with_service_account(**kwargs) or kopf.login_with_kubeconfig(**kwargs)

.. seealso::
`kopf.login_via_pykube`, `kopf.login_via_client`.
`kopf.login_via_pykube`, `kopf.login_via_client`,
`kopf.login_with_kubeconfig`, `kopf.login_with_service_account`.


Credentials lifecycle
Expand Down
9 changes: 8 additions & 1 deletion kopf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@
from kopf._core.intents.piggybacking import (
login_via_pykube,
login_via_client,
login_with_kubeconfig,
login_with_service_account,
)
from kopf._core.reactor.running import (
spawn_tasks,
Expand Down Expand Up @@ -175,7 +177,12 @@
__all__ = [
'on', 'lifecycles', 'register', 'execute', 'daemon', 'timer', 'index',
'configure', 'LogFormat',
'login_via_pykube', 'login_via_client', 'LoginError', 'ConnectionInfo',
'login_via_pykube',
'login_via_client',
'login_with_kubeconfig',
'login_with_service_account',
'LoginError',
'ConnectionInfo',
'event', 'info', 'warn', 'exception',
'spawn_tasks', 'run_tasks', 'operator', 'run',
'adopt', 'label',
Expand Down
126 changes: 125 additions & 1 deletion kopf/_core/intents/piggybacking.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
.. seealso::
:mod:`credentials` and :func:`authentication`.
"""
from typing import Any, Optional, Sequence
import os
from typing import Any, Dict, Optional, Sequence

import yaml

from kopf._cogs.structs import credentials
from kopf._core.actions import execution
Expand All @@ -19,6 +22,10 @@
PRIORITY_OF_CLIENT: int = 10
PRIORITY_OF_PYKUBE: int = 20

# Rudimentary logins are added only if the clients are absent, so the priorities can overlap.
PRIORITY_OF_KUBECONFIG: int = 10
PRIORITY_OF_SERVICE_ACCOUNT: int = 20


def has_client() -> bool:
try:
Expand Down Expand Up @@ -143,3 +150,120 @@ def login_via_pykube(
default_namespace=config.namespace,
priority=PRIORITY_OF_PYKUBE,
)


def has_service_account() -> bool:
return os.path.exists('/var/run/secrets/kubernetes.io/serviceaccount/token')


def login_with_service_account(**_: Any) -> Optional[credentials.ConnectionInfo]:
"""
A minimalistic login handler that can get raw data from a service account.
Authentication capabilities can be limited to keep the code short & simple.
No parsing or sophisticated multi-step token retrieval is performed.
This login function is intended to make Kopf runnable in trivial cases
when neither pykube-ng nor the official client library are installed.
"""

# As per https://kubernetes.io/docs/tasks/run-application/access-api-from-pod/
token_path = '/var/run/secrets/kubernetes.io/serviceaccount/token'
ns_path = '/var/run/secrets/kubernetes.io/serviceaccount/namespace'
ca_path = '/var/run/secrets/kubernetes.io/serviceaccount/ca.crt'

if os.path.exists(token_path):
with open(token_path, 'rt', encoding='utf-8') as f:
token = f.read().strip()

namespace: Optional[str] = None
if os.path.exists(ns_path):
with open(ns_path, 'rt', encoding='utf-8') as f:
namespace = f.read().strip()

return credentials.ConnectionInfo(
server='https://kubernetes.default.svc',
ca_path=ca_path if os.path.exists(ca_path) else None,
token=token or None,
default_namespace=namespace or None,
priority=PRIORITY_OF_SERVICE_ACCOUNT,
)
else:
return None


def has_kubeconfig() -> bool:
env_var_set = bool(os.environ.get('KUBECONFIG'))
file_exists = os.path.exists(os.path.expanduser('~/.kube/config'))
return env_var_set or file_exists


def login_with_kubeconfig(**_: Any) -> Optional[credentials.ConnectionInfo]:
"""
A minimalistic login handler that can get raw data from a kubeconfig file.
Authentication capabilities can be limited to keep the code short & simple.
No parsing or sophisticated multi-step token retrieval is performed.
This login function is intended to make Kopf runnable in trivial cases
when neither pykube-ng nor the official client library are installed.
"""

# As per https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/
kubeconfig = os.environ.get('KUBECONFIG')
if not kubeconfig and os.path.exists(os.path.expanduser('~/.kube/config')):
kubeconfig = '~/.kube/config'
if not kubeconfig:
return None

paths = [path.strip() for path in kubeconfig.split(os.pathsep)]
paths = [os.path.expanduser(path) for path in paths if path]

# As prescribed: if the file is absent or non-deserialisable, then fail. The first value wins.
current_context: Optional[str] = None
contexts: Dict[Any, Any] = {}
clusters: Dict[Any, Any] = {}
users: Dict[Any, Any] = {}
for path in paths:

with open(path, 'rt', encoding='utf-8') as f:
config = yaml.safe_load(f.read()) or {}

if current_context is None:
current_context = config.get('current-context')
for item in config.get('contexts', []):
if item['name'] not in contexts:
contexts[item['name']] = item.get('context') or {}
for item in config.get('clusters', []):
if item['name'] not in clusters:
clusters[item['name']] = item.get('cluster') or {}
for item in config.get('users', []):
if item['name'] not in users:
users[item['name']] = item.get('user') or {}

# Once fully parsed, use the current context only.
if current_context is None:
raise credentials.LoginError('Current context is not set in kubeconfigs.')
context = contexts[current_context]
cluster = clusters[context['cluster']]
user = users[context['user']]

# Unlike pykube's login, we do not make a fake API request to refresh the token.
provider_token = user.get('auth-provider', {}).get('config', {}).get('access-token')

# Map the retrieved fields into the credentials object.
return credentials.ConnectionInfo(
server=cluster.get('server'),
ca_path=cluster.get('certificate-authority'),
ca_data=cluster.get('certificate-authority-data'),
insecure=cluster.get('insecure-skip-tls-verify'),
certificate_path=user.get('client-certificate'),
certificate_data=user.get('client-certificate-data'),
private_key_path=user.get('client-key'),
private_key_data=user.get('client-key-data'),
username=user.get('username'),
password=user.get('password'),
token=user.get('token') or provider_token,
default_namespace=context.get('namespace'),
priority=PRIORITY_OF_KUBECONFIG,
)
22 changes: 22 additions & 0 deletions kopf/_core/intents/registries.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ def __init__(self) -> None:
class SmartOperatorRegistry(OperatorRegistry):
def __init__(self) -> None:
super().__init__()

if piggybacking.has_pykube():
self._activities.append(handlers.ActivityHandler(
id=ids.HandlerId('login_via_pykube'),
Expand All @@ -280,6 +281,27 @@ def __init__(self) -> None:
_fallback=True,
))

# As a last resort, fall back to rudimentary logins if no advanced ones are available.
thirdparties_present = piggybacking.has_pykube() or piggybacking.has_client()
if not thirdparties_present and piggybacking.has_kubeconfig():
self._activities.append(handlers.ActivityHandler(
id=ids.HandlerId('login_with_kubeconfig'),
fn=piggybacking.login_with_kubeconfig,
activity=causes.Activity.AUTHENTICATION,
errors=execution.ErrorsMode.IGNORED,
param=None, timeout=None, retries=None, backoff=None,
_fallback=True,
))
if not thirdparties_present and piggybacking.has_service_account():
self._activities.append(handlers.ActivityHandler(
id=ids.HandlerId('login_with_service_account'),
fn=piggybacking.login_with_service_account,
activity=causes.Activity.AUTHENTICATION,
errors=execution.ErrorsMode.IGNORED,
param=None, timeout=None, retries=None, backoff=None,
_fallback=True,
))


def generate_id(
fn: Callable[..., Any],
Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
'click', # 0.60 MB
'aiojobs', # 0.07 MB
'aiohttp<4.0.0', # 7.80 MB
'pyyaml', # 0.90 MB
],
extras_require={
'full-auth': [
Expand Down
Loading

0 comments on commit dcf351c

Please sign in to comment.