This is an automated email from the ASF dual-hosted git repository.

sciabarracom pushed a commit to branch 0.9.0
in repository https://gitbox.apache.org/repos/asf/openserverless-operator.git


The following commit(s) were added to refs/heads/0.9.0 by this push:
     new 245032c  Isolate Kafka and ZooKeeper from tenant action pods on 0.9.0 
(#145)
245032c is described below

commit 245032c9ee085402580f7094ef3db5b66226b831
Author: miki3421 <[email protected]>
AuthorDate: Thu Oct 22 10:41:21 2026 +0200

    Isolate Kafka and ZooKeeper from tenant action pods on 0.9.0 (#145)
    
    * Isolate Kafka and ZooKeeper from tenant action pods on 0.9.0
    
    * Release unused Kind ingress NodePorts before integration tests
    
    ---------
    
    Co-authored-by: Codex <[email protected]>
---
 .github/workflows/check.yml                       |   9 +-
 MESSAGING_SECURITY.md                             |  94 ++++++++
 deploy/kafka/kafka-001-sts.yaml                   |   4 +-
 deploy/kafka/kafka-004-networkpolicy.yaml         |  51 ++++
 deploy/zookeeper/zookeeper-003-sts.yaml           |   2 +
 deploy/zookeeper/zookeeper-005-networkpolicy.yaml |  50 ++++
 openserverless/kafka.py                           |   7 +-
 openserverless/main.py                            |   4 +
 openserverless/messaging_security.py              |  68 ++++++
 openserverless/zookeeper.py                       |   5 +-
 tests/test_messaging_security.py                  | 278 ++++++++++++++++++++++
 11 files changed, 565 insertions(+), 7 deletions(-)

diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index 965ca64..7f1d3c0 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -69,7 +69,14 @@ jobs:
       - name: BuildAndLoad
         run: task build-and-load
       - name: AddIngress
-        run: task kind:ingress
+        run: |
+          task kind:ingress
+          # Kind ingress uses hostPort 80/443. Release its unused NodePorts so
+          # dynamic allocation cannot occupy fixed test ports such as 30984.
+          kubectl -n ingress-nginx patch service ingress-nginx-controller 
--type=strategic \
+            -p 
'{"spec":{"type":"ClusterIP","allocateLoadBalancerNodePorts":null,"ports":[{"port":80,"nodePort":null},{"port":443,"nodePort":null}]}}'
+          test -z "$(kubectl -n ingress-nginx get service 
ingress-nginx-controller -o jsonpath='{.spec.ports[*].nodePort}')"
+          kubectl get services --all-namespaces
       - name: Unit Test
         run: task utest
         continue-on-error: false
diff --git a/MESSAGING_SECURITY.md b/MESSAGING_SECURITY.md
new file mode 100644
index 0000000..73f060a
--- /dev/null
+++ b/MESSAGING_SECURITY.md
@@ -0,0 +1,94 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with this
+work for additional information regarding copyright ownership. The ASF
+licenses this file to you 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.
+-->
+
+# Kafka and ZooKeeper tenant isolation
+
+The full deployment runs untrusted action pods alongside the shared Kafka and
+ZooKeeper services. Those services are private control-plane endpoints; tenant
+actions must not connect to them, even when the action knows their DNS names or
+pod IPs.
+
+The operator installs ingress NetworkPolicies before reconciling either
+component. Kafka's broker port accepts only controller, invoker, scheduler and
+Kafka pods in the same namespace. Kafka metrics accept only the installation's
+Prometheus pods. ZooKeeper's client port accepts Kafka, invoker and ZooKeeper
+pods; quorum ports accept only ZooKeeper peers. Every allowed peer selector
+also excludes pods carrying the `user-action-pod` label, regardless of its 
value.
+The invoker controls action pod labels; action annotations cannot grant these
+network permissions.
+
+The policies are included in rendered and saved component specs and inherit the
+Whisk owner reference. Policy application failure stops workload 
reconciliation.
+A pod-template revision rolls legacy Kafka and ZooKeeper pods once, closing
+connections established before the boundary was installed. Reconciliation waits
+for the rollout. When called with a saved component spec, the explicit deletion
+helper keeps the policy until all component pods have disappeared; a deletion
+timeout retains the policy.
+
+Normal owner-backed Whisk deletion currently relies on Kubernetes owner garbage
+collection, which does not guarantee that the policy outlives the messaging 
pods.
+The ordered helper is not reached when the process-local saved spec is absent.
+This teardown race is a known, accepted limitation of this change; policy-last
+deletion is not guaranteed during Whisk uninstall. Normal operation and 
automatic
+upgrade isolation are covered by the verification below.
+
+## Deployment requirements
+
+The cluster must enforce Kubernetes NetworkPolicy. The presence of a policy API
+object alone is not proof of enforcement. K3s with its network-policy 
controller
+enabled is supported by this boundary; an installation with
+`--disable-network-policy`, or a CNI without policy support, needs an enforcing
+network provider before this protection is effective. Do not claim tenant
+isolation on such a cluster without a live denied-connection test.
+
+For an existing full installation, the patched operator automatically 
reconciles
+enabled ZooKeeper and Kafka components when it resumes or processes a Whisk
+configuration update. The supported `ops update operator` flow therefore 
installs
+the boundary without manual internal calls. Reconciliation applies ZooKeeper
+before Kafka, propagates errors to the operator handler, and waits for each
+StatefulSet rollout before continuing normal post-resume or update processing.
+A missing Whisk owner aborts reconciliation. Slim installations do not deploy
+messaging components. Repeating a restart preserves existing policies and does
+not roll unchanged pod templates. Plan for the single-replica service restart 
on
+the first upgrade that installs this boundary. Do not remove the policies to 
work
+around an unrelated connectivity failure. Kubernetes NetworkPolicies are
+additive: an administrator-installed broad allow policy can reopen access.
+
+This patch preserves broker protocols, component versions and PVC contents. It
+enforces the ordinary tenant-action boundary at the network layer; it does not
+add TLS, Kafka SASL/ACLs or ZooKeeper authentication. Host/node administrators,
+Kubernetes administrators and compromised allowed control-plane pods remain
+trusted. Service-level credentials would provide additional protection against
+those separate paths. Slim deployments do not create these messaging 
components.
+
+## Verification
+
+Run `python -m unittest discover -s tests -p test_messaging_security.py` in the
+operator environment. Live acceptance must additionally demonstrate:
+
+- A normal action in each of two tenants cannot reach Kafka or ZooKeeper by
+  Service name, pod DNS name or direct pod IP, or Kafka metrics ports.
+- A pod in another namespace cannot gain access by copying trusted labels.
+- Action-labelled pods remain denied even with a trusted component label.
+- Controller/invoker Kafka traffic, invoker ZooKeeper traffic, broker-to-
+  ZooKeeper traffic and Prometheus metric scraping still work.
+- Invoker restart preserves its assigned ID and successful tenant invocations.
+- Updating an existing installation through `ops update operator` installs both
+  policies and rolls legacy messaging pods without manual reconciliation;
+  repeating the operator restart preserves the messaging pod identities.
+- Alpha/Beta application, Redis Lua, Streamer and scheduled actions still work
+  without editing their code.
diff --git a/deploy/kafka/kafka-001-sts.yaml b/deploy/kafka/kafka-001-sts.yaml
index 0c0f859..e8b7e36 100644
--- a/deploy/kafka/kafka-001-sts.yaml
+++ b/deploy/kafka/kafka-001-sts.yaml
@@ -33,6 +33,8 @@ spec:
       name: kafka
   template:
     metadata:
+      annotations:
+        openserverless.org/messaging-isolation: "v1"
       labels:
         name: kafka
         app: kafka
@@ -104,4 +106,4 @@ spec:
         volumeMounts:
         - mountPath: /kafka
           name: kafka-pvc
-          readOnly: true
\ No newline at end of file
+          readOnly: true
diff --git a/deploy/kafka/kafka-004-networkpolicy.yaml 
b/deploy/kafka/kafka-004-networkpolicy.yaml
new file mode 100644
index 0000000..b79276b
--- /dev/null
+++ b/deploy/kafka/kafka-004-networkpolicy.yaml
@@ -0,0 +1,51 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+  name: openserverless-kafka-isolation
+  namespace: openserverless
+spec:
+  podSelector:
+    matchLabels:
+      name: kafka
+  policyTypes: [Ingress]
+  ingress:
+    - from:
+        - podSelector:
+            matchExpressions:
+              - key: name
+                operator: In
+                values: [controller, invoker, scheduler, kafka]
+              - key: user-action-pod
+                operator: DoesNotExist
+      ports:
+        - port: 9092
+          protocol: TCP
+    - from:
+        - podSelector:
+            matchLabels:
+              app.kubernetes.io/name: prometheus
+              app.kubernetes.io/instance: openserverless-prometheus
+            matchExpressions:
+              - key: user-action-pod
+                operator: DoesNotExist
+      ports:
+        - port: 9071
+          protocol: TCP
+        - port: 9888
+          protocol: TCP
diff --git a/deploy/zookeeper/zookeeper-003-sts.yaml 
b/deploy/zookeeper/zookeeper-003-sts.yaml
index f2766c8..a27d21e 100644
--- a/deploy/zookeeper/zookeeper-003-sts.yaml
+++ b/deploy/zookeeper/zookeeper-003-sts.yaml
@@ -32,6 +32,8 @@ spec:
       name: zookeeper
   template:
     metadata:
+      annotations:
+        openserverless.org/messaging-isolation: "v1"
       labels:
         name: zookeeper
         app: zookeeper
diff --git a/deploy/zookeeper/zookeeper-005-networkpolicy.yaml 
b/deploy/zookeeper/zookeeper-005-networkpolicy.yaml
new file mode 100644
index 0000000..d73fa41
--- /dev/null
+++ b/deploy/zookeeper/zookeeper-005-networkpolicy.yaml
@@ -0,0 +1,50 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+  name: openserverless-zookeeper-isolation
+  namespace: openserverless
+spec:
+  podSelector:
+    matchLabels:
+      name: zookeeper
+  policyTypes: [Ingress]
+  ingress:
+    - from:
+        - podSelector:
+            matchExpressions:
+              - key: name
+                operator: In
+                values: [kafka, invoker, zookeeper]
+              - key: user-action-pod
+                operator: DoesNotExist
+      ports:
+        - port: 2181
+          protocol: TCP
+    - from:
+        - podSelector:
+            matchLabels:
+              name: zookeeper
+            matchExpressions:
+              - key: user-action-pod
+                operator: DoesNotExist
+      ports:
+        - port: 2888
+          protocol: TCP
+        - port: 3888
+          protocol: TCP
diff --git a/openserverless/kafka.py b/openserverless/kafka.py
index 2aad70b..36864b0 100644
--- a/openserverless/kafka.py
+++ b/openserverless/kafka.py
@@ -22,6 +22,7 @@ import openserverless.config as cfg
 import openserverless.enterprise_util as cfg_util
 import openserverless.enterprise_util as cfg_util
 import openserverless.util as util
+import openserverless.messaging_security as security
 
 def get_kafka_pod_name():
     pod_name = kube.kubectl("get", "pods", 
jsonpath="{.items[?(@.metadata.labels.app == 'kafka')].metadata.name}")
@@ -62,7 +63,7 @@ def create(owner=None):
         cfg.put("state.kafka.spec", spec)
 
 
-    res = kube.apply(spec)
+    res = security.apply(spec, 'kafka')
 
     # dynamically detect kafka pod and wait for readiness
     util.wait_for_pod_ready("{.items[?(@.metadata.labels.app == 
'kafka')].metadata.name}")
@@ -76,6 +77,6 @@ def delete():
     spec = cfg.get("state.kafka.spec")
     res = False
     if spec:
-        res = kube.delete(spec)
+        res = security.delete(spec, 'kafka')
         logging.info(f"delete kafka: {res}")
-    return res
\ No newline at end of file
+    return res
diff --git a/openserverless/main.py b/openserverless/main.py
index 0f3e887..ca02d9d 100644
--- a/openserverless/main.py
+++ b/openserverless/main.py
@@ -29,6 +29,7 @@ import openserverless.issuer as issuer
 import openserverless.endpoint as endpoint
 import openserverless.zookeeper as zookeeper
 import openserverless.kafka as kafka
+import openserverless.messaging_security as messaging_security
 import openserverless.invoker as invoker
 import openserverless.patcher as patcher
 import openserverless.storage_static as static
@@ -399,11 +400,14 @@ def whisk_update(spec, status, namespace, diff, name, 
**kwargs):
     operator_util.config_from_spec(spec,handler_type="on_update")
     owner = kube.get(f"wsk/{name}")
 
+    messaging_security.reconcile(owner)
     patcher.patch(diff, status, owner, name)
 
 @kopf.on.resume('openserverless.org', 'v1', 'whisks')
 def whisk_resume(spec, status, name, **kwargs):   
     operator_util.config_from_spec(spec, handler_type="on_resume")
+    if cfg.get('components.zookeeper') or cfg.get('components.kafka'):
+        messaging_security.reconcile(kube.get(f"wsk/{name}"))
     operator_util.whisk_post_resume(name)
 
 def runtimes_filter(name, type, **kwargs):
diff --git a/openserverless/messaging_security.py 
b/openserverless/messaging_security.py
new file mode 100644
index 0000000..61fe17b
--- /dev/null
+++ b/openserverless/messaging_security.py
@@ -0,0 +1,68 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+"""Apply the messaging network boundary before creating or updating 
workloads."""
+
+import openserverless.kube as kube
+import openserverless.config as cfg
+
+
+def reconcile(owner):
+    """Install the boundary on existing clusters during operator 
resume/update."""
+    # Import lazily: component create functions use this module's apply helper.
+    from openserverless import kafka, zookeeper
+
+    enabled = [(name, module) for name, module in
+               [('zookeeper', zookeeper), ('kafka', kafka)]
+               if cfg.get('components.' + name)]
+    if not enabled:
+        return
+    # Never turn a failed owner lookup into an ownerless component deployment.
+    if not owner or not owner.get('metadata', {}).get('uid'):
+        raise RuntimeError('Cannot reconcile messaging isolation without the 
Whisk owner')
+    for name, module in enabled:
+        module.create(owner)
+
+
+def apply(spec, component):
+    policy_name = 'openserverless-' + component + '-isolation'
+    policies = [item for item in spec['items']
+                if item['kind'] == 'NetworkPolicy']
+    if not any(item['metadata']['name'] == policy_name for item in policies):
+        raise RuntimeError('Missing ' + component + ' isolation policy')
+    # Do not create/reconcile an exposed service if policy application fails.
+    # The complete spec still owns the policy for reconciliation and deletion.
+    kube.apply({'apiVersion': 'v1', 'kind': 'List', 'items': policies})
+    result = kube.apply(spec)
+    # The template boundary revision rolls legacy pods to close existing
+    # anonymous sessions as well as blocking new connections.
+    kube.kubectl('rollout', 'status', 'statefulset/' + component, 
'--timeout=300s')
+    return result
+
+
+def delete(spec, component):
+    policies = [item for item in spec['items'] if item['kind'] == 
'NetworkPolicy']
+    workloads = [item for item in spec['items'] if item['kind'] != 
'NetworkPolicy']
+    result = kube.delete({'apiVersion': 'v1', 'kind': 'List', 'items': 
workloads})
+    # StatefulSet deletion can return while its dependent pods are terminating.
+    # Keep the boundary until those pods are actually gone; fail closed on 
error.
+    pods = kube.kubectl('get', 'pods', '-l', 'name=' + component, '-o', 
'name').split()
+    if pods:
+        kube.kubectl('wait', '--for=delete', *pods, '--timeout=300s')
+    if policies:
+        result += kube.delete({'apiVersion': 'v1', 'kind': 'List', 'items': 
policies})
+    return result
diff --git a/openserverless/zookeeper.py b/openserverless/zookeeper.py
index 597ac01..5b3700c 100644
--- a/openserverless/zookeeper.py
+++ b/openserverless/zookeeper.py
@@ -21,6 +21,7 @@ import openserverless.kustomize as kus
 import openserverless.config as cfg
 import openserverless.enterprise_util as cfg_util
 import openserverless.util as util
+import openserverless.messaging_security as security
 
 def get_zookeeper_pod_name():
     pod_name = kube.kubectl("get", "pods", 
jsonpath="{.items[?(@.metadata.labels.app == 'zookeeper')].metadata.name}")
@@ -56,7 +57,7 @@ def create(owner=None):
         cfg.put("state.zookeeper.spec", spec)
 
 
-    res = kube.apply(spec)
+    res = security.apply(spec, 'zookeeper')
 
     # dynamically detect zookeeper pod and wait for readiness
     util.wait_for_pod_ready("{.items[?(@.metadata.labels.app == 
'zookeeper')].metadata.name}")    
@@ -70,7 +71,7 @@ def delete():
     spec = cfg.get("state.zookeeper.spec")
     res = False
     if spec:
-        res = kube.delete(spec)
+        res = security.delete(spec, 'zookeeper')
         logging.info(f"delete zookeeper: {res}")
     return res
 
diff --git a/tests/test_messaging_security.py b/tests/test_messaging_security.py
new file mode 100644
index 0000000..211de31
--- /dev/null
+++ b/tests/test_messaging_security.py
@@ -0,0 +1,278 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you 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.
+
+"""Messaging isolation contract and component lifecycle regression tests."""
+
+import copy
+from pathlib import Path
+import unittest
+from unittest.mock import patch
+
+import yaml
+
+from openserverless import config, kafka, main, messaging_security, zookeeper
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def policy(component):
+    return yaml.safe_load(next((ROOT / 'deploy' / 
component).glob('*networkpolicy.yaml')).read_text())
+
+
+def matches(selector, labels):
+    if any(labels.get(k) != v for k, v in selector.get('matchLabels', 
{}).items()):
+        return False
+    for expression in selector.get('matchExpressions', []):
+        key, op = expression['key'], expression['operator']
+        if op == 'In' and labels.get(key) not in expression['values']:
+            return False
+        if op == 'DoesNotExist' and key in labels:
+            return False
+    return True
+
+
+def allowed(component, labels, port, namespace='openserverless'):
+    # Ingress peers without namespaceSelector select only the policy namespace.
+    if namespace != 'openserverless':
+        return False
+    return any(any(p['port'] == port for p in rule['ports']) and
+               any(matches(peer['podSelector'], labels) for peer in 
rule['from'])
+               for rule in policy(component)['spec']['ingress'])
+
+
+class MessagingSecurityTests(unittest.TestCase):
+    def tearDown(self):
+        config.clean()
+
+    def 
test_existing_cluster_resume_installs_isolation_before_post_resume(self):
+        events = []
+        owner = {'metadata': {'name': 'controller', 'uid': 'synthetic'}}
+        def configure(spec, **kwargs):
+            config.configure(spec)
+        with patch.object(main.operator_util, 'config_from_spec', 
side_effect=configure), \
+             patch.object(main.kube, 'get', return_value=owner), \
+             patch.object(zookeeper, 'create', side_effect=lambda obj: 
events.append(('zookeeper', obj))), \
+             patch.object(kafka, 'create', side_effect=lambda obj: 
events.append(('kafka', obj))), \
+             patch.object(main.operator_util, 'whisk_post_resume', 
side_effect=lambda name: events.append(('post', name))):
+            main.whisk_resume({'components': {'kafka': True, 'zookeeper': 
True}}, {}, 'controller')
+        self.assertEqual(events, [('zookeeper', owner), ('kafka', owner), 
('post', 'controller')])
+
+    def 
test_resume_failure_propagates_and_never_reports_post_resume_success(self):
+        config.configure({'components': {'kafka': True, 'zookeeper': True}})
+        with patch.object(main.operator_util, 'config_from_spec'), \
+             patch.object(main.kube, 'get', return_value={'metadata': {'uid': 
'synthetic'}}), \
+             patch.object(zookeeper, 'create', 
side_effect=RuntimeError('policy denied')), \
+             patch.object(kafka, 'create') as broker, \
+             patch.object(main.operator_util, 'whisk_post_resume') as post:
+            with self.assertRaisesRegex(RuntimeError, 'policy denied'):
+                main.whisk_resume({}, {}, 'controller')
+        broker.assert_not_called()
+        post.assert_not_called()
+
+    def test_configuration_update_reconciles_before_existing_patcher(self):
+        events = []
+        config.configure({'components': {'kafka': True}})
+        owner = {'metadata': {'uid': 'synthetic'}}
+        with patch.object(main.operator_util, 'config_from_spec'), \
+             patch.object(main.kube, 'get', return_value=owner), \
+             patch.object(kafka, 'create', side_effect=lambda obj: 
events.append(('kafka', obj))), \
+             patch.object(zookeeper, 'create') as zoo, \
+             patch.object(main.patcher, 'patch', side_effect=lambda *args: 
events.append(('patch', args))):
+            main.whisk_update({}, {}, 'openserverless', (), 'controller')
+        self.assertEqual(events, [('kafka', owner), ('patch', ((), {}, owner, 
'controller'))])
+        zoo.assert_not_called()
+
+    def test_missing_owner_aborts_before_any_component_mutation(self):
+        config.configure({'components': {'kafka': True, 'zookeeper': True}})
+        with patch.object(kafka, 'create') as broker, patch.object(zookeeper, 
'create') as zoo:
+            for owner in [None, {}, {'metadata': {}}, {'metadata': {'name': 
'controller'}}]:
+                with self.assertRaisesRegex(RuntimeError, 'Whisk owner'):
+                    messaging_security.reconcile(owner)
+        broker.assert_not_called()
+        zoo.assert_not_called()
+
+    def test_slim_resume_does_not_query_owner_or_create_messaging(self):
+        config.configure({'components': {'kafka': False, 'zookeeper': False}})
+        with patch.object(main.operator_util, 'config_from_spec'), \
+             patch.object(main.kube, 'get') as get, \
+             patch.object(kafka, 'create') as broker, \
+             patch.object(zookeeper, 'create') as zoo, \
+             patch.object(main.operator_util, 'whisk_post_resume') as post:
+            main.whisk_resume({}, {}, 'controller')
+            messaging_security.reconcile(None)
+        get.assert_not_called()
+        broker.assert_not_called()
+        zoo.assert_not_called()
+        post.assert_called_once_with('controller')
+
+    def test_kafka_rollout_failure_stops_update_processing(self):
+        config.configure({'components': {'kafka': True}})
+        with patch.object(main.operator_util, 'config_from_spec'), \
+             patch.object(main.kube, 'get', return_value={'metadata': {'uid': 
'synthetic'}}), \
+             patch.object(kafka, 'create', side_effect=RuntimeError('rollout 
timed out')), \
+             patch.object(main.patcher, 'patch') as patcher:
+            with self.assertRaisesRegex(RuntimeError, 'rollout timed out'):
+                main.whisk_update({}, {}, 'openserverless', (), 'controller')
+        patcher.assert_not_called()
+
+    def test_tenant_and_unrelated_pods_denied_on_all_ports(self):
+        for component, ports in [('kafka', [9092, 9071, 9888]),
+                                 ('zookeeper', [2181, 2888, 3888])]:
+            for labels in [{}, {'user-action-pod': 'true'},
+                           {'name': 'guest'}, {'name': 
'openserverless-system-api'}]:
+                for port in ports:
+                    with self.subTest(component=component, labels=labels, 
port=port):
+                        self.assertFalse(allowed(component, labels, port))
+
+    def test_action_cannot_impersonate_trusted_peer_or_monitoring(self):
+        for component, ports in [('kafka', [9092, 9071, 9888]),
+                                 ('zookeeper', [2181, 2888, 3888])]:
+            for name in ['controller', 'invoker', 'scheduler', 'kafka', 
'zookeeper']:
+                for marker in ['true', 'false', '']:
+                    labels = {'name': name, 'user-action-pod': marker,
+                              'app.kubernetes.io/name': 'prometheus',
+                              'app.kubernetes.io/instance': 
'openserverless-prometheus'}
+                    for port in ports:
+                        self.assertFalse(allowed(component, labels, port))
+
+    def test_same_labels_in_other_namespace_are_denied(self):
+        for component in ['kafka', 'zookeeper']:
+            for name in ['controller', 'invoker', 'kafka', 'zookeeper']:
+                self.assertFalse(allowed(component, {'name': name}, 9092, 
namespace='other'))
+                self.assertFalse(allowed(component, {'name': name}, 2181, 
namespace='other'))
+
+    def test_control_plane_clients_preserve_required_connections(self):
+        for name in ['controller', 'invoker', 'scheduler', 'kafka']:
+            self.assertTrue(allowed('kafka', {'name': name}, 9092))
+        for name in ['kafka', 'invoker', 'zookeeper']:
+            self.assertTrue(allowed('zookeeper', {'name': name}, 2181))
+        for port in [2888, 3888]:
+            self.assertTrue(allowed('zookeeper', {'name': 'zookeeper'}, port))
+            for name in ['controller', 'invoker', 'scheduler', 'kafka']:
+                self.assertFalse(allowed('zookeeper', {'name': name}, port))
+
+    def test_metrics_does_not_grant_broker_access(self):
+        labels = {'app.kubernetes.io/name': 'prometheus',
+                  'app.kubernetes.io/instance': 'openserverless-prometheus'}
+        for port in [9071, 9888]:
+            self.assertTrue(allowed('kafka', labels, port))
+        self.assertFalse(allowed('kafka', labels, 9092))
+        self.assertFalse(allowed('zookeeper', labels, 2181))
+
+    def test_all_policy_rules_have_scoped_peers_and_ports(self):
+        for component in ['kafka', 'zookeeper']:
+            obj = policy(component)
+            self.assertEqual(obj['spec']['podSelector']['matchLabels'], 
{'name': component})
+            self.assertEqual(obj['spec']['policyTypes'], ['Ingress'])
+            for rule in obj['spec']['ingress']:
+                self.assertTrue(rule['ports'])
+                self.assertTrue(rule['from'])
+                for peer in rule['from']:
+                    self.assertEqual(set(peer), {'podSelector'})
+                    self.assertTrue(peer['podSelector'])
+
+    def test_policy_apply_failure_never_applies_workloads(self):
+        for component in ['kafka', 'zookeeper']:
+            p = policy(component)
+            workload = {'kind': 'StatefulSet', 'metadata': {'name': component}}
+            spec = {'apiVersion': 'v1', 'kind': 'List', 'items': [workload, p]}
+            with patch.object(messaging_security.kube, 'apply', 
side_effect=RuntimeError('denied')) as apply:
+                with self.assertRaisesRegex(RuntimeError, 'denied'):
+                    messaging_security.apply(spec, component)
+                self.assertEqual(apply.call_count, 1)
+                self.assertEqual(apply.call_args.args[0]['items'], [p])
+
+    def test_missing_policy_fails_before_any_kubernetes_mutation(self):
+        for component in ['kafka', 'zookeeper']:
+            with patch.object(messaging_security.kube, 'apply') as apply:
+                with self.assertRaisesRegex(RuntimeError, 'Missing'):
+                    messaging_security.apply({'items': []}, component)
+                apply.assert_not_called()
+
+    def 
test_component_reconciliation_and_saved_deletion_spec_include_policy(self):
+        for component, module in [('kafka', kafka), ('zookeeper', zookeeper)]:
+            for owner in [None, {'apiVersion': 'openserverless.org/v1', 
'kind': 'Whisk',
+                                 'metadata': {'name': 'test', 'uid': 
'synthetic'}}]:
+                config.clean()
+                config.put('openserverless.zookeeper.url', 
'zookeeper-0.zookeeper:2181')
+                spec = {'apiVersion': 'v1', 'kind': 'List',
+                        'items': [{'kind': 'StatefulSet', 'metadata': {'name': 
component}},
+                                  policy(component)]}
+                calls = []
+                with patch.object(module.cfg_util, 'get_' + component + 
'_config_data',
+                                  return_value={'affinity': False, 
'tolerations': False}), \
+                     patch.object(module.kus, 'patchTemplates', 
return_value=''), \
+                     patch.object(module.kus, 'kustom_list', 
return_value=spec), \
+                     patch.object(module.util, 'wait_for_pod_ready'), \
+                     patch.object(module, 'get_' + component + 
'_connect_data', return_value=component + ':2181'), \
+                     patch.object(messaging_security.kube, 'kubectl') as 
kubectl, \
+                     patch.object(messaging_security.kube, 'apply',
+                                  side_effect=lambda obj: 
calls.append(copy.deepcopy(obj)) or 'applied'):
+                    self.assertEqual(module.create(owner), 'applied')
+                self.assertTrue(all(item['kind'] == 'NetworkPolicy' for item 
in calls[0]['items']))
+                self.assertEqual(calls[1]['items'], spec['items'])
+                kubectl.assert_called_once_with('rollout', 'status', 
'statefulset/' + component, '--timeout=300s')
+                if owner:
+                    
self.assertTrue(all(item['metadata'].get('ownerReferences') for item in 
calls[0]['items']))
+                else:
+                    saved = config.get('state.' + component + '.spec')
+                    self.assertTrue(any(item['kind'] == 'NetworkPolicy' for 
item in saved['items']))
+                    with patch.object(module.security, 'delete', 
return_value='deleted') as delete:
+                        self.assertEqual(module.delete(), 'deleted')
+                        self.assertEqual(delete.call_args.args[0], saved)
+
+    def test_legacy_sessions_are_closed_by_statefulset_rollout(self):
+        for component in ['kafka', 'zookeeper']:
+            path = next((ROOT / 'deploy' / component).glob('*sts.yaml'))
+            obj = next(item for item in yaml.safe_load_all(path.read_text())
+                       if item and item['kind'] == 'StatefulSet')
+            self.assertEqual(obj['spec']['template']['metadata']['annotations']
+                             ['openserverless.org/messaging-isolation'], 'v1')
+
+    def test_deletion_keeps_policy_until_all_component_pods_are_gone(self):
+        for component in ['kafka', 'zookeeper']:
+            p = policy(component)
+            sts = {'kind': 'StatefulSet', 'metadata': {'name': component}}
+            events = []
+            def kubectl(*args):
+                events.append(args[0])
+                return 'pod/' + component + '-0' if args[0] == 'get' else ''
+            def delete(spec):
+                events.append(spec['items'][0]['kind'])
+                return 'deleted'
+            with patch.object(messaging_security.kube, 'kubectl', 
side_effect=kubectl), \
+                 patch.object(messaging_security.kube, 'delete', 
side_effect=delete):
+                messaging_security.delete({'items': [p, sts]}, component)
+            self.assertEqual(events, ['StatefulSet', 'get', 'wait', 
'NetworkPolicy'])
+
+    def test_delete_timeout_keeps_policy(self):
+        with patch.object(messaging_security.kube, 'kubectl',
+                          side_effect=['pod/kafka-0', RuntimeError('still 
terminating')]), \
+             patch.object(messaging_security.kube, 'delete', 
return_value='deleted') as delete:
+            with self.assertRaisesRegex(RuntimeError, 'still terminating'):
+                messaging_security.delete({'items': [policy('kafka'), {'kind': 
'StatefulSet'}]}, 'kafka')
+            self.assertEqual(delete.call_count, 1)
+
+    def test_legacy_saved_spec_without_policy_can_still_be_deleted(self):
+        with patch.object(messaging_security.kube, 'kubectl', 
return_value=''), \
+             patch.object(messaging_security.kube, 'delete', 
return_value='deleted') as delete:
+            self.assertEqual(messaging_security.delete({'items': [{'kind': 
'StatefulSet'}]}, 'kafka'), 'deleted')
+            self.assertEqual(delete.call_count, 1)
+
+
+if __name__ == '__main__':
+    unittest.main()

Reply via email to