Repository: nolar/kopf

Issue:
Group_filter in scan_resources is always to None
## Long story short

In my operator I'm scaling down all deployments in the cluster. By doing that it's scaling down also metrics-server. If the operator resume it tries to contact the /apis/metrics.k8s.io/v1beta1 endpoint at the startup and fail because metrics-server is down. This raise an APIError and stop the process.


## Description
Even if I explicitly define in my handlers the groups and version of the resources I want to handle, the operator always tries to fetch all available resources in the cluster. 
This is because of in `kopf/structs/references.py`  Line 398-399
```python
CLUSTER_PEERINGS = Selector('clusterkopfpeerings')
NAMESPACED_PEERINGS = Selector('kopfpeerings')
```
The `group` value isn't set. 
Due to that in `/kopf/reactor/observation` the return of the test at line 103 is always `None`  
```python
group_filter = None if None in groups else {group for group in groups if group is not None}
```

This is rending metrics-server as a mandatory component to run kopf in a cluster.


## Environment

<!-- The following commands can help:
     `kopf --version` or `pip show kopf`
     `kubectl version`
     `python --version`
-->

* Kopf version:  1.30.3
* Kubernetes version: 1.18
* Python version: 3.7.9


Discussion:

Patch:
diff --git a/docs/resources.rst b/docs/resources.rst
index 283cc34..5cf94fe 100644
--- a/docs/resources.rst
+++ b/docs/resources.rst
@@ -194,3 +194,15 @@ if there are some accidental overlaps in the specifications.
     Keep the short forms only for prototyping and experimentation mode,
     and for ad-hoc operators with custom resources (not reusable and running
     in controlled clusters where no other similar resources can be defined).
+
+.. warning::
+
+    Some API groups are served by API extensions: e.g. ``metrics.k8s.io``.
+    If the extension's deployment/service/pods are down, such a group will
+    not be scannable (failing with "HTTP 503 Service Unavailable")
+    and will block scanning the whole cluster if resources are specified
+    with no group name (e.g. ``('pods')`` instead of ``('v1', 'pods')``).
+
+    To avoid scanning the whole cluster and all (even unused) API groups,
+    it is recommended to specify at least the group names for all resources,
+    especially in reusable and publicly distributed operators.
diff --git a/kopf/engines/peering.py b/kopf/engines/peering.py
index 257af61..4787c1d 100644
--- a/kopf/engines/peering.py
+++ b/kopf/engines/peering.py
@@ -265,13 +265,13 @@ def detect_own_id(*, manual: bool) -> Identity:
     return Identity(f'{user}@{host}' if manual else f'{user}@{host}/{now}/{rnd}')
 
 
-def guess_selector(settings: configuration.OperatorSettings) -> Optional[references.Selector]:
+def guess_selectors(settings: configuration.OperatorSettings) -> Iterable[references.Selector]:
     if settings.peering.standalone:
-        return None
+        return []
     elif settings.peering.clusterwide:
-        return references.CLUSTER_PEERINGS
+        return [references.CLUSTER_PEERINGS_K, references.CLUSTER_PEERINGS_Z]
     elif settings.peering.namespaced:
-        return references.NAMESPACED_PEERINGS
+        return [references.NAMESPACED_PEERINGS_K, references.NAMESPACED_PEERINGS_Z]
     else:
         raise TypeError("Unidentified peering mode (none of standalone/cluster/namespaced).")
 
@@ -289,10 +289,10 @@ async def touch_command(
         insights.ready_resources.wait(),
     })
 
-    selector = guess_selector(settings=settings)
-    resource = insights.backbone.get(selector) if selector else None
-    if resource is None:
-        raise RuntimeError(f"Cannot find the peering resource {selector}.")
+    selectors = guess_selectors(settings=settings)
+    resources = [insights.backbone[s] for s in selectors if s in insights.backbone]
+    if not resources:
+        raise RuntimeError(f"Cannot find the peering resource for {selectors}.")
 
     await aiotasks.wait({
         aiotasks.create_guarded_task(
@@ -305,4 +305,5 @@ async def touch_command(
                 lifetime=lifetime),
         )
         for namespace in insights.namespaces
+        for resource in resources
     })
diff --git a/kopf/reactor/orchestration.py b/kopf/reactor/orchestration.py
index 6b624fa..9906400 100644
--- a/kopf/reactor/orchestration.py
+++ b/kopf/reactor/orchestration.py
@@ -127,9 +127,8 @@ async def adjust_tasks(
         identity: peering.Identity,
         ensemble: Ensemble,
 ) -> None:
-    peering_selector = peering.guess_selector(settings=settings)
-    peering_resource = insights.backbone.get(peering_selector) if peering_selector else None
-    peering_resources = {peering_resource} if peering_resource is not None else set()
+    peering_selectors = peering.guess_selectors(settings=settings)
+    peering_resources = {insights.backbone[s] for s in peering_selectors if s in insights.backbone}
 
     # Pause or resume all streams if the peering CRDs are absent but required.
     # Ignore the CRD absence in auto-detection mode: pause only when (and if) the CRDs are added.
diff --git a/kopf/structs/references.py b/kopf/structs/references.py
index bbf3c74..26534e2 100644
--- a/kopf/structs/references.py
+++ b/kopf/structs/references.py
@@ -389,14 +389,16 @@ class Selector:
 # Some predefined API endpoints that we use in the framework itself (not exposed to the operators).
 # Note: the CRDs are versionless: we do not look into its ``spec`` stanza, we only watch for
 # the fact of changes, so the schema does not matter, any cluster-preferred API version would work.
-# Note: the peering resources are either zalando.org/v1 or kopf.dev/v1; both cannot co-exist because
-# they would share the names, so K8s will not let this. It is done for domain name transitioning.
+# Note: the peering resources are usually either zalando.org/v1 or kopf.dev/v1; if both co-exist,
+# then both will be served (for keepalives and pausing). It is done for domain name transitioning.
 CRDS = Selector('apiextensions.k8s.io', 'customresourcedefinitions')
 EVENTS = Selector('v1', 'events')
 EVENTS_K8S = Selector('events.k8s.io', 'events')  # only for exclusion from EVERYTHING
 NAMESPACES = Selector('v1', 'namespaces')
-CLUSTER_PEERINGS = Selector('clusterkopfpeerings')
-NAMESPACED_PEERINGS = Selector('kopfpeerings')
+CLUSTER_PEERINGS_K = Selector('kopf.dev/v1', 'clusterkopfpeerings')
+CLUSTER_PEERINGS_Z = Selector('zalando.org/v1', 'clusterkopfpeerings')
+NAMESPACED_PEERINGS_K = Selector('kopf.dev/v1', 'kopfpeerings')
+NAMESPACED_PEERINGS_Z = Selector('zalando.org/v1', 'kopfpeerings')
 MUTATING_WEBHOOK = Selector('admissionregistration.k8s.io', 'mutatingwebhookconfigurations')
 VALIDATING_WEBHOOK = Selector('admissionregistration.k8s.io', 'validatingwebhookconfigurations')
 
@@ -426,8 +428,9 @@ class Backbone(Mapping[Selector, Resource]):
         self._revised = asyncio.Condition()
         self.selectors = [
             NAMESPACES, EVENTS, CRDS,
-            CLUSTER_PEERINGS, NAMESPACED_PEERINGS,
             MUTATING_WEBHOOK, VALIDATING_WEBHOOK,
+            CLUSTER_PEERINGS_K, NAMESPACED_PEERINGS_K,
+            CLUSTER_PEERINGS_Z, NAMESPACED_PEERINGS_Z,
         ]
 
     def __len__(self) -> int:


Test patch:
diff --git a/tests/peering/test_resource_guessing.py b/tests/peering/test_resource_guessing.py
index 7f3c4e1..65212f1 100644
--- a/tests/peering/test_resource_guessing.py
+++ b/tests/peering/test_resource_guessing.py
@@ -1,20 +1,21 @@
 import pytest
 
-from kopf.engines.peering import guess_selector
-from kopf.structs.references import CLUSTER_PEERINGS, NAMESPACED_PEERINGS
+from kopf.engines.peering import guess_selectors
+from kopf.structs.references import CLUSTER_PEERINGS_K, CLUSTER_PEERINGS_Z, \
+                                    NAMESPACED_PEERINGS_K, NAMESPACED_PEERINGS_Z
 
 
-@pytest.mark.parametrize('namespaced, expected_resource', [
-    (False, CLUSTER_PEERINGS),
-    (True, NAMESPACED_PEERINGS),
+@pytest.mark.parametrize('namespaced, expected_selectors', [
+    (False, [CLUSTER_PEERINGS_K, CLUSTER_PEERINGS_Z]),
+    (True, [NAMESPACED_PEERINGS_K, NAMESPACED_PEERINGS_Z]),
 ])
 @pytest.mark.parametrize('mandatory', [False, True])
-def test_resource_when_not_standalone(settings, namespaced, mandatory, expected_resource):
+def test_resource_when_not_standalone(settings, namespaced, mandatory, expected_selectors):
     settings.peering.standalone = False
     settings.peering.namespaced = namespaced
     settings.peering.mandatory = mandatory
-    selector = guess_selector(settings=settings)
-    assert selector == expected_resource
+    selectors = guess_selectors(settings=settings)
+    assert selectors == expected_selectors
 
 
 @pytest.mark.parametrize('namespaced', [False, True])
@@ -23,5 +24,5 @@ def test_resource_when_standalone(settings, namespaced, mandatory):
     settings.peering.standalone = True
     settings.peering.namespaced = namespaced
     settings.peering.mandatory = mandatory
-    selector = guess_selector(settings=settings)
-    assert selector is None
+    selectors = guess_selectors(settings=settings)
+    assert not selectors
diff --git a/tests/references/test_backbone.py b/tests/references/test_backbone.py
index 9087c07..2f858aa 100644
--- a/tests/references/test_backbone.py
+++ b/tests/references/test_backbone.py
@@ -3,12 +3,15 @@ import asyncio
 import async_timeout
 import pytest
 
-from kopf.structs.references import CLUSTER_PEERINGS, CRDS, EVENTS, NAMESPACED_PEERINGS, \
+from kopf.structs.references import CLUSTER_PEERINGS_K, CLUSTER_PEERINGS_Z, CRDS, EVENTS, \
+                                    NAMESPACED_PEERINGS_K, NAMESPACED_PEERINGS_Z, \
                                     NAMESPACES, Backbone, Resource, Selector
 
 
 @pytest.mark.parametrize('selector', [
-    CRDS, EVENTS, NAMESPACES, CLUSTER_PEERINGS, NAMESPACED_PEERINGS,
+    CRDS, EVENTS, NAMESPACES,
+    CLUSTER_PEERINGS_K, NAMESPACED_PEERINGS_K,
+    CLUSTER_PEERINGS_Z, NAMESPACED_PEERINGS_Z,
 ])
 def test_empty_backbone(selector: Selector):
     backbone = Backbone()
@@ -24,10 +27,10 @@ def test_empty_backbone(selector: Selector):
     (CRDS, Resource('apiextensions.k8s.io', 'vX', 'customresourcedefinitions')),
     (EVENTS, Resource('', 'v1', 'events')),
     (NAMESPACES, Resource('', 'v1', 'namespaces')),
-    (CLUSTER_PEERINGS, Resource('kopf.dev', 'v1', 'clusterkopfpeerings')),
-    (NAMESPACED_PEERINGS, Resource('kopf.dev', 'v1', 'kopfpeerings')),
-    (CLUSTER_PEERINGS, Resource('zalando.org', 'v1', 'clusterkopfpeerings')),
-    (NAMESPACED_PEERINGS, Resource('zalando.org', 'v1', 'kopfpeerings')),
+    (CLUSTER_PEERINGS_K, Resource('kopf.dev', 'v1', 'clusterkopfpeerings')),
+    (NAMESPACED_PEERINGS_K, Resource('kopf.dev', 'v1', 'kopfpeerings')),
+    (CLUSTER_PEERINGS_Z, Resource('zalando.org', 'v1', 'clusterkopfpeerings')),
+    (NAMESPACED_PEERINGS_Z, Resource('zalando.org', 'v1', 'kopfpeerings')),
 ])
 async def test_refill_populates_the_resources(selector: Selector, resource: Resource):
     backbone = Backbone()