def login_via_client( *args: Any, logger: Union[logging.Logger, logging.LoggerAdapter], **kwargs: Any, ) -> Optional[credentials.ConnectionInfo]: try: import kubernetes.config except ImportError: return None try: kubernetes.config.load_incluster_config() # cluster env vars logger.debug("Client is configured in cluster with service account.") except kubernetes.config.ConfigException as e1: try: kubernetes.config.load_kube_config() # developer's config files logger.debug("Client is configured via kubeconfig file.") except kubernetes.config.ConfigException as e2: raise credentials.LoginError( f"Cannot authenticate client neither in-cluster, nor via kubeconfig." ) # We do not even try to understand how it works and why. Just load it, and extract the results. # For kubernetes client >= 12.0.0 use the new 'get_default_copy' method if callable( getattr(kubernetes.client.Configuration, 'get_default_copy', None)): config = kubernetes.client.Configuration.get_default_copy() else: config = kubernetes.client.Configuration() # For auth-providers, this method is monkey-patched with the auth-provider's one. # We need the actual auth-provider's token, so we call it instead of accessing api_key. # Other keys (token, tokenFile) also end up being retrieved via this method. header: Optional[str] = config.get_api_key_with_prefix('authorization') parts: Sequence[str] = header.split(' ', 1) if header else [] scheme, token = ((None, None) if len(parts) == 0 else (None, parts[0]) if len(parts) == 1 else (parts[0], parts[1])) # RFC-7235, Appendix C. # Interpret the config object for our own minimalistic credentials. # Note: kubernetes client has no concept of a "current" context's namespace. return credentials.ConnectionInfo( server=config.host, ca_path=config.ssl_ca_cert, # can be a temporary file insecure=not config.verify_ssl, username=config.username or None, # an empty string when not defined password=config.password or None, # an empty string when not defined scheme=scheme, token=token, certificate_path=config.cert_file, # can be a temporary file private_key_path=config.key_file, # can be a temporary file priority=PRIORITY_OF_CLIENT, )
def login_via_pykube( *args: Any, logger: Union[logging.Logger, logging.LoggerAdapter], **kwargs: Any, ) -> Optional[credentials.ConnectionInfo]: try: import pykube except ImportError: return None # Read the pykube config either way for later interpretation. config: pykube.KubeConfig try: config = pykube.KubeConfig.from_service_account() logger.debug("Pykube is configured in cluster with service account.") except FileNotFoundError: try: config = pykube.KubeConfig.from_file() logger.debug("Pykube is configured via kubeconfig file.") except (pykube.PyKubeError, FileNotFoundError): raise credentials.LoginError( f"Cannot authenticate pykube " f"neither in-cluster, nor via kubeconfig.") # We don't know how this token will be retrieved, we just get it afterwards. provider_token = None if config.user.get('auth-provider'): api = pykube.HTTPClient(config) api.get(version='', base='/') # ignore the response status provider_token = config.user.get('auth-provider', {}).get('config', {}).get('access-token') # Interpret the config object for our own minimalistic credentials. ca: Optional[pykube.config.BytesOrFile] = config.cluster.get( 'certificate-authority') cert: Optional[pykube.config.BytesOrFile] = config.user.get( 'client-certificate') pkey: Optional[pykube.config.BytesOrFile] = config.user.get('client-key') return credentials.ConnectionInfo( server=config.cluster.get('server'), ca_path=ca.filename() if ca else None, # can be a temporary file insecure=config.cluster.get('insecure-skip-tls-verify'), username=config.user.get('username'), password=config.user.get('password'), token=config.user.get('token') or provider_token, certificate_path=cert.filename() if cert else None, # can be a temporary file private_key_path=pkey.filename() if pkey else None, # can be a temporary file default_namespace=config.namespace, priority=PRIORITY_OF_PYKUBE, )
async def wrapper(*args: Any, **kwargs: Any) -> Any: # If a context is explicitly passed, make it a simple call without re-auth. # Exceptions are escalated to a caller, which is probably wrapped itself. if 'context' in kwargs: return await fn(*args, **kwargs) # Otherwise, attempt the execution with the vault credentials and re-authenticate on 401s. vault: credentials.Vault = vault_var.get() async for key, info, context in vault.extended(APIContext, 'contexts'): try: return await fn(*args, **kwargs, context=context) except errors.APIUnauthorizedError as e: await vault.invalidate(key, exc=e) raise credentials.LoginError("Ran out of connection credentials.")
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, )
def __init__( self, info: credentials.ConnectionInfo, ) -> None: super().__init__() # Some SSL data are not accepted directly, so we have to use temp files. tempfiles = _TempFiles() ca_path: Optional[str] certificate_path: Optional[str] private_key_path: Optional[str] if info.ca_path and info.ca_data: raise credentials.LoginError("Both CA path & data are set. Need only one.") elif info.ca_path: ca_path = info.ca_path elif info.ca_data: ca_path = tempfiles[base64.b64decode(info.ca_data)] else: ca_path = None if info.certificate_path and info.certificate_data: raise credentials.LoginError("Both certificate path & data are set. Need only one.") elif info.certificate_path: certificate_path = info.certificate_path elif info.certificate_data: certificate_path = tempfiles[base64.b64decode(info.certificate_data)] else: certificate_path = None if info.private_key_path and info.private_key_data: raise credentials.LoginError("Both private key path & data are set. Need only one.") elif info.private_key_path: private_key_path = info.private_key_path elif info.private_key_data: private_key_path = tempfiles[base64.b64decode(info.private_key_data)] else: private_key_path = None # The SSL part (both client certificate auth and CA verification). context: ssl.SSLContext if certificate_path and private_key_path: context = ssl.create_default_context( purpose=ssl.Purpose.CLIENT_AUTH, cafile=ca_path) context.load_cert_chain( certfile=certificate_path, keyfile=private_key_path) else: context = ssl.create_default_context( cafile=ca_path) if info.insecure: context.check_hostname = False context.verify_mode = ssl.CERT_NONE # The token auth part. headers: Dict[str, str] = {} if info.scheme and info.token: headers['Authorization'] = f'{info.scheme} {info.token}' elif info.scheme: headers['Authorization'] = f'{info.scheme}' elif info.token: headers['Authorization'] = f'Bearer {info.token}' # The basic auth part. auth: Optional[aiohttp.BasicAuth] if info.username and info.password: auth = aiohttp.BasicAuth(info.username, info.password) else: auth = None # It is a good practice to self-identify a bit. headers['User-Agent'] = f'kopf/unknown' # TODO: add version someday # Generic aiohttp session based on the constructed credentials. self.session = aiohttp.ClientSession( connector=aiohttp.TCPConnector( limit=0, ssl=context, ), headers=headers, auth=auth, ) # Add the extra payload information. We avoid overriding the constructor. self.server = info.server self.default_namespace = info.default_namespace # For purging on garbage collection. self._tempfiles = tempfiles