-
Notifications
You must be signed in to change notification settings - Fork 18
/
test_charm.py
1537 lines (1268 loc) · 57.1 KB
/
test_charm.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import logging
from contextlib import nullcontext as does_not_raise
from typing import Optional
from unittest.mock import MagicMock, Mock, PropertyMock, patch
import pytest
import tenacity
import yaml
from charmed_kubeflow_chisme.exceptions import ErrorWithStatus, GenericCharmRuntimeError
from charmed_kubeflow_chisme.kubernetes import KubernetesResourceHandler
from charmed_kubeflow_chisme.lightkube.mocking import FakeApiError
from lightkube import codecs
from lightkube.core.exceptions import ApiError
from lightkube.generic_resource import create_namespaced_resource
from lightkube.models.admissionregistration_v1 import (
ServiceReference,
ValidatingWebhook,
ValidatingWebhookConfiguration,
WebhookClientConfig,
)
from lightkube.models.meta_v1 import ObjectMeta
from lightkube.resources.core_v1 import Secret
from ops.charm import (
RelationBrokenEvent,
RelationChangedEvent,
RelationCreatedEvent,
RelationJoinedEvent,
)
from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus
from ops.testing import Harness
from charm import (
GATEWAY_PORTS,
Operator,
_get_gateway_address_from_svc,
_remove_envoyfilter,
_validate_upgrade_version,
_wait_for_update_rollout,
_xor,
)
from istioctl import IstioctlError
GATEWAY_LIGHTKUBE_RESOURCE = create_namespaced_resource(
group="networking.istio.io", version="v1beta1", kind="Gateway", plural="gateways"
)
VIRTUAL_SERVICE_LIGHTKUBE_RESOURCE = create_namespaced_resource(
group="networking.istio.io",
version="v1alpha3",
kind="VirtualService",
plural="virtualservices",
)
@pytest.fixture()
def all_operator_reconcile_handlers_mocked(mocker):
mocked = {
"_check_leader": mocker.patch("charm.Operator._check_leader"),
"_handle_istio_pilot_relation": mocker.patch(
"charm.Operator._handle_istio_pilot_relation"
),
"_get_ingress_auth_data": mocker.patch("charm.Operator._get_ingress_auth_data"),
"_reconcile_ingress_auth": mocker.patch("charm.Operator._reconcile_ingress_auth"),
"_reconcile_gateway": mocker.patch("charm.Operator._reconcile_gateway"),
"_remove_gateway": mocker.patch("charm.Operator._remove_gateway"),
"_send_gateway_info": mocker.patch("charm.Operator._send_gateway_info"),
"_get_ingress_data": mocker.patch("charm.Operator._get_ingress_data"),
"_reconcile_ingress": mocker.patch("charm.Operator._reconcile_ingress"),
"_report_handled_errors": mocker.patch("charm.Operator._report_handled_errors"),
}
yield mocked
@pytest.fixture()
def kubernetes_resource_handler_with_client(mocker):
mocked_client = MagicMock()
class KubernetesResourceHandlerWithClient(KubernetesResourceHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._lightkube_client = mocked_client
mocker.patch("charm.KubernetesResourceHandler", new=KubernetesResourceHandlerWithClient)
yield KubernetesResourceHandlerWithClient, mocked_client
@pytest.fixture()
def kubernetes_resource_handler_with_client_and_existing_gateway(
kubernetes_resource_handler_with_client,
):
"""Yields a KubernetesResourceHandlerWithClient with a mocked client and a mocked Gateway.
The mocked Gateway is returned from lightkube_client.list() to simulate finding a gateway
during reconciliation or deletion.
"""
mocked_krh_class, mocked_lightkube_client = kubernetes_resource_handler_with_client
# Mock a previously existing resource so we have something to remove
existing_gateway_name = "my-old-gateway"
existing_gateway_namespace = "my-namespace"
mocked_lightkube_client.list.return_value = [
GATEWAY_LIGHTKUBE_RESOURCE(
metadata=ObjectMeta(name=existing_gateway_name, namespace=existing_gateway_namespace)
)
]
yield mocked_krh_class, mocked_lightkube_client, existing_gateway_name
@pytest.fixture()
def kubernetes_resource_handler_with_client_and_existing_virtualservice(
kubernetes_resource_handler_with_client,
):
"""Yields a KubernetesResourceHandlerWithClient with a mocked client and a mocked VS.
The mocked VirtualService is returned from lightkube_client.list() to simulate finding a VS
during reconciliation or deletion.
"""
mocked_krh_class, mocked_lightkube_client = kubernetes_resource_handler_with_client
# Mock a previously existing resource so we have something to remove
existing_vs_name = "my-old-vs"
existing_vs_namespace = f"{existing_vs_name}-namespace"
mocked_lightkube_client.list.return_value = [
VIRTUAL_SERVICE_LIGHTKUBE_RESOURCE(
metadata=ObjectMeta(name=existing_vs_name, namespace=existing_vs_namespace)
)
]
yield mocked_krh_class, mocked_lightkube_client, existing_vs_name
def raise_apierror_with_code_400(*args, **kwargs):
raise FakeApiError(400)
def raise_apierror_with_code_404(*args, **kwargs):
raise FakeApiError(404)
class TestCharmEvents:
"""Test cross-cutting charm behavior.
This test class includes any end-to-end style unit tests (eg: that use Juju's events and test
their handling, etc).
"""
def test_event_observing(self, harness, mocker):
harness.begin()
mocked_install = mocker.patch("charm.Operator.install")
mocked_remove = mocker.patch("charm.Operator.remove")
mocked_upgrade_charm = mocker.patch("charm.Operator.upgrade_charm")
mocked_reconcile = mocker.patch("charm.Operator.reconcile")
RelationCreatedEvent
harness.charm.on.install.emit()
assert_called_once_and_reset(mocked_install)
harness.charm.on.remove.emit()
assert_called_once_and_reset(mocked_remove)
harness.charm.on.upgrade_charm.emit()
assert_called_once_and_reset(mocked_upgrade_charm)
exercise_relation(harness, "gateway-info")
assert mocked_reconcile.call_count == 2
assert isinstance(mocked_reconcile.call_args_list[0][0][0], RelationCreatedEvent)
assert isinstance(mocked_reconcile.call_args_list[1][0][0], RelationJoinedEvent)
mocked_reconcile.reset_mock()
exercise_relation(harness, "istio-pilot")
assert mocked_reconcile.call_count == 3
assert isinstance(mocked_reconcile.call_args_list[0][0][0], RelationCreatedEvent)
assert isinstance(mocked_reconcile.call_args_list[1][0][0], RelationJoinedEvent)
assert isinstance(mocked_reconcile.call_args_list[2][0][0], RelationChangedEvent)
mocked_reconcile.reset_mock()
exercise_relation(harness, "ingress")
assert mocked_reconcile.call_count == 2
assert isinstance(mocked_reconcile.call_args_list[0][0][0], RelationChangedEvent)
assert isinstance(mocked_reconcile.call_args_list[1][0][0], RelationBrokenEvent)
mocked_reconcile.reset_mock()
exercise_relation(harness, "ingress-auth")
assert mocked_reconcile.call_count == 4
assert isinstance(mocked_reconcile.call_args_list[0][0][0], RelationCreatedEvent)
assert isinstance(mocked_reconcile.call_args_list[1][0][0], RelationJoinedEvent)
assert isinstance(mocked_reconcile.call_args_list[2][0][0], RelationChangedEvent)
assert isinstance(mocked_reconcile.call_args_list[3][0][0], RelationBrokenEvent)
mocked_reconcile.reset_mock()
def test_not_leader(self, harness):
"""Assert that the charm does not perform any actions when not the leader."""
harness.set_leader(False)
harness.begin()
harness.charm.on.config_changed.emit()
assert harness.charm.model.unit.status == WaitingStatus("Waiting for leadership")
@patch("charm.Operator._remove_gateway")
def test_ingress_auth_and_gateway(
self,
mocked_remove_gateway,
harness,
mocked_lightkube_client,
kubernetes_resource_handler_with_client,
):
"""Charm e2e test that asserts that we correctly manage our Gateway with/without Auth.
Asserts that we:
* create a gateway on a config_changed
* remove the gateway on an incomple ingress-auth relation
* recreate the gateway and create an EnvoyFilter on a complete ingress-auth relation
"""
krh_class, krh_lightkube_client = kubernetes_resource_handler_with_client
model_name = "my-model"
gateway_name = "my-gateway"
harness.set_leader(True)
harness.set_model_name(model_name)
harness.update_config({"default-gateway": gateway_name})
harness.begin()
# Do a reconcile
harness.charm.on.config_changed.emit()
# Assert we have created a gateway during reconcile
assert krh_lightkube_client.apply.call_count == 1
assert is_lightkube_resource_in_call_args_list(
krh_lightkube_client.apply.call_args_list, gateway_name, model_name
)
krh_lightkube_client.reset_mock()
# Add "broken" ingress_auth (empty data) and check that we remove the gateway
rel_id = harness.add_relation("ingress-auth", "other")
mocked_remove_gateway.assert_called_once
mocked_remove_gateway.reset_mock()
# Remove ingress_auth relation and check that we re-add the gateway
harness.remove_relation(rel_id)
assert krh_lightkube_client.apply.call_count == 1
assert is_lightkube_resource_in_call_args_list(
krh_lightkube_client.apply.call_args_list, gateway_name, model_name
)
krh_lightkube_client.reset_mock()
# Add complete ingress_auth data and check that we created the gateway and envoyfilter
envoyfilter_name = f"{harness.model.app.name}-authn-filter"
add_ingress_auth_to_harness(harness, "other")
assert krh_lightkube_client.apply.call_count == 2
assert is_lightkube_resource_in_call_args_list(
krh_lightkube_client.apply.call_args_list, gateway_name, model_name
)
assert is_lightkube_resource_in_call_args_list(
krh_lightkube_client.apply.call_args_list, envoyfilter_name, model_name
)
assert_envoyfilter_applied_to_all_gateway_ports(
krh_lightkube_client, envoyfilter_name, model_name
)
@patch("charm.Operator._handle_istio_pilot_relation")
def test_ingress_relation(
self,
mocked_handle_istio_pilot_relation,
harness,
mocked_lightkube_client,
kubernetes_resource_handler_with_client,
):
"""Charm e2e test that asserts that we correctly manage ingress relation.
Asserts that we:
* create a gateway on a config_changed
* create a single VirtualService when we add one related app to `ingress`
* create two VirtualServices when we add another related app to `ingress`
* create the VirtualServices for ingress even when other non-fatal errors occur
"""
krh_class, krh_lightkube_client = kubernetes_resource_handler_with_client
model_name = "my-model"
gateway_name = "my-gateway"
harness.set_leader(True)
harness.set_model_name(model_name)
harness.update_config({"default-gateway": gateway_name})
harness.begin()
# Do a reconcile
harness.charm.on.config_changed.emit()
# Assert we have created a gateway during reconcile
assert krh_lightkube_client.apply.call_count == 1
assert is_lightkube_resource_in_call_args_list(
krh_lightkube_client.apply.call_args_list, gateway_name, model_name
)
krh_lightkube_client.reset_mock()
# Add ingress relation and check it results in VirtualServices being created
ingress_app1 = "other1"
add_ingress_to_harness(harness, ingress_app1)
assert is_lightkube_resource_in_call_args_list(
krh_lightkube_client.apply.call_args_list, ingress_app1, model_name
)
krh_lightkube_client.reset_mock()
# Add another ingress relation and check it results in 2 VirtualServices being created
ingress_app2 = "other2"
add_ingress_to_harness(harness, ingress_app2)
assert is_lightkube_resource_in_call_args_list(
krh_lightkube_client.apply.call_args_list, ingress_app1, model_name
)
assert is_lightkube_resource_in_call_args_list(
krh_lightkube_client.apply.call_args_list, ingress_app2, model_name
)
krh_lightkube_client.reset_mock()
# After everything, the unit should be active
assert harness.charm.model.unit.status == ActiveStatus()
# If we "break" part of the charm, we should still create the VirtualServices but the charm
# is not active
mocked_handle_istio_pilot_relation.side_effect = ErrorWithStatus(
"Test error", BlockedStatus
)
harness.charm.on.config_changed.emit()
assert is_lightkube_resource_in_call_args_list(
krh_lightkube_client.apply.call_args_list, ingress_app1, model_name
)
assert is_lightkube_resource_in_call_args_list(
krh_lightkube_client.apply.call_args_list, ingress_app2, model_name
)
assert isinstance(harness.charm.model.unit.status, BlockedStatus)
assert "handled 1 error" in harness.charm.model.unit.status.message
def test_istio_pilot_relation(
self, harness, mocked_lightkube_client, kubernetes_resource_handler_with_client
):
"""Charm e2e test that asserts we correctly broadcast data on the istio-pilot relation."""
krh_class, krh_lightkube_client = kubernetes_resource_handler_with_client
model_name = "my-model"
gateway_name = "my-gateway"
harness.set_leader(True)
harness.set_model_name(model_name)
harness.update_config({"default-gateway": gateway_name})
harness.begin()
# Do a reconcile
harness.charm.on.config_changed.emit()
# Add istio-pilot relation and check it posts data correctly
istio_pilot_relation_info = add_istio_pilot_to_harness(harness, "other")
expected_service_name = f"istiod.{model_name}.svc"
actual_service_name = yaml.safe_load(
harness.get_relation_data(istio_pilot_relation_info["rel_id"], harness.model.app)[
"data"
]
)["service-name"]
assert actual_service_name == expected_service_name
krh_lightkube_client.reset_mock()
@patch("charm.Operator._is_gateway_service_up", new_callable=PropertyMock)
def test_gateway_info_relation(
self,
mocked_is_gateway_service_up,
harness,
mocked_lightkube_client,
kubernetes_resource_handler_with_client,
):
"""Charm e2e test that asserts we correctly broadcast data on the gateway-info relation."""
# Arrange
model_name = "my-model"
gateway_name = "my-gateway"
harness.set_leader(True)
harness.set_model_name(model_name)
harness.update_config({"default-gateway": gateway_name})
mocked_is_gateway_service_up.return_value = True
harness.begin()
# Act and assert
# Add gateway-info relation and check it posts data correctly
gateway_info_relation_info = add_gateway_info_to_harness(harness, "other")
actual_gateway_name = harness.get_relation_data(
gateway_info_relation_info["rel_id"], harness.model.app
)["gateway_name"]
assert actual_gateway_name == gateway_name
actual_gateway_up = harness.get_relation_data(
gateway_info_relation_info["rel_id"], harness.model.app
)["gateway_up"]
assert actual_gateway_up == "true"
assert harness.charm.model.unit.status == ActiveStatus()
class TestCharmHelpers:
"""Directly test charm helpers and private methods."""
def test_reconcile_handling_nonfatal_errors(
self, harness, all_operator_reconcile_handlers_mocked
):
"""Test does a charm e2e simulation of a reconcile loop which handles non-fatal errors."""
# Arrange
mocks = all_operator_reconcile_handlers_mocked
mocks["_handle_istio_pilot_relation"].side_effect = ErrorWithStatus(
"_handle_istio_pilot_relation", BlockedStatus
)
mocks["_reconcile_gateway"].side_effect = ErrorWithStatus(
"_reconcile_gateway", BlockedStatus
)
mocks["_send_gateway_info"].side_effect = ErrorWithStatus(
"_send_gateway_info", BlockedStatus
)
mocks["_get_ingress_data"].side_effect = ErrorWithStatus(
"_get_ingress_data", BlockedStatus
)
harness.begin()
# Act
harness.charm.reconcile("event")
# Assert
mocks["_report_handled_errors"].assert_called_once()
assert len(mocks["_report_handled_errors"].call_args.kwargs["errors"]) == 4
def test_reconcile_not_leader(self, harness):
"""Assert that the reconcile handler does not perform any actions when not the leader."""
harness.set_leader(False)
harness.begin()
harness.charm.reconcile("mock event")
assert harness.charm.model.unit.status == WaitingStatus("Waiting for leadership")
@pytest.mark.parametrize(
"ssl_crt, ssl_key, expected_port, expected_context",
[
("", "", GATEWAY_PORTS["http"], does_not_raise()),
("x", "x", GATEWAY_PORTS["https"], does_not_raise()),
("x", "", None, pytest.raises(ErrorWithStatus)),
("", "x", None, pytest.raises(ErrorWithStatus)),
],
)
def test_gateway_port(self, ssl_crt, ssl_key, expected_port, expected_context, harness):
"""Tests that the gateway_port selection works as expected."""
harness.begin()
harness.update_config({"ssl-crt": ssl_crt, "ssl-key": ssl_key})
with expected_context:
gateway_port = harness.charm._gateway_port
assert gateway_port == expected_port
@pytest.mark.parametrize(
"lightkube_client_get_side_effect, expected_is_up, context_raised",
[
(None, True, does_not_raise()),
(raise_apierror_with_code_404, False, does_not_raise()),
(raise_apierror_with_code_400, None, pytest.raises(ApiError)),
(ValueError, None, pytest.raises(ValueError)),
],
)
def test_is_gateway_object_up(
self,
lightkube_client_get_side_effect,
expected_is_up,
context_raised,
harness,
mocked_lightkube_client,
):
"""Tests whether _is_gateway_object_up returns as expected."""
mocked_lightkube_client.get.side_effect = lightkube_client_get_side_effect
harness.begin()
with context_raised:
actual_is_up = harness.charm._is_gateway_object_up
assert actual_is_up == expected_is_up
@pytest.mark.parametrize(
"mock_service_fixture, is_gateway_up",
[
# Pass fixtures by their names
("mock_nodeport_service", True),
("mock_clusterip_service", True),
("mock_loadbalancer_hostname_service", True),
("mock_loadbalancer_ip_service", True),
("mock_loadbalancer_hostname_service_not_ready", False),
("mock_loadbalancer_ip_service_not_ready", False),
],
)
def test_is_gateway_service_up(self, mock_service_fixture, is_gateway_up, harness, request):
harness.begin()
mock_get_gateway_service = MagicMock(
return_value=request.getfixturevalue(mock_service_fixture)
)
harness.charm._get_gateway_service = mock_get_gateway_service
assert harness.charm._is_gateway_service_up is is_gateway_up
@pytest.mark.parametrize(
"mock_service_fixture, gateway_address",
[
# Pass fixtures by their names
("mock_nodeport_service", None),
("mock_clusterip_service", "10.10.10.10"),
("mock_loadbalancer_hostname_service", "test.com"),
("mock_loadbalancer_ip_service", "127.0.0.1"),
("mock_loadbalancer_hostname_service_not_ready", None),
("mock_loadbalancer_ip_service_not_ready", None),
],
)
def test_get_gateway_address_from_svc(
self,
mock_service_fixture,
gateway_address,
harness,
request,
):
"""Test that the charm._gateway_address correctly returns gateway service IP/hostname."""
mock_service = request.getfixturevalue(mock_service_fixture)
assert _get_gateway_address_from_svc(svc=mock_service) is gateway_address
def test_get_ingress_auth_data(self, harness):
"""Tests that the _get_ingress_auth_data helper returns the correct relation data."""
harness.begin()
returned_data = add_ingress_auth_to_harness(harness)
ingress_auth_data = harness.charm._get_ingress_auth_data("not-relation-broken-event")
assert ingress_auth_data == returned_data["data"]
def test_get_ingress_auth_data_empty(self, harness):
"""Tests that the _get_ingress_auth_data helper returns the correct relation data."""
harness.begin()
ingress_auth_data = harness.charm._get_ingress_auth_data("not-relation-broken-event")
assert len(ingress_auth_data) == 0
def test_get_ingress_auth_data_too_many_relations(self, harness):
"""Tests that the _get_ingress_auth_data helper raises on too many relations data."""
harness.begin()
add_ingress_auth_to_harness(harness, other_app="other1")
add_ingress_auth_to_harness(harness, other_app="other2")
with pytest.raises(ErrorWithStatus) as err:
harness.charm._get_ingress_auth_data("not-relation-broken-event")
assert "Multiple ingress-auth" in err.value.msg
def test_get_ingress_auth_data_waiting_on_version(self, harness):
"""Tests that the _get_ingress_auth_data helper raises on incomplete data."""
harness.begin()
harness.add_relation("ingress-auth", "other")
with pytest.raises(ErrorWithStatus) as err:
harness.charm._get_ingress_auth_data("not-relation-broken-event")
assert "versions not found" in err.value.msg
def test_get_ingress_data(self, harness):
"""Tests that the _get_ingress_data helper returns the correct relation data."""
harness.begin()
relation_info = [
add_ingress_to_harness(harness, "other1"),
add_ingress_to_harness(harness, "other2"),
]
event = "not-a-relation-broken-event"
ingress_data = harness.charm._get_ingress_data(event)
assert len(ingress_data) == len(relation_info)
for i, this_relation_info in enumerate(relation_info):
this_relation = harness.model.get_relation("ingress", i)
assert ingress_data[(this_relation, this_relation.app)] == this_relation_info["data"]
def test_get_ingress_data_for_broken_event(self, harness):
"""Tests that _get_ingress_data helper returns the correct for a RelationBroken event."""
harness.begin()
relation_info = [
add_ingress_to_harness(harness, "other0"),
add_ingress_to_harness(harness, "other1"),
]
# Check for data while pretending this is a RelationBrokenEvent for relation[1] of the
# above relations.
mock_relation_broken_event = MagicMock(spec=RelationBrokenEvent)
mock_relation_broken_event.relation = harness.model.get_relation("ingress", 1)
mock_relation_broken_event.app = harness.model.get_relation("ingress", 1).app
ingress_data = harness.charm._get_ingress_data(mock_relation_broken_event)
assert len(ingress_data) == 1
this_relation = harness.model.get_relation("ingress", 0)
assert ingress_data[(this_relation, this_relation.app)] == relation_info[0]["data"]
def test_get_ingress_data_empty(self, harness):
"""Tests that the _get_ingress_data helper returns the correct empty relation data."""
harness.begin()
event = "not-a-relation-broken-event"
ingress_data = harness.charm._get_ingress_data(event)
assert len(ingress_data) == 0
def test_get_ingress_data_waiting_on_version(self, harness):
"""Tests that the _get_ingress_data helper raises on incomplete data."""
harness.begin()
harness.add_relation("ingress", "other")
event = "not-a-relation-broken-event"
with pytest.raises(ErrorWithStatus) as err:
harness.charm._get_ingress_data(event)
assert "versions not found" in err.value.msg
@pytest.mark.parametrize(
"related_applications",
[
([]), # No related applications
(["other1"]), # A single related application
(["other1", "other2", "other3"]), # Multiple related applications
],
)
def test_handle_istio_pilot_relation(self, related_applications, harness):
"""Tests that the handle_istio_pilot_relation helper works as expected."""
# Assert
# Must be leader because we write to the application part of the relation data
model_name = "some-model"
expected_data = {
"service-name": f"istiod.{model_name}.svc",
"service-port": "15012",
}
harness.set_leader(True)
harness.set_model_name(model_name)
relation_info = [
add_istio_pilot_to_harness(harness, other_app=name) for name in related_applications
]
harness.begin()
# Act
harness.charm._handle_istio_pilot_relation()
# Assert on the relation data
# The correct number of relations exist
assert len(harness.model.relations["istio-pilot"]) == len(relation_info)
# For each relation, the relation data is correct
for this_relation_info in relation_info:
actual_data = yaml.safe_load(
harness.get_relation_data(this_relation_info["rel_id"], "istio-pilot")["data"]
)
assert expected_data == actual_data
def test_handle_istio_pilot_relation_waiting_on_version(self, harness):
"""Tests that the _handle_istio_pilot_relation helper raises on incomplete data."""
# Arrange
harness.add_relation("istio-pilot", "other")
harness.begin()
# Act and assert
with pytest.raises(ErrorWithStatus) as err:
harness.charm._handle_istio_pilot_relation()
assert "versions not found" in err.value.msg
def test_reconcile_gateway(
self, harness, kubernetes_resource_handler_with_client_and_existing_gateway
):
"""Tests that reconcile_gateway works when expected."""
# Arrange
(
mocked_krh_class,
mocked_lightkube_client,
existing_gateway_name,
) = kubernetes_resource_handler_with_client_and_existing_gateway
default_gateway = "my-gateway"
ssl_crt = ""
ssl_key = ""
harness.update_config(
{
"default-gateway": default_gateway,
"ssl-crt": ssl_crt,
"ssl-key": ssl_key,
}
)
harness.begin()
# Act
harness.charm._reconcile_gateway()
# Assert
# We've mocked the list method very broadly. Ensure we only get called the time we expect
assert mocked_lightkube_client.list.call_count == 2
created_resources = [
args_list.args[0] for args_list in mocked_lightkube_client.list.call_args_list
]
assert GATEWAY_LIGHTKUBE_RESOURCE in created_resources
assert Secret in created_resources
# Assert that we tried to remove the old gateway
assert mocked_lightkube_client.delete.call_args.kwargs["name"] == existing_gateway_name
# Assert that we tried to create our gateway
assert mocked_lightkube_client.apply.call_count == 1
assert (
mocked_lightkube_client.apply.call_args.kwargs["obj"].metadata.name == default_gateway
)
@pytest.mark.parametrize(
"related_applications",
[
([]), # No related applications
(["other1"]), # A single related application
(["other1", "other2", "other3"]), # Multiple related applications
],
)
def test_reconcile_ingress(
self,
related_applications,
harness,
kubernetes_resource_handler_with_client_and_existing_virtualservice,
):
"""Tests that _reconcile_ingress succeeds as expected.
Asserts that previous VirtualServices are removed and that the any new ones are created.
"""
# Arrange
(
mocked_krh_class,
mocked_lightkube_client,
existing_virtualservice_name,
) = kubernetes_resource_handler_with_client_and_existing_virtualservice
harness.begin()
relation_info = [add_ingress_to_harness(harness, name) for name in related_applications]
event = "not-a-relation-broken-event"
ingress_data = harness.charm._get_ingress_data(event)
# Act
harness.charm._reconcile_ingress(ingress_data)
# Assert
# We've mocked the list method very broadly. Ensure we only get called the time we expect
assert mocked_lightkube_client.list.call_count == 1
assert (
mocked_lightkube_client.list.call_args_list[0].args[0]
== VIRTUAL_SERVICE_LIGHTKUBE_RESOURCE
)
# Assert that we tried to remove the old VirtualService
assert (
mocked_lightkube_client.delete.call_args.kwargs["name"] == existing_virtualservice_name
)
# Assert that we tried to create a VirtualService for each related application
assert mocked_lightkube_client.apply.call_count == len(relation_info)
for i, this_relation_info in enumerate(relation_info):
assert (
mocked_lightkube_client.apply.call_args_list[i].kwargs["obj"].metadata.name
== this_relation_info["data"]["service"]
)
def test_reconcile_ingress_update_existing_virtualservice(
self, harness, kubernetes_resource_handler_with_client_and_existing_virtualservice
):
"""Tests that _reconcile_ingress works as expected when there are no related applications.
Asserts that previous VirtualServices are removed and that no new ones are created.
"""
# Arrange
(
mocked_krh_class,
mocked_lightkube_client,
existing_virtualservice_name,
) = kubernetes_resource_handler_with_client_and_existing_virtualservice
# Name this model the same as the existing VirtualService's namespace. This means when
# we try to reconcile, we will see that existing VirtualService as the same as the desired
# one, and thus we try to update it instead of delete it
harness.set_model_name(f"{existing_virtualservice_name}-namespace")
harness.begin()
# Add a VirtualService that has the same name/namespace as the existing one
relation_info = [add_ingress_to_harness(harness, existing_virtualservice_name)]
event = "not-a-relation-broken-event"
ingress_data = harness.charm._get_ingress_data(event)
# Act
harness.charm._reconcile_ingress(ingress_data)
# Assert
# We've mocked the list method very broadly. Ensure we only get called the time we expect
assert mocked_lightkube_client.list.call_count == 1
assert (
mocked_lightkube_client.list.call_args_list[0].args[0]
== VIRTUAL_SERVICE_LIGHTKUBE_RESOURCE
)
# Assert that we DO NOT try to remove the old VirtualService
assert mocked_lightkube_client.delete.call_count == 0
# Assert that we tried to apply our VirtualService to update it
assert mocked_lightkube_client.apply.call_count == len(relation_info)
for i, this_relation_info in enumerate(relation_info):
assert (
mocked_lightkube_client.apply.call_args_list[i].kwargs["obj"].metadata.name
== this_relation_info["data"]["service"]
)
@patch("charm.KubernetesResourceHandler", return_value=MagicMock())
def test_reconcile_ingress_auth(self, mocked_kubernetes_resource_handler_class, harness):
"""Tests that the _reconcile_ingress_auth helper succeeds when expected."""
mocked_krh = mocked_kubernetes_resource_handler_class.return_value
ingress_auth_data = {
"port": 1234,
"service": "some-service",
"allowed-request-headers": "header1",
"allowed-response-headers": "header2",
}
harness.begin()
harness.charm._reconcile_ingress_auth(ingress_auth_data)
mocked_krh.apply.assert_called_once()
@patch("charm._remove_envoyfilter")
@patch("charm.KubernetesResourceHandler", return_value=MagicMock())
def test_reconcile_ingress_auth_no_auth(
self, _mocked_kubernetes_resource_handler_class, mocked_remove_envoyfilter, harness
):
"""Tests that the _reconcile_ingress_auth removes the EnvoyFilter when expected."""
ingress_auth_data = {}
harness.begin()
harness.charm._reconcile_ingress_auth(ingress_auth_data)
mocked_remove_envoyfilter.assert_called_once()
def test_remove_gateway(
self, harness, kubernetes_resource_handler_with_client_and_existing_gateway
):
"""Tests that _remove_gateway works when expected.
Uses the kubernetes_resource_handler_with_client_and_existing_gateway pre-made
environment which has exactly one existing gateway that will be returned during a
client.list().
"""
# Arrange
(
mocked_krh_class,
mocked_lightkube_client,
existing_gateway_name,
) = kubernetes_resource_handler_with_client_and_existing_gateway
harness.begin()
# Act
harness.charm._remove_gateway()
# Assert
# We've mocked the list method very broadly. Ensure we only get called the time we expect
assert mocked_lightkube_client.list.call_count == 2
created_resources = [
args_list.args[0] for args_list in mocked_lightkube_client.list.call_args_list
]
assert GATEWAY_LIGHTKUBE_RESOURCE in created_resources
assert Secret in created_resources
# Assert that we tried to remove the old gateway
assert mocked_lightkube_client.delete.call_args.kwargs["name"] == existing_gateway_name
@patch("charm.Client", return_value=MagicMock())
def test_remove_envoyfilter(self, mocked_lightkube_client_class):
"""Test that _renove_envoyfilter works when expected."""
name = "test"
namespace = "test-namespace"
mocked_lightkube_client = mocked_lightkube_client_class.return_value
_remove_envoyfilter(name, namespace)
mocked_lightkube_client.delete.assert_called_once()
@pytest.mark.parametrize(
"error_code, context_raised",
[
(999, pytest.raises(ApiError)), # Generic ApiErrors are raised
(404, does_not_raise()), # 404 errors are ignored
],
)
@patch("charm.Client", return_value=MagicMock())
def test_remove_envoyfilter_error_handling(
self, mocked_lightkube_client_class, error_code, context_raised
):
"""Test that _renove_envoyfilter handles errors as expected."""
name = "test"
namespace = "test-namespace"
mocked_lightkube_client = mocked_lightkube_client_class.return_value
mocked_lightkube_client.delete.side_effect = FakeApiError(error_code)
with context_raised:
_remove_envoyfilter(name, namespace)
@pytest.mark.parametrize(
"errors, expected_status_type",
[
([], ActiveStatus),
(
[
ErrorWithStatus("0", BlockedStatus),
ErrorWithStatus("1", BlockedStatus),
ErrorWithStatus("0", WaitingStatus),
ErrorWithStatus("0", MaintenanceStatus),
],
BlockedStatus,
),
([ErrorWithStatus("0", WaitingStatus)], WaitingStatus),
],
)
def test_report_handled_errors(self, errors, expected_status_type, harness):
"""Tests that _report_handled_errors notifies users of errors via status and logging."""
# Arrange
harness.begin()
# Mock the logger
harness.charm.log = MagicMock()
# Act
harness.charm._report_handled_errors(errors)
# Assert
assert isinstance(harness.model.unit.status, expected_status_type)
if isinstance(harness.model.unit.status, ActiveStatus):
assert harness.charm.log.info.call_count == 0
else:
assert f"handled {len(errors)} errors" in harness.model.unit.status.message
assert (
harness.charm.log.info.call_count
+ harness.charm.log.warning.call_count
+ harness.charm.log.error.call_count
== len(errors) + 1
)
@pytest.mark.parametrize(
"related_applications, gateway_status",
[
([], True), # No related applications
(["other1"], True), # A single related application
(["other1", "other2", "other3"], True), # Multiple related applications
(["other1"], False), # Gateway is offline
],
)
@patch("charm.Operator._is_gateway_up", new_callable=PropertyMock)
def test_send_gateway_info(
self, mocked_is_gateway_up, related_applications, gateway_status, harness
):
"""Tests that send_gateway_info handler for the gateway-info relation works as expected."""
# Assert
# Must be leader because we write to the application part of the relation data
gateway_name = "test-gateway"
model_name = "some-model"
harness.update_config({"default-gateway": gateway_name})
harness.set_leader(True)
harness.set_model_name(model_name)
relation_info = [
add_gateway_info_to_harness(harness, other_app=name) for name in related_applications
]
harness.begin()
# Mock the gateway service status
mocked_is_gateway_up.return_value = gateway_status
expected_data = {
"gateway_name": gateway_name,
"gateway_namespace": model_name,
"gateway_up": str(gateway_status).lower(),
}
# Act
harness.charm._send_gateway_info()
# Assert on the relation data
# The correct number of relations exist
assert len(harness.model.relations["gateway-info"]) == len(relation_info)
# For each relation, the relation data is correct
for this_relation_info in relation_info:
actual_data = harness.get_relation_data(this_relation_info["rel_id"], "istio-pilot")
assert expected_data == actual_data
@pytest.mark.parametrize(