blob: 211de31c48be538b643cbba92e86cd841a878cbd [file]
# 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()