Repository: nolar/kopf

Issue:
Authentication during startup
## Problem
I'm trying to create/update CRDs using the `@kopf.on.startup()` event. However, it seems to me that the authentication even has not yet fired, which means that there is no valid connection to the Kubernetes api.

I can get around this by running `config.load_kube_config()` and/or `config.load_incluster_config()` but it seems a bit dirty (this is what the kopf authentication stuff is mode for, after all).


## Proposal
Add a new event, `@kopf.on.post_auth_startup()` that executes after the authentication stuff is initialized.

## Checklist

- [x] Many users can benefit from this feature, it is not a one-time case
- [x] The proposal is related to the K8s operator framework, not to the K8s client libraries


Comments:

Patch:
diff --git a/docs/authentication.rst b/docs/authentication.rst
index d7ae95a..e17889d 100644
--- a/docs/authentication.rst
+++ b/docs/authentication.rst
@@ -14,6 +14,12 @@ authentication methods and piggybacks the existing Kubernetes clients.
 Custom authentication
 =====================
 
+In most setups, the normal authentication from one of the API client libraries
+is enough --- it works out of the box if those clients are installed
+(see :ref:`auth-piggybacking` below). Custom authentication is only needed
+if the normal authentication methods do not work for some reason, such as if
+you have a specific and unusual cluster setup (e.g. your own auth tokens).
+
 To implement a custom authentication method, one or a few login-handlers
 can be added. The login handlers should either return nothing (``None``)
 or an instance of `kopf.ConnectionInfo`::
diff --git a/kopf/_cogs/clients/auth.py b/kopf/_cogs/clients/auth.py
index 632984d..4b4af34 100644
--- a/kopf/_cogs/clients/auth.py
+++ b/kopf/_cogs/clients/auth.py
@@ -44,7 +44,12 @@ def reauthenticated_request(fn: _F) -> _F:
                 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.")
+
+        # Normally, either `vault.extended()` or `vault.invalidate()` raise the login errors.
+        # The for-cycle can only end if the yielded credentials are not invalidated before trying
+        # the next ones -- but this case exits by `return` or by other (non-401) errors.
+        raise RuntimeError("Reached an impossible state: the end of the authentication cycle.")
+
     return cast(_F, wrapper)
 
 
@@ -76,7 +81,12 @@ def reauthenticated_stream(fn: _F) -> _F:
                 return
             except errors.APIUnauthorizedError as e:
                 await vault.invalidate(key, exc=e)
-        raise credentials.LoginError("Ran out of connection credentials.")
+
+        # Normally, either `vault.extended()` or `vault.invalidate()` raise the login errors.
+        # The for-cycle can only end if the yielded credentials are not invalidated before trying
+        # the next ones -- but this case exits by `return` or by other (non-401) errors.
+        raise RuntimeError("Reached an impossible state: the end of the authentication cycle.")
+
     return cast(_F, wrapper)
 
 
diff --git a/kopf/_cogs/structs/credentials.py b/kopf/_cogs/structs/credentials.py
index e9bca12..3d9cae6 100644
--- a/kopf/_cogs/structs/credentials.py
+++ b/kopf/_cogs/structs/credentials.py
@@ -205,7 +205,9 @@ class Vault(AsyncIterable[Tuple[VaultKey, ConnectionInfo]]):
             it can lead to improper items returned.
         """
         if not self._current:
-            raise LoginError("No valid credentials are available.")
+            raise LoginError("Ran out of valid credentials. Consider installing "
+                             "an API client library or adding a login handler. See more: "
+                             "https://kopf.readthedocs.io/en/stable/authentication/")
         prioritised: Dict[int, List[Tuple[VaultKey, VaultItem]]]
         prioritised = collections.defaultdict(list)
         for key, item in self._current.items():
@@ -252,11 +254,14 @@ class Vault(AsyncIterable[Tuple[VaultKey, ConnectionInfo]]):
 
         # If the re-auth has failed, re-raise the original exception in the current stack.
         # If the original exception is unknown, raise normally on the next iteration's yield.
+        # The error here is optional -- for better stack traces of the original exception `exc`.
         # Keep in mind, this routine is called in parallel from many tasks for the same keys.
         async with self._lock:
             if not self._current:
                 if exc is not None:
-                    raise exc
+                    raise LoginError("Ran out of valid credentials. Consider installing "
+                                     "an API client library or adding a login handler. See more: "
+                                     "https://kopf.readthedocs.io/en/stable/authentication/") from exc
 
     async def populate(
             self,
diff --git a/kopf/_core/intents/piggybacking.py b/kopf/_core/intents/piggybacking.py
index e05714d..df57ba1 100644
--- a/kopf/_core/intents/piggybacking.py
+++ b/kopf/_core/intents/piggybacking.py
@@ -46,6 +46,7 @@ def login_via_client(
         **kwargs: Any,
 ) -> Optional[credentials.ConnectionInfo]:
 
+    # Keep imports in the function, as module imports are mocked in some tests.
     try:
         import kubernetes.config
     except ImportError:
@@ -59,7 +60,8 @@ def login_via_client(
             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.")
+            raise credentials.LoginError("Cannot authenticate the client library "
+                                         "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
@@ -100,6 +102,7 @@ def login_via_pykube(
         **kwargs: Any,
 ) -> Optional[credentials.ConnectionInfo]:
 
+    # Keep imports in the function, as module imports are mocked in some tests.
     try:
         import pykube
     except ImportError:
@@ -115,8 +118,8 @@ def login_via_pykube(
             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.")
+            raise credentials.LoginError("Cannot authenticate pykube "
+                                         "neither in-cluster, nor via kubeconfig.")
 
     # We don't know how this token will be retrieved, we just get it afterwards.
     provider_token = None


Test patch:
diff --git a/tests/authentication/test_vault.py b/tests/authentication/test_vault.py
index 50cc939..d39ef7c 100644
--- a/tests/authentication/test_vault.py
+++ b/tests/authentication/test_vault.py
@@ -55,7 +55,8 @@ async def test_invalidation_reraises_if_nothing_is_left_with_exception(mocker):
     with pytest.raises(Exception) as e:
         await vault.invalidate(key1, exc=exc)
 
-    assert e.value is exc
+    assert isinstance(e.value, LoginError)
+    assert e.value.__cause__ is exc
     assert vault._ready.wait_for.await_args_list == [((True,),)]
 
 
diff --git a/tests/k8s/test_creating.py b/tests/k8s/test_creating.py
index 7d45fd5..c2958dc 100644
--- a/tests/k8s/test_creating.py
+++ b/tests/k8s/test_creating.py
@@ -40,7 +40,8 @@ async def test_full_body_with_identifiers(
     assert data == {'x': 'y', 'metadata': {'name': 'name1', 'namespace': namespace}}
 
 
-@pytest.mark.parametrize('status', [400, 401, 403, 404, 409, 500, 666])
+# Note: 401 is wrapped into a LoginError and is tested elsewhere.
+@pytest.mark.parametrize('status', [400, 403, 404, 409, 500, 666])
 async def test_raises_api_errors(
         resp_mocker, aresponses, hostname, status, resource, namespace,
         cluster_resource, namespaced_resource):
diff --git a/tests/k8s/test_errors.py b/tests/k8s/test_errors.py
index 54465f3..411d4a0 100644
--- a/tests/k8s/test_errors.py
+++ b/tests/k8s/test_errors.py
@@ -3,7 +3,7 @@ import pytest
 
 from kopf._cogs.clients.auth import APIContext, reauthenticated_request
 from kopf._cogs.clients.errors import APIConflictError, APIError, APIForbiddenError, \
-                                      APINotFoundError, APIUnauthorizedError, check_response
+                                      APINotFoundError, check_response
 
 
 @reauthenticated_request
@@ -47,9 +47,9 @@ async def test_no_error_on_success(
     await get_it(f"http://{hostname}/")
 
 
+# Note: 401 is wrapped into a LoginError and is tested elsewhere.
 @pytest.mark.parametrize('status, exctype', [
     (400, APIError),
-    (401, APIUnauthorizedError),
     (403, APIForbiddenError),
     (404, APINotFoundError),
     (409, APIConflictError),
diff --git a/tests/k8s/test_list_objs.py b/tests/k8s/test_list_objs.py
index 57822a6..cfa9e84 100644
--- a/tests/k8s/test_list_objs.py
+++ b/tests/k8s/test_list_objs.py
@@ -3,6 +3,7 @@ import pytest
 
 from kopf._cogs.clients.errors import APIError
 from kopf._cogs.clients.fetching import list_objs_rv
+from kopf._cogs.structs.credentials import LoginError
 
 
 async def test_listing_works(
@@ -23,8 +24,9 @@ async def test_listing_works(
     assert list_mock.call_count == 1
 
 
-@pytest.mark.parametrize('status', [400, 401, 403, 500, 666])
-async def test_raises_api_error(
+# Note: 401 is wrapped into a LoginError and is tested elsewhere.
+@pytest.mark.parametrize('status', [400, 403, 500, 666])
+async def test_raises_direct_api_errors(
         resp_mocker, aresponses, hostname, status, resource, namespace,
         cluster_resource, namespaced_resource):
 
diff --git a/tests/k8s/test_patching.py b/tests/k8s/test_patching.py
index 0f758da..0702df9 100644
--- a/tests/k8s/test_patching.py
+++ b/tests/k8s/test_patching.py
@@ -177,7 +177,8 @@ async def test_ignores_absent_objects(
     assert result is None
 
 
-@pytest.mark.parametrize('status', [400, 401, 403, 500, 666])
+# Note: 401 is wrapped into a LoginError and is tested elsewhere.
+@pytest.mark.parametrize('status', [400, 403, 500, 666])
 async def test_raises_api_errors(
         resp_mocker, aresponses, hostname, status, resource, namespace,
         cluster_resource, namespaced_resource):