From d91172d9b22b2ef071492ed151b8ba5f9a767c0d Mon Sep 17 00:00:00 2001 From: Tullio Sebastiani Date: Thu, 3 Oct 2024 20:48:04 +0200 Subject: [PATCH] Core Refactoring, Krkn Scenario Plugin API (#694) * relocated shared libraries from `kraken` to `krkn` folder Signed-off-by: Tullio Sebastiani * AbstractScenarioPlugin and ScenarioPluginFactory Signed-off-by: Tullio Sebastiani * application_outage porting Signed-off-by: Tullio Sebastiani * arcaflow_scenarios porting Signed-off-by: Tullio Sebastiani * managedcluster_scenarios porting Signed-off-by: Tullio Sebastiani * network_chaos porting Signed-off-by: Tullio Sebastiani * node_actions porting Signed-off-by: Tullio Sebastiani * plugin_scenarios porting Signed-off-by: Tullio Sebastiani * pvc_scenarios porting Signed-off-by: Tullio Sebastiani * service_disruption porting Signed-off-by: Tullio Sebastiani * service_hijacking porting Signed-off-by: Tullio Sebastiani * cluster_shut_down_scenarios porting Signed-off-by: Tullio Sebastiani * syn_flood porting Signed-off-by: Tullio Sebastiani * time_scenarios porting Signed-off-by: Tullio Sebastiani * zone_outages porting Signed-off-by: Tullio Sebastiani * ScenarioPluginFactory tests Signed-off-by: Tullio Sebastiani * unit tests update Signed-off-by: Tullio Sebastiani * pod_scenarios and post actions deprecated Signed-off-by: Tullio Sebastiani scenarios post_actions Signed-off-by: Tullio Sebastiani * funtests and config update Signed-off-by: Tullio Sebastiani * run_krkn.py update Signed-off-by: Tullio Sebastiani * utils porting Signed-off-by: Tullio Sebastiani * API Documentation Signed-off-by: Tullio Sebastiani * container_scenarios porting Signed-off-by: Tullio Sebastiani fix Signed-off-by: Tullio Sebastiani * funtest fix Signed-off-by: Tullio Sebastiani * document gif update Signed-off-by: Tullio Sebastiani * Documentation + tests update Signed-off-by: Tullio Sebastiani * removed example plugin Signed-off-by: Tullio Sebastiani * global renaming Signed-off-by: Tullio Sebastiani test fix Signed-off-by: Tullio Sebastiani test fix Signed-off-by: Tullio Sebastiani * config.yaml typos Signed-off-by: Tullio Sebastiani typos Signed-off-by: Tullio Sebastiani * removed `plugin_scenarios` from NativScenarioPlugin class Signed-off-by: Tullio Sebastiani * pod_network_scenarios type added Signed-off-by: Tullio Sebastiani * documentation update Signed-off-by: Tullio Sebastiani * krkn-lib update Signed-off-by: Tullio Sebastiani typo Signed-off-by: Tullio Sebastiani --------- Signed-off-by: Tullio Sebastiani --- CI/tests/test_app_outages.sh | 2 +- CI/tests/test_arca_cpu_hog.sh | 6 +- CI/tests/test_arca_io_hog.sh | 6 +- CI/tests/test_arca_memory_hog.sh | 6 +- CI/tests/test_container.sh | 2 +- CI/tests/test_namespace.sh | 4 +- CI/tests/test_net_chaos.sh | 2 +- CI/tests/test_service_hijacking.sh | 2 +- CI/tests/test_telemetry.sh | 4 +- README.md | 6 + config/config.yaml | 47 +- config/config_kind.yaml | 2 +- config/config_kubernetes.yaml | 2 +- config/config_performance.yaml | 5 +- docs/scenario_plugin_api.md | 136 +++++ docs/scenario_plugin_pycharm.gif | Bin 0 -> 348390 bytes kraken/application_outage/actions.py | 100 ---- kraken/arcaflow_plugin/__init__.py | 2 - kraken/arcaflow_plugin/arcaflow_plugin.py | 204 -------- kraken/chaos_recommender/prometheus.py | 144 ----- .../managedcluster_scenarios/manifestwork.j2 | 68 --- kraken/managedcluster_scenarios/run.py | 78 --- kraken/network_chaos/actions.py | 228 -------- kraken/node_actions/run.py | 174 ------ kraken/plugins/__init__.py | 332 ------------ kraken/plugins/__main__.py | 4 - kraken/pod_scenarios/setup.py | 269 ---------- kraken/post_actions/actions.py | 48 -- kraken/pvc/pvc_scenario.py | 392 -------------- .../common_service_disruption_functions.py | 338 ------------ kraken/service_hijacking/service_hijacking.py | 103 ---- kraken/shut_down/common_shut_down_func.py | 208 -------- kraken/syn_flood/__init__.py | 1 - kraken/syn_flood/syn_flood.py | 148 ------ kraken/time_actions/common_time_functions.py | 402 -------------- kraken/utils/functions.py | 60 --- kraken/zone_outage/actions.py | 138 ----- {kraken => krkn}/__init__.py | 0 krkn/cerberus/__init__.py | 1 + {kraken => krkn}/cerberus/setup.py | 0 .../chaos_recommender/__init__.py | 0 .../chaos_recommender/analysis.py | 102 ++-- .../chaos_recommender/kraken_tests.py | 12 +- krkn/chaos_recommender/prometheus.py | 203 +++++++ .../invoke}/__init__.py | 0 {kraken => krkn}/invoke/command.py | 0 .../performance_dashboards}/__init__.py | 0 .../performance_dashboards/setup.py | 4 +- {kraken => krkn}/prometheus/__init__.py | 0 {kraken => krkn}/prometheus/client.py | 170 +++--- .../scenario_plugins}/__init__.py | 0 .../abstract_scenario_plugin.py | 115 ++++ .../application_outage}/__init__.py | 0 .../application_outage_scenario_plugin.py | 88 ++++ .../scenario_plugins/arcaflow}/__init__.py | 0 .../arcaflow/arcaflow_scenario_plugin.py | 197 +++++++ .../arcaflow}/context_auth.py | 50 +- .../arcaflow}/fixtures/ca.crt | 0 .../arcaflow}/fixtures/client.crt | 0 .../arcaflow}/fixtures/client.key | 0 .../arcaflow}/test_context_auth.py | 18 +- .../scenario_plugins/container}/__init__.py | 0 .../container/container_scenario_plugin.py | 232 ++++++++ .../managed_cluster}/__init__.py | 0 .../managed_cluster/common_functions.py | 25 +- .../managed_cluster_scenario_plugin.py | 127 +++++ .../managed_cluster/scenarios.py | 132 +++-- .../scenario_plugins/native}/__init__.py | 0 .../native/native_scenario_plugin.py | 93 ++++ .../native}/network/cerberus.py | 0 .../native}/network/ingress_shaping.py | 0 .../scenario_plugins/native}/network/job.j2 | 0 .../native}/network/kubernetes_functions.py | 0 .../native}/network/pod_interface.j2 | 0 .../native}/network/pod_module.j2 | 0 .../native}/node_scenarios/ibmcloud_plugin.py | 177 ++++--- .../node_scenarios/kubernetes_functions.py | 0 .../native}/node_scenarios/vmware_plugin.py | 214 +++----- krkn/scenario_plugins/native/plugins.py | 176 +++++++ .../native}/pod_network_outage/cerberus.py | 0 .../native}/pod_network_outage/job.j2 | 0 .../kubernetes_functions.py | 0 .../native}/pod_network_outage/pod_module.j2 | 0 .../pod_network_outage_plugin.py | 0 .../native}/run_python_plugin.py | 0 .../network_chaos}/__init__.py | 0 .../scenario_plugins}/network_chaos/job.j2 | 0 .../network_chaos_scenario_plugin.py | 255 +++++++++ .../scenario_plugins}/network_chaos/pod.j2 | 0 .../node_actions}/__init__.py | 0 .../node_actions/abstract_node_scenarios.py | 39 +- .../node_actions/alibaba_node_scenarios.py | 123 +++-- .../node_actions/aws_node_scenarios.py | 126 +++-- .../node_actions/az_node_scenarios.py | 104 ++-- .../node_actions/bm_node_scenarios.py | 65 ++- .../node_actions/common_node_functions.py | 29 +- .../node_actions/docker_node_scenarios.py | 70 ++- .../node_actions/gcp_node_scenarios.py | 164 ++++-- .../general_cloud_node_scenarios.py | 26 +- .../node_actions_scenario_plugin.py | 219 ++++++++ .../node_actions/openstack_node_scenarios.py | 97 ++-- .../scenario_plugins/pvc}/__init__.py | 0 .../pvc/pvc_scenario_plugin.py | 324 ++++++++++++ .../scenario_plugin_factory.py | 134 +++++ .../service_disruption}/__init__.py | 0 .../service_disruption_scenario_plugin.py | 345 ++++++++++++ .../service_hijacking}/__init__.py | 0 .../service_hijacking_scenario_plugin.py | 108 ++++ .../scenario_plugins/shut_down}/__init__.py | 0 .../shut_down/shut_down_scenario_plugin.py | 151 ++++++ .../scenario_plugins/syn_flood}/__init__.py | 0 .../syn_flood/syn_flood_scenario_plugin.py | 139 +++++ .../scenario_plugins/time_actions/__init__.py | 0 .../time_actions_scenario_plugin.py | 352 +++++++++++++ krkn/scenario_plugins/zone_outage/__init__.py | 0 .../zone_outage_scenario_plugin.py | 102 ++++ krkn/tests/__init__.py | 0 krkn/tests/test_classes/__init__.py | 21 + .../test_classes/correct_scenario_plugin.py | 22 + .../duplicated_scenario_plugin.py | 20 + .../duplicated_two_scenario_plugin.py | 20 + .../test_classes/example_scenario_plugin.py | 56 ++ .../snake_case_mismatch_scenario_plugin.py | 22 + .../wrong_classname_scenario_plugin.py | 22 + krkn/tests/test_classes/wrong_module.py | 22 + krkn/tests/test_plugin_factory.py | 110 ++++ {kraken => krkn}/utils/TeeLogHandler.py | 0 {kraken => krkn}/utils/__init__.py | 0 krkn/utils/functions.py | 80 +++ requirements.txt | 2 +- run_kraken.py | 495 ++++++++---------- .../{arcaflow => kube}/cpu-hog/config.yaml | 0 .../{arcaflow => kube}/cpu-hog/input.yaml | 0 .../cpu-hog/sub-workflow.yaml | 0 .../{arcaflow => kube}/cpu-hog/workflow.yaml | 0 .../{arcaflow => kube}/io-hog/config.yaml | 0 .../{arcaflow => kube}/io-hog/input.yaml | 0 .../io-hog/sub-workflow.yaml | 0 .../{arcaflow => kube}/io-hog/workflow.yaml | 0 .../{arcaflow => kube}/memory-hog/config.yaml | 0 .../{arcaflow => kube}/memory-hog/input.yaml | 0 .../memory-hog/sub-workflow.yaml | 0 .../memory-hog/workflow.yaml | 0 .../openshift/post_action_etcd_container.py | 29 - .../openshift/post_action_etcd_example_py.py | 23 - scenarios/openshift/post_action_namespace.py | 28 - .../openshift/post_action_prometheus.yml | 6 - scenarios/openshift/post_action_regex.py | 90 ---- scenarios/openshift/post_action_regex.sh | 11 - scenarios/openshift/post_action_shut_down.py | 76 --- tests/test_ingress_network_plugin.py | 22 +- tests/test_run_python_plugin.py | 23 +- tests/test_vmware_plugin.py | 20 +- utils/chaos_recommender/chaos_recommender.py | 238 ++++++--- 154 files changed, 5412 insertions(+), 4827 deletions(-) create mode 100644 docs/scenario_plugin_api.md create mode 100644 docs/scenario_plugin_pycharm.gif delete mode 100644 kraken/application_outage/actions.py delete mode 100644 kraken/arcaflow_plugin/__init__.py delete mode 100644 kraken/arcaflow_plugin/arcaflow_plugin.py delete mode 100644 kraken/chaos_recommender/prometheus.py delete mode 100644 kraken/managedcluster_scenarios/manifestwork.j2 delete mode 100644 kraken/managedcluster_scenarios/run.py delete mode 100644 kraken/network_chaos/actions.py delete mode 100644 kraken/node_actions/run.py delete mode 100644 kraken/plugins/__init__.py delete mode 100644 kraken/plugins/__main__.py delete mode 100644 kraken/pod_scenarios/setup.py delete mode 100644 kraken/post_actions/actions.py delete mode 100644 kraken/pvc/pvc_scenario.py delete mode 100644 kraken/service_disruption/common_service_disruption_functions.py delete mode 100644 kraken/service_hijacking/service_hijacking.py delete mode 100644 kraken/shut_down/common_shut_down_func.py delete mode 100644 kraken/syn_flood/__init__.py delete mode 100644 kraken/syn_flood/syn_flood.py delete mode 100644 kraken/time_actions/common_time_functions.py delete mode 100644 kraken/utils/functions.py delete mode 100644 kraken/zone_outage/actions.py rename {kraken => krkn}/__init__.py (100%) create mode 100644 krkn/cerberus/__init__.py rename {kraken => krkn}/cerberus/setup.py (100%) rename {kraken => krkn}/chaos_recommender/__init__.py (100%) rename {kraken => krkn}/chaos_recommender/analysis.py (56%) rename {kraken => krkn}/chaos_recommender/kraken_tests.py (71%) create mode 100644 krkn/chaos_recommender/prometheus.py rename {kraken/application_outage => krkn/invoke}/__init__.py (100%) rename {kraken => krkn}/invoke/command.py (100%) rename {kraken/cerberus => krkn/performance_dashboards}/__init__.py (100%) rename {kraken => krkn}/performance_dashboards/setup.py (90%) rename {kraken => krkn}/prometheus/__init__.py (100%) rename {kraken => krkn}/prometheus/client.py (51%) rename {kraken/invoke => krkn/scenario_plugins}/__init__.py (100%) create mode 100644 krkn/scenario_plugins/abstract_scenario_plugin.py rename {kraken/managedcluster_scenarios => krkn/scenario_plugins/application_outage}/__init__.py (100%) create mode 100644 krkn/scenario_plugins/application_outage/application_outage_scenario_plugin.py rename {kraken/network_chaos => krkn/scenario_plugins/arcaflow}/__init__.py (100%) create mode 100644 krkn/scenario_plugins/arcaflow/arcaflow_scenario_plugin.py rename {kraken/arcaflow_plugin => krkn/scenario_plugins/arcaflow}/context_auth.py (80%) rename {kraken/arcaflow_plugin => krkn/scenario_plugins/arcaflow}/fixtures/ca.crt (100%) rename {kraken/arcaflow_plugin => krkn/scenario_plugins/arcaflow}/fixtures/client.crt (100%) rename {kraken/arcaflow_plugin => krkn/scenario_plugins/arcaflow}/fixtures/client.key (100%) rename {kraken/arcaflow_plugin => krkn/scenario_plugins/arcaflow}/test_context_auth.py (96%) rename {kraken/node_actions => krkn/scenario_plugins/container}/__init__.py (100%) create mode 100644 krkn/scenario_plugins/container/container_scenario_plugin.py rename {kraken/performance_dashboards => krkn/scenario_plugins/managed_cluster}/__init__.py (100%) rename kraken/managedcluster_scenarios/common_managedcluster_functions.py => krkn/scenario_plugins/managed_cluster/common_functions.py (65%) create mode 100644 krkn/scenario_plugins/managed_cluster/managed_cluster_scenario_plugin.py rename kraken/managedcluster_scenarios/managedcluster_scenarios.py => krkn/scenario_plugins/managed_cluster/scenarios.py (60%) rename {kraken/pod_scenarios => krkn/scenario_plugins/native}/__init__.py (100%) create mode 100644 krkn/scenario_plugins/native/native_scenario_plugin.py rename {kraken/plugins => krkn/scenario_plugins/native}/network/cerberus.py (100%) rename {kraken/plugins => krkn/scenario_plugins/native}/network/ingress_shaping.py (100%) rename {kraken/plugins => krkn/scenario_plugins/native}/network/job.j2 (100%) rename {kraken/plugins => krkn/scenario_plugins/native}/network/kubernetes_functions.py (100%) rename {kraken/plugins => krkn/scenario_plugins/native}/network/pod_interface.j2 (100%) rename {kraken/plugins => krkn/scenario_plugins/native}/network/pod_module.j2 (100%) rename {kraken/plugins => krkn/scenario_plugins/native}/node_scenarios/ibmcloud_plugin.py (81%) rename {kraken/plugins => krkn/scenario_plugins/native}/node_scenarios/kubernetes_functions.py (100%) rename {kraken/plugins => krkn/scenario_plugins/native}/node_scenarios/vmware_plugin.py (79%) create mode 100644 krkn/scenario_plugins/native/plugins.py rename {kraken/plugins => krkn/scenario_plugins/native}/pod_network_outage/cerberus.py (100%) rename {kraken/plugins => krkn/scenario_plugins/native}/pod_network_outage/job.j2 (100%) rename {kraken/plugins => krkn/scenario_plugins/native}/pod_network_outage/kubernetes_functions.py (100%) rename {kraken/plugins => krkn/scenario_plugins/native}/pod_network_outage/pod_module.j2 (100%) rename {kraken/plugins => krkn/scenario_plugins/native}/pod_network_outage/pod_network_outage_plugin.py (100%) rename {kraken/plugins => krkn/scenario_plugins/native}/run_python_plugin.py (100%) rename {kraken/post_actions => krkn/scenario_plugins/network_chaos}/__init__.py (100%) rename {kraken => krkn/scenario_plugins}/network_chaos/job.j2 (100%) create mode 100644 krkn/scenario_plugins/network_chaos/network_chaos_scenario_plugin.py rename {kraken => krkn/scenario_plugins}/network_chaos/pod.j2 (100%) rename {kraken/pvc => krkn/scenario_plugins/node_actions}/__init__.py (100%) rename {kraken => krkn/scenario_plugins}/node_actions/abstract_node_scenarios.py (83%) rename {kraken => krkn/scenario_plugins}/node_actions/alibaba_node_scenarios.py (75%) rename {kraken => krkn/scenario_plugins}/node_actions/aws_node_scenarios.py (77%) rename {kraken => krkn/scenario_plugins}/node_actions/az_node_scenarios.py (80%) rename {kraken => krkn/scenario_plugins}/node_actions/bm_node_scenarios.py (79%) rename {kraken => krkn/scenario_plugins}/node_actions/common_node_functions.py (76%) rename {kraken => krkn/scenario_plugins}/node_actions/docker_node_scenarios.py (67%) rename {kraken => krkn/scenario_plugins}/node_actions/gcp_node_scenarios.py (66%) rename {kraken => krkn/scenario_plugins}/node_actions/general_cloud_node_scenarios.py (52%) create mode 100644 krkn/scenario_plugins/node_actions/node_actions_scenario_plugin.py rename {kraken => krkn/scenario_plugins}/node_actions/openstack_node_scenarios.py (79%) rename {kraken/service_disruption => krkn/scenario_plugins/pvc}/__init__.py (100%) create mode 100644 krkn/scenario_plugins/pvc/pvc_scenario_plugin.py create mode 100644 krkn/scenario_plugins/scenario_plugin_factory.py rename {kraken/service_hijacking => krkn/scenario_plugins/service_disruption}/__init__.py (100%) create mode 100644 krkn/scenario_plugins/service_disruption/service_disruption_scenario_plugin.py rename {kraken/shut_down => krkn/scenario_plugins/service_hijacking}/__init__.py (100%) create mode 100644 krkn/scenario_plugins/service_hijacking/service_hijacking_scenario_plugin.py rename {kraken/time_actions => krkn/scenario_plugins/shut_down}/__init__.py (100%) create mode 100644 krkn/scenario_plugins/shut_down/shut_down_scenario_plugin.py rename {kraken/zone_outage => krkn/scenario_plugins/syn_flood}/__init__.py (100%) create mode 100644 krkn/scenario_plugins/syn_flood/syn_flood_scenario_plugin.py create mode 100644 krkn/scenario_plugins/time_actions/__init__.py create mode 100644 krkn/scenario_plugins/time_actions/time_actions_scenario_plugin.py create mode 100644 krkn/scenario_plugins/zone_outage/__init__.py create mode 100644 krkn/scenario_plugins/zone_outage/zone_outage_scenario_plugin.py create mode 100644 krkn/tests/__init__.py create mode 100644 krkn/tests/test_classes/__init__.py create mode 100644 krkn/tests/test_classes/correct_scenario_plugin.py create mode 100644 krkn/tests/test_classes/duplicated_scenario_plugin.py create mode 100644 krkn/tests/test_classes/duplicated_two_scenario_plugin.py create mode 100644 krkn/tests/test_classes/example_scenario_plugin.py create mode 100644 krkn/tests/test_classes/snake_case_mismatch_scenario_plugin.py create mode 100644 krkn/tests/test_classes/wrong_classname_scenario_plugin.py create mode 100644 krkn/tests/test_classes/wrong_module.py create mode 100644 krkn/tests/test_plugin_factory.py rename {kraken => krkn}/utils/TeeLogHandler.py (100%) rename {kraken => krkn}/utils/__init__.py (100%) create mode 100644 krkn/utils/functions.py rename scenarios/{arcaflow => kube}/cpu-hog/config.yaml (100%) rename scenarios/{arcaflow => kube}/cpu-hog/input.yaml (100%) rename scenarios/{arcaflow => kube}/cpu-hog/sub-workflow.yaml (100%) rename scenarios/{arcaflow => kube}/cpu-hog/workflow.yaml (100%) rename scenarios/{arcaflow => kube}/io-hog/config.yaml (100%) rename scenarios/{arcaflow => kube}/io-hog/input.yaml (100%) rename scenarios/{arcaflow => kube}/io-hog/sub-workflow.yaml (100%) rename scenarios/{arcaflow => kube}/io-hog/workflow.yaml (100%) rename scenarios/{arcaflow => kube}/memory-hog/config.yaml (100%) rename scenarios/{arcaflow => kube}/memory-hog/input.yaml (100%) rename scenarios/{arcaflow => kube}/memory-hog/sub-workflow.yaml (100%) rename scenarios/{arcaflow => kube}/memory-hog/workflow.yaml (100%) delete mode 100755 scenarios/openshift/post_action_etcd_container.py delete mode 100755 scenarios/openshift/post_action_etcd_example_py.py delete mode 100755 scenarios/openshift/post_action_namespace.py delete mode 100644 scenarios/openshift/post_action_prometheus.yml delete mode 100755 scenarios/openshift/post_action_regex.py delete mode 100755 scenarios/openshift/post_action_regex.sh delete mode 100644 scenarios/openshift/post_action_shut_down.py diff --git a/CI/tests/test_app_outages.sh b/CI/tests/test_app_outages.sh index 51bf3372..8448b619 100755 --- a/CI/tests/test_app_outages.sh +++ b/CI/tests/test_app_outages.sh @@ -10,7 +10,7 @@ function functional_test_app_outage { yq -i '.application_outage.duration=10' scenarios/openshift/app_outage.yaml yq -i '.application_outage.pod_selector={"scenario":"outage"}' scenarios/openshift/app_outage.yaml yq -i '.application_outage.namespace="default"' scenarios/openshift/app_outage.yaml - export scenario_type="application_outages" + export scenario_type="application_outages_scenarios" export scenario_file="scenarios/openshift/app_outage.yaml" export post_config="" envsubst < CI/config/common_test_config.yaml > CI/config/app_outage.yaml diff --git a/CI/tests/test_arca_cpu_hog.sh b/CI/tests/test_arca_cpu_hog.sh index 51fc24e1..7989be21 100644 --- a/CI/tests/test_arca_cpu_hog.sh +++ b/CI/tests/test_arca_cpu_hog.sh @@ -7,9 +7,9 @@ trap finish EXIT function functional_test_arca_cpu_hog { - yq -i '.input_list[0].node_selector={"kubernetes.io/hostname":"kind-worker2"}' scenarios/arcaflow/cpu-hog/input.yaml - export scenario_type="arcaflow_scenarios" - export scenario_file="scenarios/arcaflow/cpu-hog/input.yaml" + yq -i '.input_list[0].node_selector={"kubernetes.io/hostname":"kind-worker2"}' scenarios/kube/cpu-hog/input.yaml + export scenario_type="hog_scenarios" + export scenario_file="scenarios/kube/cpu-hog/input.yaml" export post_config="" envsubst < CI/config/common_test_config.yaml > CI/config/arca_cpu_hog.yaml python3 -m coverage run -a run_kraken.py -c CI/config/arca_cpu_hog.yaml diff --git a/CI/tests/test_arca_io_hog.sh b/CI/tests/test_arca_io_hog.sh index 652e883b..155cbd11 100644 --- a/CI/tests/test_arca_io_hog.sh +++ b/CI/tests/test_arca_io_hog.sh @@ -7,9 +7,9 @@ trap finish EXIT function functional_test_arca_io_hog { - yq -i '.input_list[0].node_selector={"kubernetes.io/hostname":"kind-worker2"}' scenarios/arcaflow/io-hog/input.yaml - export scenario_type="arcaflow_scenarios" - export scenario_file="scenarios/arcaflow/io-hog/input.yaml" + yq -i '.input_list[0].node_selector={"kubernetes.io/hostname":"kind-worker2"}' scenarios/kube/io-hog/input.yaml + export scenario_type="hog_scenarios" + export scenario_file="scenarios/kube/io-hog/input.yaml" export post_config="" envsubst < CI/config/common_test_config.yaml > CI/config/arca_io_hog.yaml python3 -m coverage run -a run_kraken.py -c CI/config/arca_io_hog.yaml diff --git a/CI/tests/test_arca_memory_hog.sh b/CI/tests/test_arca_memory_hog.sh index c6f6b7bb..83e12961 100644 --- a/CI/tests/test_arca_memory_hog.sh +++ b/CI/tests/test_arca_memory_hog.sh @@ -7,9 +7,9 @@ trap finish EXIT function functional_test_arca_memory_hog { - yq -i '.input_list[0].node_selector={"kubernetes.io/hostname":"kind-worker2"}' scenarios/arcaflow/memory-hog/input.yaml - export scenario_type="arcaflow_scenarios" - export scenario_file="scenarios/arcaflow/memory-hog/input.yaml" + yq -i '.input_list[0].node_selector={"kubernetes.io/hostname":"kind-worker2"}' scenarios/kube/memory-hog/input.yaml + export scenario_type="hog_scenarios" + export scenario_file="scenarios/kube/memory-hog/input.yaml" export post_config="" envsubst < CI/config/common_test_config.yaml > CI/config/arca_memory_hog.yaml python3 -m coverage run -a run_kraken.py -c CI/config/arca_memory_hog.yaml diff --git a/CI/tests/test_container.sh b/CI/tests/test_container.sh index 93e3676c..9042b021 100755 --- a/CI/tests/test_container.sh +++ b/CI/tests/test_container.sh @@ -12,7 +12,7 @@ function functional_test_container_crash { yq -i '.scenarios[0].label_selector="scenario=container"' scenarios/openshift/container_etcd.yml yq -i '.scenarios[0].container_name="fedtools"' scenarios/openshift/container_etcd.yml export scenario_type="container_scenarios" - export scenario_file="- scenarios/openshift/container_etcd.yml" + export scenario_file="scenarios/openshift/container_etcd.yml" export post_config="" envsubst < CI/config/common_test_config.yaml > CI/config/container_config.yaml diff --git a/CI/tests/test_namespace.sh b/CI/tests/test_namespace.sh index ce5e8a01..b2a1d578 100755 --- a/CI/tests/test_namespace.sh +++ b/CI/tests/test_namespace.sh @@ -6,8 +6,8 @@ trap error ERR trap finish EXIT function funtional_test_namespace_deletion { - export scenario_type="namespace_scenarios" - export scenario_file="- scenarios/openshift/ingress_namespace.yaml" + export scenario_type="service_disruption_scenarios" + export scenario_file="scenarios/openshift/ingress_namespace.yaml" export post_config="" yq '.scenarios[0].namespace="^namespace-scenario$"' -i scenarios/openshift/ingress_namespace.yaml yq '.scenarios[0].wait_time=30' -i scenarios/openshift/ingress_namespace.yaml diff --git a/CI/tests/test_net_chaos.sh b/CI/tests/test_net_chaos.sh index b7a4eb5a..767ab0d1 100755 --- a/CI/tests/test_net_chaos.sh +++ b/CI/tests/test_net_chaos.sh @@ -15,7 +15,7 @@ function functional_test_network_chaos { yq -i 'del(.network_chaos.egress.latency)' scenarios/openshift/network_chaos.yaml yq -i 'del(.network_chaos.egress.loss)' scenarios/openshift/network_chaos.yaml - export scenario_type="network_chaos" + export scenario_type="network_chaos_scenarios" export scenario_file="scenarios/openshift/network_chaos.yaml" export post_config="" envsubst < CI/config/common_test_config.yaml > CI/config/network_chaos.yaml diff --git a/CI/tests/test_service_hijacking.sh b/CI/tests/test_service_hijacking.sh index fedb75ca..37c092c3 100644 --- a/CI/tests/test_service_hijacking.sh +++ b/CI/tests/test_service_hijacking.sh @@ -35,7 +35,7 @@ TEXT_MIME="text/plain; charset=utf-8" function functional_test_service_hijacking { - export scenario_type="service_hijacking" + export scenario_type="service_hijacking_scenarios" export scenario_file="scenarios/kube/service_hijacking.yaml" export post_config="" envsubst < CI/config/common_test_config.yaml > CI/config/service_hijacking.yaml diff --git a/CI/tests/test_telemetry.sh b/CI/tests/test_telemetry.sh index 15c47220..e1f83bf3 100644 --- a/CI/tests/test_telemetry.sh +++ b/CI/tests/test_telemetry.sh @@ -18,8 +18,8 @@ function functional_test_telemetry { yq -i '.performance_monitoring.prometheus_url="http://localhost:9090"' CI/config/common_test_config.yaml yq -i '.telemetry.run_tag=env(RUN_TAG)' CI/config/common_test_config.yaml - export scenario_type="arcaflow_scenarios" - export scenario_file="scenarios/arcaflow/cpu-hog/input.yaml" + export scenario_type="hog_scenarios" + export scenario_file="scenarios/kube/cpu-hog/input.yaml" export post_config="" envsubst < CI/config/common_test_config.yaml > CI/config/telemetry.yaml retval=$(python3 -m coverage run -a run_kraken.py -c CI/config/telemetry.yaml) diff --git a/README.md b/README.md index e9e9f089..c75052f9 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,12 @@ If adding a new scenario or tweaking the main config, be sure to add in updates Please read [this file]((CI/README.md#adding-a-test-case)) for more information on updates. +### Scenario Plugin Development + +If you're gearing up to develop new scenarios, take a moment to review our +[Scenario Plugin API Documentation](docs/scenario_plugin_api.md). +It’s the perfect starting point to tap into your chaotic creativity! + ### Community Key Members(slack_usernames/full name): paigerube14/Paige Rubendall, mffiedler/Mike Fiedler, tsebasti/Tullio Sebastiani, yogi/Yogananth Subramanian, sahil/Sahil Shah, pradeep/Pradeep Surisetty and ravielluri/Naga Ravi Chaitanya Elluri. * [**#krkn on Kubernetes Slack**](https://kubernetes.slack.com/messages/C05SFMHRWK1) diff --git a/config/config.yaml b/config/config.yaml index 7c1d52c6..f26121de 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1,6 +1,6 @@ kraken: distribution: kubernetes # Distribution can be kubernetes or openshift - kubeconfig_path: ~/.kube/config # Path to kubeconfig + kubeconfig_path: ~/.kube/config # Path to kubeconfig exit_on_failure: False # Exit when a post action scenario fails publish_kraken_status: True # Can be accessed at http://0.0.0.0:8081 signal_state: RUN # Will wait for the RUN signal when set to PAUSE before running the scenarios, refer docs/signal.md for more details @@ -8,43 +8,46 @@ kraken: port: 8081 # Signal port chaos_scenarios: # List of policies/chaos scenarios to load - - arcaflow_scenarios: - - scenarios/arcaflow/cpu-hog/input.yaml - - scenarios/arcaflow/memory-hog/input.yaml - - scenarios/arcaflow/io-hog/input.yaml - - application_outages: + - hog_scenarios: + - scenarios/kube/cpu-hog/input.yaml + - scenarios/kube/memory-hog/input.yaml + - scenarios/kube/io-hog/input.yaml + - scenarios/kube/io-hog/input.yaml + - application_outages_scenarios: - scenarios/openshift/app_outage.yaml - container_scenarios: # List of chaos pod scenarios to load - - - scenarios/openshift/container_etcd.yml - - plugin_scenarios: + - scenarios/openshift/container_etcd.yml + - pod_network_scenarios: + - scenarios/openshift/network_chaos_ingress.yml + - scenarios/openshift/pod_network_outage.yml + - pod_disruption_scenarios: - scenarios/openshift/etcd.yml - scenarios/openshift/regex_openshift_pod_kill.yml - - scenarios/openshift/vmware_node_scenarios.yml - - scenarios/openshift/network_chaos_ingress.yml - scenarios/openshift/prom_kill.yml - - node_scenarios: # List of chaos node scenarios to load - - scenarios/openshift/aws_node_scenarios.yml - - plugin_scenarios: - scenarios/openshift/openshift-apiserver.yml - scenarios/openshift/openshift-kube-apiserver.yml + - vmware_node_scenarios: + - scenarios/openshift/vmware_node_scenarios.yml + - ibmcloud_node_scenarios: + - scenarios/openshift/ibmcloud_node_scenarios.yml + - node_scenarios: # List of chaos node scenarios to load + - scenarios/openshift/aws_node_scenarios.yml - time_scenarios: # List of chaos time scenarios to load - scenarios/openshift/time_scenarios_example.yml - cluster_shut_down_scenarios: - - - scenarios/openshift/cluster_shut_down_scenario.yml - - scenarios/openshift/post_action_shut_down.py + - scenarios/openshift/cluster_shut_down_scenario.yml - service_disruption_scenarios: - - - scenarios/openshift/regex_namespace.yaml - - - scenarios/openshift/ingress_namespace.yaml - - scenarios/openshift/post_action_namespace.py - - zone_outages: + - scenarios/openshift/regex_namespace.yaml + - scenarios/openshift/ingress_namespace.yaml + - zone_outages_scenarios: - scenarios/openshift/zone_outage.yaml - pvc_scenarios: - scenarios/openshift/pvc_scenario.yaml - - network_chaos: + - network_chaos_scenarios: - scenarios/openshift/network_chaos.yaml - - service_hijacking: + - service_hijacking_scenarios: - scenarios/kube/service_hijacking.yaml - - syn_flood: + - syn_flood_scenarios: - scenarios/kube/syn_flood.yaml cerberus: diff --git a/config/config_kind.yaml b/config/config_kind.yaml index 495528c1..6c56b78d 100644 --- a/config/config_kind.yaml +++ b/config/config_kind.yaml @@ -6,7 +6,7 @@ kraken: publish_kraken_status: True # Can be accessed at http://0.0.0.0:8081 signal_state: RUN # Will wait for the RUN signal when set to PAUSE before running the scenarios, refer docs/signal.md for more details signal_address: 0.0.0.0 # Signal listening address - chaos_scenarios: # List of policies/chaos scenarios to load + chaos_scenarios: # List of policies/chaos scenarios to load - plugin_scenarios: - scenarios/kind/scheduler.yml - node_scenarios: diff --git a/config/config_kubernetes.yaml b/config/config_kubernetes.yaml index 13003edc..b2652acc 100644 --- a/config/config_kubernetes.yaml +++ b/config/config_kubernetes.yaml @@ -7,7 +7,7 @@ kraken: signal_state: RUN # Will wait for the RUN signal when set to PAUSE before running the scenarios, refer docs/signal.md for more details chaos_scenarios: # List of policies/chaos scenarios to load - container_scenarios: # List of chaos pod scenarios to load - - - scenarios/kube/container_dns.yml + - scenarios/kube/container_dns.yml - plugin_scenarios: - scenarios/kube/scheduler.yml diff --git a/config/config_performance.yaml b/config/config_performance.yaml index ec22023b..a03c38ef 100644 --- a/config/config_performance.yaml +++ b/config/config_performance.yaml @@ -12,15 +12,14 @@ kraken: - scenarios/openshift/regex_openshift_pod_kill.yml - scenarios/openshift/prom_kill.yml - node_scenarios: # List of chaos node scenarios to load - - scenarios/openshift/node_scenarios_example.yml + - scenarios/openshift/node_scenarios_example.yml - plugin_scenarios: - scenarios/openshift/openshift-apiserver.yml - scenarios/openshift/openshift-kube-apiserver.yml - time_scenarios: # List of chaos time scenarios to load - scenarios/openshift/time_scenarios_example.yml - cluster_shut_down_scenarios: - - - scenarios/openshift/cluster_shut_down_scenario.yml - - scenarios/openshift/post_action_shut_down.py + - scenarios/openshift/cluster_shut_down_scenario.yml - service_disruption_scenarios: - scenarios/openshift/regex_namespace.yaml - scenarios/openshift/ingress_namespace.yaml diff --git a/docs/scenario_plugin_api.md b/docs/scenario_plugin_api.md new file mode 100644 index 00000000..42260dcf --- /dev/null +++ b/docs/scenario_plugin_api.md @@ -0,0 +1,136 @@ +# Scenario Plugin API: + +This API enables seamless integration of Scenario Plugins for Krkn. Plugins are automatically +detected and loaded by the plugin loader, provided they extend the `AbstractPluginScenario` +abstract class, implement the required methods, and adhere to the specified [naming conventions](#naming-conventions). + +## Plugin folder: + +The plugin loader automatically loads plugins found in the `krkn/scenario_plugins` directory, +relative to the Krkn root folder. Each plugin must reside in its own directory and can consist +of one or more Python files. The entry point for each plugin is a Python class that extends the +[AbstractPluginScenario](../krkn/scenario_plugins/abstract_scenario_plugin.py) abstract class and implements its required methods. + +## `AbstractPluginScenario` abstract class: + +This [abstract class](../krkn/scenario_plugins/abstract_scenario_plugin.py) defines the contract between the plugin and krkn. +It consists of two methods: +- `run(...)` +- `get_scenario_type()` + +Most IDEs can automatically suggest and implement the abstract methods defined in `AbstractPluginScenario`: +![pycharm](scenario_plugin_pycharm.gif) +_(IntelliJ PyCharm)_ + +### `run(...)` + +```python + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + +``` + +This method represents the entry point of the plugin and the first method +that will be executed. +#### Parameters: + +- `run_uuid`: + - the uuid of the chaos run generated by krkn for every single run. +- `scenario`: + - the config file of the scenario that is currently executed +- `krkn_config`: + - the full dictionary representation of the `config.yaml` +- `lib_telemetry` + - it is a composite object of all the [krkn-lib](https://krkn-chaos.github.io/krkn-lib-docs/modules.html) objects and methods needed by a krkn plugin to run. +- `scenario_telemetry` + - the `ScenarioTelemetry` object of the scenario that is currently executed + +### Return value: +Returns 0 if the scenario succeeds and 1 if it fails. +> [!WARNING] +> All the exception must be handled __inside__ the run method and not propagated. + +### `get_scenario_types()`: + +```python def get_scenario_types(self) -> list[str]:``` + +Indicates the scenario types specified in the `config.yaml`. For the plugin to be properly +loaded, recognized and executed, it must be implemented and must return one or more +strings matching `scenario_type` strings set in the config. +> [!WARNING] +> Multiple strings can map to a *single* `ScenarioPlugin` but the same string cannot map +> to different plugins, an exception will be thrown for scenario_type redefinition. + +> [!Note] +> The `scenario_type` strings must be unique across all plugins; otherwise, an exception will be thrown. + +## Naming conventions: +A key requirement for developing a plugin that will be properly loaded +by the plugin loader is following the established naming conventions. +These conventions are enforced to maintain a uniform and readable codebase, +making it easier to onboard new developers from the community. + +### plugin folder: +- the plugin folder must be placed in the `krkn/scenario_plugin` folder starting from the krkn root folder +- the plugin folder __cannot__ contain the words + - `plugin` + - `scenario` +### plugin file name and class name: +- the plugin file containing the main plugin class must be named in _snake case_ and must have the suffix `_scenario_plugin`: + - `example_scenario_plugin.py` +- the main plugin class must named in _capital camel case_ and must have the suffix `ScenarioPlugin` : + - `ExampleScenarioPlugin` +- the file name must match the class name in the respective syntax: + - `example_scenario_plugin.py` -> `ExampleScenarioPlugin` + +### scenario type: +- the scenario type __must__ be unique between all the scenarios. + +### logging: +If your new scenario does not adhere to the naming conventions, an error log will be generated in the Krkn standard output, +providing details about the issue: + +```commandline +2024-10-03 18:06:31,136 [INFO] 📣 `ScenarioPluginFactory`: types from config.yaml mapped to respective classes for execution: +2024-10-03 18:06:31,136 [INFO] ✅ type: application_outages_scenarios ➡️ `ApplicationOutageScenarioPlugin` +2024-10-03 18:06:31,136 [INFO] ✅ types: [hog_scenarios, arcaflow_scenario] ➡️ `ArcaflowScenarioPlugin` +2024-10-03 18:06:31,136 [INFO] ✅ type: container_scenarios ➡️ `ContainerScenarioPlugin` +2024-10-03 18:06:31,136 [INFO] ✅ type: managedcluster_scenarios ➡️ `ManagedClusterScenarioPlugin` +2024-10-03 18:06:31,137 [INFO] ✅ types: [pod_disruption_scenarios, pod_network_scenario, vmware_node_scenarios, ibmcloud_node_scenarios] ➡️ `NativeScenarioPlugin` +2024-10-03 18:06:31,137 [INFO] ✅ type: network_chaos_scenarios ➡️ `NetworkChaosScenarioPlugin` +2024-10-03 18:06:31,137 [INFO] ✅ type: node_scenarios ➡️ `NodeActionsScenarioPlugin` +2024-10-03 18:06:31,137 [INFO] ✅ type: pvc_scenarios ➡️ `PvcScenarioPlugin` +2024-10-03 18:06:31,137 [INFO] ✅ type: service_disruption_scenarios ➡️ `ServiceDisruptionScenarioPlugin` +2024-10-03 18:06:31,137 [INFO] ✅ type: service_hijacking_scenarios ➡️ `ServiceHijackingScenarioPlugin` +2024-10-03 18:06:31,137 [INFO] ✅ type: cluster_shut_down_scenarios ➡️ `ShutDownScenarioPlugin` +2024-10-03 18:06:31,137 [INFO] ✅ type: syn_flood_scenarios ➡️ `SynFloodScenarioPlugin` +2024-10-03 18:06:31,137 [INFO] ✅ type: time_scenarios ➡️ `TimeActionsScenarioPlugin` +2024-10-03 18:06:31,137 [INFO] ✅ type: zone_outages_scenarios ➡️ `ZoneOutageScenarioPlugin` + +2024-09-18 14:48:41,735 [INFO] Failed to load Scenario Plugins: + +2024-09-18 14:48:41,735 [ERROR] ⛔ Class: ExamplePluginScenario Module: krkn.scenario_plugins.example.example_scenario_plugin +2024-09-18 14:48:41,735 [ERROR] ⚠️ scenario plugin class name must start with a capital letter, end with `ScenarioPlugin`, and cannot be just `ScenarioPlugin`. +``` + +>[!NOTE] +>If you're trying to understand how the scenario types in the config.yaml are mapped to +> their corresponding plugins, this log will guide you! +> Each scenario plugin class mentioned can be found in the `krkn/scenario_plugin` folder +> simply convert the camel case notation and remove the ScenarioPlugin suffix from the class name +> e.g `ShutDownScenarioPlugin` class can be found in the `krkn/scenario_plugin/shut_down` folder. + +## ExampleScenarioPlugin +The [ExampleScenarioPlugin](../krkn/tests/test_classes/example_scenario_plugin.py) class included in the tests folder can be used as a scaffolding for new plugins and it is considered +part of the documentation. + +For any questions or further guidance, feel free to reach out to us on the +[Kubernetes workspace](https://kubernetes.slack.com/) in the `#krkn` channel. +We’re happy to assist. Now, __release the Krkn!__ + diff --git a/docs/scenario_plugin_pycharm.gif b/docs/scenario_plugin_pycharm.gif new file mode 100644 index 0000000000000000000000000000000000000000..60221be463c9cac9030534ed4bb7223e1d64de82 GIT binary patch literal 348390 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%0A;Fqg zs8xb&Ly)&69Rp?#MI%`YBB-*mc8RHb&f~}S;NGl8KBL}zMicSkPGqKNWu2ee^}m?= z%lj12d*7+L&-apY<|@*=tvN;*{~!W$&Z&VSvW`CO+!UWW?7e-9&SQ)~N01=c0zQ*E zdizM2g6j{jp{gWiW5qFVQ1_m$7-I}UiBIptrU$-UnzMfXIugt-K-XxJNN)XvdxMOE zhy4*L#i#L6Q+%vM|J~oCOP34iOTON`H%RfwBa$Wt^yDG84C6bfu>tj{$Pr#&nYi_J zw<+<_Ywwp2_v0`TJX3zVTE0p0r?V}0qkd}FMQVvJNxyjVnKJRM*n}i5bO8LU%s-r@uznE;TGh@-J>7BwCm_yIQze}>p^lLk>A>N z`EBXCpP131ZWvi{_%H4HE^+a1?fP7P-B0a0yqj*~r*?hGxtHZcyKc986msmfvJ<1m z$xeA|sPFWEUaZdX=u){nkHIkq-40k&-hU=!CtmIlMn8Bwx}=-nbUeB=G;o3$O(U~52?s}Oi4~C=Hyl%{&RFG}I~3GxO)@Qkm2sVMr-wH*Za=OlEpmXSv{B{2E6l_Oow2DU)}q`xT&rq-DzpA^BKQCOff!+im-%Fd_XS~yJx_rk2SdWC z*r|u=*c8K|G<<$)Lr4w=b7s*pjlN`F^)?G4vf2DF zZu=jaZ_kXdx<4Irm@H4@?j~eiWJY(m<^nK}usej~{?&x=ocgp60-wN(&(t!4Jv zZHm{HH~(L0xU~L{r3ekCIq+Pq~AuFF(C4_@`>~AD6?wVnEQ0{1gH5{`}W}#{gwVkh^o9 zDJH}~{$z4{jiPuUg_znOs_;atqdV$-L3Xx zy$Hcjk!HX4Nptd{t)n&_7(cV)UwujOl+yLufjUKUuTfm-h)$BkZCr&px!&2zaK! zK6)E}B^>&~_XpxxZKrJ5`_;ecy#8@%og>8U|0)XDPsYmzQqbr)5R~}+1Q4*#*1mAj zP$Y|_B_W~2^`-F&ooGXP(m=50A%bLBAd@W%v{6@v?+o0I7~HImJRE{rbtgB(Y(<*TXg})k?JNcndU9(Rt2sZ zMrC>Me5&SE7STt-5j7@EMpx&qcs(ASH>Aw8I@bgnd|i zs7M)vB}hp=N*zU#>#_pB?)^-qA=anhmf}wR4G3Zq`miVSWYWkO^AajjB6c- ziC>w5LZO?I%`SrJ-h(K}V}PL&qzNo)gVLmBvq}#*)y*!|lQ2wJQrZ6H8QS7!IJ#)h zwq@ChHEZRQt%Tist1EU!-)+WBuU9otJVkwkIBiWslhVJ9jMu_(=%>(zDKaLV_P5Zc z|57q3Ybs%FwK56sBSk|$Z%(nv%@EdBK>kskJ^WRYjco9LkLhClpIco2-Q1)f5k>V! zZjv}e+x6<4y7c(qI5!P9dj=nf#qZ&*QbheWtYjQx@7lXD#JRXDFy&D@3hTHYh`oLh zO2cPHB#C6zD^Rjp7RX}jg;I#PYP1oj6}Jm#vL^)~eFFMZDgMgoJ{Q1$<@C^c4)0$% zeSi3mobLTsPN(~yNyD7}a{-kMD*tphx0;2nXzSh{X`)KXzd$+|o!Q=ZQ{#!}*ySoS zY!wq_^~nhZ#U`PkI40^W>2DCS{-}&rTVI;EgQEu7j`*kxG>b?%W^K$l$GQb}#9o6g zABB<%)@KkDU|E(?4199S{r!qdsYW$Xa={7_2;H;UP!{t^t|v+C4io8qR9^iVnd}cv z^BUiO7tO`ti903>E%PY7u7>;ktU1Fxi5v%4KYc>6y^IO{m`2XVLkO`R*SdTq1<_XPNrHP=APe9E@41c6>~TxYh^!I!W2Z>TH{PdpWGuV+i^0 zom($Cfn&uO<6u6__8rL4&AK(v)F8=8YbnUO@^X-3J z;r}1ytFls1<7Qgp&%&OBSGnY76j#M|`LDv>={Z|mNs~y-5am1mo|6FKDBb6fW~JHG zngNIxDFH$*L;TOD@6{vU*>zRXd5jma+ZJ|q(R1kDak1=S-bgwvqAn$A(I+*K+LxE7 zSV*W^PwM%Ozt!`eOq`cb>iJC-Qa3_9-y!V_CohzAL7jbj{_T?@j+k1jKg;=l+-G5g zK(YQ7u@Y0a6yUqV3@Q1vr0NP3!(hlWmiu*fN(Anmwu9;TQhykOq|W-rqDJUQ{-}g@xxoa%zK1zrI;D;FKUs$ zk6N9ZIlm4STMs@;Xnf>=Q9_JJoq#WDvN|E*c~|8q%l3OJlQMzhl5O0nArxM^gq}#? zT^9+Fazx!{x9~XKP^wyWx32|oq-NN6#S6m>a)EMtxGD+1*E-=05_OL2PgWc9N;D_@ zoCnFY^6zV3$Zy%bGyaC;=W*dj%^smj{p+~6#&v(kiz|?DUT9tJLw|b$-d^b?^Z?hx zp`UY)(u5wK3p@BhO#KJwG=UH~&c7meT!YAm!YOpjhP-ThqTxu!2%b7y#W-Rvortw3 z`O4AsFgLlO@s+`3c2$F|eQ(6b4G#5lTT)Is8Sy;!p4g$@R5%68G)^%fdPy@MbPN!J zl(+L-sXnVnB*JMB$`}B5kUs9QjfFkOl7AMuhYmma7cIyArGPR8Q$3-szn^pS2D@Hg z5YzN=H?XB}m=k7>{^! zAXB=}_EMmh>6 zVUsppCi~&#@og@P+a?t$Rl&!d0`8`Ql2SgS#jpDK`)o4Bb;@;Bf>mkPG@NL!AiXOb zbuNYiOl~xTC8BxoNjHMR&WH^}bDc*I`$xxc`+qghk)70eR}cnsM^(AiM(lXAFA%-t=Vz&O%~Vv$v@)oWvvsLt5fq&G`qoFR4ZdCsq2 zq^5Wq+$67DaznT1{npoKTu+b;A}H!>Dahk)YJ-^|sCLdrUo;V5_}QN?5cadP^6&oK zi-|`TR7U8}->Hy>+|j~z%J{>{>m^;1S>B%CoNC~J44t`d`v)NHA2)RAe=cMkav)gk ze1q)2Y7)OG%5k>%=F}@KFJ#(;*Ne>QB$DflePYF}jkx3VLgd4#FRf#A7$~dFXQFug z0t}r5^NLdyuOdZrmWs*q@4Jj`muJ?@^IlU<>Wu3yoFml8osQ-5uJ%2!A{}I~k9W-@ zz8Az?aRXdWvfewfr}xWOLhXs_eKycM>Dgy6@atY9-rWs}|<8L`ZK5 z?}m_U9_k_9g$|PyY#%(wP8dsck(A0Sl+Id#qABWaykD0zG%3!ez0PU#>;7H)dQh{2 z1Af=O-8h9gIX0?Nt*4;WBwm0&^rG&qoeGBqo|y3&=~&}9SX(+Qq4m4bkXDMvxW^l zrAR#jc3_`Ud?o#7{b8U4Ze*BhR;CZwqHg1O=k}st`PoZeLLbjDuUCQ4eUU8B<(4k2 zSfU|`-(MEUmu0=u_1%xSVUd`cPl;w%i-3g4E8&j;O<$TJPYBp*i4ZiZ`9R99r7&h~f%Sl}VWSGE5u|Pu4EcCvkHBKNDwm{e!3rZvtyWHGo z^*PKLfieL?YsIg8hj4^q;xD|bBUJk|n$E{7FM#cUv*nA&oa#w!m$-dg+i5?>`Rqgu zTx0WnA|hC8+h-^h{fYFzyM}~OZP03Y({wqSQ7LaMH<1KE;pT3;&ppp+a`%j)3YES* zb;}Kh)=+`9-J-(Q*pC-<8!1B(X3D3BUDVD9-^gUPAGTB57H+yd%5c%%^2-T< z<0!E26JwJYthk2&ZZkgWV(>h~@U* zPt||i)BpHjf`GLmBY>>{aI0h1&wbmG6I`W0ke*FXB$VkK^N?PlLL3FT4z;QNigFSI z#dS{li6sK5M&rl4ly<~OG~*9j-&wB`Swby0(HLaA_#}K?dYZ%skt7{tL_J7%vh5Cbl-p@?QMqRqkUq7{;#a$#ddW zE@WJru=a#SF#~h#Ri4J7lOsX*V@sXjRh}rqHPs=lM|4v}^d`Xylx7LaYO3U5#5nmD zN`5=@(z0GtwOJuUCU~VukzXT5Gtx5#>ynjv-+6a~tCcN0T5nLP<;}=sM6(?07e4E& z`uHfyRz}CJO-!vW&oi*06Tz!|8c}GYZB49T)Xd3FR*6ZR6H!6BgShv!+V{HPac;rj zCD-Ysa0YAy_#uq?_S;tx z7-E9sVJPx~xwxf~vmUr$h#dqaHD8Mr!&~BjLDbFkY33b%YbiE}NE;F!Mu%w{6>Z!8 z!gy@GpYa6?VXzr1G-F0TS*uy;Mc^uzl6|VIxxYc0X%go%J=k;bF(7E03!&$h{w7=P zl3B4r`B($u?A;buqU$DwuH4FQwb|&ii7wlY9HcgZZ}D7QHfse@BH-WXcO{LrVh*-A z9>o+Vz6$yO)^+MXE|$|kRlvjv@%r6ye<++34-JD-unM$b*c2mCr!@T3?iKXM&AmIGvFzb7-si=<80eG)%o-dAOV3u(*3371&5l@UetBov z?!U6ySw(mE)dSpD#LL`0x2ayxL!Ym2DU+hI5Y%#}*kB*W5fTCCf$CP#w@+^=UZ#DQ zEw(mZu32`jrcHcvx>TLNT<%Jf2PDRqyX*U9kuWf^&mX?A=a^f37kU0?8|=*qj*y0s z1mXZkt-fFG;=7Sdqir2p~9ylN@z?Crn+=^|VyQs}ZY)Ay@&2p8ANNYjj;cEd`Y}+7rW;Wym%^%2>N6F zbDW;2O}G9j&My9KAh6_eT#vlkuJ2+iYw$}a?A2oDc{#|lfEcV45P1JA73zs^u-EOG zG7h?{*do>5uqIswI)SNgfxY&_zOd^*Y@rm?N-;yp1>Yb3`7P!jcgFwwg9yMW0tk+d zur5jfM{HOxo5ij$IQZr6^i!4!zwd`HsP>zWzU8w(*bf%C zO~EPL^&=1*%e&nLh-cM5eK@!acmjY1A&kF@uIUxBgt97cjIZh4QO`$2&iU~gm#E&= zcxN{0HeG!&nDXjU{i!G*ghqtf%z#}v9$1Q-&!Xm3^e*npE_MyY0f>kx>3O`cz^EGx zJX&}!V7T4D-w?0=S~Ar)CJPP(a zdfmy^Ey)?um^upOU=rF6kK#ov=ocvvpnmfsnOqQYA};I(vQ7?L7d8z8ACYyWN29xq zh!B*VxDh;Mc__TphxqG6=@b~bSAGDXT{8+Et8=qDX8x6f$X^E!zj>wAN)q&>i{Gn2 zRnSuDj7>H=LeRA{M402FF2CY^uS-0paf{`S33OB*XM!+GJvWS>`p8uQ_<=-a4Y!}S z<%oyD5J@AqAA2u)fbSbVg}!M2xV_RB&!nnj(b|g~E4u8(`o}%S@!ux^IQ?^UoQqzq zZt>wy2A-a6cQi3rB2UemtuLBJRHq|GzeqKj9(kRaH`!h(m4~g+(#(MKQ3~J1o~?Zf zU>K3arDY{O;Zjxbm&4D#s(e=GZ+!W&v!M2@=sW;e=_N`9z&!+LG9669jd*wg#Yr!z zyl~4G5@8_IH5mTYLjY(f=hDdME?k+(u+wvm=@noHkkmzPldf*Dm0A5q6C?eOa_lL( zi)TkCU#kr#@>?W%v@Ayvd|58j2LtZ{5Vw`1Pn*qcxMmk?7e&3h*Yt3@QG9M7|B77{ z$@!T*Sz&_1%}Y|zPZmGK1q?_BQ@HcC#k#1yjcORP1ivpvzWLtwS;dW80{G?q5iJU> z7n+tyv`vH8%?brUD32Dk>b$Q1=vh08@JWU%9QuaACxBAsiada<+^`>9x5kKx-=UTw zPub#=!~Z;&bOAhoIH1(e&=S~`f8hnUFNQ^uE#lN!x1bRjph0`PqQeW;lv@C^t|jtQ}`HhqR>aK6~T)LW;)p(^s80ZV+pd zwHDOB8Fv2;gzH7-W&;3+40v3tnChcj+G(9*cf|>R8sjTXta;PBxN`niP)+&JP^#~M zPr2wt9Eh|%O=z@JzIB?E8tt;jZ4(+v@}z;up|*1~avkIrcr^Wl`SX6&E5+#ZcRH_n z?&&`>q$5AtgCgC#LM>o#uU#wqSo7u1wJ-T~3TJ}%B^UL96ZdUgGiz)#S=o#C7WJQ9 z!*oYh8n+M#U3%qA(JDJ4FIY|`XXU|(V%f)_YKP$pP`;N+MUZFeALSuJQ);HZpnEZd zzv{r{nO{^f4=PdRCFJ#aWr^7|PU$D8(Dcx=yd3iCHnCJ01zhUMusE^Zo0-<76;49k zpG8Of=PMZKa%P5y4WKojcg;PLY`x^6xH;bPbSf9xF6zarv~w=u5_6pHHL;3uQuX7j zYbAW|0o|~20X&Ype8vVxPMB!yAGk?Rx2_AJxm7oC+G4AINaV^^!-xdGVB-@N&3BDcPT#`eT2mnfAP_IUi)cBF={?a= zP+V=x5znG)3sLsGXUC@=_YWOkMyf7%?(eluER*ml?RD|GKuncj20PhtvYdQTvpg+JF^ z9$`j(eZ!HGOShWm`{xa-Hc$kRK$xaDLA!p}#1b4>LjOkRRNE=&jwgm*SUA}}%#tz8 z>OQ@Fq;v*OLpXV^A;R`KC!qe!p^x;tPa8)@?VX3$5h6p(8q^MtQz^OkiBNcilqe9e z&w(u{e_lm(GcGTP&H20mK<#VCEj-^;gA3h6wVHe=kc_a%8%`Fv>u#x)g)k8WuKAtV z)MhE{Wv}U1&kmF+&a5vvman$FdS_)Q>!MESey`8!Q=uCFmHz%!l@bG%aO=k@vt+0O zhuH14?50aG4a&!#*_FVd4cr$HT7ZJ}`5_uUL#LYiJF(0du*CMaJ7ceBAW4Pju=_Cx zW>N6b^+k00=dIa`m-KdqrF$qM3cr)Q^cwtB*;^i@rzBWFNxtStn;<@9G{JNY&Nigy zXh30;r7-xt_t910s<;7^DFzSJW?DWCvadKxTYzP9(JQbvPxPCBobP-7l$c;m{~{J& z((+u}I+QE({1+PxM`D(DK{we7T)jBu2)&N7(bC>AGWvmER!luQ#G{jgv%`}!R}PCx zuoq=sXUMjJ zBf;f`u`gT8ixSWAxfZ8;w&1YjfHrI?9C)BxddsJ6y##S+f6D@fvccWH^TTkXjNIl- zoL+VP)LdmvpdF!R4TWe}01k6PE5GND*|reB);r{+0a+1t}r>pJ=j_ z! zbCPhXw=dSw5Zdl3hwby*BO9vTn(09;!% zAXVX6ilDyWvJ{CpS8i)XAF#F*jgI26yTO#XKM})ITD}}7pyRR}&;2;C>o3Ezl_a^n zwUwJ#DDUfJC0f_lDQc%HUf#Mn5p0Plv;gtHDG#wS!w|UkEi*N7XpnnaGpZ<$?slvBjoPGgOam#lunEN)D zTMiIv`GaZ=T%75)QBhu6xp886R#i7}#J5@9IQ4sIydE*am7n9&R#1j_TQ4i`I9;_> zKPs`g)i9>QUsH)2S+A%gcQfCD82W9#yT2I4|Nb8g&#ri8c1^_!;^y3>`-%VHU2<1% z|1JA_?DW%fS35q)ea~e5(8OT-@&M;8kB`GVWz`=EhG$nXkbUUpPLRKOtG-Zu!q&$p zzj&A0BBR3d(hWfewcVOjd_jc`V<(1ZMkep-?kI0w@e7MHg%BHz9dR{hWbnoTWFGuh0&lOW%lV9+;aSNDvl(3^K)b3E&{p!T< zj2m{QWi;}#Bv+=XNuBEX02}Prwc=Zz2VI%YERL&lU+aM76oRdb|2TaV%pY3&W>4|1|@s=<&4!xo4x4&GJ^pPx@i-a@@gbF%!SXx$x z{?x-*-4!YBMG-+)tbOJ15gvMK^`QWRAa3%WVJ44WC1jrnz?1OmkaQ2^Sho!3L3NXu z79=1EhG*%b&)+mc71k#`2+CPD#1S3O!*JI^I(B+fA+gY~@EsgwOI;ti^>n1*=#K2e@X0*^9CPi%y8{JmM71)r7+0Y|OQ4rA?ZxRfuLQ^TxD3m=F*KQZ@Tqubp zeG#)IzPP8}i!|omgbfeAS1vB4&g3>eAyG}9F?=sf7`{vf+{K}5hr(soKH9LL!&w%( zv?OG95;*5qJ}eF>p67FBX6{~9Y5aQAy9>zpih3DEXNGaiV!H|ORYa0NLH1oB8?;p9 z8M)S2CL>@zcZ)m)NaZN}6d(b5whoN|nXAiqQ9^k1LWS}uFxQ|Sc%eFOMptZfAT`^~ zgFGI2eXsCSdXt6wa`LkFp&A;XCHPVGnlda0BqlSN2+Sz&a=}1uysx(#ZVYGBv*{)y zHH?qK%c~J(YS|cwB#EIanFdjla;N=Ex!1URi`SdaneR%UUmPOaj=B$}HXvGNE<+GU zk(^shBX3D&>*lmcFee>NYeA{X%#Fi0kll26xbxEA~MUveaFifk9>@{RG?QYg7 zL?%9tYC-&No>nNg<~Ug=`%UCJZcv6XG_1XigXIkm5#qukQV-t+emMhndfT0-f)apk z4avI^t(uJ;^#B`kE`iUo#G5?Q$fQsIT*LJYm=wHVS1##jiWFeeI`hddOOb$M1dK=E z61$L>BG?#kyqG&lK-AUbW#S;T)y8cb@KwNFw!?KR?G{@oNfFHoD93nQuJAc zgWW6653mAu4N_$$Qkjv1CxkGr)%MLwMA}Wl)v2wjEu)Yh623Q{50XC89wY zGF}9FCy@mk#z;GfNtg9Pc)Si8uI_KiMyH}4H*ss{+M^y~2elhNv*7d~oT%)$vbFxEyUYfrMOgTB zvpDlYRv5oz%I%@3g8JC*Eo$dk`VJmNL{i0S^w&=?UQK{c`C#|q&EMyk?szO0VcwxE1aoG z&#{x{0+Bpv1ZfWHQXaC?Xj|vj5);n`u@`Aw_szhvn=pEk;#{wDqin=U&RGNe*KkQ! z@}$w{aYO3YBd+@qMQ(eALnOLFImtYtw!Naa%t99|oY>be#Y8gYWDxVRZ(uIej?Jt- z!p*3Qrkck!&`O_FtPoHBeE-@u6I-Y*NMLyNTxbiO5C-`rjKymad$b3k-EuSfLA|!v z^+A6-e55OvwWExqXG*Dl*{21_y^PrVDAinqcSg!A(~M_ zeF0IHfpE!Iw39dnN!Pr$!FS4-3nW8+pwD`GkqZF;xG^N17e**qRW1_4X+t_#N0jf6 z+;zCdJ?1Nmw;k_t{IIReC_#=#gIodRlX@6o40-*cz8QwR6c;YD?5RtldY1Gc}T;g?u>T(lYT2WpZ}C)f~zk#F{JN z^TUhWbDO++jD%pI@I?vArHLBx5vmzy9@&z<6GzhWdlGMoJpst#io}^Hw?juy4pXJaE=G#vKIO7^@TyW z`IE>*e4WK$P<&xYg4Yym7frUiXdl>uiG!;vi{ruuU||@Q#6WPk8sci#%^W;9c@5{c z2jTz(IbxG>x2&IM1d}ubGn>T5rT{3{=`z=7SzG`d(}W)nV8Rl8A>1fp5uIomx^O*7 zf~C5b7W3j&qG$U2^q3}9%=f+^P3HAzx3U3)#9I4xetEaUF9LHrkxx;T+AgDd@h@+w6NRd6QFEG?b1 zPYxC+F$iqwLIBZmHG!BB^p#D{jB`|(A;Yp{>amW&^v=>*l-ZfK_cFm4NJ$WALKJW` zMt-#Ib4@xs-BskqY%t1F1R;Y<^Zn-n?|yr&gL*6?ecPIoqz85e#^9VLW6G9}P7$8x^&{ zN(^r$DXuaFQ=EbVo&$icL(;WY9rQ}erOaw{X&q}Zj>b-&W_#)u686?Awl-0=_7|L- zGo5QjWE@Qf-1!t>_$A^4CeS}HFgWnu;@4WHTt=35qQVWpSB)a2Hn_LL1=1kI zD$IIq5Xe|c!f$l_?XByZ)7Pcii6^-2b_1{N!4&t)ZYXHxD_Jy_jo)KO0So6!&?u03 z9sDYmI37z}$%C<>$Jp;zUX5?Q(H>imq_H=Ng~u{;0WR{GJf1 zMvSi>y-0Kw5Kaqj0yZQ9pCy`9B^tnWFZEk&1Cj`ornb0*xBC&YQ^1X(r1$i#gVL?( zvWzSwis!ljs&U0So9X%ALX={|2Hl%k-dt5s$D9dH%W%kG0c0e{x8JX5Z)alcBvF*W zR+8c&JV#jK+ApB~N0LTvo$0Ziv)>rz=aty8<-!iIz}))*UJur;JR~c6usP2#|1E?! zOLZ^)A*dR2(Ec!Y{2@_Z7r$W_G>-vZ-G%bVX4t%g^k5`s?xw#gOlsMk>D|q6*j*{z z{d%~Y*0OMuxQAy!gh{I>ZK<30uxDC2_PcbCz-aqvO8_^bS0+K2-?BG(sYjN%5Adw_ z+!npLn~+*wpGHTY)9!4h&`4tSsCp<{p8uAJ4O2@^$#}aST3`Ys{?5Gd3B=jGJU;+SS^(GL2A*3Eg zcIDA<>eev+DnnGj*H9Lf-dS|Sh`@F>hsu517$~TbOwdWR?dKu=4bUYMoRmRQ5D<8cp_I4|)B_AJXS& zDTK4geQx$(+pJ}1%(!U0@J%`aWcym&N*k?Zzi6EuXeHI=GUpE)*217!CZeS$kVO;V zqUY{yzjso?~7{>0X6#M3V2MN3a+aj3N9y&Z|mcJ~t|R4z?9d0b)<=SZFgb4=4uenv-7QqXgt0mj1Q z{xy=b3w(-mV_-!s<)A3~SmcZnTur?P49OTt%#1d^?y~(>c{^5mE05S%nJ{NC;L(Wcr``(Z1#MH82j*wM} zFyhS?vIhXt#w!1JF7nGm7I$b8-y)Yziw3_@hVYsuhdPJS^0*wd6x;Rnv!7dbv|L6g zK7tt9klX9RKc|TSGDevme|?=Mb4J{nK@~8X;q*<9$MCrZD@~`xNLIpdwI!w}i2Tx- z1b%3`g=e0F^{Os#7~`6uCH;0e`*i^8pw^SOr!3#z&?($w$d@{j8wn%pj<84O~Tou0s%4(d3;>7!1ppKj`Tf$FK= z=lNa5tp3QN{_6Kz>a$K0u5RlX8|$f^>vOT|yq>$O4(xCt?8CmFzMjv}iYZ z&3+=!o<&>KMM`=x9UuzDNL0QI%)*Q^PZUK*Nik)#-U3raktpcWKJ6`G>7qnRrsSv? z<4GJ^4^ERpe2X zlmPWwK9@&;ivj{IPzB9Tni^Fqg;5F3sO|($@^k_fk53sjFpvsEJ^#R!#f$|~7lv9q zL#z`9d%d+lIaLck6|7fWUPP;@YaE8V6o{n9Z#(=~n5 z$EDMpRjWfCFl^;k0)zBQ--|X5T0>o{a6nHe0Mjx}(+0oAG{8kXpTEWa^PXAl7t>-d zHia_QWfN8p?Z*a0HnFAvu*P5t=mY|`U|LmgL0=u#C@|J#y{>5O3O)v8IO$*zCSlh= z_$XlbT=4NgHih1b4fNyzlt0#UPxp4On|ZJIUIFRFhEK?bYYvN8QilpDpnEd_TV5D{ zPESc1cG#?t*e1!?I!CdPoqdyCZC)oZtJZ2x_zJxbTX!%8RR2)f+Xq9x1xm}`*fe;? zI)C^0joQpk`&WJF>Y3;(({xWKFgm*l^u(=hC2rd~Fi=np0I}o5z=0rKg=58vRGAYU zDy0)A%^0v3LxREMRtp7+H0n^C5CTu!NO9}9ydano#gK_1h;2e?uvSQtCAkPOnbPGZ zhY%$K+nG}6P@+YR9z~i|=~AXmojyJKf)%chPze+;VBo-31YNuS0~=QCSh8i!o<*Bh z?OL{N-M)nzSMFT8b?x4@>$U6F16d0Ym;$wF2Uo>Vok->ea1ZPb!ra4e3|2^3{s~?|Aa%&7Vh~Uj2IZX2bLK3mCi6 z81rQPppj*sDTC2=bb#cY9YVOK94m*}#2Wpkc%4BQDok=8*pIx%OZwN@lIqAp7CsN}L&Uw;KQSYg%j zvM&Sv3bR%ket6-@8-7^xgv)3sLZ}khxg*b%zL+Lcq;9BUqZd%3p^6<2Lm`D8Y`dX| zafjOIhADPPL0V!^=>Q9D32Ham-JbOpTwHA>xL|`19!S?Iy&AS*haZMGV!Db26Jeud z9jF5`PC*A=GVZAEN$YkZ&SH~KMmc4L4+u|UmtTfCW?(5kR=$)80^yJeQvxBCU78?D zf7X1vRXKmMD~SHFJyiDmyw_iIIAjD!ri8c{rui6#i`Iy|5RE-b;Uq71Nr zP*FiDFa?zk@WB!)@&F|yQ8J{cVE+}1CFV=CKp)3(8SvbZ( zBG`y}J;fl6Nl8ekv6O4kMsY6~gCK5r0uKnHfIPTE7mdOLK)AsSWLRJY+(;-ltcwSh z2m(sr=&h7!Y>fw;;8J=BL`JGlh}PlF6gQ#*UpRwb)w0Y>P_V9nEDJH~0sJN;=LTkR6P-mt z!N*R(opY)P1wW8uCsEkRc|vOp-TdZI7THLGdhe0damg!6hJq!iVp5Pw#}rerlb~=! z9WQXnC&U2{bo61PtRhA!+7Zz_G;WuES})wpp|hPcfIgBe#hVBu;Nlti=s$;NFC0lkQ^ zZ+dM*PCiJYA!Lxm2HIE#5!BU?@QXa`}hPnXpD}lAOv>k#%SDJYfmQD#IG!&e8nZqQrC`LOY0asM!IYsKQibJ@PiO_^3IDUz%M`Dsr zt0Xo;uCtoyD1O0 zbih4_Y5&0x#xT2443SR&nMQ!%B}aztMT4@+5tn$^7flInyv!SP5ZN|4j%IBLqVRhL z8RW-dc!W}oah#PJnZHQQ3!On zwRLKf3-vPdo!@xvf9~r9M^E})-;K@MjLX?gPyryAK2C9?`^-uKxWLs;ID!kNKKIGb zes)yc0lh>s)A)fDj3M#P7^D?LhfZgeOb`~iXb3RUjEiz4>P_4TMZ0x{e|pqg5$Q&_ z{-OJ>2R{(a%mO6t2$FO~F!uoMU`N}{gX0(PwyGXR1%gOIwTGZyqtm`2;Pxe8?@-Q1 zBmcde{(f{fGj97p|NgvmokJ5|Fakjw=wr>|fq#w9SKB8g?Q6$A-P-52sT1%lQ)Tzidd?7V1;O6lT!((tofq{aW}Q%13_4jhA5?!U;(YtsyM2)akGT0 znmU5;fDW{<4hX@F(YgnMxBdw%O+W*I7y-1>E2*mrd7CyxlC*^Zoa{3~%Go~YSe9l< ziDwxYm0*^98VC;HprG-Vc$t@c>KC4CLU*wZ6abhX>j9#0LX@y4Zz&lL;zBor7XNes z!xM29F9ffl_(FoXz;ud2q(~QU*(brcC)*&GciIh==tIUD!pEVsBUHq}NkaLlj(a#0 zNQ4JTl*B@G#GpBy>s!Q49G?6!7OJ_#g2|J^AjN^W!%%!3P2|K?jF?75#aDzy>pMiI zVZ~aE8dr?PT-3#cnMKI4#b4}1uV^1o+{I!vMp^t0BJ3PsWX6}l#bbm<_)*1Xq{b_G z#%RPw?U}}E5#%u(~k6pcBY}B*=m^$b&@4gjC3dWXOhe$cKc;h?K~Qq{xc2$p4GPNQgAV zWJJUw{KtZmZV9&dC8bO$(gLlom`un z#L17;NuCr+uJOsA^v9qa%A_=!qBP2SL`tQE%9CNrrew#bjLNGtn5m>naq^OKK!bvt&!9>BU4$%eV|8l}wwqv`h1OOOKSxxFpL8Vg*W2h)hU= z2uh@*@E{N(2t!K=01KhJJeUzGp%+S_6>1?9D#RF?p&B}*qZk1*@J!FFf>S&JwA@Qu zBuj$`2V9tn``98WB#JU(u0h!iH9&_dbWDpuBQ;_{Jc}cwpd&lNqyHZuvqI{lG6N(* zDompIDmP$GJQ6~Vq{Y%q%hME;7x1Ne=_O$bO_i8}XL72RlTE~cgyoti9a4!<;?5p8 zrAtx~8Zgh>L<(!dCX}eQe-gx@s3395tr@JRbdoQ2La_S`n$>u##!Ly)l+IP8&hX5n zP2j1Kk|fDX-Y?drYhZnnrW)XvH5C?ath9rC zYCRRqD#ap*x=PHkA{4XAHnno2w%RJVlB*g8E25yI0NO~$iOvM2O6qh5J8%O#zye)E zF4bbKRT2j6It2`?(C}11beMrBLj?}VfnoRo4@d!LS{vpblxJ*qPwEV^?8RzDL~g7~8~JPHJTR7Ipt($I;A6Fd|s zw#fojSAeApWvvvj2#nB(o`{H61C(P+Hj}7`i^vEAbxdc2Hff`_`l7mQ1B7kswiEca zaC;zfYa??zvvkWVZMy?*yS*|HOXOG4Ta+}IFKV`S|-5jSj0dA z*D#FUvNEJr?S;-^`)ogXpZ9Tm#&4Vv|f_4~%++u^4&4CV~GLgNI3;~87$p$nS z17F}SS5UkZ$pKs7q7T^w*M!d)y*k*Fz3Mx<{ldNF12^77H{ZioXcaz0EhIkpz~@9# zh)r6QTu>FMSP$C>mhhAbWVYSdgKKb;c4#F<5eG)eEJJ}6ickTK>V`iE68&3^J}?wU zX;`z&z{H%v+w;K0jKP5z!F$sL6BIXc1KKld!T;tvrrQ16)^*s#h0Dcd+(p(vLV;g@j16&er-45831JSKCITNf3YIyA+kK!GI;-(d2kp|Hf+L0aUM zSmn)|NTlCMya#S;Oq6(zYA}PdEkL6nUYDH?c}1P~gW4WhAf+k&EhXU(n-@xE>=q~2IDgpDE|W{<7!M`jrpI3MIeRvOI8?#^hIM-+~PI9 zG&5Eb6*z?=I-(ItiXl)zhKebjDk-9X0zi2vq>ANGejOS}2vSBy;~isE zu0^suwq_!c;j*swy@GW}f|xMo;^Jjoz8qcN4y#aOU^Y!YMv~!33~7*tYUnWx(=czD zWiOkuaE9j78D(mI*yFWk1;yqg(XA*E|UR3v7!?g=YbeDBROZ%kqW91j7M(g z+96BLFbqvjgL;rLFj~0CuuXkdX8)9+If(ve%}M8huI7T~M6whCA+V1o=m7ly4$>14 zSq2o7Hqwh;WGCs4jppb@TuTwqyYJJ8QfWXqA*5MeW}FtlMrP@*c|wg|=UkwI<=IZtDzwYq(Znxt{9~u4}u7VZ6@k6W(jSwqd{i>lO}d!RBGYF6UJ6HzV7kKZtZTx-R^Fh{BH1m815eL>?v>aZW#1VZ|GTX_7;}DGvj`_?GXP5$~ozP1VGO`zVSfDx=l}aIazH=@9S%_m#%I&MqW~ z=y0ZEQx_mxmthK~?p)6YH<*l8XJ4Lf3cun4XNspy3I5xu&XC+9iYCtLsR)%^6Rlhl zw-srA=4th96_+p;Uy93eQYbBE;?k_!GKMQfJ&KxA<#J#)MZK%$@%ixa6E|fcUt%Ix z3QDz9OqDV)`q~Y9iT_S*3{6e374y`A@Kh_m^6N11qm^zh*X^bK#OIz=a21Mvj@nJ= z)->1FR9nAXr{Aw0Dn}Uwd(+ zdbdAsxQ}}eo_o4)Z@a&H-f4Ti7YnuT`=j}Lz@N*pAAGaVd&4Ji#1|94qy$n}pri1B zP!I=fP5gUbe8#VEzGtq~#09jaES30zbufd)x}q$~{HBQTm#%Zpe|XOaPsDKbj;*B= zxB*?F0{^B%&y={OT-qge!K6=mf!@#jPp5MfhyB=h{3Ur>-cW~!suP&%gPLlGO+bMU z4bc&m{#BVN^lJ_n|MgFB2lCAPEPr;OU;erwbrmR#H`OhR;=D7hWD(=77f>$XLV@aX zW�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 QM5!(O5R6d>m3#=py@&Et; literal 0 HcmV?d00001 diff --git a/kraken/application_outage/actions.py b/kraken/application_outage/actions.py deleted file mode 100644 index 0bd35a3c..00000000 --- a/kraken/application_outage/actions.py +++ /dev/null @@ -1,100 +0,0 @@ -import yaml -import logging -import time - -from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift - -import kraken.cerberus.setup as cerberus -from jinja2 import Template -from krkn_lib.models.telemetry import ScenarioTelemetry -from krkn_lib.utils.functions import get_yaml_item_value, log_exception - -from kraken import utils - - -# Reads the scenario config, applies and deletes a network policy to -# block the traffic for the specified duration -def run(scenarios_list, - config, - wait_duration, - telemetry: KrknTelemetryOpenshift, - telemetry_request_id: str) -> (list[str], list[ScenarioTelemetry]): - failed_post_scenarios = "" - scenario_telemetries: list[ScenarioTelemetry] = [] - failed_scenarios = [] - for app_outage_config in scenarios_list: - scenario_telemetry = ScenarioTelemetry() - scenario_telemetry.scenario = app_outage_config - scenario_telemetry.start_timestamp = time.time() - parsed_scenario_config = telemetry.set_parameters_base64(scenario_telemetry, app_outage_config) - if len(app_outage_config) > 1: - try: - with open(app_outage_config, "r") as f: - app_outage_config_yaml = yaml.full_load(f) - scenario_config = app_outage_config_yaml["application_outage"] - pod_selector = get_yaml_item_value( - scenario_config, "pod_selector", "{}" - ) - traffic_type = get_yaml_item_value( - scenario_config, "block", "[Ingress, Egress]" - ) - namespace = get_yaml_item_value( - scenario_config, "namespace", "" - ) - duration = get_yaml_item_value( - scenario_config, "duration", 60 - ) - - start_time = int(time.time()) - - network_policy_template = """--- -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: kraken-deny -spec: - podSelector: - matchLabels: {{ pod_selector }} - policyTypes: {{ traffic_type }} -""" - t = Template(network_policy_template) - rendered_spec = t.render(pod_selector=pod_selector, traffic_type=traffic_type) - yaml_spec = yaml.safe_load(rendered_spec) - # Block the traffic by creating network policy - logging.info("Creating the network policy") - - telemetry.kubecli.create_net_policy(yaml_spec, namespace) - - # wait for the specified duration - logging.info("Waiting for the specified duration in the config: %s" % (duration)) - time.sleep(duration) - - # unblock the traffic by deleting the network policy - logging.info("Deleting the network policy") - telemetry.kubecli.delete_net_policy("kraken-deny", namespace) - - logging.info("End of scenario. Waiting for the specified duration: %s" % (wait_duration)) - time.sleep(wait_duration) - - end_time = int(time.time()) - cerberus.publish_kraken_status(config, failed_post_scenarios, start_time, end_time) - except Exception as e : - scenario_telemetry.exit_status = 1 - failed_scenarios.append(app_outage_config) - log_exception(app_outage_config) - else: - scenario_telemetry.exit_status = 0 - scenario_telemetry.end_timestamp = time.time() - utils.collect_and_put_ocp_logs(telemetry, - parsed_scenario_config, - telemetry_request_id, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - utils.populate_cluster_events(scenario_telemetry, - parsed_scenario_config, - telemetry.kubecli, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - scenario_telemetries.append(scenario_telemetry) - return failed_scenarios, scenario_telemetries - diff --git a/kraken/arcaflow_plugin/__init__.py b/kraken/arcaflow_plugin/__init__.py deleted file mode 100644 index 9438d945..00000000 --- a/kraken/arcaflow_plugin/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .arcaflow_plugin import * -from .context_auth import ContextAuth diff --git a/kraken/arcaflow_plugin/arcaflow_plugin.py b/kraken/arcaflow_plugin/arcaflow_plugin.py deleted file mode 100644 index 5cd11da8..00000000 --- a/kraken/arcaflow_plugin/arcaflow_plugin.py +++ /dev/null @@ -1,204 +0,0 @@ -import time -import arcaflow -import os -import yaml -import logging -from pathlib import Path -from typing import List - -from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift - -from .context_auth import ContextAuth -from krkn_lib.models.telemetry import ScenarioTelemetry - -from .. import utils - - -def run(scenarios_list: List[str], - telemetry: KrknTelemetryOpenshift, - telemetry_request_id: str - ) -> (list[str], list[ScenarioTelemetry]): - scenario_telemetries: list[ScenarioTelemetry] = [] - failed_post_scenarios = [] - for scenario in scenarios_list: - scenario_telemetry = ScenarioTelemetry() - scenario_telemetry.scenario = scenario - start_time = time.time() - scenario_telemetry.start_timestamp = start_time - parsed_scenario_config = telemetry.set_parameters_base64(scenario_telemetry, scenario) - engine_args = build_args(scenario) - status_code = run_workflow(engine_args, telemetry.kubecli.get_kubeconfig_path()) - end_time = time.time() - scenario_telemetry.end_timestamp = end_time - scenario_telemetry.exit_status = status_code - - utils.populate_cluster_events(scenario_telemetry, - parsed_scenario_config, - telemetry.kubecli, - int(start_time), - int(end_time)) - - # this is the design proposal for the namespaced logs collection - # check the krkn-lib latest commit to follow also the changes made here - utils.collect_and_put_ocp_logs(telemetry, - parsed_scenario_config, - telemetry_request_id, - int(start_time), - int(end_time)) - - scenario_telemetries.append(scenario_telemetry) - if status_code != 0: - failed_post_scenarios.append(scenario) - return failed_post_scenarios, scenario_telemetries - - -def run_workflow(engine_args: arcaflow.EngineArgs, kubeconfig_path: str) -> int: - set_arca_kubeconfig(engine_args, kubeconfig_path) - exit_status = arcaflow.run(engine_args) - return exit_status - - -def build_args(input_file: str) -> arcaflow.EngineArgs: - """sets the kubeconfig parsed by setArcaKubeConfig as an input to the arcaflow workflow""" - current_path = Path().resolve() - context = f"{current_path}/{Path(input_file).parent}" - workflow = f"{context}/workflow.yaml" - config = f"{context}/config.yaml" - if not os.path.exists(context): - raise Exception( - "context folder for arcaflow workflow not found: {}".format( - context) - ) - if not os.path.exists(input_file): - raise Exception( - "input file for arcaflow workflow not found: {}".format(input_file)) - if not os.path.exists(workflow): - raise Exception( - "workflow file for arcaflow workflow not found: {}".format( - workflow) - ) - if not os.path.exists(config): - raise Exception( - "configuration file for arcaflow workflow not found: {}".format( - config) - ) - - engine_args = arcaflow.EngineArgs() - engine_args.context = context - engine_args.config = config - engine_args.workflow = workflow - engine_args.input = f"{current_path}/{input_file}" - return engine_args - - -def set_arca_kubeconfig(engine_args: arcaflow.EngineArgs, kubeconfig_path: str): - - context_auth = ContextAuth() - if not os.path.exists(kubeconfig_path): - raise Exception("kubeconfig not found in {}".format(kubeconfig_path)) - - with open(kubeconfig_path, "r") as stream: - try: - kubeconfig = yaml.safe_load(stream) - context_auth.fetch_auth_data(kubeconfig) - except Exception as e: - logging.error("impossible to read kubeconfig file in: {}".format( - kubeconfig_path)) - raise e - - kubeconfig_str = set_kubeconfig_auth(kubeconfig, context_auth) - - with open(engine_args.input, "r") as stream: - input_file = yaml.safe_load(stream) - if "input_list" in input_file and isinstance(input_file["input_list"],list): - for index, _ in enumerate(input_file["input_list"]): - if isinstance(input_file["input_list"][index], dict): - input_file["input_list"][index]["kubeconfig"] = kubeconfig_str - else: - input_file["kubeconfig"] = kubeconfig_str - stream.close() - with open(engine_args.input, "w") as stream: - yaml.safe_dump(input_file, stream) - - with open(engine_args.config, "r") as stream: - config_file = yaml.safe_load(stream) - if config_file["deployers"]["image"]["deployer_name"] == "kubernetes": - kube_connection = set_kubernetes_deployer_auth(config_file["deployers"]["image"]["connection"], context_auth) - config_file["deployers"]["image"]["connection"]=kube_connection - with open(engine_args.config, "w") as stream: - yaml.safe_dump(config_file, stream,explicit_start=True, width=4096) - - -def set_kubernetes_deployer_auth(deployer: any, context_auth: ContextAuth) -> any: - if context_auth.clusterHost is not None : - deployer["host"] = context_auth.clusterHost - if context_auth.clientCertificateData is not None : - deployer["cert"] = context_auth.clientCertificateData - if context_auth.clientKeyData is not None: - deployer["key"] = context_auth.clientKeyData - if context_auth.clusterCertificateData is not None: - deployer["cacert"] = context_auth.clusterCertificateData - if context_auth.username is not None: - deployer["username"] = context_auth.username - if context_auth.password is not None: - deployer["password"] = context_auth.password - if context_auth.bearerToken is not None: - deployer["bearerToken"] = context_auth.bearerToken - return deployer - - -def set_kubeconfig_auth(kubeconfig: any, context_auth: ContextAuth) -> str: - """ - Builds an arcaflow-compatible kubeconfig representation and returns it as a string. - In order to run arcaflow plugins in kubernetes/openshift the kubeconfig must contain client certificate/key - and server certificate base64 encoded within the kubeconfig file itself in *-data fields. That is not always the - case, infact kubeconfig may contain filesystem paths to those files, this function builds an arcaflow-compatible - kubeconfig file and returns it as a string that can be safely included in input.yaml - """ - - if "current-context" not in kubeconfig.keys(): - raise Exception( - "invalid kubeconfig file, impossible to determine current-context" - ) - user_id = None - cluster_id = None - user_name = None - cluster_name = None - current_context = kubeconfig["current-context"] - for context in kubeconfig["contexts"]: - if context["name"] == current_context: - user_name = context["context"]["user"] - cluster_name = context["context"]["cluster"] - if user_name is None: - raise Exception( - "user not set for context {} in kubeconfig file".format(current_context) - ) - if cluster_name is None: - raise Exception( - "cluster not set for context {} in kubeconfig file".format(current_context) - ) - - for index, user in enumerate(kubeconfig["users"]): - if user["name"] == user_name: - user_id = index - for index, cluster in enumerate(kubeconfig["clusters"]): - if cluster["name"] == cluster_name: - cluster_id = index - - if cluster_id is None: - raise Exception( - "no cluster {} found in kubeconfig users".format(cluster_name) - ) - if "client-certificate" in kubeconfig["users"][user_id]["user"]: - kubeconfig["users"][user_id]["user"]["client-certificate-data"] = context_auth.clientCertificateDataBase64 - del kubeconfig["users"][user_id]["user"]["client-certificate"] - - if "client-key" in kubeconfig["users"][user_id]["user"]: - kubeconfig["users"][user_id]["user"]["client-key-data"] = context_auth.clientKeyDataBase64 - del kubeconfig["users"][user_id]["user"]["client-key"] - - if "certificate-authority" in kubeconfig["clusters"][cluster_id]["cluster"]: - kubeconfig["clusters"][cluster_id]["cluster"]["certificate-authority-data"] = context_auth.clusterCertificateDataBase64 - del kubeconfig["clusters"][cluster_id]["cluster"]["certificate-authority"] - kubeconfig_str = yaml.dump(kubeconfig) - return kubeconfig_str diff --git a/kraken/chaos_recommender/prometheus.py b/kraken/chaos_recommender/prometheus.py deleted file mode 100644 index 723b6d5e..00000000 --- a/kraken/chaos_recommender/prometheus.py +++ /dev/null @@ -1,144 +0,0 @@ -import logging - -from prometheus_api_client import PrometheusConnect -import pandas as pd -import urllib3 - - -saved_metrics_path = "./utilisation.txt" - - -def convert_data_to_dataframe(data, label): - df = pd.DataFrame() - df['service'] = [item['metric']['pod'] for item in data] - df[label] = [item['value'][1] for item in data] - - return df - - -def convert_data(data, service): - result = {} - for entry in data: - pod_name = entry['metric']['pod'] - value = entry['value'][1] - result[pod_name] = value - return result.get(service) # for those pods whose limits are not defined they can take as much resources, there assigning a very high value - - -def convert_data_limits(data, node_data, service, prometheus): - result = {} - for entry in data: - pod_name = entry['metric']['pod'] - value = entry['value'][1] - result[pod_name] = value - return result.get(service, get_node_capacity(node_data, service, prometheus)) # for those pods whose limits are not defined they can take as much resources, there assigning a very high value - -def get_node_capacity(node_data, pod_name, prometheus ): - - # Get the node name on which the pod is running - query = f'kube_pod_info{{pod="{pod_name}"}}' - result = prometheus.custom_query(query) - if not result: - return None - - node_name = result[0]['metric']['node'] - - for item in node_data: - if item['metric']['node'] == node_name: - return item['value'][1] - - return '1000000000' - - -def save_utilization_to_file(utilization, filename, prometheus): - - merged_df = pd.DataFrame(columns=['namespace', 'service', 'CPU', 'CPU_LIMITS', 'MEM', 'MEM_LIMITS', 'NETWORK']) - for namespace in utilization: - # Loading utilization_data[] for namespace - # indexes -- 0 CPU, 1 CPU limits, 2 mem, 3 mem limits, 4 network - utilization_data = utilization[namespace] - df_cpu = convert_data_to_dataframe(utilization_data[0], "CPU") - services = df_cpu.service.unique() - logging.info(f"Services for namespace {namespace}: {services}") - - for s in services: - - new_row_df = pd.DataFrame({ - "namespace": namespace, "service": s, - "CPU": convert_data(utilization_data[0], s), - "CPU_LIMITS": convert_data_limits(utilization_data[1],utilization_data[5], s, prometheus), - "MEM": convert_data(utilization_data[2], s), - "MEM_LIMITS": convert_data_limits(utilization_data[3], utilization_data[6], s, prometheus), - "NETWORK": convert_data(utilization_data[4], s)}, index=[0]) - merged_df = pd.concat([merged_df, new_row_df], ignore_index=True) - - # Convert columns to string - merged_df['CPU'] = merged_df['CPU'].astype(str) - merged_df['MEM'] = merged_df['MEM'].astype(str) - merged_df['CPU_LIMITS'] = merged_df['CPU_LIMITS'].astype(str) - merged_df['MEM_LIMITS'] = merged_df['MEM_LIMITS'].astype(str) - merged_df['NETWORK'] = merged_df['NETWORK'].astype(str) - - # Extract integer part before the decimal point - #merged_df['CPU'] = merged_df['CPU'].str.split('.').str[0] - #merged_df['MEM'] = merged_df['MEM'].str.split('.').str[0] - #merged_df['CPU_LIMITS'] = merged_df['CPU_LIMITS'].str.split('.').str[0] - #merged_df['MEM_LIMITS'] = merged_df['MEM_LIMITS'].str.split('.').str[0] - #merged_df['NETWORK'] = merged_df['NETWORK'].str.split('.').str[0] - - merged_df.to_csv(filename, sep='\t', index=False) - - -def fetch_utilization_from_prometheus(prometheus_endpoint, auth_token, - namespaces, scrape_duration): - urllib3.disable_warnings() - prometheus = PrometheusConnect(url=prometheus_endpoint, headers={ - 'Authorization':'Bearer {}'.format(auth_token)}, disable_ssl=True) - - # Dicts for saving utilisation and queries -- key is namespace - utilization = {} - queries = {} - - logging.info("Fetching utilization...") - for namespace in namespaces: - - # Fetch CPU utilization - cpu_query = 'sum (rate (container_cpu_usage_seconds_total{image!="", namespace="%s"}[%s])) by (pod) *1000' % (namespace,scrape_duration) - cpu_result = prometheus.custom_query(cpu_query) - - cpu_limits_query = '(sum by (pod) (kube_pod_container_resource_limits{resource="cpu", namespace="%s"}))*1000' %(namespace) - cpu_limits_result = prometheus.custom_query(cpu_limits_query) - - node_cpu_limits_query = 'kube_node_status_capacity{resource="cpu", unit="core"}*1000' - node_cpu_limits_result = prometheus.custom_query(node_cpu_limits_query) - - mem_query = 'sum by (pod) (avg_over_time(container_memory_usage_bytes{image!="", namespace="%s"}[%s]))' % (namespace, scrape_duration) - mem_result = prometheus.custom_query(mem_query) - - mem_limits_query = 'sum by (pod) (kube_pod_container_resource_limits{resource="memory", namespace="%s"}) ' %(namespace) - mem_limits_result = prometheus.custom_query(mem_limits_query) - - node_mem_limits_query = 'kube_node_status_capacity{resource="memory", unit="byte"}' - node_mem_limits_result = prometheus.custom_query(node_mem_limits_query) - - network_query = 'sum by (pod) ((avg_over_time(container_network_transmit_bytes_total{namespace="%s"}[%s])) + \ - (avg_over_time(container_network_receive_bytes_total{namespace="%s"}[%s])))' % (namespace, scrape_duration, namespace, scrape_duration) - network_result = prometheus.custom_query(network_query) - - utilization[namespace] = [cpu_result, cpu_limits_result, mem_result, mem_limits_result, network_result, node_cpu_limits_result, node_mem_limits_result ] - queries[namespace] = json_queries(cpu_query, cpu_limits_query, mem_query, mem_limits_query, network_query) - - save_utilization_to_file(utilization, saved_metrics_path, prometheus) - - return saved_metrics_path, queries - - -def json_queries(cpu_query, cpu_limits_query, mem_query, mem_limits_query, network_query): - queries = { - "cpu_query": cpu_query, - "cpu_limit_query": cpu_limits_query, - "memory_query": mem_query, - "memory_limit_query": mem_limits_query, - "network_query": network_query - } - return queries diff --git a/kraken/managedcluster_scenarios/manifestwork.j2 b/kraken/managedcluster_scenarios/manifestwork.j2 deleted file mode 100644 index 0d66e47f..00000000 --- a/kraken/managedcluster_scenarios/manifestwork.j2 +++ /dev/null @@ -1,68 +0,0 @@ -apiVersion: work.open-cluster-management.io/v1 -kind: ManifestWork -metadata: - namespace: {{managedcluster_name}} - name: managedcluster-scenarios-template -spec: - workload: - manifests: - - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRole - metadata: - name: scale-deploy - namespace: open-cluster-management - rules: - - apiGroups: ["apps"] - resources: ["deployments/scale"] - verbs: ["patch"] - - apiGroups: ["apps"] - resources: ["deployments"] - verbs: ["get"] - - apiVersion: rbac.authorization.k8s.io/v1 - kind: RoleBinding - metadata: - name: scale-deploy-to-sa - namespace: open-cluster-management - subjects: - - kind: ServiceAccount - name: internal-kubectl - namespace: open-cluster-management - roleRef: - kind: ClusterRole - name: scale-deploy - apiGroup: rbac.authorization.k8s.io - - apiVersion: rbac.authorization.k8s.io/v1 - kind: RoleBinding - metadata: - name: scale-deploy-to-sa - namespace: open-cluster-management-agent - subjects: - - kind: ServiceAccount - name: internal-kubectl - namespace: open-cluster-management - roleRef: - kind: ClusterRole - name: scale-deploy - apiGroup: rbac.authorization.k8s.io - - apiVersion: v1 - kind: ServiceAccount - metadata: - name: internal-kubectl - namespace: open-cluster-management - - apiVersion: batch/v1 - kind: Job - metadata: - name: managedcluster-scenarios-template - namespace: open-cluster-management - spec: - template: - spec: - serviceAccountName: internal-kubectl - containers: - - name: kubectl - image: quay.io/sighup/kubectl-kustomize:1.21.6_3.9.1 - command: ["/bin/sh", "-c"] - args: - - {{args}} - restartPolicy: Never - backoffLimit: 0 \ No newline at end of file diff --git a/kraken/managedcluster_scenarios/run.py b/kraken/managedcluster_scenarios/run.py deleted file mode 100644 index eb6a4f1d..00000000 --- a/kraken/managedcluster_scenarios/run.py +++ /dev/null @@ -1,78 +0,0 @@ -import yaml -import logging -import time -from kraken.managedcluster_scenarios.managedcluster_scenarios import managedcluster_scenarios -import kraken.managedcluster_scenarios.common_managedcluster_functions as common_managedcluster_functions -import kraken.cerberus.setup as cerberus -from krkn_lib.k8s import KrknKubernetes -from krkn_lib.utils.functions import get_yaml_item_value - -# Get the managedcluster scenarios object of specfied cloud type -# krkn_lib -def get_managedcluster_scenario_object(managedcluster_scenario, kubecli: KrknKubernetes): - return managedcluster_scenarios(kubecli) - -# Run defined scenarios -# krkn_lib -def run(scenarios_list, config, wait_duration, kubecli: KrknKubernetes): - for managedcluster_scenario_config in scenarios_list: - with open(managedcluster_scenario_config, "r") as f: - managedcluster_scenario_config = yaml.full_load(f) - for managedcluster_scenario in managedcluster_scenario_config["managedcluster_scenarios"]: - managedcluster_scenario_object = get_managedcluster_scenario_object(managedcluster_scenario, kubecli) - if managedcluster_scenario["actions"]: - for action in managedcluster_scenario["actions"]: - start_time = int(time.time()) - inject_managedcluster_scenario(action, managedcluster_scenario, managedcluster_scenario_object, kubecli) - logging.info("Waiting for the specified duration: %s" % (wait_duration)) - time.sleep(wait_duration) - end_time = int(time.time()) - cerberus.get_status(config, start_time, end_time) - logging.info("") - - -# Inject the specified managedcluster scenario -# krkn_lib -def inject_managedcluster_scenario(action, managedcluster_scenario, managedcluster_scenario_object, kubecli: KrknKubernetes): - # Get the managedcluster scenario configurations - run_kill_count = get_yaml_item_value( - managedcluster_scenario, "runs", 1 - ) - instance_kill_count = get_yaml_item_value( - managedcluster_scenario, "instance_count", 1 - ) - managedcluster_name = get_yaml_item_value( - managedcluster_scenario, "managedcluster_name", "" - ) - label_selector = get_yaml_item_value( - managedcluster_scenario, "label_selector", "" - ) - timeout = get_yaml_item_value(managedcluster_scenario, "timeout", 120) - # Get the managedcluster to apply the scenario - if managedcluster_name: - managedcluster_name_list = managedcluster_name.split(",") - else: - managedcluster_name_list = [managedcluster_name] - for single_managedcluster_name in managedcluster_name_list: - managedclusters = common_managedcluster_functions.get_managedcluster(single_managedcluster_name, label_selector, instance_kill_count, kubecli) - for single_managedcluster in managedclusters: - if action == "managedcluster_start_scenario": - managedcluster_scenario_object.managedcluster_start_scenario(run_kill_count, single_managedcluster, timeout) - elif action == "managedcluster_stop_scenario": - managedcluster_scenario_object.managedcluster_stop_scenario(run_kill_count, single_managedcluster, timeout) - elif action == "managedcluster_stop_start_scenario": - managedcluster_scenario_object.managedcluster_stop_start_scenario(run_kill_count, single_managedcluster, timeout) - elif action == "managedcluster_termination_scenario": - managedcluster_scenario_object.managedcluster_termination_scenario(run_kill_count, single_managedcluster, timeout) - elif action == "managedcluster_reboot_scenario": - managedcluster_scenario_object.managedcluster_reboot_scenario(run_kill_count, single_managedcluster, timeout) - elif action == "stop_start_klusterlet_scenario": - managedcluster_scenario_object.stop_start_klusterlet_scenario(run_kill_count, single_managedcluster, timeout) - elif action == "start_klusterlet_scenario": - managedcluster_scenario_object.stop_klusterlet_scenario(run_kill_count, single_managedcluster, timeout) - elif action == "stop_klusterlet_scenario": - managedcluster_scenario_object.stop_klusterlet_scenario(run_kill_count, single_managedcluster, timeout) - elif action == "managedcluster_crash_scenario": - managedcluster_scenario_object.managedcluster_crash_scenario(run_kill_count, single_managedcluster, timeout) - else: - logging.info("There is no managedcluster action that matches %s, skipping scenario" % action) diff --git a/kraken/network_chaos/actions.py b/kraken/network_chaos/actions.py deleted file mode 100644 index 1fc851d1..00000000 --- a/kraken/network_chaos/actions.py +++ /dev/null @@ -1,228 +0,0 @@ -import yaml -import logging -import time -import os -import random - -from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift - -import kraken.cerberus.setup as cerberus -import kraken.node_actions.common_node_functions as common_node_functions -from jinja2 import Environment, FileSystemLoader -from krkn_lib.k8s import KrknKubernetes -from krkn_lib.models.telemetry import ScenarioTelemetry -from krkn_lib.utils.functions import get_yaml_item_value, log_exception - -from kraken import utils - - -# krkn_lib -# Reads the scenario config and introduces traffic variations in Node's host network interface. -def run(scenarios_list, - config, - wait_duration, - telemetry: KrknTelemetryOpenshift, - telemetry_request_id: str) -> (list[str], list[ScenarioTelemetry]): - logging.info("Runing the Network Chaos tests") - failed_post_scenarios = "" - scenario_telemetries: list[ScenarioTelemetry] = [] - failed_scenarios = [] - for net_config in scenarios_list: - scenario_telemetry = ScenarioTelemetry() - scenario_telemetry.scenario = net_config - scenario_telemetry.start_timestamp = time.time() - parsed_scenario_config = telemetry.set_parameters_base64(scenario_telemetry, net_config) - try: - with open(net_config, "r") as file: - param_lst = ["latency", "loss", "bandwidth"] - test_config = yaml.safe_load(file) - test_dict = test_config["network_chaos"] - test_duration = int( - get_yaml_item_value(test_dict, "duration", 300) - ) - test_interface = get_yaml_item_value( - test_dict, "interfaces", [] - ) - test_node = get_yaml_item_value(test_dict, "node_name", "") - test_node_label = get_yaml_item_value( - test_dict, "label_selector", - "node-role.kubernetes.io/master" - ) - test_execution = get_yaml_item_value( - test_dict, "execution", "serial" - ) - test_instance_count = get_yaml_item_value( - test_dict, "instance_count", 1 - ) - test_egress = get_yaml_item_value( - test_dict, "egress", {"bandwidth": "100mbit"} - ) - if test_node: - node_name_list = test_node.split(",") - else: - node_name_list = [test_node] - nodelst = [] - for single_node_name in node_name_list: - nodelst.extend(common_node_functions.get_node(single_node_name, test_node_label, test_instance_count, telemetry.kubecli)) - file_loader = FileSystemLoader(os.path.abspath(os.path.dirname(__file__))) - env = Environment(loader=file_loader, autoescape=True) - pod_template = env.get_template("pod.j2") - test_interface = verify_interface(test_interface, nodelst, pod_template, telemetry.kubecli) - joblst = [] - egress_lst = [i for i in param_lst if i in test_egress] - chaos_config = { - "network_chaos": { - "duration": test_duration, - "interfaces": test_interface, - "node_name": ",".join(nodelst), - "execution": test_execution, - "instance_count": test_instance_count, - "egress": test_egress, - } - } - logging.info("Executing network chaos with config \n %s" % yaml.dump(chaos_config)) - job_template = env.get_template("job.j2") - try: - for i in egress_lst: - for node in nodelst: - exec_cmd = get_egress_cmd( - test_execution, test_interface, i, test_dict["egress"], duration=test_duration - ) - logging.info("Executing %s on node %s" % (exec_cmd, node)) - job_body = yaml.safe_load( - job_template.render(jobname=i + str(hash(node))[:5], nodename=node, cmd=exec_cmd) - ) - joblst.append(job_body["metadata"]["name"]) - api_response = telemetry.kubecli.create_job(job_body) - if api_response is None: - raise Exception("Error creating job") - if test_execution == "serial": - logging.info("Waiting for serial job to finish") - start_time = int(time.time()) - wait_for_job(joblst[:], telemetry.kubecli, test_duration + 300) - logging.info("Waiting for wait_duration %s" % wait_duration) - time.sleep(wait_duration) - end_time = int(time.time()) - cerberus.publish_kraken_status(config, failed_post_scenarios, start_time, end_time) - if test_execution == "parallel": - break - if test_execution == "parallel": - logging.info("Waiting for parallel job to finish") - start_time = int(time.time()) - wait_for_job(joblst[:], telemetry.kubecli, test_duration + 300) - logging.info("Waiting for wait_duration %s" % wait_duration) - time.sleep(wait_duration) - end_time = int(time.time()) - cerberus.publish_kraken_status(config, failed_post_scenarios, start_time, end_time) - except Exception as e: - logging.error("Network Chaos exiting due to Exception %s" % e) - raise RuntimeError() - finally: - logging.info("Deleting jobs") - delete_job(joblst[:], telemetry.kubecli) - except (RuntimeError, Exception): - scenario_telemetry.exit_status = 1 - failed_scenarios.append(net_config) - log_exception(net_config) - else: - scenario_telemetry.exit_status = 0 - scenario_telemetry.end_timestamp = time.time() - utils.collect_and_put_ocp_logs(telemetry, - parsed_scenario_config, - telemetry_request_id, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - utils.populate_cluster_events(scenario_telemetry, - parsed_scenario_config, - telemetry.kubecli, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - scenario_telemetries.append(scenario_telemetry) - return failed_scenarios, scenario_telemetries - - -# krkn_lib -def verify_interface(test_interface, nodelst, template, kubecli: KrknKubernetes): - pod_index = random.randint(0, len(nodelst) - 1) - pod_body = yaml.safe_load(template.render(nodename=nodelst[pod_index])) - logging.info("Creating pod to query interface on node %s" % nodelst[pod_index]) - kubecli.create_pod(pod_body, "default", 300) - try: - if test_interface == []: - cmd = "ip r | grep default | awk '/default/ {print $5}'" - output = kubecli.exec_cmd_in_pod(cmd, "fedtools", "default") - test_interface = [output.replace("\n", "")] - else: - cmd = "ip -br addr show|awk -v ORS=',' '{print $1}'" - output = kubecli.exec_cmd_in_pod(cmd, "fedtools", "default") - interface_lst = output[:-1].split(",") - for interface in test_interface: - if interface not in interface_lst: - logging.error("Interface %s not found in node %s interface list %s" % (interface, nodelst[pod_index], interface_lst)) - #sys.exit(1) - raise RuntimeError() - return test_interface - finally: - logging.info("Deleteing pod to query interface on node") - kubecli.delete_pod("fedtools", "default") - - -# krkn_lib -def get_job_pods(api_response, kubecli: KrknKubernetes): - controllerUid = api_response.metadata.labels["controller-uid"] - pod_label_selector = "controller-uid=" + controllerUid - pods_list = kubecli.list_pods(label_selector=pod_label_selector, namespace="default") - return pods_list[0] - - -# krkn_lib -def wait_for_job(joblst, kubecli: KrknKubernetes, timeout=300): - waittime = time.time() + timeout - count = 0 - joblen = len(joblst) - while count != joblen: - for jobname in joblst: - try: - api_response = kubecli.get_job_status(jobname, namespace="default") - if api_response.status.succeeded is not None or api_response.status.failed is not None: - count += 1 - joblst.remove(jobname) - except Exception: - logging.warning("Exception in getting job status") - if time.time() > waittime: - raise Exception("Starting pod failed") - time.sleep(5) - - -# krkn_lib -def delete_job(joblst, kubecli: KrknKubernetes): - for jobname in joblst: - try: - api_response = kubecli.get_job_status(jobname, namespace="default") - if api_response.status.failed is not None: - pod_name = get_job_pods(api_response, kubecli) - pod_stat = kubecli.read_pod(name=pod_name, namespace="default") - logging.error(pod_stat.status.container_statuses) - pod_log_response = kubecli.get_pod_log(name=pod_name, namespace="default") - pod_log = pod_log_response.data.decode("utf-8") - logging.error(pod_log) - except Exception: - logging.warning("Exception in getting job status") - kubecli.delete_job(name=jobname, namespace="default") - - -def get_egress_cmd(execution, test_interface, mod, vallst, duration=30): - tc_set = tc_unset = tc_ls = "" - param_map = {"latency": "delay", "loss": "loss", "bandwidth": "rate"} - for i in test_interface: - tc_set = "{0} tc qdisc add dev {1} root netem".format(tc_set, i) - tc_unset = "{0} tc qdisc del dev {1} root ;".format(tc_unset, i) - tc_ls = "{0} tc qdisc ls dev {1} ;".format(tc_ls, i) - if execution == "parallel": - for val in vallst.keys(): - tc_set += " {0} {1} ".format(param_map[val], vallst[val]) - tc_set += ";" - else: - tc_set += " {0} {1} ;".format(param_map[mod], vallst[mod]) - exec_cmd = "{0} {1} sleep {2};{3} sleep 20;{4}".format(tc_set, tc_ls, duration, tc_unset, tc_ls) - return exec_cmd diff --git a/kraken/node_actions/run.py b/kraken/node_actions/run.py deleted file mode 100644 index 50214dd9..00000000 --- a/kraken/node_actions/run.py +++ /dev/null @@ -1,174 +0,0 @@ -import yaml -import logging -import sys -import time - -from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift - -from kraken import utils -from kraken.node_actions.aws_node_scenarios import aws_node_scenarios -from kraken.node_actions.general_cloud_node_scenarios import general_node_scenarios -from kraken.node_actions.az_node_scenarios import azure_node_scenarios -from kraken.node_actions.gcp_node_scenarios import gcp_node_scenarios -from kraken.node_actions.openstack_node_scenarios import openstack_node_scenarios -from kraken.node_actions.alibaba_node_scenarios import alibaba_node_scenarios -from kraken.node_actions.bm_node_scenarios import bm_node_scenarios -from kraken.node_actions.docker_node_scenarios import docker_node_scenarios -import kraken.node_actions.common_node_functions as common_node_functions -import kraken.cerberus.setup as cerberus -from krkn_lib.k8s import KrknKubernetes -from krkn_lib.telemetry.k8s import KrknTelemetryKubernetes -from krkn_lib.models.telemetry import ScenarioTelemetry -from krkn_lib.utils.functions import get_yaml_item_value, log_exception - -node_general = False - - -# Get the node scenarios object of specfied cloud type -# krkn_lib -def get_node_scenario_object(node_scenario, kubecli: KrknKubernetes): - if "cloud_type" not in node_scenario.keys() or node_scenario["cloud_type"] == "generic": - global node_general - node_general = True - return general_node_scenarios(kubecli) - if node_scenario["cloud_type"] == "aws": - return aws_node_scenarios(kubecli) - elif node_scenario["cloud_type"] == "gcp": - return gcp_node_scenarios(kubecli) - elif node_scenario["cloud_type"] == "openstack": - return openstack_node_scenarios(kubecli) - elif node_scenario["cloud_type"] == "azure" or node_scenario["cloud_type"] == "az": - return azure_node_scenarios(kubecli) - elif node_scenario["cloud_type"] == "alibaba" or node_scenario["cloud_type"] == "alicloud": - return alibaba_node_scenarios(kubecli) - elif node_scenario["cloud_type"] == "bm": - return bm_node_scenarios( - node_scenario.get("bmc_info"), node_scenario.get("bmc_user", None), node_scenario.get("bmc_password", None), - kubecli - ) - elif node_scenario["cloud_type"] == "docker": - return docker_node_scenarios(kubecli) - else: - logging.error( - "Cloud type " + node_scenario["cloud_type"] + " is not currently supported; " - "try using 'generic' if wanting to stop/start kubelet or fork bomb on any " - "cluster" - ) - sys.exit(1) - - -# Run defined scenarios -# krkn_lib -def run(scenarios_list, - config, - wait_duration, - telemetry: KrknTelemetryOpenshift, - telemetry_request_id: str) -> (list[str], list[ScenarioTelemetry]): - scenario_telemetries: list[ScenarioTelemetry] = [] - failed_scenarios = [] - for node_scenario_config in scenarios_list: - scenario_telemetry = ScenarioTelemetry() - scenario_telemetry.scenario = node_scenario_config - scenario_telemetry.start_timestamp = time.time() - parsed_scenario_config = telemetry.set_parameters_base64(scenario_telemetry, node_scenario_config) - with open(node_scenario_config, "r") as f: - node_scenario_config = yaml.full_load(f) - for node_scenario in node_scenario_config["node_scenarios"]: - node_scenario_object = get_node_scenario_object(node_scenario, telemetry.kubecli) - if node_scenario["actions"]: - for action in node_scenario["actions"]: - start_time = int(time.time()) - try: - inject_node_scenario(action, node_scenario, node_scenario_object, telemetry.kubecli) - logging.info("Waiting for the specified duration: %s" % (wait_duration)) - time.sleep(wait_duration) - end_time = int(time.time()) - cerberus.get_status(config, start_time, end_time) - logging.info("") - except (RuntimeError, Exception) as e: - scenario_telemetry.exit_status = 1 - failed_scenarios.append(node_scenario_config) - log_exception(node_scenario_config) - else: - scenario_telemetry.exit_status = 0 - - scenario_telemetry.end_timestamp = time.time() - utils.collect_and_put_ocp_logs(telemetry, - parsed_scenario_config, - telemetry_request_id, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - utils.populate_cluster_events(scenario_telemetry, - parsed_scenario_config, - telemetry.kubecli, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - scenario_telemetries.append(scenario_telemetry) - - return failed_scenarios, scenario_telemetries - - -# Inject the specified node scenario -def inject_node_scenario(action, node_scenario, node_scenario_object, kubecli: KrknKubernetes): - generic_cloud_scenarios = ("stop_kubelet_scenario", "node_crash_scenario") - # Get the node scenario configurations - run_kill_count = get_yaml_item_value(node_scenario, "runs", 1) - instance_kill_count = get_yaml_item_value( - node_scenario, "instance_count", 1 - ) - node_name = get_yaml_item_value(node_scenario, "node_name", "") - label_selector = get_yaml_item_value(node_scenario, "label_selector", "") - if action == "node_stop_start_scenario": - duration = get_yaml_item_value(node_scenario, "duration", 120) - timeout = get_yaml_item_value(node_scenario, "timeout", 120) - service = get_yaml_item_value(node_scenario, "service", "") - ssh_private_key = get_yaml_item_value( - node_scenario, "ssh_private_key", "~/.ssh/id_rsa" - ) - # Get the node to apply the scenario - if node_name: - node_name_list = node_name.split(",") - else: - node_name_list = [node_name] - for single_node_name in node_name_list: - nodes = common_node_functions.get_node(single_node_name, label_selector, instance_kill_count, kubecli) - for single_node in nodes: - if node_general and action not in generic_cloud_scenarios: - logging.info("Scenario: " + action + " is not set up for generic cloud type, skipping action") - else: - if action == "node_start_scenario": - node_scenario_object.node_start_scenario(run_kill_count, single_node, timeout) - elif action == "node_stop_scenario": - node_scenario_object.node_stop_scenario(run_kill_count, single_node, timeout) - elif action == "node_stop_start_scenario": - node_scenario_object.node_stop_start_scenario(run_kill_count, single_node, timeout, duration) - elif action == "node_termination_scenario": - node_scenario_object.node_termination_scenario(run_kill_count, single_node, timeout) - elif action == "node_reboot_scenario": - node_scenario_object.node_reboot_scenario(run_kill_count, single_node, timeout) - elif action == "stop_start_kubelet_scenario": - node_scenario_object.stop_start_kubelet_scenario(run_kill_count, single_node, timeout) - elif action == "restart_kubelet_scenario": - node_scenario_object.restart_kubelet_scenario(run_kill_count, single_node, timeout) - elif action == "stop_kubelet_scenario": - node_scenario_object.stop_kubelet_scenario(run_kill_count, single_node, timeout) - elif action == "node_crash_scenario": - node_scenario_object.node_crash_scenario(run_kill_count, single_node, timeout) - elif action == "stop_start_helper_node_scenario": - if node_scenario["cloud_type"] != "openstack": - logging.error( - "Scenario: " + action + " is not supported for " - "cloud type " + node_scenario["cloud_type"] + ", skipping action" - ) - else: - if not node_scenario["helper_node_ip"]: - logging.error("Helper node IP address is not provided") - sys.exit(1) - node_scenario_object.helper_node_stop_start_scenario( - run_kill_count, node_scenario["helper_node_ip"], timeout - ) - node_scenario_object.helper_node_service_status( - node_scenario["helper_node_ip"], service, ssh_private_key, timeout - ) - else: - logging.info("There is no node action that matches %s, skipping scenario" % action) diff --git a/kraken/plugins/__init__.py b/kraken/plugins/__init__.py deleted file mode 100644 index 970deed6..00000000 --- a/kraken/plugins/__init__.py +++ /dev/null @@ -1,332 +0,0 @@ -import dataclasses -import json -import logging -from os.path import abspath -from typing import List, Dict, Any -import time - -from arcaflow_plugin_sdk import schema, serialization, jsonschema -from arcaflow_plugin_kill_pod import kill_pods, wait_for_pods -from krkn_lib.k8s import KrknKubernetes -from krkn_lib.k8s.pods_monitor_pool import PodsMonitorPool -from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift - -import kraken.plugins.node_scenarios.vmware_plugin as vmware_plugin -import kraken.plugins.node_scenarios.ibmcloud_plugin as ibmcloud_plugin -from kraken import utils -from kraken.plugins.run_python_plugin import run_python_file -from kraken.plugins.network.ingress_shaping import network_chaos -from kraken.plugins.pod_network_outage.pod_network_outage_plugin import pod_outage -from kraken.plugins.pod_network_outage.pod_network_outage_plugin import pod_egress_shaping -from krkn_lib.telemetry.k8s import KrknTelemetryKubernetes -from kraken.plugins.pod_network_outage.pod_network_outage_plugin import pod_ingress_shaping -from krkn_lib.models.telemetry import ScenarioTelemetry -from krkn_lib.utils.functions import log_exception - - -@dataclasses.dataclass -class PluginStep: - schema: schema.StepSchema - error_output_ids: List[str] - - def render_output(self, output_id: str, output_data) -> str: - return json.dumps({ - "output_id": output_id, - "output_data": self.schema.outputs[output_id].serialize(output_data), - }, indent='\t') - - -class Plugins: - """ - Plugins is a class that can run plugins sequentially. The output is rendered to the standard output and the process - is aborted if a step fails. - """ - steps_by_id: Dict[str, PluginStep] - - def __init__(self, steps: List[PluginStep]): - self.steps_by_id = dict() - for step in steps: - if step.schema.id in self.steps_by_id: - raise Exception( - "Duplicate step ID: {}".format(step.schema.id) - ) - self.steps_by_id[step.schema.id] = step - - def unserialize_scenario(self, file: str) -> Any: - return serialization.load_from_file(abspath(file)) - - def run(self, file: str, kubeconfig_path: str, kraken_config: str, run_uuid:str): - """ - Run executes a series of steps - """ - data = self.unserialize_scenario(abspath(file)) - if not isinstance(data, list): - raise Exception( - "Invalid scenario configuration file: {} expected list, found {}".format(file, type(data).__name__) - ) - i = 0 - for entry in data: - if not isinstance(entry, dict): - raise Exception( - "Invalid scenario configuration file: {} expected a list of dict's, found {} on step {}".format( - file, - type(entry).__name__, - i - ) - ) - if "id" not in entry: - raise Exception( - "Invalid scenario configuration file: {} missing 'id' field on step {}".format( - file, - i, - ) - ) - if "config" not in entry: - raise Exception( - "Invalid scenario configuration file: {} missing 'config' field on step {}".format( - file, - i, - ) - ) - - if entry["id"] not in self.steps_by_id: - raise Exception( - "Invalid step {} in {} ID: {} expected one of: {}".format( - i, - file, - entry["id"], - ', '.join(self.steps_by_id.keys()) - ) - ) - step = self.steps_by_id[entry["id"]] - unserialized_input = step.schema.input.unserialize(entry["config"]) - if "kubeconfig_path" in step.schema.input.properties: - unserialized_input.kubeconfig_path = kubeconfig_path - if "kraken_config" in step.schema.input.properties: - unserialized_input.kraken_config = kraken_config - output_id, output_data = step.schema(params=unserialized_input, run_id=run_uuid) - - logging.info(step.render_output(output_id, output_data) + "\n") - if output_id in step.error_output_ids: - raise Exception( - "Step {} in {} ({}) failed".format(i, file, step.schema.id) - ) - i = i + 1 - - def json_schema(self): - """ - This function generates a JSON schema document and renders it from the steps passed. - """ - result = { - "$id": "https://github.com/redhat-chaos/krkn/", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Kraken Arcaflow scenarios", - "description": "Serial execution of Arcaflow Python plugins. See https://github.com/arcaflow for details.", - "type": "array", - "minContains": 1, - "items": { - "oneOf": [ - - ] - } - } - for step_id in self.steps_by_id.keys(): - step = self.steps_by_id[step_id] - step_input = jsonschema.step_input(step.schema) - del step_input["$id"] - del step_input["$schema"] - del step_input["title"] - del step_input["description"] - result["items"]["oneOf"].append({ - "type": "object", - "properties": { - "id": { - "type": "string", - "const": step_id, - }, - "config": step_input, - }, - "required": [ - "id", - "config", - ] - }) - return json.dumps(result, indent="\t") - - -PLUGINS = Plugins( - [ - PluginStep( - kill_pods, - [ - "error", - ] - ), - PluginStep( - wait_for_pods, - [ - "error" - ] - ), - PluginStep( - run_python_file, - [ - "error" - ] - ), - PluginStep( - vmware_plugin.node_start, - [ - "error" - ] - ), - PluginStep( - vmware_plugin.node_stop, - [ - "error" - ] - ), - PluginStep( - vmware_plugin.node_reboot, - [ - "error" - ] - ), - PluginStep( - vmware_plugin.node_terminate, - [ - "error" - ] - ), - PluginStep( - ibmcloud_plugin.node_start, - [ - "error" - ] - ), - PluginStep( - ibmcloud_plugin.node_stop, - [ - "error" - ] - ), - PluginStep( - ibmcloud_plugin.node_reboot, - [ - "error" - ] - ), - PluginStep( - ibmcloud_plugin.node_terminate, - [ - "error" - ] - ), - PluginStep( - network_chaos, - [ - "error" - ] - ), - PluginStep( - pod_outage, - [ - "error" - ] - ), - PluginStep( - pod_egress_shaping, - [ - "error" - ] - ), - PluginStep( - pod_ingress_shaping, - [ - "error" - ] - ) - ] -) - - -def run(scenarios: List[str], - kraken_config: str, - failed_post_scenarios: List[str], - wait_duration: int, - telemetry: KrknTelemetryOpenshift, - run_uuid: str, - telemetry_request_id: str, - ) -> (List[str], list[ScenarioTelemetry]): - - scenario_telemetries: list[ScenarioTelemetry] = [] - for scenario in scenarios: - scenario_telemetry = ScenarioTelemetry() - scenario_telemetry.scenario = scenario - scenario_telemetry.start_timestamp = time.time() - parsed_scenario_config = telemetry.set_parameters_base64(scenario_telemetry, scenario) - logging.info('scenario ' + str(scenario)) - pool = PodsMonitorPool(telemetry.kubecli) - kill_scenarios = [kill_scenario for kill_scenario in PLUGINS.unserialize_scenario(scenario) if kill_scenario["id"] == "kill-pods"] - - try: - start_monitoring(pool, kill_scenarios) - PLUGINS.run(scenario, telemetry.kubecli.get_kubeconfig_path(), kraken_config, run_uuid) - result = pool.join() - scenario_telemetry.affected_pods = result - if result.error: - raise Exception(f"unrecovered pods: {result.error}") - - except Exception as e: - logging.error(f"scenario exception: {str(e)}") - scenario_telemetry.exit_status = 1 - pool.cancel() - failed_post_scenarios.append(scenario) - log_exception(scenario) - else: - scenario_telemetry.exit_status = 0 - logging.info("Waiting for the specified duration: %s" % (wait_duration)) - time.sleep(wait_duration) - scenario_telemetry.end_timestamp = time.time() - utils.collect_and_put_ocp_logs(telemetry, - parsed_scenario_config, - telemetry_request_id, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - utils.populate_cluster_events(scenario_telemetry, - parsed_scenario_config, - telemetry.kubecli, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - - scenario_telemetries.append(scenario_telemetry) - - return failed_post_scenarios, scenario_telemetries - - -def start_monitoring(pool: PodsMonitorPool, scenarios: list[Any]): - for kill_scenario in scenarios: - recovery_time = kill_scenario["config"]["krkn_pod_recovery_time"] - if ("namespace_pattern" in kill_scenario["config"] and - "label_selector" in kill_scenario["config"]): - namespace_pattern = kill_scenario["config"]["namespace_pattern"] - label_selector = kill_scenario["config"]["label_selector"] - pool.select_and_monitor_by_namespace_pattern_and_label( - namespace_pattern=namespace_pattern, - label_selector=label_selector, - max_timeout=recovery_time) - logging.info( - f"waiting {recovery_time} seconds for pod recovery, " - f"pod label selector: {label_selector} namespace pattern: {namespace_pattern}") - - elif ("namespace_pattern" in kill_scenario["config"] and - "name_pattern" in kill_scenario["config"]): - namespace_pattern = kill_scenario["config"]["namespace_pattern"] - name_pattern = kill_scenario["config"]["name_pattern"] - pool.select_and_monitor_by_name_pattern_and_namespace_pattern(pod_name_pattern=name_pattern, - namespace_pattern=namespace_pattern, - max_timeout=recovery_time) - logging.info(f"waiting {recovery_time} seconds for pod recovery, " - f"pod name pattern: {name_pattern} namespace pattern: {namespace_pattern}") - else: - raise Exception(f"impossible to determine monitor parameters, check {kill_scenario} configuration") diff --git a/kraken/plugins/__main__.py b/kraken/plugins/__main__.py deleted file mode 100644 index 6cbd0454..00000000 --- a/kraken/plugins/__main__.py +++ /dev/null @@ -1,4 +0,0 @@ -from kraken.plugins import PLUGINS - -if __name__ == "__main__": - print(PLUGINS.json_schema()) \ No newline at end of file diff --git a/kraken/pod_scenarios/setup.py b/kraken/pod_scenarios/setup.py deleted file mode 100644 index 38218818..00000000 --- a/kraken/pod_scenarios/setup.py +++ /dev/null @@ -1,269 +0,0 @@ -import logging -import time -from typing import Any - -import yaml -import sys -import random -import arcaflow_plugin_kill_pod -from krkn_lib.k8s.pods_monitor_pool import PodsMonitorPool -from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift - -import kraken.cerberus.setup as cerberus -import kraken.post_actions.actions as post_actions -from krkn_lib.k8s import KrknKubernetes -from krkn_lib.models.telemetry import ScenarioTelemetry -from arcaflow_plugin_sdk import serialization -from krkn_lib.utils.functions import get_yaml_item_value, log_exception - -from kraken import utils - - -# Run pod based scenarios -def run(kubeconfig_path, scenarios_list, config, failed_post_scenarios, wait_duration): - # Loop to run the scenarios starts here - for pod_scenario in scenarios_list: - if len(pod_scenario) > 1: - pre_action_output = post_actions.run(kubeconfig_path, pod_scenario[1]) - else: - pre_action_output = "" - try: - # capture start time - start_time = int(time.time()) - - input = serialization.load_from_file(pod_scenario) - - s = arcaflow_plugin_kill_pod.get_schema() - input_data: arcaflow_plugin_kill_pod.KillPodConfig = s.unserialize_input("pod", input) - - if kubeconfig_path is not None: - input_data.kubeconfig_path = kubeconfig_path - - output_id, output_data = s.call_step("pod", input_data) - - if output_id == "error": - data: arcaflow_plugin_kill_pod.PodErrorOutput = output_data - logging.error("Failed to run pod scenario: {}".format(data.error)) - else: - data: arcaflow_plugin_kill_pod.PodSuccessOutput = output_data - for pod in data.pods: - print("Deleted pod {} in namespace {}\n".format(pod.pod_name, pod.pod_namespace)) - except Exception as e: - logging.error( - "Failed to run scenario: %s. Encountered the following " "exception: %s" % (pod_scenario[0], e) - ) - sys.exit(1) - - logging.info("Scenario: %s has been successfully injected!" % (pod_scenario[0])) - logging.info("Waiting for the specified duration: %s" % (wait_duration)) - time.sleep(wait_duration) - - try: - failed_post_scenarios = post_actions.check_recovery( - kubeconfig_path, pod_scenario, failed_post_scenarios, pre_action_output - ) - except Exception as e: - logging.error("Failed to run post action checks: %s" % e) - sys.exit(1) - - # capture end time - end_time = int(time.time()) - - # publish cerberus status - cerberus.publish_kraken_status(config, failed_post_scenarios, start_time, end_time) - return failed_post_scenarios - - -# krkn_lib -def container_run( - scenarios_list, - config, - failed_post_scenarios, - wait_duration, - telemetry: KrknTelemetryOpenshift, - telemetry_request_id: str - ) -> (list[str], list[ScenarioTelemetry]): - - failed_scenarios = [] - scenario_telemetries: list[ScenarioTelemetry] = [] - pool = PodsMonitorPool(telemetry.kubecli) - - for container_scenario_config in scenarios_list: - scenario_telemetry = ScenarioTelemetry() - scenario_telemetry.scenario = container_scenario_config[0] - scenario_telemetry.start_timestamp = time.time() - parsed_scenario_config = telemetry.set_parameters_base64(scenario_telemetry, container_scenario_config[0]) - if len(container_scenario_config) > 1: - pre_action_output = post_actions.run(telemetry.kubecli.get_kubeconfig_path(), container_scenario_config[1]) - else: - pre_action_output = "" - with open(container_scenario_config[0], "r") as f: - cont_scenario_config = yaml.full_load(f) - start_monitoring(kill_scenarios=cont_scenario_config["scenarios"], pool=pool) - for cont_scenario in cont_scenario_config["scenarios"]: - # capture start time - start_time = int(time.time()) - try: - killed_containers = container_killing_in_pod(cont_scenario, telemetry.kubecli) - logging.info(f"killed containers: {str(killed_containers)}") - result = pool.join() - if result.error: - raise Exception(f"pods failed to recovery: {result.error}") - scenario_telemetry.affected_pods = result - logging.info("Waiting for the specified duration: %s" % (wait_duration)) - time.sleep(wait_duration) - - # capture end time - end_time = int(time.time()) - - # publish cerberus status - cerberus.publish_kraken_status(config, failed_post_scenarios, start_time, end_time) - except (RuntimeError, Exception): - pool.cancel() - failed_scenarios.append(container_scenario_config[0]) - log_exception(container_scenario_config[0]) - scenario_telemetry.exit_status = 1 - # removed_exit - # sys.exit(1) - else: - scenario_telemetry.exit_status = 0 - scenario_telemetry.end_timestamp = time.time() - utils.populate_cluster_events(scenario_telemetry, - parsed_scenario_config, - telemetry.kubecli, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - utils.collect_and_put_ocp_logs(telemetry, - parsed_scenario_config, - telemetry_request_id, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - scenario_telemetries.append(scenario_telemetry) - - return failed_scenarios, scenario_telemetries - -def start_monitoring(kill_scenarios: list[Any], pool: PodsMonitorPool): - for kill_scenario in kill_scenarios: - namespace_pattern = f"^{kill_scenario['namespace']}$" - label_selector = kill_scenario["label_selector"] - recovery_time = kill_scenario["expected_recovery_time"] - pool.select_and_monitor_by_namespace_pattern_and_label( - namespace_pattern=namespace_pattern, - label_selector=label_selector, - max_timeout=recovery_time) - - -def container_killing_in_pod(cont_scenario, kubecli: KrknKubernetes): - scenario_name = get_yaml_item_value(cont_scenario, "name", "") - namespace = get_yaml_item_value(cont_scenario, "namespace", "*") - label_selector = get_yaml_item_value(cont_scenario, "label_selector", None) - pod_names = get_yaml_item_value(cont_scenario, "pod_names", []) - container_name = get_yaml_item_value(cont_scenario, "container_name", "") - kill_action = get_yaml_item_value(cont_scenario, "action", 1) - kill_count = get_yaml_item_value(cont_scenario, "count", 1) - if not isinstance(kill_action, int): - logging.error("Please make sure the action parameter defined in the " - "config is an integer") - raise RuntimeError() - if (kill_action < 1) or (kill_action > 15): - logging.error("Only 1-15 kill signals are supported.") - raise RuntimeError() - kill_action = "kill " + str(kill_action) - if type(pod_names) != list: - logging.error("Please make sure your pod_names are in a list format") - # removed_exit - # sys.exit(1) - raise RuntimeError() - if len(pod_names) == 0: - if namespace == "*": - # returns double array of pod name and namespace - pods = kubecli.get_all_pods(label_selector) - else: - # Only returns pod names - pods = kubecli.list_pods(namespace, label_selector) - else: - if namespace == "*": - logging.error("You must specify the namespace to kill a container in a specific pod") - logging.error("Scenario " + scenario_name + " failed") - # removed_exit - # sys.exit(1) - raise RuntimeError() - pods = pod_names - # get container and pod name - container_pod_list = [] - for pod in pods: - if type(pod) == list: - pod_output = kubecli.get_pod_info(pod[0], pod[1]) - container_names = [container.name for container in pod_output.containers] - - container_pod_list.append([pod[0], pod[1], container_names]) - else: - pod_output = kubecli.get_pod_info(pod, namespace) - container_names = [container.name for container in pod_output.containers] - container_pod_list.append([pod, namespace, container_names]) - - killed_count = 0 - killed_container_list = [] - while killed_count < kill_count: - if len(container_pod_list) == 0: - logging.error("Trying to kill more containers than were found, try lowering kill count") - logging.error("Scenario " + scenario_name + " failed") - # removed_exit - # sys.exit(1) - raise RuntimeError() - selected_container_pod = container_pod_list[random.randint(0, len(container_pod_list) - 1)] - for c_name in selected_container_pod[2]: - if container_name != "": - if c_name == container_name: - killed_container_list.append([selected_container_pod[0], selected_container_pod[1], c_name]) - retry_container_killing(kill_action, selected_container_pod[0], selected_container_pod[1], c_name, kubecli) - break - else: - killed_container_list.append([selected_container_pod[0], selected_container_pod[1], c_name]) - retry_container_killing(kill_action, selected_container_pod[0], selected_container_pod[1], c_name, kubecli) - break - container_pod_list.remove(selected_container_pod) - killed_count += 1 - logging.info("Scenario " + scenario_name + " successfully injected") - return killed_container_list - - -def retry_container_killing(kill_action, podname, namespace, container_name, kubecli: KrknKubernetes): - i = 0 - while i < 5: - logging.info("Killing container %s in pod %s (ns %s)" % (str(container_name), str(podname), str(namespace))) - response = kubecli.exec_cmd_in_pod(kill_action, podname, namespace, container_name) - i += 1 - # Blank response means it is done - if not response: - break - elif "unauthorized" in response.lower() or "authorization" in response.lower(): - time.sleep(2) - continue - else: - logging.warning(response) - continue - - -def check_failed_containers(killed_container_list, wait_time, kubecli: KrknKubernetes): - - container_ready = [] - timer = 0 - while timer <= wait_time: - for killed_container in killed_container_list: - # pod namespace contain name - pod_output = kubecli.get_pod_info(killed_container[0], killed_container[1]) - - for container in pod_output.containers: - if container.name == killed_container[2]: - if container.ready: - container_ready.append(killed_container) - if len(container_ready) != 0: - for item in container_ready: - killed_container_list = killed_container_list.remove(item) - if killed_container_list is None or len(killed_container_list) == 0: - return [] - timer += 5 - logging.info("Waiting 5 seconds for containers to become ready") - time.sleep(5) - return killed_container_list diff --git a/kraken/post_actions/actions.py b/kraken/post_actions/actions.py deleted file mode 100644 index e7cb3a5a..00000000 --- a/kraken/post_actions/actions.py +++ /dev/null @@ -1,48 +0,0 @@ -import logging -import kraken.invoke.command as runcommand - - -def run(kubeconfig_path, scenario, pre_action_output=""): - - if scenario.endswith(".yaml") or scenario.endswith(".yml"): - logging.error("Powerfulseal support has recently been removed. Please switch to using plugins instead.") - elif scenario.endswith(".py"): - action_output = runcommand.invoke("python3 " + scenario).strip() - if pre_action_output: - if pre_action_output == action_output: - logging.info(scenario + " post action checks passed") - else: - logging.info(scenario + " post action response did not match pre check output") - logging.info("Pre action output: " + str(pre_action_output) + "\n") - logging.info("Post action output: " + str(action_output)) - return False - elif scenario != "": - # invoke custom bash script - action_output = runcommand.invoke(scenario).strip() - if pre_action_output: - if pre_action_output == action_output: - logging.info(scenario + " post action checks passed") - else: - logging.info(scenario + " post action response did not match pre check output") - return False - - return action_output - - -# Perform the post scenario actions to see if components recovered -def check_recovery(kubeconfig_path, scenario, failed_post_scenarios, pre_action_output): - if failed_post_scenarios: - for failed_scenario in failed_post_scenarios: - post_action_output = run(kubeconfig_path, failed_scenario[0], failed_scenario[1]) - if post_action_output is not False: - failed_post_scenarios.remove(failed_scenario) - else: - logging.info("Post action scenario " + str(failed_scenario) + "is still failing") - - # check post actions - if len(scenario) > 1: - post_action_output = run(kubeconfig_path, scenario[1], pre_action_output) - if post_action_output is False: - failed_post_scenarios.append([scenario[1], pre_action_output]) - - return failed_post_scenarios diff --git a/kraken/pvc/pvc_scenario.py b/kraken/pvc/pvc_scenario.py deleted file mode 100644 index 2b893655..00000000 --- a/kraken/pvc/pvc_scenario.py +++ /dev/null @@ -1,392 +0,0 @@ -import logging -import random -import re -import time -import yaml -from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift - -from .. import utils -from ..cerberus import setup as cerberus -from krkn_lib.k8s import KrknKubernetes -from krkn_lib.models.telemetry import ScenarioTelemetry -from krkn_lib.utils.functions import get_yaml_item_value, log_exception - - -# krkn_lib -def run(scenarios_list, - config, - wait_duration, - telemetry: KrknTelemetryOpenshift, - telemetry_request_id: str) -> (list[str], list[ScenarioTelemetry]): - """ - Reads the scenario config and creates a temp file to fill up the PVC - """ - failed_post_scenarios = "" - scenario_telemetries: list[ScenarioTelemetry] = [] - failed_scenarios = [] - for app_config in scenarios_list: - scenario_telemetry = ScenarioTelemetry() - scenario_telemetry.scenario = app_config - scenario_telemetry.start_timestamp = time.time() - parsed_scenario_config = telemetry.set_parameters_base64(scenario_telemetry, app_config) - try: - if len(app_config) > 1: - with open(app_config, "r") as f: - config_yaml = yaml.full_load(f) - scenario_config = config_yaml["pvc_scenario"] - pvc_name = get_yaml_item_value( - scenario_config, "pvc_name", "" - ) - pod_name = get_yaml_item_value( - scenario_config, "pod_name", "" - ) - namespace = get_yaml_item_value( - scenario_config, "namespace", "" - ) - target_fill_percentage = get_yaml_item_value( - scenario_config, "fill_percentage", "50" - ) - duration = get_yaml_item_value( - scenario_config, "duration", 60 - ) - - logging.info( - "Input params:\n" - "pvc_name: '%s'\n" - "pod_name: '%s'\n" - "namespace: '%s'\n" - "target_fill_percentage: '%s%%'\nduration: '%ss'" - % ( - str(pvc_name), - str(pod_name), - str(namespace), - str(target_fill_percentage), - str(duration) - ) - ) - - # Check input params - if namespace is None: - logging.error( - "You must specify the namespace where the PVC is" - ) - #sys.exit(1) - raise RuntimeError() - if pvc_name is None and pod_name is None: - logging.error( - "You must specify the pvc_name or the pod_name" - ) - # sys.exit(1) - raise RuntimeError() - if pvc_name and pod_name: - logging.info( - "pod_name will be ignored, pod_name used will be " - "a retrieved from the pod used in the pvc_name" - ) - - # Get pod name - if pvc_name: - if pod_name: - logging.info( - "pod_name '%s' will be overridden with one of " - "the pods mounted in the PVC" % (str(pod_name)) - ) - pvc = telemetry.kubecli.get_pvc_info(pvc_name, namespace) - try: - # random generator not used for - # security/cryptographic purposes. - pod_name = random.choice(pvc.podNames) # nosec - logging.info("Pod name: %s" % pod_name) - except Exception: - logging.error( - "Pod associated with %s PVC, on namespace %s, " - "not found" % (str(pvc_name), str(namespace)) - ) - # sys.exit(1) - raise RuntimeError() - - # Get volume name - pod = telemetry.kubecli.get_pod_info(name=pod_name, namespace=namespace) - - if pod is None: - logging.error( - "Exiting as pod '%s' doesn't exist " - "in namespace '%s'" % ( - str(pod_name), - str(namespace) - ) - ) - # sys.exit(1) - raise RuntimeError() - - for volume in pod.volumes: - if volume.pvcName is not None: - volume_name = volume.name - pvc_name = volume.pvcName - pvc = telemetry.kubecli.get_pvc_info(pvc_name, namespace) - break - if 'pvc' not in locals(): - logging.error( - "Pod '%s' in namespace '%s' does not use a pvc" % ( - str(pod_name), - str(namespace) - ) - ) - # sys.exit(1) - raise RuntimeError() - logging.info("Volume name: %s" % volume_name) - logging.info("PVC name: %s" % pvc_name) - - # Get container name and mount path - for container in pod.containers: - for vol in container.volumeMounts: - if vol.name == volume_name: - mount_path = vol.mountPath - container_name = container.name - break - logging.info("Container path: %s" % container_name) - logging.info("Mount path: %s" % mount_path) - - # Get PVC capacity and used bytes - command = "df %s -B 1024 | sed 1d" % (str(mount_path)) - command_output = ( - telemetry.kubecli.exec_cmd_in_pod( - command, - pod_name, - namespace, - container_name - ) - ).split() - pvc_used_kb = int(command_output[2]) - pvc_capacity_kb = pvc_used_kb + int(command_output[3]) - logging.info("PVC used: %s KB" % pvc_used_kb) - logging.info("PVC capacity: %s KB" % pvc_capacity_kb) - - # Check valid fill percentage - current_fill_percentage = pvc_used_kb / pvc_capacity_kb - if not ( - current_fill_percentage * 100 - < float(target_fill_percentage) - <= 99 - ): - logging.error( - "Target fill percentage (%.2f%%) is lower than " - "current fill percentage (%.2f%%) " - "or higher than 99%%" % ( - target_fill_percentage, - current_fill_percentage * 100 - ) - ) - # sys.exit(1) - raise RuntimeError() - - # Calculate file size - file_size_kb = int( - ( - float( - target_fill_percentage / 100 - ) * float(pvc_capacity_kb) - ) - float(pvc_used_kb) - ) - logging.debug("File size: %s KB" % file_size_kb) - - file_name = "kraken.tmp" - logging.info( - "Creating %s file, %s KB size, in pod %s at %s (ns %s)" - % ( - str(file_name), - str(file_size_kb), - str(pod_name), - str(mount_path), - str(namespace) - ) - ) - - start_time = int(time.time()) - # Create temp file in the PVC - full_path = "%s/%s" % (str(mount_path), str(file_name)) - command = "fallocate -l $((%s*1024)) %s" % ( - str(file_size_kb), - str(full_path) - ) - logging.debug( - "Create temp file in the PVC command:\n %s" % command - ) - telemetry.kubecli.exec_cmd_in_pod( - command, - pod_name, - namespace, - container_name, - ) - - # Check if file is created - command = "ls -lh %s" % (str(mount_path)) - logging.debug("Check file is created command:\n %s" % command) - response = telemetry.kubecli.exec_cmd_in_pod( - command, pod_name, namespace, container_name - ) - logging.info("\n" + str(response)) - if str(file_name).lower() in str(response).lower(): - logging.info( - "%s file successfully created" % (str(full_path)) - ) - else: - logging.error( - "Failed to create tmp file with %s size" % ( - str(file_size_kb) - ) - ) - remove_temp_file( - file_name, - full_path, - pod_name, - namespace, - container_name, - mount_path, - file_size_kb, - telemetry.kubecli - ) - # sys.exit(1) - raise RuntimeError() - - # Calculate file size - file_size_kb = int( - ( - float( - target_fill_percentage / 100 - ) * float(pvc_capacity_kb) - ) - float(pvc_used_kb) - ) - logging.debug("File size: %s KB" % file_size_kb) - - file_name = "kraken.tmp" - logging.info( - "Creating %s file, %s KB size, in pod %s at %s (ns %s)" - % ( - str(file_name), - str(file_size_kb), - str(pod_name), - str(mount_path), - str(namespace) - ) - ) - - start_time = int(time.time()) - # Create temp file in the PVC - full_path = "%s/%s" % (str(mount_path), str(file_name)) - command = "fallocate -l $((%s*1024)) %s" % ( - str(file_size_kb), - str(full_path) - ) - logging.debug( - "Create temp file in the PVC command:\n %s" % command - ) - telemetry.kubecli.exec_cmd_in_pod( - command, pod_name, namespace, container_name - ) - - # Check if file is created - command = "ls -lh %s" % (str(mount_path)) - logging.debug("Check file is created command:\n %s" % command) - response = telemetry.kubecli.exec_cmd_in_pod( - command, pod_name, namespace, container_name - ) - logging.info("\n" + str(response)) - if str(file_name).lower() in str(response).lower(): - logging.info( - "Waiting for the specified duration in the config: %ss" % ( - duration - ) - ) - time.sleep(duration) - logging.info("Finish waiting") - - remove_temp_file( - file_name, - full_path, - pod_name, - namespace, - container_name, - mount_path, - file_size_kb, - telemetry.kubecli - ) - logging.info("End of scenario. Waiting for the specified duration: %s" % (wait_duration)) - time.sleep(wait_duration) - - end_time = int(time.time()) - cerberus.publish_kraken_status( - config, - failed_post_scenarios, - start_time, - end_time - ) - except (RuntimeError, Exception): - scenario_telemetry.exit_status = 1 - failed_scenarios.append(app_config) - log_exception(app_config) - else: - scenario_telemetry.exit_status = 0 - - scenario_telemetry.end_timestamp = time.time() - utils.collect_and_put_ocp_logs(telemetry, - parsed_scenario_config, - telemetry_request_id, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - utils.populate_cluster_events(scenario_telemetry, - parsed_scenario_config, - telemetry.kubecli, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - scenario_telemetries.append(scenario_telemetry) - - return failed_scenarios, scenario_telemetries - - - - -# krkn_lib -def remove_temp_file( - file_name, - full_path, - pod_name, - namespace, - container_name, - mount_path, - file_size_kb, - kubecli: KrknKubernetes -): - command = "rm -f %s" % (str(full_path)) - logging.debug("Remove temp file from the PVC command:\n %s" % command) - kubecli.exec_cmd_in_pod(command, pod_name, namespace, container_name) - command = "ls -lh %s" % (str(mount_path)) - logging.debug("Check temp file is removed command:\n %s" % command) - response = kubecli.exec_cmd_in_pod( - command, - pod_name, - namespace, - container_name - ) - logging.info("\n" + str(response)) - if not (str(file_name).lower() in str(response).lower()): - logging.info("Temp file successfully removed") - else: - logging.error( - "Failed to delete tmp file with %s size" % (str(file_size_kb)) - ) - raise RuntimeError() - - -def toKbytes(value): - if not re.match("^[0-9]+[K|M|G|T]i$", value): - logging.error( - "PVC capacity %s does not match expression " - "regexp '^[0-9]+[K|M|G|T]i$'" - ) - raise RuntimeError() - unit = {"K": 0, "M": 1, "G": 2, "T": 3} - base = 1024 if ("i" in value) else 1000 - exp = unit[value[-2:-1]] - res = int(value[:-2]) * (base**exp) - return res diff --git a/kraken/service_disruption/common_service_disruption_functions.py b/kraken/service_disruption/common_service_disruption_functions.py deleted file mode 100644 index 80e9474f..00000000 --- a/kraken/service_disruption/common_service_disruption_functions.py +++ /dev/null @@ -1,338 +0,0 @@ -import time -import random -import logging - -from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift - -import kraken.cerberus.setup as cerberus -import kraken.post_actions.actions as post_actions -import yaml -from krkn_lib.k8s import KrknKubernetes -from krkn_lib.models.telemetry import ScenarioTelemetry -from krkn_lib.utils.functions import get_yaml_item_value, log_exception - -from kraken import utils - - -def delete_objects(kubecli, namespace): - - services = delete_all_services_namespace(kubecli, namespace) - daemonsets = delete_all_daemonset_namespace(kubecli, namespace) - statefulsets = delete_all_statefulsets_namespace(kubecli, namespace) - replicasets = delete_all_replicaset_namespace(kubecli, namespace) - deployments = delete_all_deployment_namespace(kubecli, namespace) - - objects = { "daemonsets": daemonsets, - "deployments": deployments, - "replicasets": replicasets, - "statefulsets": statefulsets, - "services": services - } - - return objects - - -def get_list_running_pods(kubecli: KrknKubernetes, namespace: str): - running_pods = [] - pods = kubecli.list_pods(namespace) - for pod in pods: - pod_status = kubecli.get_pod_info(pod, namespace) - if pod_status and pod_status.status == "Running": - running_pods.append(pod) - logging.info('all running pods ' + str(running_pods)) - return running_pods - - -def delete_all_deployment_namespace(kubecli: KrknKubernetes, namespace: str): - """ - Delete all the deployments in the specified namespace - - :param kubecli: krkn kubernetes python package - :param namespace: namespace - """ - try: - deployments = kubecli.get_deployment_ns(namespace) - for deployment in deployments: - logging.info("Deleting deployment" + deployment) - kubecli.delete_deployment(deployment, namespace) - except Exception as e: - logging.error( - "Exception when calling delete_all_deployment_namespace: %s\n", - str(e), - ) - raise e - - return deployments - - -def delete_all_daemonset_namespace(kubecli: KrknKubernetes, namespace: str): - """ - Delete all the daemonset in the specified namespace - - :param kubecli: krkn kubernetes python package - :param namespace: namespace - """ - try: - daemonsets = kubecli.get_daemonset(namespace) - for daemonset in daemonsets: - logging.info("Deleting daemonset" + daemonset) - kubecli.delete_daemonset(daemonset, namespace) - except Exception as e: - logging.error( - "Exception when calling delete_all_daemonset_namespace: %s\n", - str(e), - ) - raise e - - return daemonsets - - -def delete_all_statefulsets_namespace(kubecli: KrknKubernetes, namespace: str): - """ - Delete all the statefulsets in the specified namespace - - - :param kubecli: krkn kubernetes python package - :param namespace: namespace - """ - try: - statefulsets = kubecli.get_all_statefulset(namespace) - for statefulset in statefulsets: - logging.info("Deleting statefulsets" + statefulsets) - kubecli.delete_statefulset(statefulset, namespace) - except Exception as e: - logging.error( - "Exception when calling delete_all_statefulsets_namespace: %s\n", - str(e), - ) - raise e - - return statefulsets - - -def delete_all_replicaset_namespace(kubecli: KrknKubernetes, namespace: str): - """ - Delete all the replicasets in the specified namespace - - :param kubecli: krkn kubernetes python package - :param namespace: namespace - """ - try: - replicasets = kubecli.get_all_replicasets(namespace) - for replicaset in replicasets: - logging.info("Deleting replicaset" + replicaset) - kubecli.delete_replicaset(replicaset, namespace) - except Exception as e: - logging.error( - "Exception when calling delete_all_replicaset_namespace: %s\n", - str(e), - ) - raise e - - return replicasets - -def delete_all_services_namespace(kubecli: KrknKubernetes, namespace: str): - """ - Delete all the services in the specified namespace - - - :param kubecli: krkn kubernetes python package - :param namespace: namespace - """ - try: - services = kubecli.get_all_services(namespace) - for service in services: - logging.info("Deleting services" + service) - kubecli.delete_services(service, namespace) - except Exception as e: - logging.error( - "Exception when calling delete_all_services_namespace: %s\n", - str(e), - ) - raise e - - return services - - -# krkn_lib -def run( - scenarios_list, - config, - wait_duration, - failed_post_scenarios, - telemetry: KrknTelemetryOpenshift, - telemetry_request_id: str -) -> (list[str], list[ScenarioTelemetry]): - scenario_telemetries: list[ScenarioTelemetry] = [] - failed_scenarios = [] - for scenario_config in scenarios_list: - scenario_telemetry = ScenarioTelemetry() - scenario_telemetry.scenario = scenario_config[0] - scenario_telemetry.start_timestamp = time.time() - parsed_scenario_config = telemetry.set_parameters_base64(scenario_telemetry, scenario_config[0]) - try: - if len(scenario_config) > 1: - pre_action_output = post_actions.run(telemetry.kubecli.get_kubeconfig_path(), scenario_config[1]) - else: - pre_action_output = "" - with open(scenario_config[0], "r") as f: - scenario_config_yaml = yaml.full_load(f) - for scenario in scenario_config_yaml["scenarios"]: - scenario_namespace = get_yaml_item_value( - scenario, "namespace", "" - ) - scenario_label = get_yaml_item_value( - scenario, "label_selector", "" - ) - if scenario_namespace is not None and scenario_namespace.strip() != "": - if scenario_label is not None and scenario_label.strip() != "": - logging.error("You can only have namespace or label set in your namespace scenario") - logging.error( - "Current scenario config has namespace '%s' and label selector '%s'" - % (scenario_namespace, scenario_label) - ) - logging.error( - "Please set either namespace to blank ('') or label_selector to blank ('') to continue" - ) - # removed_exit - # sys.exit(1) - raise RuntimeError() - delete_count = get_yaml_item_value( - scenario, "delete_count", 1 - ) - run_count = get_yaml_item_value(scenario, "runs", 1) - run_sleep = get_yaml_item_value(scenario, "sleep", 10) - wait_time = get_yaml_item_value(scenario, "wait_time", 30) - - logging.info(str(scenario_namespace) + str(scenario_label) + str(delete_count) + str(run_count) + str(run_sleep) + str(wait_time)) - logging.info("done") - start_time = int(time.time()) - for i in range(run_count): - killed_namespaces = {} - namespaces = telemetry.kubecli.check_namespaces([scenario_namespace], scenario_label) - for j in range(delete_count): - if len(namespaces) == 0: - logging.error( - "Couldn't delete %s namespaces, not enough namespaces matching %s with label %s" - % (str(run_count), scenario_namespace, str(scenario_label)) - ) - # removed_exit - # sys.exit(1) - raise RuntimeError() - selected_namespace = namespaces[random.randint(0, len(namespaces) - 1)] - logging.info('Delete objects in selected namespace: ' + selected_namespace ) - try: - # delete all pods in namespace - objects = delete_objects(telemetry.kubecli,selected_namespace) - killed_namespaces[selected_namespace] = objects - logging.info("Deleted all objects in namespace %s was successful" % str(selected_namespace)) - except Exception as e: - logging.info("Delete all objects in namespace %s was unsuccessful" % str(selected_namespace)) - logging.info("Namespace action error: " + str(e)) - raise RuntimeError() - namespaces.remove(selected_namespace) - logging.info("Waiting %s seconds between namespace deletions" % str(run_sleep)) - time.sleep(run_sleep) - - logging.info("Waiting for the specified duration: %s" % wait_duration) - time.sleep(wait_duration) - if len(scenario_config) > 1: - try: - failed_post_scenarios = post_actions.check_recovery( - telemetry.kubecli.get_kubeconfig_path(), scenario_config, failed_post_scenarios, pre_action_output - ) - except Exception as e: - logging.error("Failed to run post action checks: %s" % e) - # removed_exit - # sys.exit(1) - raise RuntimeError() - else: - failed_post_scenarios = check_all_running_deployment(killed_namespaces, wait_time, telemetry.kubecli) - - end_time = int(time.time()) - cerberus.publish_kraken_status(config, failed_post_scenarios, start_time, end_time) - except (Exception, RuntimeError): - scenario_telemetry.exit_status = 1 - failed_scenarios.append(scenario_config[0]) - log_exception(scenario_config[0]) - else: - scenario_telemetry.exit_status = 0 - scenario_telemetry.end_timestamp = time.time() - utils.collect_and_put_ocp_logs(telemetry, - parsed_scenario_config, - telemetry_request_id, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - utils.populate_cluster_events(scenario_telemetry, - parsed_scenario_config, - telemetry.kubecli, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - scenario_telemetries.append(scenario_telemetry) - return failed_scenarios, scenario_telemetries - - -def check_all_running_pods(kubecli: KrknKubernetes, namespace_name, wait_time): - - timer = 0 - while timer < wait_time: - pod_list = kubecli.list_pods(namespace_name) - pods_running = 0 - for pod in pod_list: - pod_info = kubecli.get_pod_info(pod, namespace_name) - if pod_info.status != "Running" and pod_info.status != "Succeeded": - logging.info("Pods %s still not running or completed" % pod_info.name) - break - pods_running += 1 - if len(pod_list) == pods_running: - break - timer += 5 - time.sleep(5) - logging.info("Waiting 5 seconds for pods to become active") - -# krkn_lib -def check_all_running_deployment(killed_namespaces, wait_time, kubecli: KrknKubernetes): - - timer = 0 - while timer < wait_time and killed_namespaces: - still_missing_ns = killed_namespaces.copy() - for namespace_name, objects in killed_namespaces.items(): - still_missing_obj = objects.copy() - for obj_name, obj_list in objects.items(): - if "deployments" == obj_name: - deployments = kubecli.get_deployment_ns(namespace_name) - if len(obj_list) == len(deployments): - still_missing_obj.pop(obj_name) - elif "replicasets" == obj_name: - replicasets = kubecli.get_all_replicasets(namespace_name) - if len(obj_list) == len(replicasets): - still_missing_obj.pop(obj_name) - elif "statefulsets" == obj_name: - statefulsets = kubecli.get_all_statefulset(namespace_name) - if len(obj_list) == len(statefulsets): - still_missing_obj.pop(obj_name) - elif "services" == obj_name: - services = kubecli.get_all_services(namespace_name) - if len(obj_list) == len(services): - still_missing_obj.pop(obj_name) - elif "daemonsets" == obj_name: - daemonsets = kubecli.get_daemonset(namespace_name) - if len(obj_list) == len(daemonsets): - still_missing_obj.pop(obj_name) - logging.info("Still missing objects " + str(still_missing_obj)) - killed_namespaces[namespace_name] = still_missing_obj.copy() - if len(killed_namespaces[namespace_name].keys()) == 0: - logging.info("Wait for pods to become running for namespace: " + namespace_name) - check_all_running_pods(kubecli, namespace_name, wait_time) - still_missing_ns.pop(namespace_name) - killed_namespaces = still_missing_ns - if len(killed_namespaces.keys()) == 0: - return [] - - timer += 10 - time.sleep(10) - logging.info("Waiting 10 seconds for objects in namespaces to become active") - - logging.error("Objects are still not ready after waiting " + str(wait_time) + "seconds") - logging.error("Non active namespaces " + str(killed_namespaces)) - return killed_namespaces diff --git a/kraken/service_hijacking/service_hijacking.py b/kraken/service_hijacking/service_hijacking.py deleted file mode 100644 index 3f5ca1ba..00000000 --- a/kraken/service_hijacking/service_hijacking.py +++ /dev/null @@ -1,103 +0,0 @@ -import logging -import time -import yaml - -from krkn_lib.models.telemetry import ScenarioTelemetry -from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift -from krkn_lib.utils import log_exception - -from kraken import utils - - -def run(scenarios_list: list[str], - wait_duration: int, - telemetry: KrknTelemetryOpenshift, - telemetry_request_id: str) -> (list[str], list[ScenarioTelemetry]): - - scenario_telemetries = list[ScenarioTelemetry]() - failed_post_scenarios = [] - for scenario in scenarios_list: - scenario_telemetry = ScenarioTelemetry() - scenario_telemetry.scenario = scenario - scenario_telemetry.start_timestamp = time.time() - parsed_scenario_config = telemetry.set_parameters_base64(scenario_telemetry, scenario) - with open(scenario) as stream: - scenario_config = yaml.safe_load(stream) - - service_name = scenario_config['service_name'] - service_namespace = scenario_config['service_namespace'] - plan = scenario_config["plan"] - image = scenario_config["image"] - target_port = scenario_config["service_target_port"] - chaos_duration = scenario_config["chaos_duration"] - - logging.info(f"checking service {service_name} in namespace: {service_namespace}") - if not telemetry.kubecli.service_exists(service_name, service_namespace): - logging.error(f"service: {service_name} not found in namespace: {service_namespace}, failed to run scenario.") - fail_scenario_telemetry(scenario_telemetry) - failed_post_scenarios.append(scenario) - break - try: - logging.info(f"service: {service_name} found in namespace: {service_namespace}") - logging.info(f"creating webservice and initializing test plan...") - # both named ports and port numbers can be used - if isinstance(target_port, int): - logging.info(f"webservice will listen on port {target_port}") - webservice = telemetry.kubecli.deploy_service_hijacking(service_namespace, plan, image, port_number=target_port) - else: - logging.info(f"traffic will be redirected to named port: {target_port}") - webservice = telemetry.kubecli.deploy_service_hijacking(service_namespace, plan, image, port_name=target_port) - logging.info(f"successfully deployed pod: {webservice.pod_name} " - f"in namespace:{service_namespace} with selector {webservice.selector}!" - ) - logging.info(f"patching service: {service_name} to hijack traffic towards: {webservice.pod_name}") - original_service = telemetry.kubecli.replace_service_selector([webservice.selector], service_name, service_namespace) - if original_service is None: - logging.error(f"failed to patch service: {service_name}, namespace: {service_namespace} with selector {webservice.selector}") - fail_scenario_telemetry(scenario_telemetry) - failed_post_scenarios.append(scenario) - break - - logging.info(f"service: {service_name} successfully patched!") - logging.info(f"original service manifest:\n\n{yaml.dump(original_service)}") - logging.info(f"waiting {chaos_duration} before restoring the service") - time.sleep(chaos_duration) - selectors = ["=".join([key, original_service["spec"]["selector"][key]]) for key in original_service["spec"]["selector"].keys()] - logging.info(f"restoring the service selectors {selectors}") - original_service = telemetry.kubecli.replace_service_selector(selectors, service_name, service_namespace) - if original_service is None: - logging.error(f"failed to restore original service: {service_name}, namespace: {service_namespace} with selectors: {selectors}") - fail_scenario_telemetry(scenario_telemetry) - failed_post_scenarios.append(scenario) - break - logging.info("selectors successfully restored") - logging.info("undeploying service-hijacking resources...") - telemetry.kubecli.undeploy_service_hijacking(webservice) - - logging.info("End of scenario. Waiting for the specified duration: %s" % (wait_duration)) - time.sleep(wait_duration) - scenario_telemetry.exit_status = 0 - logging.info("success") - except Exception as e: - logging.error(f"scenario {scenario} failed with exception: {e}") - fail_scenario_telemetry(scenario_telemetry) - log_exception(scenario) - - scenario_telemetry.end_timestamp = time.time() - utils.collect_and_put_ocp_logs(telemetry, - parsed_scenario_config, - telemetry_request_id, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - utils.populate_cluster_events(scenario_telemetry, - parsed_scenario_config, - telemetry.kubecli, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - scenario_telemetries.append(scenario_telemetry) - - return failed_post_scenarios, scenario_telemetries - -def fail_scenario_telemetry(scenario_telemetry: ScenarioTelemetry): - scenario_telemetry.exit_status = 1 - scenario_telemetry.end_timestamp = time.time() \ No newline at end of file diff --git a/kraken/shut_down/common_shut_down_func.py b/kraken/shut_down/common_shut_down_func.py deleted file mode 100644 index d89f85b7..00000000 --- a/kraken/shut_down/common_shut_down_func.py +++ /dev/null @@ -1,208 +0,0 @@ -#!/usr/bin/env python -import yaml -import logging -import time -from multiprocessing.pool import ThreadPool - -from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift - -from .. import utils -from ..cerberus import setup as cerberus -from ..post_actions import actions as post_actions -from ..node_actions.aws_node_scenarios import AWS -from ..node_actions.openstack_node_scenarios import OPENSTACKCLOUD -from ..node_actions.az_node_scenarios import Azure -from ..node_actions.gcp_node_scenarios import GCP -from krkn_lib.k8s import KrknKubernetes -from krkn_lib.models.telemetry import ScenarioTelemetry -from krkn_lib.utils.functions import log_exception - -def multiprocess_nodes(cloud_object_function, nodes, processes=0): - try: - # pool object with number of element - - if processes == 0: - pool = ThreadPool(processes=len(nodes)) - else: - pool = ThreadPool(processes=processes) - logging.info("nodes type " + str(type(nodes[0]))) - if type(nodes[0]) is tuple: - node_id = [] - node_info = [] - for node in nodes: - node_id.append(node[0]) - node_info.append(node[1]) - logging.info("node id " + str(node_id)) - logging.info("node info" + str(node_info)) - pool.starmap(cloud_object_function, zip(node_info, node_id)) - - else: - logging.info("pool type" + str(type(nodes))) - pool.map(cloud_object_function, nodes) - pool.close() - except Exception as e: - logging.info("Error on pool multiprocessing: " + str(e)) - - -# Inject the cluster shut down scenario -# krkn_lib -def cluster_shut_down(shut_down_config, kubecli: KrknKubernetes): - runs = shut_down_config["runs"] - shut_down_duration = shut_down_config["shut_down_duration"] - cloud_type = shut_down_config["cloud_type"] - timeout = shut_down_config["timeout"] - processes = 0 - if cloud_type.lower() == "aws": - cloud_object = AWS() - elif cloud_type.lower() == "gcp": - cloud_object = GCP() - processes = 1 - elif cloud_type.lower() == "openstack": - cloud_object = OPENSTACKCLOUD() - elif cloud_type.lower() in ["azure", "az"]: - cloud_object = Azure() - else: - logging.error( - "Cloud type %s is not currently supported for cluster shut down" % - cloud_type - ) - # removed_exit - # sys.exit(1) - raise RuntimeError() - - nodes = kubecli.list_nodes() - node_id = [] - for node in nodes: - instance_id = cloud_object.get_instance_id(node) - node_id.append(instance_id) - logging.info("node id list " + str(node_id)) - for _ in range(runs): - logging.info("Starting cluster_shut_down scenario injection") - stopping_nodes = set(node_id) - multiprocess_nodes(cloud_object.stop_instances, node_id, processes) - stopped_nodes = stopping_nodes.copy() - while len(stopping_nodes) > 0: - for node in stopping_nodes: - if type(node) is tuple: - node_status = cloud_object.wait_until_stopped( - node[1], - node[0], - timeout - ) - else: - node_status = cloud_object.wait_until_stopped( - node, - timeout - ) - - # Only want to remove node from stopping list - # when fully stopped/no error - if node_status: - stopped_nodes.remove(node) - - stopping_nodes = stopped_nodes.copy() - - logging.info( - "Shutting down the cluster for the specified duration: %s" % - (shut_down_duration) - ) - time.sleep(shut_down_duration) - logging.info("Restarting the nodes") - restarted_nodes = set(node_id) - multiprocess_nodes(cloud_object.start_instances, node_id, processes) - logging.info("Wait for each node to be running again") - not_running_nodes = restarted_nodes.copy() - while len(not_running_nodes) > 0: - for node in not_running_nodes: - if type(node) is tuple: - node_status = cloud_object.wait_until_running( - node[1], - node[0], - timeout - ) - else: - node_status = cloud_object.wait_until_running( - node, - timeout - ) - if node_status: - restarted_nodes.remove(node) - not_running_nodes = restarted_nodes.copy() - logging.info( - "Waiting for 150s to allow cluster component initialization" - ) - time.sleep(150) - - logging.info("Successfully injected cluster_shut_down scenario!") - -# krkn_lib - -def run(scenarios_list, - config, - wait_duration, - telemetry: KrknTelemetryOpenshift, - telemetry_request_id: str) -> (list[str], list[ScenarioTelemetry]): - failed_post_scenarios = [] - failed_scenarios = [] - scenario_telemetries: list[ScenarioTelemetry] = [] - - for shut_down_config in scenarios_list: - config_path = shut_down_config - pre_action_output = "" - if isinstance(shut_down_config, list) : - if len(shut_down_config) == 0: - raise Exception("bad config file format for shutdown scenario") - - config_path = shut_down_config[0] - if len(shut_down_config) > 1: - pre_action_output = post_actions.run("", shut_down_config[1]) - - scenario_telemetry = ScenarioTelemetry() - scenario_telemetry.scenario = config_path - scenario_telemetry.start_timestamp = time.time() - parsed_scenario_config = telemetry.set_parameters_base64(scenario_telemetry, config_path) - - with open(config_path, "r") as f: - shut_down_config_yaml = yaml.full_load(f) - shut_down_config_scenario = \ - shut_down_config_yaml["cluster_shut_down_scenario"] - start_time = int(time.time()) - try: - cluster_shut_down(shut_down_config_scenario, telemetry.kubecli) - logging.info( - "Waiting for the specified duration: %s" % (wait_duration) - ) - time.sleep(wait_duration) - failed_post_scenarios = post_actions.check_recovery( - "", shut_down_config, failed_post_scenarios, pre_action_output - ) - end_time = int(time.time()) - cerberus.publish_kraken_status( - config, - failed_post_scenarios, - start_time, - end_time - ) - - except (RuntimeError, Exception): - log_exception(config_path) - failed_scenarios.append(config_path) - scenario_telemetry.exit_status = 1 - else: - scenario_telemetry.exit_status = 0 - - scenario_telemetry.end_timestamp = time.time() - utils.collect_and_put_ocp_logs(telemetry, - parsed_scenario_config, - telemetry_request_id, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - utils.populate_cluster_events(scenario_telemetry, - parsed_scenario_config, - telemetry.kubecli, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - scenario_telemetries.append(scenario_telemetry) - - return failed_scenarios, scenario_telemetries - diff --git a/kraken/syn_flood/__init__.py b/kraken/syn_flood/__init__.py deleted file mode 100644 index 57180326..00000000 --- a/kraken/syn_flood/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .syn_flood import * \ No newline at end of file diff --git a/kraken/syn_flood/syn_flood.py b/kraken/syn_flood/syn_flood.py deleted file mode 100644 index 4036388d..00000000 --- a/kraken/syn_flood/syn_flood.py +++ /dev/null @@ -1,148 +0,0 @@ -import logging -import os.path -import time -from typing import List - -import krkn_lib.utils -import yaml -from krkn_lib.k8s import KrknKubernetes -from krkn_lib.models.telemetry import ScenarioTelemetry -from krkn_lib.telemetry.k8s import KrknTelemetryKubernetes -from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift - -from kraken import utils - - -def run(scenarios_list: list[str], - telemetry: KrknTelemetryOpenshift, - telemetry_request_id: str - ) -> (list[str], list[ScenarioTelemetry]): - scenario_telemetries: list[ScenarioTelemetry] = [] - failed_post_scenarios = [] - for scenario in scenarios_list: - scenario_telemetry = ScenarioTelemetry() - scenario_telemetry.scenario = scenario - scenario_telemetry.start_timestamp = time.time() - parsed_scenario_config = telemetry.set_parameters_base64(scenario_telemetry, scenario) - - try: - pod_names = [] - config = parse_config(scenario) - if config["target-service-label"]: - target_services = telemetry.kubecli.select_service_by_label(config["namespace"], config["target-service-label"]) - else: - target_services = [config["target-service"]] - - for target in target_services: - if not telemetry.kubecli.service_exists(target, config["namespace"]): - raise Exception(f"{target} service not found") - for i in range(config["number-of-pods"]): - pod_name = "syn-flood-" + krkn_lib.utils.get_random_string(10) - telemetry.kubecli.deploy_syn_flood(pod_name, - config["namespace"], - config["image"], - target, - config["target-port"], - config["packet-size"], - config["window-size"], - config["duration"], - config["attacker-nodes"] - ) - pod_names.append(pod_name) - - logging.info("waiting all the attackers to finish:") - did_finish = False - finished_pods = [] - while not did_finish: - for pod_name in pod_names: - if not telemetry.kubecli.is_pod_running(pod_name, config["namespace"]): - finished_pods.append(pod_name) - if set(pod_names) == set(finished_pods): - did_finish = True - time.sleep(1) - - except Exception as e: - logging.error(f"Failed to run syn flood scenario {scenario}: {e}") - failed_post_scenarios.append(scenario) - scenario_telemetry.exit_status = 1 - else: - scenario_telemetry.exit_status = 0 - scenario_telemetry.end_timestamp = time.time() - utils.collect_and_put_ocp_logs(telemetry, - parsed_scenario_config, - telemetry_request_id, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - utils.populate_cluster_events(scenario_telemetry, - parsed_scenario_config, - telemetry.kubecli, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - scenario_telemetries.append(scenario_telemetry) - return failed_post_scenarios, scenario_telemetries - -def parse_config(scenario_file: str) -> dict[str,any]: - if not os.path.exists(scenario_file): - raise Exception(f"failed to load scenario file {scenario_file}") - - try: - with open(scenario_file) as stream: - config = yaml.safe_load(stream) - except Exception: - raise Exception(f"{scenario_file} is not a valid yaml file") - - missing = [] - if not check_key_value(config ,"packet-size"): - missing.append("packet-size") - if not check_key_value(config,"window-size"): - missing.append("window-size") - if not check_key_value(config, "duration"): - missing.append("duration") - if not check_key_value(config, "namespace"): - missing.append("namespace") - if not check_key_value(config, "number-of-pods"): - missing.append("number-of-pods") - if not check_key_value(config, "target-port"): - missing.append("target-port") - if not check_key_value(config, "image"): - missing.append("image") - if "target-service" not in config.keys(): - missing.append("target-service") - if "target-service-label" not in config.keys(): - missing.append("target-service-label") - - - - - if len(missing) > 0: - raise Exception(f"{(',').join(missing)} parameter(s) are missing") - - if not config["target-service"] and not config["target-service-label"]: - raise Exception("you have either to set a target service or a label") - if config["target-service"] and config["target-service-label"]: - raise Exception("you cannot select both target-service and target-service-label") - - if 'attacker-nodes' and not is_node_affinity_correct(config['attacker-nodes']): - raise Exception("attacker-nodes format is not correct") - return config - -def check_key_value(dictionary, key): - if key in dictionary: - value = dictionary[key] - if value is not None and value != '': - return True - return False - -def is_node_affinity_correct(obj) -> bool: - if not isinstance(obj, dict): - return False - for key in obj.keys(): - if not isinstance(key, str): - return False - if not isinstance(obj[key], list): - return False - return True - - - - diff --git a/kraken/time_actions/common_time_functions.py b/kraken/time_actions/common_time_functions.py deleted file mode 100644 index 8c9f039d..00000000 --- a/kraken/time_actions/common_time_functions.py +++ /dev/null @@ -1,402 +0,0 @@ -import datetime -import time -import logging -import re - -import yaml -import random - -from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift -from kubernetes.client import ApiException - -from .. import utils -from ..cerberus import setup as cerberus -from krkn_lib.k8s import KrknKubernetes -from krkn_lib.models.telemetry import ScenarioTelemetry -from krkn_lib.utils.functions import get_yaml_item_value, log_exception, get_random_string - - -# krkn_lib -def pod_exec(pod_name, command, namespace, container_name, kubecli:KrknKubernetes): - for i in range(5): - response = kubecli.exec_cmd_in_pod( - command, - pod_name, - namespace, - container_name - ) - if not response: - time.sleep(2) - continue - elif ( - "unauthorized" in response.lower() or - "authorization" in response.lower() - ): - time.sleep(2) - continue - else: - break - return response - - -# krkn_lib -def get_container_name(pod_name, namespace, kubecli:KrknKubernetes, container_name=""): - - container_names = kubecli.get_containers_in_pod(pod_name, namespace) - if container_name != "": - if container_name in container_names: - return container_name - else: - logging.error( - "Container name %s not an existing container in pod %s" % ( - container_name, - pod_name - ) - ) - else: - container_name = container_names[ - # random module here is not used for security/cryptographic - # purposes - random.randint(0, len(container_names) - 1) # nosec - ] - return container_name - - - -def skew_node(node_name: str, action: str, kubecli: KrknKubernetes): - pod_namespace = "default" - status_pod_name = f"time-skew-pod-{get_random_string(5)}" - skew_pod_name = f"time-skew-pod-{get_random_string(5)}" - ntp_enabled = True - logging.info(f'Creating pod to skew {"time" if action == "skew_time" else "date"} on node {node_name}') - status_command = ["timedatectl"] - param = "2001-01-01" - skew_command = ["timedatectl", "set-time"] - if action == "skew_time": - skew_command.append("01:01:01") - else: - skew_command.append("2001-01-01") - - try: - status_response = kubecli.exec_command_on_node(node_name, status_command, status_pod_name, pod_namespace) - if "Network time on: no" in status_response: - ntp_enabled = False - - logging.warning(f'ntp unactive on node {node_name} skewing {"time" if action == "skew_time" else "date"} to {param}') - pod_exec(skew_pod_name, skew_command, pod_namespace, None, kubecli) - else: - logging.info(f'ntp active in cluster node, {"time" if action == "skew_time" else "date"} skewing will have no effect, skipping') - except ApiException: - pass - except Exception as e: - logging.error(f"failed to execute skew command in pod: {e}") - finally: - kubecli.delete_pod(status_pod_name, pod_namespace) - if not ntp_enabled : - kubecli.delete_pod(skew_pod_name, pod_namespace) - - - -# krkn_lib -def skew_time(scenario, kubecli:KrknKubernetes): - if scenario["action"] not in ["skew_date","skew_time"]: - raise RuntimeError(f'{scenario["action"]} is not a valid time skew action') - - if "node" in scenario["object_type"]: - node_names = [] - if "object_name" in scenario.keys() and scenario["object_name"]: - node_names = scenario["object_name"] - elif ( - "label_selector" in scenario.keys() and - scenario["label_selector"] - ): - node_names = kubecli.list_nodes(scenario["label_selector"]) - for node in node_names: - skew_node(node, scenario["action"], kubecli) - logging.info("Reset date/time on node " + str(node)) - return "node", node_names - - elif "pod" in scenario["object_type"]: - skew_command = "date --date " - if scenario["action"] == "skew_date": - skewed_date = "00-01-01" - skew_command += skewed_date - elif scenario["action"] == "skew_time": - skewed_time = "01:01:01" - skew_command += skewed_time - container_name = get_yaml_item_value(scenario, "container_name", "") - pod_names = [] - if "object_name" in scenario.keys() and scenario["object_name"]: - for name in scenario["object_name"]: - if "namespace" not in scenario.keys(): - logging.error("Need to set namespace when using pod name") - # removed_exit - # sys.exit(1) - raise RuntimeError() - pod_names.append([name, scenario["namespace"]]) - elif "namespace" in scenario.keys() and scenario["namespace"]: - if "label_selector" not in scenario.keys(): - logging.info( - "label_selector key not found, querying for all the pods " - "in namespace: %s" % (scenario["namespace"]) - ) - pod_names = kubecli.list_pods(scenario["namespace"]) - else: - logging.info( - "Querying for the pods matching the %s label_selector " - "in namespace %s" - % (scenario["label_selector"], scenario["namespace"]) - ) - pod_names = kubecli.list_pods( - scenario["namespace"], - scenario["label_selector"] - ) - counter = 0 - for pod_name in pod_names: - pod_names[counter] = [pod_name, scenario["namespace"]] - counter += 1 - elif ( - "label_selector" in scenario.keys() and - scenario["label_selector"] - ): - pod_names = kubecli.get_all_pods(scenario["label_selector"]) - - if len(pod_names) == 0: - logging.info( - "Cannot find pods matching the namespace/label_selector, " - "please check" - ) - # removed_exit - # sys.exit(1) - raise RuntimeError() - pod_counter = 0 - for pod in pod_names: - if len(pod) > 1: - selected_container_name = get_container_name( - pod[0], - pod[1], - kubecli, - container_name, - - ) - pod_exec_response = pod_exec( - pod[0], - skew_command, - pod[1], - selected_container_name, - kubecli, - - ) - if pod_exec_response is False: - logging.error( - "Couldn't reset time on container %s " - "in pod %s in namespace %s" - % (selected_container_name, pod[0], pod[1]) - ) - # removed_exit - # sys.exit(1) - raise RuntimeError() - pod_names[pod_counter].append(selected_container_name) - else: - selected_container_name = get_container_name( - pod, - scenario["namespace"], - kubecli, - container_name - ) - pod_exec_response = pod_exec( - pod, - skew_command, - scenario["namespace"], - selected_container_name, - kubecli - ) - if pod_exec_response is False: - logging.error( - "Couldn't reset time on container " - "%s in pod %s in namespace %s" - % ( - selected_container_name, - pod, - scenario["namespace"] - ) - ) - # removed_exit - # sys.exit(1) - raise RuntimeError() - pod_names[pod_counter].append(selected_container_name) - logging.info("Reset date/time on pod " + str(pod[0])) - pod_counter += 1 - return "pod", pod_names - - -# From kubectl/oc command get time output -def parse_string_date(obj_datetime): - try: - logging.info("Obj_date time " + str(obj_datetime)) - obj_datetime = re.sub(r"\s\s+", " ", obj_datetime).strip() - logging.info("Obj_date sub time " + str(obj_datetime)) - date_line = re.match( - r"[\s\S\n]*\w{3} \w{3} \d{1,} \d{2}:\d{2}:\d{2} \w{3} \d{4}[\s\S\n]*", # noqa - obj_datetime - ) - if date_line is not None: - search_response = date_line.group().strip() - logging.info("Search response: " + str(search_response)) - return search_response - else: - return "" - except Exception as e: - logging.info( - "Exception %s when trying to parse string to date" % str(e) - ) - return "" - - -# Get date and time from string returned from OC -def string_to_date(obj_datetime): - obj_datetime = parse_string_date(obj_datetime) - try: - date_time_obj = datetime.datetime.strptime( - obj_datetime, - "%a %b %d %H:%M:%S %Z %Y" - ) - return date_time_obj - except Exception: - logging.info("Couldn't parse string to datetime object") - return datetime.datetime(datetime.MINYEAR, 1, 1) - - -# krkn_lib -def check_date_time(object_type, names, kubecli:KrknKubernetes): - skew_command = "date" - not_reset = [] - max_retries = 30 - if object_type == "node": - for node_name in names: - first_date_time = datetime.datetime.utcnow() - check_pod_name = f"time-skew-pod-{get_random_string(5)}" - node_datetime_string = kubecli.exec_command_on_node(node_name, [skew_command], check_pod_name) - node_datetime = string_to_date(node_datetime_string) - counter = 0 - while not ( - first_date_time < node_datetime < datetime.datetime.utcnow() - ): - time.sleep(10) - logging.info( - "Date/time on node %s still not reset, " - "waiting 10 seconds and retrying" % node_name - ) - - node_datetime_string = kubecli.exec_cmd_in_pod([skew_command], check_pod_name, "default") - node_datetime = string_to_date(node_datetime_string) - counter += 1 - if counter > max_retries: - logging.error( - "Date and time in node %s didn't reset properly" % - node_name - ) - not_reset.append(node_name) - break - if counter < max_retries: - logging.info( - "Date in node " + str(node_name) + " reset properly" - ) - kubecli.delete_pod(check_pod_name) - - elif object_type == "pod": - for pod_name in names: - first_date_time = datetime.datetime.utcnow() - counter = 0 - pod_datetime_string = pod_exec( - pod_name[0], - skew_command, - pod_name[1], - pod_name[2], - kubecli - ) - pod_datetime = string_to_date(pod_datetime_string) - while not ( - first_date_time < pod_datetime < datetime.datetime.utcnow() - ): - time.sleep(10) - logging.info( - "Date/time on pod %s still not reset, " - "waiting 10 seconds and retrying" % pod_name[0] - ) - pod_datetime = pod_exec( - pod_name[0], - skew_command, - pod_name[1], - pod_name[2], - kubecli - ) - pod_datetime = string_to_date(pod_datetime) - counter += 1 - if counter > max_retries: - logging.error( - "Date and time in pod %s didn't reset properly" % - pod_name[0] - ) - not_reset.append(pod_name[0]) - break - if counter < max_retries: - logging.info( - "Date in pod " + str(pod_name[0]) + " reset properly" - ) - return not_reset - - -# krkn_lib -def run(scenarios_list, - config, - wait_duration, - telemetry: KrknTelemetryOpenshift, - telemetry_request_id: str) -> (list[str], list[ScenarioTelemetry]): - failed_scenarios = [] - scenario_telemetries: list[ScenarioTelemetry] = [] - for time_scenario_config in scenarios_list: - scenario_telemetry = ScenarioTelemetry() - scenario_telemetry.scenario = time_scenario_config - scenario_telemetry.start_timestamp = time.time() - parsed_scenario_config = telemetry.set_parameters_base64(scenario_telemetry, time_scenario_config) - try: - with open(time_scenario_config, "r") as f: - scenario_config = yaml.full_load(f) - for time_scenario in scenario_config["time_scenarios"]: - start_time = int(time.time()) - object_type, object_names = skew_time(time_scenario, telemetry.kubecli) - not_reset = check_date_time(object_type, object_names, telemetry.kubecli) - if len(not_reset) > 0: - logging.info("Object times were not reset") - logging.info( - "Waiting for the specified duration: %s" % (wait_duration) - ) - time.sleep(wait_duration) - end_time = int(time.time()) - cerberus.publish_kraken_status( - config, - not_reset, - start_time, - end_time - ) - except (RuntimeError, Exception): - scenario_telemetry.exit_status = 1 - log_exception(time_scenario_config) - failed_scenarios.append(time_scenario_config) - else: - scenario_telemetry.exit_status = 0 - scenario_telemetry.end_timestamp = time.time() - utils.collect_and_put_ocp_logs(telemetry, - parsed_scenario_config, - telemetry_request_id, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - utils.populate_cluster_events(scenario_telemetry, - parsed_scenario_config, - telemetry.kubecli, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - scenario_telemetries.append(scenario_telemetry) - - return failed_scenarios, scenario_telemetries diff --git a/kraken/utils/functions.py b/kraken/utils/functions.py deleted file mode 100644 index 222283ff..00000000 --- a/kraken/utils/functions.py +++ /dev/null @@ -1,60 +0,0 @@ -import krkn_lib.utils -from krkn_lib.k8s import KrknKubernetes -from krkn_lib.models.telemetry import ScenarioTelemetry -from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift -from tzlocal.unix import get_localzone - - -def populate_cluster_events(scenario_telemetry: ScenarioTelemetry, - scenario_config: dict, - kubecli: KrknKubernetes, - start_timestamp: int, - end_timestamp: int - ): - events = [] - namespaces = __retrieve_namespaces(scenario_config, kubecli) - - if len(namespaces) == 0: - events.extend(kubecli.collect_and_parse_cluster_events(start_timestamp, end_timestamp, str(get_localzone()))) - else: - for namespace in namespaces: - events.extend(kubecli.collect_and_parse_cluster_events(start_timestamp, end_timestamp, str(get_localzone()), - namespace=namespace)) - - scenario_telemetry.set_cluster_events(events) - - -def collect_and_put_ocp_logs(telemetry_ocp: KrknTelemetryOpenshift, - scenario_config: dict, - request_id: str, - start_timestamp: int, - end_timestamp: int, - ): - if ( - telemetry_ocp.krkn_telemetry_config and - telemetry_ocp.krkn_telemetry_config["enabled"] and - telemetry_ocp.krkn_telemetry_config["logs_backup"] and - not telemetry_ocp.kubecli.is_kubernetes() - ): - namespaces = __retrieve_namespaces(scenario_config, telemetry_ocp.kubecli) - if len(namespaces) > 0: - for namespace in namespaces: - telemetry_ocp.put_ocp_logs(request_id, - telemetry_ocp.krkn_telemetry_config, - start_timestamp, - end_timestamp, - namespace) - else: - telemetry_ocp.put_ocp_logs(request_id, - telemetry_ocp.krkn_telemetry_config, - start_timestamp, - end_timestamp) - - -def __retrieve_namespaces(scenario_config: dict, kubecli: KrknKubernetes) -> set[str]: - namespaces = list() - namespaces.extend(krkn_lib.utils.deep_get_attribute("namespace", scenario_config)) - namespace_patterns = krkn_lib.utils.deep_get_attribute("namespace_pattern", scenario_config) - for pattern in namespace_patterns: - namespaces.extend(kubecli.list_namespaces_by_regex(pattern)) - return set(namespaces) diff --git a/kraken/zone_outage/actions.py b/kraken/zone_outage/actions.py deleted file mode 100644 index 7e42375b..00000000 --- a/kraken/zone_outage/actions.py +++ /dev/null @@ -1,138 +0,0 @@ -import yaml -import logging -import time - -from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift - -from .. import utils -from ..node_actions.aws_node_scenarios import AWS -from ..cerberus import setup as cerberus -from krkn_lib.models.telemetry import ScenarioTelemetry -from krkn_lib.utils.functions import log_exception - -def run(scenarios_list, - config, - wait_duration, - telemetry: KrknTelemetryOpenshift, - telemetry_request_id: str) -> (list[str], list[ScenarioTelemetry]) : - """ - filters the subnet of interest and applies the network acl - to create zone outage - """ - failed_post_scenarios = "" - scenario_telemetries: list[ScenarioTelemetry] = [] - failed_scenarios = [] - - for zone_outage_config in scenarios_list: - scenario_telemetry = ScenarioTelemetry() - scenario_telemetry.scenario = zone_outage_config - scenario_telemetry.start_timestamp = time.time() - parsed_scenario_config = telemetry.set_parameters_base64(scenario_telemetry, zone_outage_config) - try: - if len(zone_outage_config) > 1: - with open(zone_outage_config, "r") as f: - zone_outage_config_yaml = yaml.full_load(f) - scenario_config = zone_outage_config_yaml["zone_outage"] - vpc_id = scenario_config["vpc_id"] - subnet_ids = scenario_config["subnet_id"] - duration = scenario_config["duration"] - cloud_type = scenario_config["cloud_type"] - ids = {} - acl_ids_created = [] - - if cloud_type.lower() == "aws": - cloud_object = AWS() - else: - logging.error( - "Cloud type %s is not currently supported for " - "zone outage scenarios" - % cloud_type - ) - # removed_exit - # sys.exit(1) - raise RuntimeError() - - start_time = int(time.time()) - - for subnet_id in subnet_ids: - logging.info("Targeting subnet_id") - network_association_ids = [] - associations, original_acl_id = \ - cloud_object.describe_network_acls(vpc_id, subnet_id) - for entry in associations: - if entry["SubnetId"] == subnet_id: - network_association_ids.append( - entry["NetworkAclAssociationId"] - ) - logging.info( - "Network association ids associated with " - "the subnet %s: %s" - % (subnet_id, network_association_ids) - ) - acl_id = cloud_object.create_default_network_acl(vpc_id) - new_association_id = \ - cloud_object.replace_network_acl_association( - network_association_ids[0], acl_id - ) - - # capture the orginal_acl_id, created_acl_id and - # new association_id to use during the recovery - ids[new_association_id] = original_acl_id - acl_ids_created.append(acl_id) - - # wait for the specified duration - logging.info( - "Waiting for the specified duration " - "in the config: %s" % (duration) - ) - time.sleep(duration) - - # replace the applied acl with the previous acl in use - for new_association_id, original_acl_id in ids.items(): - cloud_object.replace_network_acl_association( - new_association_id, - original_acl_id - ) - logging.info( - "Wating for 60 seconds to make sure " - "the changes are in place" - ) - time.sleep(60) - - # delete the network acl created for the run - for acl_id in acl_ids_created: - cloud_object.delete_network_acl(acl_id) - - logging.info( - "End of scenario. " - "Waiting for the specified duration: %s" % (wait_duration) - ) - time.sleep(wait_duration) - - end_time = int(time.time()) - cerberus.publish_kraken_status( - config, - failed_post_scenarios, - start_time, - end_time - ) - except (RuntimeError, Exception): - scenario_telemetry.exit_status = 1 - failed_scenarios.append(zone_outage_config) - log_exception(zone_outage_config) - else: - scenario_telemetry.exit_status = 0 - scenario_telemetry.end_timestamp = time.time() - utils.collect_and_put_ocp_logs(telemetry, - parsed_scenario_config, - telemetry_request_id, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - utils.populate_cluster_events(scenario_telemetry, - parsed_scenario_config, - telemetry.kubecli, - int(scenario_telemetry.start_timestamp), - int(scenario_telemetry.end_timestamp)) - scenario_telemetries.append(scenario_telemetry) - return failed_scenarios, scenario_telemetries - diff --git a/kraken/__init__.py b/krkn/__init__.py similarity index 100% rename from kraken/__init__.py rename to krkn/__init__.py diff --git a/krkn/cerberus/__init__.py b/krkn/cerberus/__init__.py new file mode 100644 index 00000000..9ca22d34 --- /dev/null +++ b/krkn/cerberus/__init__.py @@ -0,0 +1 @@ +from .setup import * diff --git a/kraken/cerberus/setup.py b/krkn/cerberus/setup.py similarity index 100% rename from kraken/cerberus/setup.py rename to krkn/cerberus/setup.py diff --git a/kraken/chaos_recommender/__init__.py b/krkn/chaos_recommender/__init__.py similarity index 100% rename from kraken/chaos_recommender/__init__.py rename to krkn/chaos_recommender/__init__.py diff --git a/kraken/chaos_recommender/analysis.py b/krkn/chaos_recommender/analysis.py similarity index 56% rename from kraken/chaos_recommender/analysis.py rename to krkn/chaos_recommender/analysis.py index 2c41f40e..90bf9a1c 100644 --- a/kraken/chaos_recommender/analysis.py +++ b/krkn/chaos_recommender/analysis.py @@ -1,7 +1,6 @@ import logging import pandas as pd -import kraken.chaos_recommender.kraken_tests as kraken_tests import time KRAKEN_TESTS_PATH = "./kraken_chaos_tests.txt" @@ -23,7 +22,9 @@ def calculate_zscores(data): zscores["Service"] = data["service"] zscores["CPU"] = (data["CPU"] - data["CPU"].mean()) / data["CPU"].std() zscores["Memory"] = (data["MEM"] - data["MEM"].mean()) / data["MEM"].std() - zscores["Network"] = (data["NETWORK"] - data["NETWORK"].mean()) / data["NETWORK"].std() + zscores["Network"] = (data["NETWORK"] - data["NETWORK"].mean()) / data[ + "NETWORK" + ].std() return zscores @@ -37,18 +38,28 @@ def identify_outliers(data, threshold): def get_services_above_heatmap_threshold(dataframe, cpu_threshold, mem_threshold): # Filter the DataFrame based on CPU_HEATMAP and MEM_HEATMAP thresholds - filtered_df = dataframe[((dataframe['CPU']/dataframe['CPU_LIMITS']) > cpu_threshold)] + filtered_df = dataframe[ + ((dataframe["CPU"] / dataframe["CPU_LIMITS"]) > cpu_threshold) + ] # Get the lists of services - cpu_services = filtered_df['service'].tolist() + cpu_services = filtered_df["service"].tolist() - filtered_df = dataframe[((dataframe['MEM']/dataframe['MEM_LIMITS']) > mem_threshold)] - mem_services = filtered_df['service'].tolist() + filtered_df = dataframe[ + ((dataframe["MEM"] / dataframe["MEM_LIMITS"]) > mem_threshold) + ] + mem_services = filtered_df["service"].tolist() return cpu_services, mem_services -def analysis(file_path, namespaces, chaos_tests_config, threshold, - heatmap_cpu_threshold, heatmap_mem_threshold): +def analysis( + file_path, + namespaces, + chaos_tests_config, + threshold, + heatmap_cpu_threshold, + heatmap_mem_threshold, +): # Load the telemetry data from file logging.info("Fetching the Telemetry data...") data = load_telemetry_data(file_path) @@ -66,29 +77,43 @@ def analysis(file_path, namespaces, chaos_tests_config, threshold, namespace_zscores = zscores.loc[zscores["Namespace"] == namespace] namespace_data = data.loc[data["namespace"] == namespace] outliers_cpu, outliers_memory, outliers_network = identify_outliers( - namespace_zscores, threshold) + namespace_zscores, threshold + ) cpu_services, mem_services = get_services_above_heatmap_threshold( - namespace_data, heatmap_cpu_threshold, heatmap_mem_threshold) + namespace_data, heatmap_cpu_threshold, heatmap_mem_threshold + ) - analysis_data[namespace] = analysis_json(outliers_cpu, outliers_memory, - outliers_network, - cpu_services, mem_services, - chaos_tests_config) + analysis_data[namespace] = analysis_json( + outliers_cpu, + outliers_memory, + outliers_network, + cpu_services, + mem_services, + chaos_tests_config, + ) if cpu_services: - logging.info(f"These services use significant CPU compared to " - f"their assigned limits: {cpu_services}") + logging.info( + f"These services use significant CPU compared to " + f"their assigned limits: {cpu_services}" + ) else: - logging.info("There are no services that are using significant " - "CPU compared to their assigned limits " - "(infinite in case no limits are set).") + logging.info( + "There are no services that are using significant " + "CPU compared to their assigned limits " + "(infinite in case no limits are set)." + ) if mem_services: - logging.info(f"These services use significant MEMORY compared to " - f"their assigned limits: {mem_services}") + logging.info( + f"These services use significant MEMORY compared to " + f"their assigned limits: {mem_services}" + ) else: - logging.info("There are no services that are using significant " - "MEMORY compared to their assigned limits " - "(infinite in case no limits are set).") + logging.info( + "There are no services that are using significant " + "MEMORY compared to their assigned limits " + "(infinite in case no limits are set)." + ) time.sleep(2) logging.info("Please check data in utilisation.txt for further analysis") @@ -96,36 +121,41 @@ def analysis(file_path, namespaces, chaos_tests_config, threshold, return analysis_data -def analysis_json(outliers_cpu, outliers_memory, outliers_network, - cpu_services, mem_services, chaos_tests_config): +def analysis_json( + outliers_cpu, + outliers_memory, + outliers_network, + cpu_services, + mem_services, + chaos_tests_config, +): profiling = { "cpu_outliers": outliers_cpu, "memory_outliers": outliers_memory, - "network_outliers": outliers_network + "network_outliers": outliers_network, } heatmap = { "services_with_cpu_heatmap_above_threshold": cpu_services, - "services_with_mem_heatmap_above_threshold": mem_services + "services_with_mem_heatmap_above_threshold": mem_services, } recommendations = {} if cpu_services: - cpu_recommend = {"services": cpu_services, - "tests": chaos_tests_config['CPU']} + cpu_recommend = {"services": cpu_services, "tests": chaos_tests_config["CPU"]} recommendations["cpu_services_recommendations"] = cpu_recommend if mem_services: - mem_recommend = {"services": mem_services, - "tests": chaos_tests_config['MEM']} + mem_recommend = {"services": mem_services, "tests": chaos_tests_config["MEM"]} recommendations["mem_services_recommendations"] = mem_recommend if outliers_network: - outliers_network_recommend = {"outliers_networks": outliers_network, - "tests": chaos_tests_config['NETWORK']} - recommendations["outliers_network_recommendations"] = ( - outliers_network_recommend) + outliers_network_recommend = { + "outliers_networks": outliers_network, + "tests": chaos_tests_config["NETWORK"], + } + recommendations["outliers_network_recommendations"] = outliers_network_recommend return [profiling, heatmap, recommendations] diff --git a/kraken/chaos_recommender/kraken_tests.py b/krkn/chaos_recommender/kraken_tests.py similarity index 71% rename from kraken/chaos_recommender/kraken_tests.py rename to krkn/chaos_recommender/kraken_tests.py index deb3fa2b..8909e329 100644 --- a/kraken/chaos_recommender/kraken_tests.py +++ b/krkn/chaos_recommender/kraken_tests.py @@ -1,13 +1,13 @@ def get_entries_by_category(filename, category): # Read the file - with open(filename, 'r') as file: + with open(filename, "r") as file: content = file.read() # Split the content into sections based on the square brackets - sections = content.split('\n\n') + sections = content.split("\n\n") # Define the categories - valid_categories = ['CPU', 'NETWORK', 'MEM', 'GENERIC'] + valid_categories = ["CPU", "NETWORK", "MEM", "GENERIC"] # Validate the provided category if category not in valid_categories: @@ -25,6 +25,10 @@ def get_entries_by_category(filename, category): return [] # Extract the entries from the category section - entries = [entry.strip() for entry in target_section.split('\n') if entry and not entry.startswith('[')] + entries = [ + entry.strip() + for entry in target_section.split("\n") + if entry and not entry.startswith("[") + ] return entries diff --git a/krkn/chaos_recommender/prometheus.py b/krkn/chaos_recommender/prometheus.py new file mode 100644 index 00000000..c00f73d3 --- /dev/null +++ b/krkn/chaos_recommender/prometheus.py @@ -0,0 +1,203 @@ +import logging + +from prometheus_api_client import PrometheusConnect +import pandas as pd +import urllib3 + + +saved_metrics_path = "./utilisation.txt" + + +def convert_data_to_dataframe(data, label): + df = pd.DataFrame() + df["service"] = [item["metric"]["pod"] for item in data] + df[label] = [item["value"][1] for item in data] + + return df + + +def convert_data(data, service): + result = {} + for entry in data: + pod_name = entry["metric"]["pod"] + value = entry["value"][1] + result[pod_name] = value + return result.get( + service + ) # for those pods whose limits are not defined they can take as much resources, there assigning a very high value + + +def convert_data_limits(data, node_data, service, prometheus): + result = {} + for entry in data: + pod_name = entry["metric"]["pod"] + value = entry["value"][1] + result[pod_name] = value + return result.get( + service, get_node_capacity(node_data, service, prometheus) + ) # for those pods whose limits are not defined they can take as much resources, there assigning a very high value + + +def get_node_capacity(node_data, pod_name, prometheus): + + # Get the node name on which the pod is running + query = f'kube_pod_info{{pod="{pod_name}"}}' + result = prometheus.custom_query(query) + if not result: + return None + + node_name = result[0]["metric"]["node"] + + for item in node_data: + if item["metric"]["node"] == node_name: + return item["value"][1] + + return "1000000000" + + +def save_utilization_to_file(utilization, filename, prometheus): + + merged_df = pd.DataFrame( + columns=[ + "namespace", + "service", + "CPU", + "CPU_LIMITS", + "MEM", + "MEM_LIMITS", + "NETWORK", + ] + ) + for namespace in utilization: + # Loading utilization_data[] for namespace + # indexes -- 0 CPU, 1 CPU limits, 2 mem, 3 mem limits, 4 network + utilization_data = utilization[namespace] + df_cpu = convert_data_to_dataframe(utilization_data[0], "CPU") + services = df_cpu.service.unique() + logging.info(f"Services for namespace {namespace}: {services}") + + for s in services: + + new_row_df = pd.DataFrame( + { + "namespace": namespace, + "service": s, + "CPU": convert_data(utilization_data[0], s), + "CPU_LIMITS": convert_data_limits( + utilization_data[1], utilization_data[5], s, prometheus + ), + "MEM": convert_data(utilization_data[2], s), + "MEM_LIMITS": convert_data_limits( + utilization_data[3], utilization_data[6], s, prometheus + ), + "NETWORK": convert_data(utilization_data[4], s), + }, + index=[0], + ) + merged_df = pd.concat([merged_df, new_row_df], ignore_index=True) + + # Convert columns to string + merged_df["CPU"] = merged_df["CPU"].astype(str) + merged_df["MEM"] = merged_df["MEM"].astype(str) + merged_df["CPU_LIMITS"] = merged_df["CPU_LIMITS"].astype(str) + merged_df["MEM_LIMITS"] = merged_df["MEM_LIMITS"].astype(str) + merged_df["NETWORK"] = merged_df["NETWORK"].astype(str) + + # Extract integer part before the decimal point + # merged_df['CPU'] = merged_df['CPU'].str.split('.').str[0] + # merged_df['MEM'] = merged_df['MEM'].str.split('.').str[0] + # merged_df['CPU_LIMITS'] = merged_df['CPU_LIMITS'].str.split('.').str[0] + # merged_df['MEM_LIMITS'] = merged_df['MEM_LIMITS'].str.split('.').str[0] + # merged_df['NETWORK'] = merged_df['NETWORK'].str.split('.').str[0] + + merged_df.to_csv(filename, sep="\t", index=False) + + +def fetch_utilization_from_prometheus( + prometheus_endpoint, auth_token, namespaces, scrape_duration +): + urllib3.disable_warnings() + prometheus = PrometheusConnect( + url=prometheus_endpoint, + headers={"Authorization": "Bearer {}".format(auth_token)}, + disable_ssl=True, + ) + + # Dicts for saving utilisation and queries -- key is namespace + utilization = {} + queries = {} + + logging.info("Fetching utilization...") + for namespace in namespaces: + + # Fetch CPU utilization + cpu_query = ( + 'sum (rate (container_cpu_usage_seconds_total{image!="", namespace="%s"}[%s])) by (pod) *1000' + % (namespace, scrape_duration) + ) + cpu_result = prometheus.custom_query(cpu_query) + + cpu_limits_query = ( + '(sum by (pod) (kube_pod_container_resource_limits{resource="cpu", namespace="%s"}))*1000' + % (namespace) + ) + cpu_limits_result = prometheus.custom_query(cpu_limits_query) + + node_cpu_limits_query = ( + 'kube_node_status_capacity{resource="cpu", unit="core"}*1000' + ) + node_cpu_limits_result = prometheus.custom_query(node_cpu_limits_query) + + mem_query = ( + 'sum by (pod) (avg_over_time(container_memory_usage_bytes{image!="", namespace="%s"}[%s]))' + % (namespace, scrape_duration) + ) + mem_result = prometheus.custom_query(mem_query) + + mem_limits_query = ( + 'sum by (pod) (kube_pod_container_resource_limits{resource="memory", namespace="%s"}) ' + % (namespace) + ) + mem_limits_result = prometheus.custom_query(mem_limits_query) + + node_mem_limits_query = ( + 'kube_node_status_capacity{resource="memory", unit="byte"}' + ) + node_mem_limits_result = prometheus.custom_query(node_mem_limits_query) + + network_query = ( + 'sum by (pod) ((avg_over_time(container_network_transmit_bytes_total{namespace="%s"}[%s])) + \ + (avg_over_time(container_network_receive_bytes_total{namespace="%s"}[%s])))' + % (namespace, scrape_duration, namespace, scrape_duration) + ) + network_result = prometheus.custom_query(network_query) + + utilization[namespace] = [ + cpu_result, + cpu_limits_result, + mem_result, + mem_limits_result, + network_result, + node_cpu_limits_result, + node_mem_limits_result, + ] + queries[namespace] = json_queries( + cpu_query, cpu_limits_query, mem_query, mem_limits_query, network_query + ) + + save_utilization_to_file(utilization, saved_metrics_path, prometheus) + + return saved_metrics_path, queries + + +def json_queries( + cpu_query, cpu_limits_query, mem_query, mem_limits_query, network_query +): + queries = { + "cpu_query": cpu_query, + "cpu_limit_query": cpu_limits_query, + "memory_query": mem_query, + "memory_limit_query": mem_limits_query, + "network_query": network_query, + } + return queries diff --git a/kraken/application_outage/__init__.py b/krkn/invoke/__init__.py similarity index 100% rename from kraken/application_outage/__init__.py rename to krkn/invoke/__init__.py diff --git a/kraken/invoke/command.py b/krkn/invoke/command.py similarity index 100% rename from kraken/invoke/command.py rename to krkn/invoke/command.py diff --git a/kraken/cerberus/__init__.py b/krkn/performance_dashboards/__init__.py similarity index 100% rename from kraken/cerberus/__init__.py rename to krkn/performance_dashboards/__init__.py diff --git a/kraken/performance_dashboards/setup.py b/krkn/performance_dashboards/setup.py similarity index 90% rename from kraken/performance_dashboards/setup.py rename to krkn/performance_dashboards/setup.py index 33c92a70..f8bf6fea 100644 --- a/kraken/performance_dashboards/setup.py +++ b/krkn/performance_dashboards/setup.py @@ -14,7 +14,9 @@ def setup(repo, distribution): logging.error("Provided distribution: %s is not supported" % (distribution)) sys.exit(1) delete_repo = "rm -rf performance-dashboards || exit 0" - logging.info("Cloning, installing mutable grafana on the cluster and loading the dashboards") + logging.info( + "Cloning, installing mutable grafana on the cluster and loading the dashboards" + ) try: # delete repo to clone the latest copy if exists subprocess.run(delete_repo, shell=True, universal_newlines=True, timeout=45) diff --git a/kraken/prometheus/__init__.py b/krkn/prometheus/__init__.py similarity index 100% rename from kraken/prometheus/__init__.py rename to krkn/prometheus/__init__.py diff --git a/kraken/prometheus/client.py b/krkn/prometheus/client.py similarity index 51% rename from kraken/prometheus/client.py rename to krkn/prometheus/client.py index e3466fdd..b444f5e8 100644 --- a/kraken/prometheus/client.py +++ b/krkn/prometheus/client.py @@ -16,15 +16,18 @@ from krkn_lib.prometheus.krkn_prometheus import KrknPrometheus urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) -def alerts(prom_cli: KrknPrometheus, - elastic: KrknElastic, - run_uuid, - start_time, - end_time, - alert_profile, - elastic_collect_alerts, - elastic_alerts_index - ): + + +def alerts( + prom_cli: KrknPrometheus, + elastic: KrknElastic, + run_uuid, + start_time, + end_time, + alert_profile, + elastic_collect_alerts, + elastic_alerts_index, +): if alert_profile is None or os.path.exists(alert_profile) is False: logging.error(f"{alert_profile} alert profile does not exist") @@ -33,69 +36,102 @@ def alerts(prom_cli: KrknPrometheus, with open(alert_profile) as profile: profile_yaml = yaml.safe_load(profile) if not isinstance(profile_yaml, list): - logging.error(f"{alert_profile} wrong file format, alert profile must be " - f"a valid yaml file containing a list of items with at least 3 properties: " - f"expr, description, severity" ) + logging.error( + f"{alert_profile} wrong file format, alert profile must be " + f"a valid yaml file containing a list of items with at least 3 properties: " + f"expr, description, severity" + ) sys.exit(1) for alert in profile_yaml: if list(alert.keys()).sort() != ["expr", "description", "severity"].sort(): logging.error(f"wrong alert {alert}, skipping") - processed_alert = prom_cli.process_alert(alert, - datetime.datetime.fromtimestamp(start_time), - datetime.datetime.fromtimestamp(end_time)) - if processed_alert[0] and processed_alert[1] and elastic and elastic_collect_alerts: - elastic_alert = ElasticAlert(run_uuid=run_uuid, - severity=alert["severity"], - alert=processed_alert[1], - created_at=datetime.datetime.fromtimestamp(processed_alert[0]) - ) + processed_alert = prom_cli.process_alert( + alert, + datetime.datetime.fromtimestamp(start_time), + datetime.datetime.fromtimestamp(end_time), + ) + if ( + processed_alert[0] + and processed_alert[1] + and elastic + and elastic_collect_alerts + ): + elastic_alert = ElasticAlert( + run_uuid=run_uuid, + severity=alert["severity"], + alert=processed_alert[1], + created_at=datetime.datetime.fromtimestamp(processed_alert[0]), + ) result = elastic.push_alert(elastic_alert, elastic_alerts_index) if result == -1: logging.error("failed to save alert on ElasticSearch") pass - -def critical_alerts(prom_cli: KrknPrometheus, - summary: ChaosRunAlertSummary, - run_id, - scenario, - start_time, - end_time): +def critical_alerts( + prom_cli: KrknPrometheus, + summary: ChaosRunAlertSummary, + run_id, + scenario, + start_time, + end_time, +): summary.scenario = scenario summary.run_id = run_id query = r"""ALERTS{severity="critical"}""" logging.info("Checking for critical alerts firing post chaos") during_critical_alerts = prom_cli.process_prom_query_in_range( - query, - start_time=datetime.datetime.fromtimestamp(start_time), - end_time=end_time - + query, start_time=datetime.datetime.fromtimestamp(start_time), end_time=end_time ) for alert in during_critical_alerts: if "metric" in alert: - alertname = alert["metric"]["alertname"] if "alertname" in alert["metric"] else "none" - alertstate = alert["metric"]["alertstate"] if "alertstate" in alert["metric"] else "none" - namespace = alert["metric"]["namespace"] if "namespace" in alert["metric"] else "none" - severity = alert["metric"]["severity"] if "severity" in alert["metric"] else "none" + alertname = ( + alert["metric"]["alertname"] + if "alertname" in alert["metric"] + else "none" + ) + alertstate = ( + alert["metric"]["alertstate"] + if "alertstate" in alert["metric"] + else "none" + ) + namespace = ( + alert["metric"]["namespace"] + if "namespace" in alert["metric"] + else "none" + ) + severity = ( + alert["metric"]["severity"] if "severity" in alert["metric"] else "none" + ) alert = ChaosRunAlert(alertname, alertstate, namespace, severity) summary.chaos_alerts.append(alert) - - post_critical_alerts = prom_cli.process_query( - query - ) + post_critical_alerts = prom_cli.process_query(query) for alert in post_critical_alerts: if "metric" in alert: - alertname = alert["metric"]["alertname"] if "alertname" in alert["metric"] else "none" - alertstate = alert["metric"]["alertstate"] if "alertstate" in alert["metric"] else "none" - namespace = alert["metric"]["namespace"] if "namespace" in alert["metric"] else "none" - severity = alert["metric"]["severity"] if "severity" in alert["metric"] else "none" + alertname = ( + alert["metric"]["alertname"] + if "alertname" in alert["metric"] + else "none" + ) + alertstate = ( + alert["metric"]["alertstate"] + if "alertstate" in alert["metric"] + else "none" + ) + namespace = ( + alert["metric"]["namespace"] + if "namespace" in alert["metric"] + else "none" + ) + severity = ( + alert["metric"]["severity"] if "severity" in alert["metric"] else "none" + ) alert = ChaosRunAlert(alertname, alertstate, namespace, severity) summary.post_chaos_alerts.append(alert) @@ -113,15 +149,16 @@ def critical_alerts(prom_cli: KrknPrometheus, logging.info("No critical alerts are firing!!") -def metrics(prom_cli: KrknPrometheus, - elastic: KrknElastic, - run_uuid, - start_time, - end_time, - metrics_profile, - elastic_collect_metrics, - elastic_metrics_index - ) -> list[dict[str, list[(int, float)] | str]]: +def metrics( + prom_cli: KrknPrometheus, + elastic: KrknElastic, + run_uuid, + start_time, + end_time, + metrics_profile, + elastic_collect_metrics, + elastic_metrics_index, +) -> list[dict[str, list[(int, float)] | str]]: metrics_list: list[dict[str, list[(int, float)] | str]] = [] if metrics_profile is None or os.path.exists(metrics_profile) is False: logging.error(f"{metrics_profile} alert profile does not exist") @@ -129,22 +166,26 @@ def metrics(prom_cli: KrknPrometheus, with open(metrics_profile) as profile: profile_yaml = yaml.safe_load(profile) if not profile_yaml["metrics"] or not isinstance(profile_yaml["metrics"], list): - logging.error(f"{metrics_profile} wrong file format, alert profile must be " - f"a valid yaml file containing a list of items with 3 properties: " - f"expr, description, severity" ) + logging.error( + f"{metrics_profile} wrong file format, alert profile must be " + f"a valid yaml file containing a list of items with 3 properties: " + f"expr, description, severity" + ) sys.exit(1) for metric_query in profile_yaml["metrics"]: - if list(metric_query.keys()).sort() != ["query", "metricName", "instant"].sort(): + if ( + list(metric_query.keys()).sort() + != ["query", "metricName", "instant"].sort() + ): logging.error(f"wrong alert {metric_query}, skipping") metrics_result = prom_cli.process_prom_query_in_range( metric_query["query"], start_time=datetime.datetime.fromtimestamp(start_time), - end_time=datetime.datetime.fromtimestamp(end_time) - + end_time=datetime.datetime.fromtimestamp(end_time), ) - metric = {"name": metric_query["metricName"], "values":[]} + metric = {"name": metric_query["metricName"], "values": []} for returned_metric in metrics_result: if "values" in returned_metric: for value in returned_metric["values"]: @@ -155,13 +196,10 @@ def metrics(prom_cli: KrknPrometheus, metrics_list.append(metric) if elastic_collect_metrics and elastic: - result = elastic.upload_metrics_to_elasticsearch(run_uuid=run_uuid, index=elastic_metrics_index, raw_data=metrics_list) + result = elastic.upload_metrics_to_elasticsearch( + run_uuid=run_uuid, index=elastic_metrics_index, raw_data=metrics_list + ) if result == -1: logging.error("failed to save metrics on ElasticSearch") - return metrics_list - - - - diff --git a/kraken/invoke/__init__.py b/krkn/scenario_plugins/__init__.py similarity index 100% rename from kraken/invoke/__init__.py rename to krkn/scenario_plugins/__init__.py diff --git a/krkn/scenario_plugins/abstract_scenario_plugin.py b/krkn/scenario_plugins/abstract_scenario_plugin.py new file mode 100644 index 00000000..060d9ec3 --- /dev/null +++ b/krkn/scenario_plugins/abstract_scenario_plugin.py @@ -0,0 +1,115 @@ +import logging +import time +from abc import ABC, abstractmethod +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift + +from krkn import utils + + +class AbstractScenarioPlugin(ABC): + @abstractmethod + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + """ + This method serves as the entry point for a ScenarioPlugin. To make the plugin loadable, + the AbstractScenarioPlugin class must be extended, and this method must be implemented. + No exception must be propagated outside of this method. + + :param run_uuid: the uuid of the chaos run generated by krkn for every single run + :param scenario: the config file of the scenario that is currently executed + :param krkn_config: the full dictionary representation of the `config.yaml` + :param lib_telemetry: it is a composite object of all the + krkn-lib objects and methods needed by a krkn plugin to run. + :param scenario_telemetry: the `ScenarioTelemetry` object of the scenario that is currently executed + :return: 0 if the scenario suceeded 1 if failed + """ + pass + + @abstractmethod + def get_scenario_types(self) -> list[str]: + """ + Indicates the scenario types specified in the `config.yaml`. For the plugin to be properly + loaded, recognized and executed, it must be implemented and must return the matching `scenario_type` strings. + One plugin can be mapped one or many different strings unique across the other plugins otherwise an exception + will be thrown. + + + :return: the corresponding scenario_type as a list of strings + """ + pass + + def run_scenarios( + self, + run_uuid: str, + scenarios_list: list[str], + krkn_config: dict[str, any], + telemetry: KrknTelemetryOpenshift, + ) -> tuple[list[str], list[ScenarioTelemetry]]: + + scenario_telemetries: list[ScenarioTelemetry] = [] + failed_scenarios = [] + wait_duration = krkn_config["tunings"]["wait_duration"] + for scenario_config in scenarios_list: + if isinstance(scenario_config, list): + logging.error( + "post scenarios have been deprecated, please " + "remove sub-lists from `scenarios` in config.yaml" + ) + failed_scenarios.append(scenario_config) + break + + scenario_telemetry = ScenarioTelemetry() + scenario_telemetry.scenario = scenario_config + scenario_telemetry.start_timestamp = time.time() + parsed_scenario_config = telemetry.set_parameters_base64( + scenario_telemetry, scenario_config + ) + + try: + logging.info( + f"Running {self.__class__.__name__}: {self.get_scenario_types()} -> {scenario_config}" + ) + return_value = self.run( + run_uuid, + scenario_config, + krkn_config, + telemetry, + scenario_telemetry, + ) + except Exception as e: + logging.error( + f"uncaught exception on scenario `run()` method: {e} " + f"please report an issue on https://github.com/krkn-chaos/krkn" + ) + return_value = 1 + + scenario_telemetry.exit_status = return_value + scenario_telemetry.end_timestamp = time.time() + utils.collect_and_put_ocp_logs( + telemetry, + parsed_scenario_config, + telemetry.get_telemetry_request_id(), + int(scenario_telemetry.start_timestamp), + int(scenario_telemetry.end_timestamp), + ) + utils.populate_cluster_events( + scenario_telemetry, + parsed_scenario_config, + telemetry.get_lib_kubernetes(), + int(scenario_telemetry.start_timestamp), + int(scenario_telemetry.end_timestamp), + ) + + if scenario_telemetry.exit_status != 0: + failed_scenarios.append(scenario_config) + scenario_telemetries.append(scenario_telemetry) + logging.info(f"wating {wait_duration} before running the next scenario") + time.sleep(wait_duration) + return failed_scenarios, scenario_telemetries diff --git a/kraken/managedcluster_scenarios/__init__.py b/krkn/scenario_plugins/application_outage/__init__.py similarity index 100% rename from kraken/managedcluster_scenarios/__init__.py rename to krkn/scenario_plugins/application_outage/__init__.py diff --git a/krkn/scenario_plugins/application_outage/application_outage_scenario_plugin.py b/krkn/scenario_plugins/application_outage/application_outage_scenario_plugin.py new file mode 100644 index 00000000..e016c2dc --- /dev/null +++ b/krkn/scenario_plugins/application_outage/application_outage_scenario_plugin.py @@ -0,0 +1,88 @@ +import logging +import time +import yaml +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from krkn_lib.utils import get_yaml_item_value +from jinja2 import Template +from krkn import cerberus +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class ApplicationOutageScenarioPlugin(AbstractScenarioPlugin): + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + wait_duration = krkn_config["tunings"]["wait_duration"] + try: + with open(scenario, "r") as f: + app_outage_config_yaml = yaml.full_load(f) + scenario_config = app_outage_config_yaml["application_outage"] + pod_selector = get_yaml_item_value( + scenario_config, "pod_selector", "{}" + ) + traffic_type = get_yaml_item_value( + scenario_config, "block", "[Ingress, Egress]" + ) + namespace = get_yaml_item_value(scenario_config, "namespace", "") + duration = get_yaml_item_value(scenario_config, "duration", 60) + + start_time = int(time.time()) + + network_policy_template = """--- + apiVersion: networking.k8s.io/v1 + kind: NetworkPolicy + metadata: + name: kraken-deny + spec: + podSelector: + matchLabels: {{ pod_selector }} + policyTypes: {{ traffic_type }} + """ + t = Template(network_policy_template) + rendered_spec = t.render( + pod_selector=pod_selector, traffic_type=traffic_type + ) + yaml_spec = yaml.safe_load(rendered_spec) + # Block the traffic by creating network policy + logging.info("Creating the network policy") + + lib_telemetry.get_lib_kubernetes().create_net_policy( + yaml_spec, namespace + ) + + # wait for the specified duration + logging.info( + "Waiting for the specified duration in the config: %s" % duration + ) + time.sleep(duration) + + # unblock the traffic by deleting the network policy + logging.info("Deleting the network policy") + lib_telemetry.get_lib_kubernetes().delete_net_policy( + "kraken-deny", namespace + ) + + logging.info( + "End of scenario. Waiting for the specified duration: %s" + % wait_duration + ) + time.sleep(wait_duration) + + end_time = int(time.time()) + cerberus.publish_kraken_status(krkn_config, [], start_time, end_time) + except Exception as e: + logging.error( + "ApplicationOutageScenarioPlugin exiting due to Exception %s" % e + ) + return 1 + else: + return 0 + + def get_scenario_types(self) -> list[str]: + return ["application_outages_scenarios"] diff --git a/kraken/network_chaos/__init__.py b/krkn/scenario_plugins/arcaflow/__init__.py similarity index 100% rename from kraken/network_chaos/__init__.py rename to krkn/scenario_plugins/arcaflow/__init__.py diff --git a/krkn/scenario_plugins/arcaflow/arcaflow_scenario_plugin.py b/krkn/scenario_plugins/arcaflow/arcaflow_scenario_plugin.py new file mode 100644 index 00000000..a61cd167 --- /dev/null +++ b/krkn/scenario_plugins/arcaflow/arcaflow_scenario_plugin.py @@ -0,0 +1,197 @@ +import logging +import os +from pathlib import Path +import arcaflow +import yaml +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin +from krkn.scenario_plugins.arcaflow.context_auth import ContextAuth + + +class ArcaflowScenarioPlugin(AbstractScenarioPlugin): + + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + try: + engine_args = self.build_args(scenario) + status_code = self.run_workflow( + engine_args, lib_telemetry.get_lib_kubernetes().get_kubeconfig_path() + ) + return status_code + except Exception as e: + logging.error("ArcaflowScenarioPlugin exiting due to Exception %s" % e) + return 1 + + def get_scenario_types(self) -> [str]: + return ["hog_scenarios", "arcaflow_scenario"] + + def run_workflow( + self, engine_args: arcaflow.EngineArgs, kubeconfig_path: str + ) -> int: + self.set_arca_kubeconfig(engine_args, kubeconfig_path) + exit_status = arcaflow.run(engine_args) + return exit_status + + def build_args(self, input_file: str) -> arcaflow.EngineArgs: + """sets the kubeconfig parsed by setArcaKubeConfig as an input to the arcaflow workflow""" + current_path = Path().resolve() + context = f"{current_path}/{Path(input_file).parent}" + workflow = f"{context}/workflow.yaml" + config = f"{context}/config.yaml" + if not os.path.exists(context): + raise Exception( + "context folder for arcaflow workflow not found: {}".format(context) + ) + if not os.path.exists(input_file): + raise Exception( + "input file for arcaflow workflow not found: {}".format(input_file) + ) + if not os.path.exists(workflow): + raise Exception( + "workflow file for arcaflow workflow not found: {}".format(workflow) + ) + if not os.path.exists(config): + raise Exception( + "configuration file for arcaflow workflow not found: {}".format(config) + ) + + engine_args = arcaflow.EngineArgs() + engine_args.context = context + engine_args.config = config + engine_args.workflow = workflow + engine_args.input = f"{current_path}/{input_file}" + return engine_args + + def set_arca_kubeconfig( + self, engine_args: arcaflow.EngineArgs, kubeconfig_path: str + ): + + context_auth = ContextAuth() + if not os.path.exists(kubeconfig_path): + raise Exception("kubeconfig not found in {}".format(kubeconfig_path)) + + with open(kubeconfig_path, "r") as stream: + try: + kubeconfig = yaml.safe_load(stream) + context_auth.fetch_auth_data(kubeconfig) + except Exception as e: + logging.error( + "impossible to read kubeconfig file in: {}".format(kubeconfig_path) + ) + raise e + + kubeconfig_str = self.set_kubeconfig_auth(kubeconfig, context_auth) + + with open(engine_args.input, "r") as stream: + input_file = yaml.safe_load(stream) + if "input_list" in input_file and isinstance( + input_file["input_list"], list + ): + for index, _ in enumerate(input_file["input_list"]): + if isinstance(input_file["input_list"][index], dict): + input_file["input_list"][index]["kubeconfig"] = kubeconfig_str + else: + input_file["kubeconfig"] = kubeconfig_str + stream.close() + with open(engine_args.input, "w") as stream: + yaml.safe_dump(input_file, stream) + + with open(engine_args.config, "r") as stream: + config_file = yaml.safe_load(stream) + if config_file["deployers"]["image"]["deployer_name"] == "kubernetes": + kube_connection = self.set_kubernetes_deployer_auth( + config_file["deployers"]["image"]["connection"], context_auth + ) + config_file["deployers"]["image"]["connection"] = kube_connection + with open(engine_args.config, "w") as stream: + yaml.safe_dump(config_file, stream, explicit_start=True, width=4096) + + def set_kubernetes_deployer_auth( + self, deployer: any, context_auth: ContextAuth + ) -> any: + if context_auth.clusterHost is not None: + deployer["host"] = context_auth.clusterHost + if context_auth.clientCertificateData is not None: + deployer["cert"] = context_auth.clientCertificateData + if context_auth.clientKeyData is not None: + deployer["key"] = context_auth.clientKeyData + if context_auth.clusterCertificateData is not None: + deployer["cacert"] = context_auth.clusterCertificateData + if context_auth.username is not None: + deployer["username"] = context_auth.username + if context_auth.password is not None: + deployer["password"] = context_auth.password + if context_auth.bearerToken is not None: + deployer["bearerToken"] = context_auth.bearerToken + return deployer + + def set_kubeconfig_auth(self, kubeconfig: any, context_auth: ContextAuth) -> str: + """ + Builds an arcaflow-compatible kubeconfig representation and returns it as a string. + In order to run arcaflow plugins in kubernetes/openshift the kubeconfig must contain client certificate/key + and server certificate base64 encoded within the kubeconfig file itself in *-data fields. That is not always the + case, infact kubeconfig may contain filesystem paths to those files, this function builds an arcaflow-compatible + kubeconfig file and returns it as a string that can be safely included in input.yaml + """ + + if "current-context" not in kubeconfig.keys(): + raise Exception( + "invalid kubeconfig file, impossible to determine current-context" + ) + user_id = None + cluster_id = None + user_name = None + cluster_name = None + current_context = kubeconfig["current-context"] + for context in kubeconfig["contexts"]: + if context["name"] == current_context: + user_name = context["context"]["user"] + cluster_name = context["context"]["cluster"] + if user_name is None: + raise Exception( + "user not set for context {} in kubeconfig file".format(current_context) + ) + if cluster_name is None: + raise Exception( + "cluster not set for context {} in kubeconfig file".format( + current_context + ) + ) + + for index, user in enumerate(kubeconfig["users"]): + if user["name"] == user_name: + user_id = index + for index, cluster in enumerate(kubeconfig["clusters"]): + if cluster["name"] == cluster_name: + cluster_id = index + + if cluster_id is None: + raise Exception( + "no cluster {} found in kubeconfig users".format(cluster_name) + ) + if "client-certificate" in kubeconfig["users"][user_id]["user"]: + kubeconfig["users"][user_id]["user"][ + "client-certificate-data" + ] = context_auth.clientCertificateDataBase64 + del kubeconfig["users"][user_id]["user"]["client-certificate"] + + if "client-key" in kubeconfig["users"][user_id]["user"]: + kubeconfig["users"][user_id]["user"][ + "client-key-data" + ] = context_auth.clientKeyDataBase64 + del kubeconfig["users"][user_id]["user"]["client-key"] + + if "certificate-authority" in kubeconfig["clusters"][cluster_id]["cluster"]: + kubeconfig["clusters"][cluster_id]["cluster"][ + "certificate-authority-data" + ] = context_auth.clusterCertificateDataBase64 + del kubeconfig["clusters"][cluster_id]["cluster"]["certificate-authority"] + kubeconfig_str = yaml.dump(kubeconfig) + return kubeconfig_str diff --git a/kraken/arcaflow_plugin/context_auth.py b/krkn/scenario_plugins/arcaflow/context_auth.py similarity index 80% rename from kraken/arcaflow_plugin/context_auth.py rename to krkn/scenario_plugins/arcaflow/context_auth.py index 47866c14..bb07e926 100644 --- a/kraken/arcaflow_plugin/context_auth.py +++ b/krkn/scenario_plugins/arcaflow/context_auth.py @@ -1,4 +1,3 @@ -import yaml import os import base64 @@ -20,23 +19,25 @@ class ContextAuth: @property def clusterCertificateDataBase64(self): if self.clusterCertificateData is not None: - return base64.b64encode(bytes(self.clusterCertificateData,'utf8')).decode("ascii") + return base64.b64encode(bytes(self.clusterCertificateData, "utf8")).decode( + "ascii" + ) return @property def clientCertificateDataBase64(self): if self.clientCertificateData is not None: - return base64.b64encode(bytes(self.clientCertificateData,'utf8')).decode("ascii") + return base64.b64encode(bytes(self.clientCertificateData, "utf8")).decode( + "ascii" + ) return @property def clientKeyDataBase64(self): if self.clientKeyData is not None: - return base64.b64encode(bytes(self.clientKeyData,"utf-8")).decode("ascii") + return base64.b64encode(bytes(self.clientKeyData, "utf-8")).decode("ascii") return - - def fetch_auth_data(self, kubeconfig: any): context_username = None current_context = kubeconfig["current-context"] @@ -56,8 +57,10 @@ class ContextAuth: for index, user in enumerate(kubeconfig["users"]): if user["name"] == context_username: user_id = index - if user_id is None : - raise Exception("user {0} not found in kubeconfig users".format(context_username)) + if user_id is None: + raise Exception( + "user {0} not found in kubeconfig users".format(context_username) + ) for index, cluster in enumerate(kubeconfig["clusters"]): if cluster["name"] == self.clusterName: @@ -83,7 +86,9 @@ class ContextAuth: if "client-key-data" in user: try: - self.clientKeyData = base64.b64decode(user["client-key-data"]).decode('utf-8') + self.clientKeyData = base64.b64decode(user["client-key-data"]).decode( + "utf-8" + ) except Exception as e: raise Exception("impossible to decode client-key-data") @@ -96,7 +101,9 @@ class ContextAuth: if "client-certificate-data" in user: try: - self.clientCertificateData = base64.b64decode(user["client-certificate-data"]).decode('utf-8') + self.clientCertificateData = base64.b64decode( + user["client-certificate-data"] + ).decode("utf-8") except Exception as e: raise Exception("impossible to decode client-certificate-data") @@ -105,13 +112,17 @@ class ContextAuth: if "certificate-authority" in cluster: try: self.clusterCertificate = cluster["certificate-authority"] - self.clusterCertificateData = self.read_file(cluster["certificate-authority"]) + self.clusterCertificateData = self.read_file( + cluster["certificate-authority"] + ) except Exception as e: raise e if "certificate-authority-data" in cluster: try: - self.clusterCertificateData = base64.b64decode(cluster["certificate-authority-data"]).decode('utf-8') + self.clusterCertificateData = base64.b64decode( + cluster["certificate-authority-data"] + ).decode("utf-8") except Exception as e: raise Exception("impossible to decode certificate-authority-data") @@ -124,19 +135,8 @@ class ContextAuth: if "token" in user: self.bearerToken = user["token"] - def read_file(self, filename:str) -> str: + def read_file(self, filename: str) -> str: if not os.path.exists(filename): raise Exception("file not found {0} ".format(filename)) with open(filename, "rb") as file_stream: - return file_stream.read().decode('utf-8') - - - - - - - - - - - + return file_stream.read().decode("utf-8") diff --git a/kraken/arcaflow_plugin/fixtures/ca.crt b/krkn/scenario_plugins/arcaflow/fixtures/ca.crt similarity index 100% rename from kraken/arcaflow_plugin/fixtures/ca.crt rename to krkn/scenario_plugins/arcaflow/fixtures/ca.crt diff --git a/kraken/arcaflow_plugin/fixtures/client.crt b/krkn/scenario_plugins/arcaflow/fixtures/client.crt similarity index 100% rename from kraken/arcaflow_plugin/fixtures/client.crt rename to krkn/scenario_plugins/arcaflow/fixtures/client.crt diff --git a/kraken/arcaflow_plugin/fixtures/client.key b/krkn/scenario_plugins/arcaflow/fixtures/client.key similarity index 100% rename from kraken/arcaflow_plugin/fixtures/client.key rename to krkn/scenario_plugins/arcaflow/fixtures/client.key diff --git a/kraken/arcaflow_plugin/test_context_auth.py b/krkn/scenario_plugins/arcaflow/test_context_auth.py similarity index 96% rename from kraken/arcaflow_plugin/test_context_auth.py rename to krkn/scenario_plugins/arcaflow/test_context_auth.py index 5571018e..75e48113 100644 --- a/kraken/arcaflow_plugin/test_context_auth.py +++ b/krkn/scenario_plugins/arcaflow/test_context_auth.py @@ -1,7 +1,9 @@ import os import unittest -from context_auth import ContextAuth +import yaml + +from .context_auth import ContextAuth class TestCurrentContext(unittest.TestCase): @@ -9,7 +11,7 @@ class TestCurrentContext(unittest.TestCase): def get_kubeconfig_with_data(self) -> str: """ This function returns a test kubeconfig file as a string. - + :return: a test kubeconfig file in string format (for unit testing purposes) """ # NOQA return """apiVersion: v1 @@ -71,7 +73,8 @@ users: def test_current_context(self): cwd = os.getcwd() current_context_data = ContextAuth() - current_context_data.fetch_auth_data(self.get_kubeconfig_with_data()) + data = yaml.safe_load(self.get_kubeconfig_with_data()) + current_context_data.fetch_auth_data(data) self.assertIsNotNone(current_context_data.clusterCertificateData) self.assertIsNotNone(current_context_data.clientCertificateData) self.assertIsNotNone(current_context_data.clientKeyData) @@ -81,7 +84,8 @@ users: self.assertIsNotNone(current_context_data.clusterHost) current_context_no_data = ContextAuth() - current_context_no_data.fetch_auth_data(self.get_kubeconfig_with_paths()) + data = yaml.safe_load(self.get_kubeconfig_with_paths()) + current_context_no_data.fetch_auth_data(data) self.assertIsNotNone(current_context_no_data.clusterCertificate) self.assertIsNotNone(current_context_no_data.clusterCertificateData) self.assertIsNotNone(current_context_no_data.clientCertificate) @@ -92,9 +96,3 @@ users: self.assertIsNotNone(current_context_no_data.password) self.assertIsNotNone(current_context_no_data.bearerToken) self.assertIsNotNone(current_context_data.clusterHost) - - - - - - diff --git a/kraken/node_actions/__init__.py b/krkn/scenario_plugins/container/__init__.py similarity index 100% rename from kraken/node_actions/__init__.py rename to krkn/scenario_plugins/container/__init__.py diff --git a/krkn/scenario_plugins/container/container_scenario_plugin.py b/krkn/scenario_plugins/container/container_scenario_plugin.py new file mode 100644 index 00000000..9da36d11 --- /dev/null +++ b/krkn/scenario_plugins/container/container_scenario_plugin.py @@ -0,0 +1,232 @@ +import logging +import random +import time + +import yaml +from krkn_lib.k8s import KrknKubernetes +from krkn_lib.k8s.pods_monitor_pool import PodsMonitorPool +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from krkn_lib.utils import get_yaml_item_value + +from krkn import cerberus +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class ContainerScenarioPlugin(AbstractScenarioPlugin): + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + start_time = int(time.time()) + pool = PodsMonitorPool(lib_telemetry.get_lib_kubernetes()) + wait_duration = krkn_config["tunings"]["wait_duration"] + try: + with open(scenario, "r") as f: + cont_scenario_config = yaml.full_load(f) + self.start_monitoring( + kill_scenarios=cont_scenario_config["scenarios"], pool=pool + ) + killed_containers = self.container_killing_in_pod( + cont_scenario_config, lib_telemetry.get_lib_kubernetes() + ) + logging.info(f"killed containers: {str(killed_containers)}") + result = pool.join() + if result.error: + logging.error( + logging.error( + f"ContainerScenarioPlugin pods failed to recovery: {result.error}" + ) + ) + return 1 + scenario_telemetry.affected_pods = result + logging.info("Waiting for the specified duration: %s" % (wait_duration)) + time.sleep(wait_duration) + + # capture end time + end_time = int(time.time()) + + # publish cerberus status + cerberus.publish_kraken_status(krkn_config, [], start_time, end_time) + except (RuntimeError, Exception): + logging.error("ContainerScenarioPlugin exiting due to Exception %s" % e) + return 1 + else: + return 0 + + def get_scenario_types(self) -> list[str]: + return ["container_scenarios"] + + def start_monitoring(self, kill_scenarios: list[any], pool: PodsMonitorPool): + for kill_scenario in kill_scenarios: + namespace_pattern = f"^{kill_scenario['namespace']}$" + label_selector = kill_scenario["label_selector"] + recovery_time = kill_scenario["expected_recovery_time"] + pool.select_and_monitor_by_namespace_pattern_and_label( + namespace_pattern=namespace_pattern, + label_selector=label_selector, + max_timeout=recovery_time, + ) + + def container_killing_in_pod(self, cont_scenario, kubecli: KrknKubernetes): + scenario_name = get_yaml_item_value(cont_scenario, "name", "") + namespace = get_yaml_item_value(cont_scenario, "namespace", "*") + label_selector = get_yaml_item_value(cont_scenario, "label_selector", None) + pod_names = get_yaml_item_value(cont_scenario, "pod_names", []) + container_name = get_yaml_item_value(cont_scenario, "container_name", "") + kill_action = get_yaml_item_value(cont_scenario, "action", 1) + kill_count = get_yaml_item_value(cont_scenario, "count", 1) + if not isinstance(kill_action, int): + logging.error( + "Please make sure the action parameter defined in the " + "config is an integer" + ) + raise RuntimeError() + if (kill_action < 1) or (kill_action > 15): + logging.error("Only 1-15 kill signals are supported.") + raise RuntimeError() + kill_action = "kill " + str(kill_action) + if type(pod_names) != list: + logging.error("Please make sure your pod_names are in a list format") + # removed_exit + # sys.exit(1) + raise RuntimeError() + if len(pod_names) == 0: + if namespace == "*": + # returns double array of pod name and namespace + pods = kubecli.get_all_pods(label_selector) + else: + # Only returns pod names + pods = kubecli.list_pods(namespace, label_selector) + else: + if namespace == "*": + logging.error( + "You must specify the namespace to kill a container in a specific pod" + ) + logging.error("Scenario " + scenario_name + " failed") + # removed_exit + # sys.exit(1) + raise RuntimeError() + pods = pod_names + # get container and pod name + container_pod_list = [] + for pod in pods: + if type(pod) == list: + pod_output = kubecli.get_pod_info(pod[0], pod[1]) + container_names = [ + container.name for container in pod_output.containers + ] + + container_pod_list.append([pod[0], pod[1], container_names]) + else: + pod_output = kubecli.get_pod_info(pod, namespace) + container_names = [ + container.name for container in pod_output.containers + ] + container_pod_list.append([pod, namespace, container_names]) + + killed_count = 0 + killed_container_list = [] + while killed_count < kill_count: + if len(container_pod_list) == 0: + logging.error( + "Trying to kill more containers than were found, try lowering kill count" + ) + logging.error("Scenario " + scenario_name + " failed") + # removed_exit + # sys.exit(1) + raise RuntimeError() + selected_container_pod = container_pod_list[ + random.randint(0, len(container_pod_list) - 1) + ] + for c_name in selected_container_pod[2]: + if container_name != "": + if c_name == container_name: + killed_container_list.append( + [ + selected_container_pod[0], + selected_container_pod[1], + c_name, + ] + ) + self.retry_container_killing( + kill_action, + selected_container_pod[0], + selected_container_pod[1], + c_name, + kubecli, + ) + break + else: + killed_container_list.append( + [selected_container_pod[0], selected_container_pod[1], c_name] + ) + self.retry_container_killing( + kill_action, + selected_container_pod[0], + selected_container_pod[1], + c_name, + kubecli, + ) + break + container_pod_list.remove(selected_container_pod) + killed_count += 1 + logging.info("Scenario " + scenario_name + " successfully injected") + return killed_container_list + + def retry_container_killing( + self, kill_action, podname, namespace, container_name, kubecli: KrknKubernetes + ): + i = 0 + while i < 5: + logging.info( + "Killing container %s in pod %s (ns %s)" + % (str(container_name), str(podname), str(namespace)) + ) + response = kubecli.exec_cmd_in_pod( + kill_action, podname, namespace, container_name + ) + i += 1 + # Blank response means it is done + if not response: + break + elif ( + "unauthorized" in response.lower() + or "authorization" in response.lower() + ): + time.sleep(2) + continue + else: + logging.warning(response) + continue + + def check_failed_containers( + self, killed_container_list, wait_time, kubecli: KrknKubernetes + ): + + container_ready = [] + timer = 0 + while timer <= wait_time: + for killed_container in killed_container_list: + # pod namespace contain name + pod_output = kubecli.get_pod_info( + killed_container[0], killed_container[1] + ) + + for container in pod_output.containers: + if container.name == killed_container[2]: + if container.ready: + container_ready.append(killed_container) + if len(container_ready) != 0: + for item in container_ready: + killed_container_list = killed_container_list.remove(item) + if killed_container_list is None or len(killed_container_list) == 0: + return [] + timer += 5 + logging.info("Waiting 5 seconds for containers to become ready") + time.sleep(5) + return killed_container_list diff --git a/kraken/performance_dashboards/__init__.py b/krkn/scenario_plugins/managed_cluster/__init__.py similarity index 100% rename from kraken/performance_dashboards/__init__.py rename to krkn/scenario_plugins/managed_cluster/__init__.py diff --git a/kraken/managedcluster_scenarios/common_managedcluster_functions.py b/krkn/scenario_plugins/managed_cluster/common_functions.py similarity index 65% rename from kraken/managedcluster_scenarios/common_managedcluster_functions.py rename to krkn/scenario_plugins/managed_cluster/common_functions.py index b4a17c4c..15e73c13 100644 --- a/kraken/managedcluster_scenarios/common_managedcluster_functions.py +++ b/krkn/scenario_plugins/managed_cluster/common_functions.py @@ -2,28 +2,37 @@ import random import logging from krkn_lib.k8s import KrknKubernetes + # krkn_lib # Pick a random managedcluster with specified label selector def get_managedcluster( - managedcluster_name, - label_selector, - instance_kill_count, - kubecli: KrknKubernetes): + managedcluster_name, label_selector, instance_kill_count, kubecli: KrknKubernetes +): if managedcluster_name in kubecli.list_killable_managedclusters(): return [managedcluster_name] elif managedcluster_name: - logging.info("managedcluster with provided managedcluster_name does not exist or the managedcluster might " "be in unavailable state.") + logging.info( + "managedcluster with provided managedcluster_name does not exist or the managedcluster might " + "be in unavailable state." + ) managedclusters = kubecli.list_killable_managedclusters(label_selector) if not managedclusters: - raise Exception("Available managedclusters with the provided label selector do not exist") - logging.info("Available managedclusters with the label selector %s: %s" % (label_selector, managedclusters)) + raise Exception( + "Available managedclusters with the provided label selector do not exist" + ) + logging.info( + "Available managedclusters with the label selector %s: %s" + % (label_selector, managedclusters) + ) number_of_managedclusters = len(managedclusters) if instance_kill_count == number_of_managedclusters: return managedclusters managedclusters_to_return = [] for i in range(instance_kill_count): - managedcluster_to_add = managedclusters[random.randint(0, len(managedclusters) - 1)] + managedcluster_to_add = managedclusters[ + random.randint(0, len(managedclusters) - 1) + ] managedclusters_to_return.append(managedcluster_to_add) managedclusters.remove(managedcluster_to_add) return managedclusters_to_return diff --git a/krkn/scenario_plugins/managed_cluster/managed_cluster_scenario_plugin.py b/krkn/scenario_plugins/managed_cluster/managed_cluster_scenario_plugin.py new file mode 100644 index 00000000..b95238d8 --- /dev/null +++ b/krkn/scenario_plugins/managed_cluster/managed_cluster_scenario_plugin.py @@ -0,0 +1,127 @@ +import logging +import time + +import yaml +from krkn_lib.k8s import KrknKubernetes +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from krkn_lib.utils import get_yaml_item_value + +from krkn import cerberus, utils +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin +from krkn.scenario_plugins.managed_cluster.common_functions import get_managedcluster +from krkn.scenario_plugins.managed_cluster.scenarios import Scenarios + + +class ManagedClusterScenarioPlugin(AbstractScenarioPlugin): + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + with open(scenario, "r") as f: + scenario = yaml.full_load(f) + for managedcluster_scenario in scenario["managedcluster_scenarios"]: + managedcluster_scenario_object = Scenarios( + lib_telemetry.get_lib_kubernetes() + ) + if managedcluster_scenario["actions"]: + for action in managedcluster_scenario["actions"]: + start_time = int(time.time()) + try: + self.inject_managedcluster_scenario( + action, + managedcluster_scenario, + managedcluster_scenario_object, + lib_telemetry.get_lib_kubernetes(), + ) + end_time = int(time.time()) + cerberus.get_status(krkn_config, start_time, end_time) + except Exception as e: + logging.error( + "ManagedClusterScenarioPlugin exiting due to Exception %s" + % e + ) + return 1 + else: + return 0 + + def inject_managedcluster_scenario( + self, + action, + managedcluster_scenario, + managedcluster_scenario_object, + kubecli: KrknKubernetes, + ): + # Get the managedcluster scenario configurations + run_kill_count = get_yaml_item_value(managedcluster_scenario, "runs", 1) + instance_kill_count = get_yaml_item_value( + managedcluster_scenario, "instance_count", 1 + ) + managedcluster_name = get_yaml_item_value( + managedcluster_scenario, "managedcluster_name", "" + ) + label_selector = get_yaml_item_value( + managedcluster_scenario, "label_selector", "" + ) + timeout = get_yaml_item_value(managedcluster_scenario, "timeout", 120) + # Get the managedcluster to apply the scenario + if managedcluster_name: + managedcluster_name_list = managedcluster_name.split(",") + else: + managedcluster_name_list = [managedcluster_name] + for single_managedcluster_name in managedcluster_name_list: + managedclusters = get_managedcluster( + single_managedcluster_name, label_selector, instance_kill_count, kubecli + ) + for single_managedcluster in managedclusters: + if action == "managedcluster_start_scenario": + managedcluster_scenario_object.managedcluster_start_scenario( + run_kill_count, single_managedcluster, timeout + ) + elif action == "managedcluster_stop_scenario": + managedcluster_scenario_object.managedcluster_stop_scenario( + run_kill_count, single_managedcluster, timeout + ) + elif action == "managedcluster_stop_start_scenario": + managedcluster_scenario_object.managedcluster_stop_start_scenario( + run_kill_count, single_managedcluster, timeout + ) + elif action == "managedcluster_termination_scenario": + managedcluster_scenario_object.managedcluster_termination_scenario( + run_kill_count, single_managedcluster, timeout + ) + elif action == "managedcluster_reboot_scenario": + managedcluster_scenario_object.managedcluster_reboot_scenario( + run_kill_count, single_managedcluster, timeout + ) + elif action == "stop_start_klusterlet_scenario": + managedcluster_scenario_object.stop_start_klusterlet_scenario( + run_kill_count, single_managedcluster, timeout + ) + elif action == "start_klusterlet_scenario": + managedcluster_scenario_object.stop_klusterlet_scenario( + run_kill_count, single_managedcluster, timeout + ) + elif action == "stop_klusterlet_scenario": + managedcluster_scenario_object.stop_klusterlet_scenario( + run_kill_count, single_managedcluster, timeout + ) + elif action == "managedcluster_crash_scenario": + managedcluster_scenario_object.managedcluster_crash_scenario( + run_kill_count, single_managedcluster, timeout + ) + else: + logging.info( + "There is no managedcluster action that matches %s, skipping scenario" + % action + ) + + def get_managedcluster_scenario_object(self, kubecli: KrknKubernetes): + return Scenarios(kubecli) + + def get_scenario_types(self) -> list[str]: + return ["managedcluster_scenarios"] diff --git a/kraken/managedcluster_scenarios/managedcluster_scenarios.py b/krkn/scenario_plugins/managed_cluster/scenarios.py similarity index 60% rename from kraken/managedcluster_scenarios/managedcluster_scenarios.py rename to krkn/scenario_plugins/managed_cluster/scenarios.py index b2478067..5e22a31e 100644 --- a/kraken/managedcluster_scenarios/managedcluster_scenarios.py +++ b/krkn/scenario_plugins/managed_cluster/scenarios.py @@ -2,104 +2,148 @@ from jinja2 import Environment, FileSystemLoader import os import time import logging -import sys import yaml -import kraken.managedcluster_scenarios.common_managedcluster_functions as common_managedcluster_functions +import krkn.scenario_plugins.managed_cluster.common_functions as common_managedcluster_functions from krkn_lib.k8s import KrknKubernetes + class GENERAL: def __init__(self): pass + # krkn_lib -class managedcluster_scenarios(): +class Scenarios: kubecli: KrknKubernetes + def __init__(self, kubecli: KrknKubernetes): self.kubecli = kubecli self.general = GENERAL() # managedcluster scenario to start the managedcluster - def managedcluster_start_scenario(self, instance_kill_count, managedcluster, timeout): + def managedcluster_start_scenario( + self, instance_kill_count, managedcluster, timeout + ): for _ in range(instance_kill_count): try: logging.info("Starting managedcluster_start_scenario injection") - file_loader = FileSystemLoader(os.path.abspath(os.path.dirname(__file__))) + file_loader = FileSystemLoader( + os.path.abspath(os.path.dirname(__file__)) + ) env = Environment(loader=file_loader, autoescape=False) template = env.get_template("manifestwork.j2") body = yaml.safe_load( - template.render(managedcluster_name=managedcluster, + template.render( + managedcluster_name=managedcluster, args="""kubectl scale deployment.apps/klusterlet --replicas 3 & - kubectl scale deployment.apps/klusterlet-registration-agent --replicas 1 -n open-cluster-management-agent""") + kubectl scale deployment.apps/klusterlet-registration-agent --replicas 1 -n open-cluster-management-agent""", + ) ) self.kubecli.create_manifestwork(body, managedcluster) - logging.info("managedcluster_start_scenario has been successfully injected!") + logging.info( + "managedcluster_start_scenario has been successfully injected!" + ) logging.info("Waiting for the specified timeout: %s" % timeout) - common_managedcluster_functions.wait_for_available_status(managedcluster, timeout, self.kubecli) + common_managedcluster_functions.wait_for_available_status( + managedcluster, timeout, self.kubecli + ) except Exception as e: logging.error("managedcluster scenario exiting due to Exception %s" % e) - sys.exit(1) + raise e finally: logging.info("Deleting manifestworks") self.kubecli.delete_manifestwork(managedcluster) # managedcluster scenario to stop the managedcluster - def managedcluster_stop_scenario(self, instance_kill_count, managedcluster, timeout): + def managedcluster_stop_scenario( + self, instance_kill_count, managedcluster, timeout + ): for _ in range(instance_kill_count): try: logging.info("Starting managedcluster_stop_scenario injection") - file_loader = FileSystemLoader(os.path.abspath(os.path.dirname(__file__)),encoding='utf-8') + file_loader = FileSystemLoader( + os.path.abspath(os.path.dirname(__file__)), encoding="utf-8" + ) env = Environment(loader=file_loader, autoescape=False) template = env.get_template("manifestwork.j2") body = yaml.safe_load( - template.render(managedcluster_name=managedcluster, + template.render( + managedcluster_name=managedcluster, args="""kubectl scale deployment.apps/klusterlet --replicas 0 && - kubectl scale deployment.apps/klusterlet-registration-agent --replicas 0 -n open-cluster-management-agent""") + kubectl scale deployment.apps/klusterlet-registration-agent --replicas 0 -n open-cluster-management-agent""", + ) ) self.kubecli.create_manifestwork(body, managedcluster) - logging.info("managedcluster_stop_scenario has been successfully injected!") + logging.info( + "managedcluster_stop_scenario has been successfully injected!" + ) logging.info("Waiting for the specified timeout: %s" % timeout) - common_managedcluster_functions.wait_for_unavailable_status(managedcluster, timeout, self.kubecli) + common_managedcluster_functions.wait_for_unavailable_status( + managedcluster, timeout, self.kubecli + ) except Exception as e: logging.error("managedcluster scenario exiting due to Exception %s" % e) - sys.exit(1) + raise e finally: logging.info("Deleting manifestworks") self.kubecli.delete_manifestwork(managedcluster) # managedcluster scenario to stop and then start the managedcluster - def managedcluster_stop_start_scenario(self, instance_kill_count, managedcluster, timeout): + def managedcluster_stop_start_scenario( + self, instance_kill_count, managedcluster, timeout + ): logging.info("Starting managedcluster_stop_start_scenario injection") self.managedcluster_stop_scenario(instance_kill_count, managedcluster, timeout) time.sleep(10) self.managedcluster_start_scenario(instance_kill_count, managedcluster, timeout) - logging.info("managedcluster_stop_start_scenario has been successfully injected!") + logging.info( + "managedcluster_stop_start_scenario has been successfully injected!" + ) # managedcluster scenario to terminate the managedcluster - def managedcluster_termination_scenario(self, instance_kill_count, managedcluster, timeout): - logging.info("managedcluster termination is not implemented, " "no action is going to be taken") + def managedcluster_termination_scenario( + self, instance_kill_count, managedcluster, timeout + ): + logging.info( + "managedcluster termination is not implemented, " + "no action is going to be taken" + ) # managedcluster scenario to reboot the managedcluster - def managedcluster_reboot_scenario(self, instance_kill_count, managedcluster, timeout): - logging.info("managedcluster reboot is not implemented," " no action is going to be taken") + def managedcluster_reboot_scenario( + self, instance_kill_count, managedcluster, timeout + ): + logging.info( + "managedcluster reboot is not implemented," + " no action is going to be taken" + ) # managedcluster scenario to start the klusterlet def start_klusterlet_scenario(self, instance_kill_count, managedcluster, timeout): for _ in range(instance_kill_count): try: logging.info("Starting start_klusterlet_scenario injection") - file_loader = FileSystemLoader(os.path.abspath(os.path.dirname(__file__))) + file_loader = FileSystemLoader( + os.path.abspath(os.path.dirname(__file__)) + ) env = Environment(loader=file_loader, autoescape=False) template = env.get_template("manifestwork.j2") body = yaml.safe_load( - template.render(managedcluster_name=managedcluster, - args="""kubectl scale deployment.apps/klusterlet --replicas 3""") + template.render( + managedcluster_name=managedcluster, + args="""kubectl scale deployment.apps/klusterlet --replicas 3""", + ) ) self.kubecli.create_manifestwork(body, managedcluster) - logging.info("start_klusterlet_scenario has been successfully injected!") - time.sleep(30) # until https://github.com/open-cluster-management-io/OCM/issues/118 gets solved + logging.info( + "start_klusterlet_scenario has been successfully injected!" + ) + time.sleep( + 30 + ) # until https://github.com/open-cluster-management-io/OCM/issues/118 gets solved except Exception as e: logging.error("managedcluster scenario exiting due to Exception %s" % e) - sys.exit(1) + raise e finally: logging.info("Deleting manifestworks") self.kubecli.delete_manifestwork(managedcluster) @@ -109,25 +153,33 @@ class managedcluster_scenarios(): for _ in range(instance_kill_count): try: logging.info("Starting stop_klusterlet_scenario injection") - file_loader = FileSystemLoader(os.path.abspath(os.path.dirname(__file__))) + file_loader = FileSystemLoader( + os.path.abspath(os.path.dirname(__file__)) + ) env = Environment(loader=file_loader, autoescape=False) template = env.get_template("manifestwork.j2") body = yaml.safe_load( - template.render(managedcluster_name=managedcluster, - args="""kubectl scale deployment.apps/klusterlet --replicas 0""") + template.render( + managedcluster_name=managedcluster, + args="""kubectl scale deployment.apps/klusterlet --replicas 0""", + ) ) self.kubecli.create_manifestwork(body, managedcluster) logging.info("stop_klusterlet_scenario has been successfully injected!") - time.sleep(30) # until https://github.com/open-cluster-management-io/OCM/issues/118 gets solved + time.sleep( + 30 + ) # until https://github.com/open-cluster-management-io/OCM/issues/118 gets solved except Exception as e: logging.error("managedcluster scenario exiting due to Exception %s" % e) - sys.exit(1) + raise e finally: logging.info("Deleting manifestworks") self.kubecli.delete_manifestwork(managedcluster) # managedcluster scenario to stop and start the klusterlet - def stop_start_klusterlet_scenario(self, instance_kill_count, managedcluster, timeout): + def stop_start_klusterlet_scenario( + self, instance_kill_count, managedcluster, timeout + ): logging.info("Starting stop_start_klusterlet_scenario injection") self.stop_klusterlet_scenario(instance_kill_count, managedcluster, timeout) time.sleep(10) @@ -135,6 +187,10 @@ class managedcluster_scenarios(): logging.info("stop_start_klusterlet_scenario has been successfully injected!") # managedcluster scenario to crash the managedcluster - def managedcluster_crash_scenario(self, instance_kill_count, managedcluster, timeout): - logging.info("managedcluster crash scenario is not implemented, " "no action is going to be taken") - + def managedcluster_crash_scenario( + self, instance_kill_count, managedcluster, timeout + ): + logging.info( + "managedcluster crash scenario is not implemented, " + "no action is going to be taken" + ) diff --git a/kraken/pod_scenarios/__init__.py b/krkn/scenario_plugins/native/__init__.py similarity index 100% rename from kraken/pod_scenarios/__init__.py rename to krkn/scenario_plugins/native/__init__.py diff --git a/krkn/scenario_plugins/native/native_scenario_plugin.py b/krkn/scenario_plugins/native/native_scenario_plugin.py new file mode 100644 index 00000000..4c4605b7 --- /dev/null +++ b/krkn/scenario_plugins/native/native_scenario_plugin.py @@ -0,0 +1,93 @@ +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin +from krkn.scenario_plugins.native.plugins import PLUGINS +from krkn_lib.k8s.pods_monitor_pool import PodsMonitorPool +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from typing import Any +import logging + + +class NativeScenarioPlugin(AbstractScenarioPlugin): + + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + pool = PodsMonitorPool(lib_telemetry.get_lib_kubernetes()) + kill_scenarios = [ + kill_scenario + for kill_scenario in PLUGINS.unserialize_scenario(scenario) + if kill_scenario["id"] == "kill-pods" + ] + + try: + self.start_monitoring(pool, kill_scenarios) + PLUGINS.run( + scenario, + lib_telemetry.get_lib_kubernetes().get_kubeconfig_path(), + krkn_config, + run_uuid, + ) + result = pool.join() + scenario_telemetry.affected_pods = result + if result.error: + logging.error(f"NativeScenarioPlugin unrecovered pods: {result.error}") + return 1 + + except Exception as e: + logging.error("NativeScenarioPlugin exiting due to Exception %s" % e) + pool.cancel() + return 1 + else: + return 0 + + def get_scenario_types(self) -> list[str]: + return [ + "pod_disruption_scenarios", + "pod_network_scenario", + "vmware_node_scenarios", + "ibmcloud_node_scenarios", + ] + + def start_monitoring(self, pool: PodsMonitorPool, scenarios: list[Any]): + for kill_scenario in scenarios: + recovery_time = kill_scenario["config"]["krkn_pod_recovery_time"] + if ( + "namespace_pattern" in kill_scenario["config"] + and "label_selector" in kill_scenario["config"] + ): + namespace_pattern = kill_scenario["config"]["namespace_pattern"] + label_selector = kill_scenario["config"]["label_selector"] + pool.select_and_monitor_by_namespace_pattern_and_label( + namespace_pattern=namespace_pattern, + label_selector=label_selector, + max_timeout=recovery_time, + ) + logging.info( + f"waiting {recovery_time} seconds for pod recovery, " + f"pod label selector: {label_selector} namespace pattern: {namespace_pattern}" + ) + + elif ( + "namespace_pattern" in kill_scenario["config"] + and "name_pattern" in kill_scenario["config"] + ): + namespace_pattern = kill_scenario["config"]["namespace_pattern"] + name_pattern = kill_scenario["config"]["name_pattern"] + pool.select_and_monitor_by_name_pattern_and_namespace_pattern( + pod_name_pattern=name_pattern, + namespace_pattern=namespace_pattern, + max_timeout=recovery_time, + ) + logging.info( + f"waiting {recovery_time} seconds for pod recovery, " + f"pod name pattern: {name_pattern} namespace pattern: {namespace_pattern}" + ) + else: + raise Exception( + f"impossible to determine monitor parameters, check {kill_scenario} configuration" + ) diff --git a/kraken/plugins/network/cerberus.py b/krkn/scenario_plugins/native/network/cerberus.py similarity index 100% rename from kraken/plugins/network/cerberus.py rename to krkn/scenario_plugins/native/network/cerberus.py diff --git a/kraken/plugins/network/ingress_shaping.py b/krkn/scenario_plugins/native/network/ingress_shaping.py similarity index 100% rename from kraken/plugins/network/ingress_shaping.py rename to krkn/scenario_plugins/native/network/ingress_shaping.py diff --git a/kraken/plugins/network/job.j2 b/krkn/scenario_plugins/native/network/job.j2 similarity index 100% rename from kraken/plugins/network/job.j2 rename to krkn/scenario_plugins/native/network/job.j2 diff --git a/kraken/plugins/network/kubernetes_functions.py b/krkn/scenario_plugins/native/network/kubernetes_functions.py similarity index 100% rename from kraken/plugins/network/kubernetes_functions.py rename to krkn/scenario_plugins/native/network/kubernetes_functions.py diff --git a/kraken/plugins/network/pod_interface.j2 b/krkn/scenario_plugins/native/network/pod_interface.j2 similarity index 100% rename from kraken/plugins/network/pod_interface.j2 rename to krkn/scenario_plugins/native/network/pod_interface.j2 diff --git a/kraken/plugins/network/pod_module.j2 b/krkn/scenario_plugins/native/network/pod_module.j2 similarity index 100% rename from kraken/plugins/network/pod_module.j2 rename to krkn/scenario_plugins/native/network/pod_module.j2 diff --git a/kraken/plugins/node_scenarios/ibmcloud_plugin.py b/krkn/scenario_plugins/native/node_scenarios/ibmcloud_plugin.py similarity index 81% rename from kraken/plugins/node_scenarios/ibmcloud_plugin.py rename to krkn/scenario_plugins/native/node_scenarios/ibmcloud_plugin.py index 078bb10c..f7d52921 100644 --- a/kraken/plugins/node_scenarios/ibmcloud_plugin.py +++ b/krkn/scenario_plugins/native/node_scenarios/ibmcloud_plugin.py @@ -1,19 +1,17 @@ #!/usr/bin/env python -import sys import time import typing from os import environ from dataclasses import dataclass, field -import random from traceback import format_exc import logging -from kraken.plugins.node_scenarios import kubernetes_functions as kube_helper +from krkn.scenario_plugins.native.node_scenarios import ( + kubernetes_functions as kube_helper, +) from arcaflow_plugin_sdk import validation, plugin from kubernetes import client, watch from ibm_vpc import VpcV1 from ibm_cloud_sdk_core.authenticators import IAMAuthenticator -from ibm_cloud_sdk_core import ApiException -import requests import sys @@ -26,19 +24,15 @@ class IbmCloud: apiKey = environ.get("IBMC_APIKEY") service_url = environ.get("IBMC_URL") if not apiKey: - raise Exception( - "Environmental variable 'IBMC_APIKEY' is not set" - ) + raise Exception("Environmental variable 'IBMC_APIKEY' is not set") if not service_url: - raise Exception( - "Environmental variable 'IBMC_URL' is not set" - ) - try: + raise Exception("Environmental variable 'IBMC_URL' is not set") + try: authenticator = IAMAuthenticator(apiKey) self.service = VpcV1(authenticator=authenticator) self.service.set_service_url(service_url) - except Exception as e: + except Exception as e: logging.error("error authenticating" + str(e)) sys.exit(1) @@ -46,15 +40,11 @@ class IbmCloud: """ Deletes the Instance whose name is given by 'instance_id' """ - try: + try: self.service.delete_instance(instance_id) logging.info("Deleted Instance -- '{}'".format(instance_id)) except Exception as e: - logging.info( - "Instance '{}' could not be deleted. ".format( - instance_id - ) - ) + logging.info("Instance '{}' could not be deleted. ".format(instance_id)) return False def reboot_instances(self, instance_id): @@ -65,17 +55,13 @@ class IbmCloud: try: self.service.create_instance_action( - instance_id, - type='reboot', - ) + instance_id, + type="reboot", + ) logging.info("Reset Instance -- '{}'".format(instance_id)) return True except Exception as e: - logging.info( - "Instance '{}' could not be rebooted".format( - instance_id - ) - ) + logging.info("Instance '{}' could not be rebooted".format(instance_id)) return False def stop_instances(self, instance_id): @@ -86,15 +72,13 @@ class IbmCloud: try: self.service.create_instance_action( - instance_id, - type='stop', - ) + instance_id, + type="stop", + ) logging.info("Stopped Instance -- '{}'".format(instance_id)) return True except Exception as e: - logging.info( - "Instance '{}' could not be stopped".format(instance_id) - ) + logging.info("Instance '{}' could not be stopped".format(instance_id)) logging.info("error" + str(e)) return False @@ -106,9 +90,9 @@ class IbmCloud: try: self.service.create_instance_action( - instance_id, - type='start', - ) + instance_id, + type="start", + ) logging.info("Started Instance -- '{}'".format(instance_id)) return True except Exception as e: @@ -120,27 +104,29 @@ class IbmCloud: Returns a list of Instances present in the datacenter """ instance_names = [] - try: + try: instances_result = self.service.list_instances().get_result() - instances_list = instances_result['instances'] + instances_list = instances_result["instances"] for vpc in instances_list: - instance_names.append({"vpc_name": vpc['name'], "vpc_id": vpc['id']}) - starting_count = instances_result['total_count'] - while instances_result['total_count'] == instances_result['limit']: - instances_result = self.service.list_instances(start=starting_count).get_result() - instances_list = instances_result['instances'] - starting_count += instances_result['total_count'] + instance_names.append({"vpc_name": vpc["name"], "vpc_id": vpc["id"]}) + starting_count = instances_result["total_count"] + while instances_result["total_count"] == instances_result["limit"]: + instances_result = self.service.list_instances( + start=starting_count + ).get_result() + instances_list = instances_result["instances"] + starting_count += instances_result["total_count"] for vpc in instances_list: instance_names.append({"vpc_name": vpc.name, "vpc_id": vpc.id}) - except Exception as e: + except Exception as e: logging.error("Error listing out instances: " + str(e)) sys.exit(1) return instance_names - - def find_id_in_list(self, name, vpc_list): + + def find_id_in_list(self, name, vpc_list): for vpc in vpc_list: - if vpc['vpc_name'] == name: - return vpc['vpc_id'] + if vpc["vpc_name"] == name: + return vpc["vpc_id"] def get_instance_status(self, instance_id): """ @@ -149,7 +135,7 @@ class IbmCloud: try: instance = self.service.get_instance(instance_id).get_result() - state = instance['status'] + state = instance["status"] return state except Exception as e: logging.error( @@ -169,7 +155,8 @@ class IbmCloud: while vpc is not None: vpc = self.get_instance_status(instance_id) logging.info( - "Instance %s is still being deleted, sleeping for 5 seconds" % instance_id + "Instance %s is still being deleted, sleeping for 5 seconds" + % instance_id ) time.sleep(5) time_counter += 5 @@ -196,7 +183,9 @@ class IbmCloud: time.sleep(5) time_counter += 5 if time_counter >= timeout: - logging.info("Instance %s is still not ready in allotted time" % instance_id) + logging.info( + "Instance %s is still not ready in allotted time" % instance_id + ) return False return True @@ -216,7 +205,9 @@ class IbmCloud: time.sleep(5) time_counter += 5 if time_counter >= timeout: - logging.info("Instance %s is still not stopped in allotted time" % instance_id) + logging.info( + "Instance %s is still not stopped in allotted time" % instance_id + ) return False return True @@ -236,7 +227,9 @@ class IbmCloud: time.sleep(5) time_counter += 5 if time_counter >= timeout: - logging.info("Instance %s is still restarting after allotted time" % instance_id) + logging.info( + "Instance %s is still restarting after allotted time" % instance_id + ) return False self.wait_until_running(instance_id, timeout) return True @@ -303,9 +296,7 @@ class NodeScenarioConfig: ) label_selector: typing.Annotated[ - typing.Optional[str], - validation.min(1), - validation.required_if_not("name") + typing.Optional[str], validation.min(1), validation.required_if_not("name") ] = field( default=None, metadata={ @@ -374,7 +365,7 @@ def node_start( logging.info("Starting node_start_scenario injection") logging.info("Starting the node %s " % (name)) instance_id = ibmcloud.find_id_in_list(name, node_name_id_list) - if instance_id: + if instance_id: vm_started = ibmcloud.start_instances(instance_id) if vm_started: ibmcloud.wait_until_running(instance_id, cfg.timeout) @@ -383,12 +374,19 @@ def node_start( name, cfg.timeout, watch_resource, core_v1 ) nodes_started[int(time.time_ns())] = Node(name=name) - logging.info("Node with instance ID: %s is in running state" % name) - logging.info("node_start_scenario has been successfully injected!") - else: - logging.error("Failed to find node that matched instances on ibm cloud in region") + logging.info( + "Node with instance ID: %s is in running state" % name + ) + logging.info( + "node_start_scenario has been successfully injected!" + ) + else: + logging.error( + "Failed to find node that matched instances on ibm cloud in region" + ) return "error", NodeScenarioErrorOutput( - "No matching vpc with node name " + name, kube_helper.Actions.START + "No matching vpc with node name " + name, + kube_helper.Actions.START, ) except Exception as e: logging.error("Failed to start node instance. Test Failed") @@ -417,11 +415,11 @@ def node_stop( ibmcloud = IbmCloud() core_v1 = client.CoreV1Api(cli) watch_resource = watch.Watch() - logging.info('set up done') + logging.info("set up done") node_list = kube_helper.get_node_list(cfg, kube_helper.Actions.STOP, core_v1) logging.info("set node list" + str(node_list)) node_name_id_list = ibmcloud.list_instances() - logging.info('node names' + str(node_name_id_list)) + logging.info("node names" + str(node_name_id_list)) nodes_stopped = {} for name in node_list: try: @@ -438,12 +436,19 @@ def node_stop( name, cfg.timeout, watch_resource, core_v1 ) nodes_stopped[int(time.time_ns())] = Node(name=name) - logging.info("Node with instance ID: %s is in stopped state" % name) - logging.info("node_stop_scenario has been successfully injected!") - else: - logging.error("Failed to find node that matched instances on ibm cloud in region") + logging.info( + "Node with instance ID: %s is in stopped state" % name + ) + logging.info( + "node_stop_scenario has been successfully injected!" + ) + else: + logging.error( + "Failed to find node that matched instances on ibm cloud in region" + ) return "error", NodeScenarioErrorOutput( - "No matching vpc with node name " + name, kube_helper.Actions.STOP + "No matching vpc with node name " + name, + kube_helper.Actions.STOP, ) except Exception as e: logging.error("Failed to stop node instance. Test Failed") @@ -495,11 +500,16 @@ def node_reboot( logging.info( "Node with instance ID: %s has rebooted successfully" % name ) - logging.info("node_reboot_scenario has been successfully injected!") - else: - logging.error("Failed to find node that matched instances on ibm cloud in region") + logging.info( + "node_reboot_scenario has been successfully injected!" + ) + else: + logging.error( + "Failed to find node that matched instances on ibm cloud in region" + ) return "error", NodeScenarioErrorOutput( - "No matching vpc with node name " + name, kube_helper.Actions.REBOOT + "No matching vpc with node name " + name, + kube_helper.Actions.REBOOT, ) except Exception as e: logging.error("Failed to reboot node instance. Test Failed") @@ -540,16 +550,23 @@ def node_terminate( ) instance_id = ibmcloud.find_id_in_list(name, node_name_id_list) logging.info("Deleting the node with instance ID: %s " % (name)) - if instance_id: + if instance_id: ibmcloud.delete_instance(instance_id) ibmcloud.wait_until_released(name, cfg.timeout) nodes_terminated[int(time.time_ns())] = Node(name=name) - logging.info("Node with instance ID: %s has been released" % name) - logging.info("node_terminate_scenario has been successfully injected!") - else: - logging.error("Failed to find instances that matched the node specifications on ibm cloud in the set region") + logging.info( + "Node with instance ID: %s has been released" % name + ) + logging.info( + "node_terminate_scenario has been successfully injected!" + ) + else: + logging.error( + "Failed to find instances that matched the node specifications on ibm cloud in the set region" + ) return "error", NodeScenarioErrorOutput( - "No matching vpc with node name " + name, kube_helper.Actions.TERMINATE + "No matching vpc with node name " + name, + kube_helper.Actions.TERMINATE, ) except Exception as e: logging.error("Failed to terminate node instance. Test Failed") diff --git a/kraken/plugins/node_scenarios/kubernetes_functions.py b/krkn/scenario_plugins/native/node_scenarios/kubernetes_functions.py similarity index 100% rename from kraken/plugins/node_scenarios/kubernetes_functions.py rename to krkn/scenario_plugins/native/node_scenarios/kubernetes_functions.py diff --git a/kraken/plugins/node_scenarios/vmware_plugin.py b/krkn/scenario_plugins/native/node_scenarios/vmware_plugin.py similarity index 79% rename from kraken/plugins/node_scenarios/vmware_plugin.py rename to krkn/scenario_plugins/native/node_scenarios/vmware_plugin.py index 270f8378..93c10252 100644 --- a/kraken/plugins/node_scenarios/vmware_plugin.py +++ b/krkn/scenario_plugins/native/node_scenarios/vmware_plugin.py @@ -9,14 +9,18 @@ from os import environ from traceback import format_exc import requests from arcaflow_plugin_sdk import plugin, validation -from com.vmware.vapi.std.errors_client import (AlreadyInDesiredState, - NotAllowedInCurrentState) +from com.vmware.vapi.std.errors_client import ( + AlreadyInDesiredState, + NotAllowedInCurrentState, +) from com.vmware.vcenter.vm_client import Power from com.vmware.vcenter_client import VM, ResourcePool from kubernetes import client, watch from vmware.vapi.vsphere.client import create_vsphere_client -from kraken.plugins.node_scenarios import kubernetes_functions as kube_helper +from krkn.scenario_plugins.native.node_scenarios import ( + kubernetes_functions as kube_helper, +) class vSphere: @@ -104,9 +108,7 @@ class vSphere: return True except NotAllowedInCurrentState: logging.info( - "VM '{}'-'({})' is not Powered On. Cannot reset it", - instance_id, - vm + "VM '{}'-'({})' is not Powered On. Cannot reset it", instance_id, vm ) return False @@ -122,9 +124,7 @@ class vSphere: logging.info(f"Stopped VM -- '{instance_id}-({vm})'") return True except AlreadyInDesiredState: - logging.info( - f"VM '{instance_id}'-'({vm})' is already Powered Off" - ) + logging.info(f"VM '{instance_id}'-'({vm})' is already Powered Off") return False def start_instances(self, instance_id): @@ -139,9 +139,7 @@ class vSphere: logging.info(f"Started VM -- '{instance_id}-({vm})'") return True except AlreadyInDesiredState: - logging.info( - f"VM '{instance_id}'-'({vm})' is already Powered On" - ) + logging.info(f"VM '{instance_id}'-'({vm})' is already Powered On") return False def list_instances(self, datacenter): @@ -152,18 +150,14 @@ class vSphere: datacenter_filter = self.client.vcenter.Datacenter.FilterSpec( names=set([datacenter]) ) - datacenter_summaries = self.client.vcenter.Datacenter.list( - datacenter_filter - ) + datacenter_summaries = self.client.vcenter.Datacenter.list(datacenter_filter) try: datacenter_id = datacenter_summaries[0].datacenter except IndexError: logging.error("Datacenter '{}' doesn't exist", datacenter) sys.exit(1) - vm_filter = self.client.vcenter.VM.FilterSpec( - datacenters={datacenter_id} - ) + vm_filter = self.client.vcenter.VM.FilterSpec(datacenters={datacenter_id}) vm_summaries = self.client.vcenter.VM.list(vm_filter) vm_names = [] for vm in vm_summaries: @@ -177,10 +171,7 @@ class vSphere: datacenter_summaries = self.client.vcenter.Datacenter.list() datacenter_names = [ - { - "datacenter_id": datacenter.datacenter, - "datacenter_name": datacenter.name - } + {"datacenter_id": datacenter.datacenter, "datacenter_name": datacenter.name} for datacenter in datacenter_summaries ] return datacenter_names @@ -194,16 +185,11 @@ class vSphere: datastore_filter = self.client.vcenter.Datastore.FilterSpec( datacenters={datacenter} ) - datastore_summaries = self.client.vcenter.Datastore.list( - datastore_filter - ) + datastore_summaries = self.client.vcenter.Datastore.list(datastore_filter) datastore_names = [] for datastore in datastore_summaries: datastore_names.append( - { - "datastore_name": datastore.name, - "datastore_id": datastore.datastore - } + {"datastore_name": datastore.name, "datastore_id": datastore.datastore} ) return datastore_names @@ -213,9 +199,7 @@ class vSphere: IDs belonging to a specific datacenter """ - folder_filter = self.client.vcenter.Folder.FilterSpec( - datacenters={datacenter} - ) + folder_filter = self.client.vcenter.Folder.FilterSpec(datacenters={datacenter}) folder_summaries = self.client.vcenter.Folder.list(folder_filter) folder_names = [] for folder in folder_summaries: @@ -234,17 +218,12 @@ class vSphere: filter_spec = ResourcePool.FilterSpec( datacenters=set([datacenter]), names=names ) - resource_pool_summaries = self.client.vcenter.ResourcePool.list( - filter_spec - ) + resource_pool_summaries = self.client.vcenter.ResourcePool.list(filter_spec) if len(resource_pool_summaries) > 0: resource_pool = resource_pool_summaries[0].resource_pool return resource_pool else: - logging.error( - "ResourcePool not found in Datacenter '{}'", - datacenter - ) + logging.error("ResourcePool not found in Datacenter '{}'", datacenter) return None def create_default_vm(self, guest_os="RHEL_7_64", max_attempts=10): @@ -277,9 +256,7 @@ class vSphere: # random generator not used for # security/cryptographic purposes in this loop datacenter = random.choice(datacenter_list) # nosec - resource_pool = self.get_resource_pool( - datacenter["datacenter_id"] - ) + resource_pool = self.get_resource_pool(datacenter["datacenter_id"]) folder = random.choice( # nosec self.get_folder_list(datacenter["datacenter_id"]) )["folder_id"] @@ -288,25 +265,18 @@ class vSphere: )["datastore_id"] vm_name = "Test-" + str(time.time_ns()) return ( - create_vm( - vm_name, - resource_pool, - folder, - datastore, - guest_os - ), + create_vm(vm_name, resource_pool, folder, datastore, guest_os), vm_name, ) except Exception as e: logging.error( - "Default VM could not be created, retrying. " - "Error was: %s", - str(e) + "Default VM could not be created, retrying. " "Error was: %s", + str(e), ) logging.error( "Default VM could not be created in %s attempts. " "Check your VMware resources", - max_attempts + max_attempts, ) return None, None @@ -338,15 +308,12 @@ class vSphere: while vm is not None: vm = self.get_vm(instance_id) logging.info( - f"VM {instance_id} is still being deleted, " - f"sleeping for 5 seconds" + f"VM {instance_id} is still being deleted, " f"sleeping for 5 seconds" ) time.sleep(5) time_counter += 5 if time_counter >= timeout: - logging.info( - f"VM {instance_id} is still not deleted in allotted time" - ) + logging.info(f"VM {instance_id} is still not deleted in allotted time") return False return True @@ -361,16 +328,12 @@ class vSphere: while status != Power.State.POWERED_ON: status = self.get_vm_status(instance_id) logging.info( - "VM %s is still not running, " - "sleeping for 5 seconds", - instance_id + "VM %s is still not running, " "sleeping for 5 seconds", instance_id ) time.sleep(5) time_counter += 5 if time_counter >= timeout: - logging.info( - f"VM {instance_id} is still not ready in allotted time" - ) + logging.info(f"VM {instance_id} is still not ready in allotted time") return False return True @@ -385,15 +348,12 @@ class vSphere: while status != Power.State.POWERED_OFF: status = self.get_vm_status(instance_id) logging.info( - f"VM {instance_id} is still not running, " - f"sleeping for 5 seconds" + f"VM {instance_id} is still not running, " f"sleeping for 5 seconds" ) time.sleep(5) time_counter += 5 if time_counter >= timeout: - logging.info( - f"VM {instance_id} is still not ready in allotted time" - ) + logging.info(f"VM {instance_id} is still not ready in allotted time") return False return True @@ -410,16 +370,16 @@ class NodeScenarioSuccessOutput: metadata={ "name": "Nodes started/stopped/terminated/rebooted", "description": "Map between timestamps and the pods " - "started/stopped/terminated/rebooted. " - "The timestamp is provided in nanoseconds", + "started/stopped/terminated/rebooted. " + "The timestamp is provided in nanoseconds", } ) action: kube_helper.Actions = field( metadata={ "name": "The action performed on the node", "description": "The action performed or attempted to be " - "performed on the node. Possible values" - "are : Start, Stop, Terminate, Reboot", + "performed on the node. Possible values" + "are : Start, Stop, Terminate, Reboot", } ) @@ -449,7 +409,7 @@ class NodeScenarioConfig: metadata={ "name": "Name", "description": "Name(s) for target nodes. " - "Required if label_selector is not set.", + "Required if label_selector is not set.", }, ) @@ -458,20 +418,18 @@ class NodeScenarioConfig: metadata={ "name": "Number of runs per node", "description": "Number of times to inject each scenario under " - "actions (will perform on same node each time)", + "actions (will perform on same node each time)", }, ) label_selector: typing.Annotated[ - typing.Optional[str], - validation.min(1), - validation.required_if_not("name") + typing.Optional[str], validation.min(1), validation.required_if_not("name") ] = field( default=None, metadata={ "name": "Label selector", "description": "Kubernetes label selector for the target nodes. " - "Required if name is not set.\n" + "Required if name is not set.\n" "See https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ " # noqa "for details.", }, @@ -482,19 +440,16 @@ class NodeScenarioConfig: metadata={ "name": "Timeout", "description": "Timeout to wait for the target pod(s) " - "to be removed in seconds.", + "to be removed in seconds.", }, ) - instance_count: typing.Annotated[ - typing.Optional[int], - validation.min(1) - ] = field( + instance_count: typing.Annotated[typing.Optional[int], validation.min(1)] = field( default=1, metadata={ "name": "Instance Count", "description": "Number of nodes to perform action/select " - "that match the label selector.", + "that match the label selector.", }, ) @@ -511,7 +466,7 @@ class NodeScenarioConfig: metadata={ "name": "Verify API Session", "description": "Verifies the vSphere client session. " - "It is enabled by default", + "It is enabled by default", }, ) @@ -520,7 +475,7 @@ class NodeScenarioConfig: metadata={ "name": "Kubeconfig path", "description": "Path to your Kubeconfig file. " - "Defaults to ~/.kube/config.\n" + "Defaults to ~/.kube/config.\n" "See https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/ " # noqa "for details.", }, @@ -531,11 +486,8 @@ class NodeScenarioConfig: id="vmware-node-start", name="Start the node", description="Start the node(s) by starting the VMware VM " - "on which the node is configured", - outputs={ - "success": NodeScenarioSuccessOutput, - "error": NodeScenarioErrorOutput - }, + "on which the node is configured", + outputs={"success": NodeScenarioSuccessOutput, "error": NodeScenarioErrorOutput}, ) def node_start( cfg: NodeScenarioConfig, @@ -546,11 +498,7 @@ def node_start( vsphere = vSphere(verify=cfg.verify_session) core_v1 = client.CoreV1Api(cli) watch_resource = watch.Watch() - node_list = kube_helper.get_node_list( - cfg, - kube_helper.Actions.START, - core_v1 - ) + node_list = kube_helper.get_node_list(cfg, kube_helper.Actions.START, core_v1) nodes_started = {} for name in node_list: try: @@ -565,17 +513,12 @@ def node_start( name, cfg.timeout, watch_resource, core_v1 ) nodes_started[int(time.time_ns())] = Node(name=name) - logging.info( - f"Node with instance ID: {name} is in running state" - ) - logging.info( - "node_start_scenario has been successfully injected!" - ) + logging.info(f"Node with instance ID: {name} is in running state") + logging.info("node_start_scenario has been successfully injected!") except Exception as e: logging.error("Failed to start node instance. Test Failed") logging.error( - f"node_start_scenario injection failed! " - f"Error was: {str(e)}" + f"node_start_scenario injection failed! " f"Error was: {str(e)}" ) return "error", NodeScenarioErrorOutput( format_exc(), kube_helper.Actions.START @@ -590,11 +533,8 @@ def node_start( id="vmware-node-stop", name="Stop the node", description="Stop the node(s) by starting the VMware VM " - "on which the node is configured", - outputs={ - "success": NodeScenarioSuccessOutput, - "error": NodeScenarioErrorOutput - }, + "on which the node is configured", + outputs={"success": NodeScenarioSuccessOutput, "error": NodeScenarioErrorOutput}, ) def node_stop( cfg: NodeScenarioConfig, @@ -605,11 +545,7 @@ def node_stop( vsphere = vSphere(verify=cfg.verify_session) core_v1 = client.CoreV1Api(cli) watch_resource = watch.Watch() - node_list = kube_helper.get_node_list( - cfg, - kube_helper.Actions.STOP, - core_v1 - ) + node_list = kube_helper.get_node_list(cfg, kube_helper.Actions.STOP, core_v1) nodes_stopped = {} for name in node_list: try: @@ -624,17 +560,12 @@ def node_stop( name, cfg.timeout, watch_resource, core_v1 ) nodes_stopped[int(time.time_ns())] = Node(name=name) - logging.info( - f"Node with instance ID: {name} is in stopped state" - ) - logging.info( - "node_stop_scenario has been successfully injected!" - ) + logging.info(f"Node with instance ID: {name} is in stopped state") + logging.info("node_stop_scenario has been successfully injected!") except Exception as e: logging.error("Failed to stop node instance. Test Failed") logging.error( - f"node_stop_scenario injection failed! " - f"Error was: {str(e)}" + f"node_stop_scenario injection failed! " f"Error was: {str(e)}" ) return "error", NodeScenarioErrorOutput( format_exc(), kube_helper.Actions.STOP @@ -649,11 +580,8 @@ def node_stop( id="vmware-node-reboot", name="Reboot VMware VM", description="Reboot the node(s) by starting the VMware VM " - "on which the node is configured", - outputs={ - "success": NodeScenarioSuccessOutput, - "error": NodeScenarioErrorOutput - }, + "on which the node is configured", + outputs={"success": NodeScenarioSuccessOutput, "error": NodeScenarioErrorOutput}, ) def node_reboot( cfg: NodeScenarioConfig, @@ -664,11 +592,7 @@ def node_reboot( vsphere = vSphere(verify=cfg.verify_session) core_v1 = client.CoreV1Api(cli) watch_resource = watch.Watch() - node_list = kube_helper.get_node_list( - cfg, - kube_helper.Actions.REBOOT, - core_v1 - ) + node_list = kube_helper.get_node_list(cfg, kube_helper.Actions.REBOOT, core_v1) nodes_rebooted = {} for name in node_list: try: @@ -685,17 +609,13 @@ def node_reboot( ) nodes_rebooted[int(time.time_ns())] = Node(name=name) logging.info( - f"Node with instance ID: {name} has rebooted " - "successfully" - ) - logging.info( - "node_reboot_scenario has been successfully injected!" + f"Node with instance ID: {name} has rebooted " "successfully" ) + logging.info("node_reboot_scenario has been successfully injected!") except Exception as e: logging.error("Failed to reboot node instance. Test Failed") logging.error( - f"node_reboot_scenario injection failed! " - f"Error was: {str(e)}" + f"node_reboot_scenario injection failed! " f"Error was: {str(e)}" ) return "error", NodeScenarioErrorOutput( format_exc(), kube_helper.Actions.REBOOT @@ -733,24 +653,18 @@ def node_terminate( ) vsphere.stop_instances(name) vsphere.wait_until_stopped(name, cfg.timeout) - logging.info( - f"Releasing the node with instance ID: {name} " - ) + logging.info(f"Releasing the node with instance ID: {name} ") vsphere.release_instances(name) vsphere.wait_until_released(name, cfg.timeout) nodes_terminated[int(time.time_ns())] = Node(name=name) + logging.info(f"Node with instance ID: {name} has been released") logging.info( - f"Node with instance ID: {name} has been released" - ) - logging.info( - "node_terminate_scenario has been " - "successfully injected!" + "node_terminate_scenario has been " "successfully injected!" ) except Exception as e: logging.error("Failed to terminate node instance. Test Failed") logging.error( - f"node_terminate_scenario injection failed! " - f"Error was: {str(e)}" + f"node_terminate_scenario injection failed! " f"Error was: {str(e)}" ) return "error", NodeScenarioErrorOutput( format_exc(), kube_helper.Actions.TERMINATE diff --git a/krkn/scenario_plugins/native/plugins.py b/krkn/scenario_plugins/native/plugins.py new file mode 100644 index 00000000..34347c0d --- /dev/null +++ b/krkn/scenario_plugins/native/plugins.py @@ -0,0 +1,176 @@ +import dataclasses +import json +import logging +from os.path import abspath +from typing import List, Any, Dict +from krkn.scenario_plugins.native.run_python_plugin import run_python_file +from arcaflow_plugin_kill_pod import kill_pods, wait_for_pods +from krkn.scenario_plugins.native.network.ingress_shaping import network_chaos +from krkn.scenario_plugins.native.pod_network_outage.pod_network_outage_plugin import ( + pod_outage, +) +from krkn.scenario_plugins.native.pod_network_outage.pod_network_outage_plugin import ( + pod_egress_shaping, +) +import krkn.scenario_plugins.native.node_scenarios.ibmcloud_plugin as ibmcloud_plugin +from krkn.scenario_plugins.native.pod_network_outage.pod_network_outage_plugin import ( + pod_ingress_shaping, +) +from arcaflow_plugin_sdk import schema, serialization, jsonschema + +from krkn.scenario_plugins.native.node_scenarios import vmware_plugin + + +@dataclasses.dataclass +class PluginStep: + schema: schema.StepSchema + error_output_ids: List[str] + + def render_output(self, output_id: str, output_data) -> str: + return json.dumps( + { + "output_id": output_id, + "output_data": self.schema.outputs[output_id].serialize(output_data), + }, + indent="\t", + ) + + +class Plugins: + """ + Plugins is a class that can run plugins sequentially. The output is rendered to the standard output and the process + is aborted if a step fails. + """ + + steps_by_id: Dict[str, PluginStep] + + def __init__(self, steps: List[PluginStep]): + self.steps_by_id = dict() + for step in steps: + if step.schema.id in self.steps_by_id: + raise Exception("Duplicate step ID: {}".format(step.schema.id)) + self.steps_by_id[step.schema.id] = step + + def unserialize_scenario(self, file: str) -> Any: + return serialization.load_from_file(abspath(file)) + + def run(self, file: str, kubeconfig_path: str, kraken_config: str, run_uuid: str): + """ + Run executes a series of steps + """ + data = self.unserialize_scenario(abspath(file)) + if not isinstance(data, list): + raise Exception( + "Invalid scenario configuration file: {} expected list, found {}".format( + file, type(data).__name__ + ) + ) + i = 0 + for entry in data: + if not isinstance(entry, dict): + raise Exception( + "Invalid scenario configuration file: {} expected a list of dict's, found {} on step {}".format( + file, type(entry).__name__, i + ) + ) + if "id" not in entry: + raise Exception( + "Invalid scenario configuration file: {} missing 'id' field on step {}".format( + file, + i, + ) + ) + if "config" not in entry: + raise Exception( + "Invalid scenario configuration file: {} missing 'config' field on step {}".format( + file, + i, + ) + ) + + if entry["id"] not in self.steps_by_id: + raise Exception( + "Invalid step {} in {} ID: {} expected one of: {}".format( + i, file, entry["id"], ", ".join(self.steps_by_id.keys()) + ) + ) + step = self.steps_by_id[entry["id"]] + unserialized_input = step.schema.input.unserialize(entry["config"]) + if "kubeconfig_path" in step.schema.input.properties: + unserialized_input.kubeconfig_path = kubeconfig_path + if "kraken_config" in step.schema.input.properties: + unserialized_input.kraken_config = kraken_config + output_id, output_data = step.schema( + params=unserialized_input, run_id=run_uuid + ) + + logging.info(step.render_output(output_id, output_data) + "\n") + if output_id in step.error_output_ids: + raise Exception( + "Step {} in {} ({}) failed".format(i, file, step.schema.id) + ) + i = i + 1 + + def json_schema(self): + """ + This function generates a JSON schema document and renders it from the steps passed. + """ + result = { + "$id": "https://github.com/redhat-chaos/krkn/", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Kraken Arcaflow scenarios", + "description": "Serial execution of Arcaflow Python plugins. See https://github.com/arcaflow for details.", + "type": "array", + "minContains": 1, + "items": {"oneOf": []}, + } + for step_id in self.steps_by_id.keys(): + step = self.steps_by_id[step_id] + step_input = jsonschema.step_input(step.schema) + del step_input["$id"] + del step_input["$schema"] + del step_input["title"] + del step_input["description"] + result["items"]["oneOf"].append( + { + "type": "object", + "properties": { + "id": { + "type": "string", + "const": step_id, + }, + "config": step_input, + }, + "required": [ + "id", + "config", + ], + } + ) + return json.dumps(result, indent="\t") + + +PLUGINS = Plugins( + [ + PluginStep( + kill_pods, + [ + "error", + ], + ), + PluginStep(wait_for_pods, ["error"]), + PluginStep(run_python_file, ["error"]), + PluginStep(vmware_plugin.node_start, ["error"]), + PluginStep(vmware_plugin.node_stop, ["error"]), + PluginStep(vmware_plugin.node_reboot, ["error"]), + PluginStep(vmware_plugin.node_terminate, ["error"]), + PluginStep(ibmcloud_plugin.node_start, ["error"]), + PluginStep(ibmcloud_plugin.node_stop, ["error"]), + PluginStep(ibmcloud_plugin.node_reboot, ["error"]), + PluginStep(ibmcloud_plugin.node_terminate, ["error"]), + PluginStep(network_chaos, ["error"]), + PluginStep(pod_outage, ["error"]), + PluginStep(pod_egress_shaping, ["error"]), + PluginStep(pod_ingress_shaping, ["error"]), + ] +) diff --git a/kraken/plugins/pod_network_outage/cerberus.py b/krkn/scenario_plugins/native/pod_network_outage/cerberus.py similarity index 100% rename from kraken/plugins/pod_network_outage/cerberus.py rename to krkn/scenario_plugins/native/pod_network_outage/cerberus.py diff --git a/kraken/plugins/pod_network_outage/job.j2 b/krkn/scenario_plugins/native/pod_network_outage/job.j2 similarity index 100% rename from kraken/plugins/pod_network_outage/job.j2 rename to krkn/scenario_plugins/native/pod_network_outage/job.j2 diff --git a/kraken/plugins/pod_network_outage/kubernetes_functions.py b/krkn/scenario_plugins/native/pod_network_outage/kubernetes_functions.py similarity index 100% rename from kraken/plugins/pod_network_outage/kubernetes_functions.py rename to krkn/scenario_plugins/native/pod_network_outage/kubernetes_functions.py diff --git a/kraken/plugins/pod_network_outage/pod_module.j2 b/krkn/scenario_plugins/native/pod_network_outage/pod_module.j2 similarity index 100% rename from kraken/plugins/pod_network_outage/pod_module.j2 rename to krkn/scenario_plugins/native/pod_network_outage/pod_module.j2 diff --git a/kraken/plugins/pod_network_outage/pod_network_outage_plugin.py b/krkn/scenario_plugins/native/pod_network_outage/pod_network_outage_plugin.py similarity index 100% rename from kraken/plugins/pod_network_outage/pod_network_outage_plugin.py rename to krkn/scenario_plugins/native/pod_network_outage/pod_network_outage_plugin.py diff --git a/kraken/plugins/run_python_plugin.py b/krkn/scenario_plugins/native/run_python_plugin.py similarity index 100% rename from kraken/plugins/run_python_plugin.py rename to krkn/scenario_plugins/native/run_python_plugin.py diff --git a/kraken/post_actions/__init__.py b/krkn/scenario_plugins/network_chaos/__init__.py similarity index 100% rename from kraken/post_actions/__init__.py rename to krkn/scenario_plugins/network_chaos/__init__.py diff --git a/kraken/network_chaos/job.j2 b/krkn/scenario_plugins/network_chaos/job.j2 similarity index 100% rename from kraken/network_chaos/job.j2 rename to krkn/scenario_plugins/network_chaos/job.j2 diff --git a/krkn/scenario_plugins/network_chaos/network_chaos_scenario_plugin.py b/krkn/scenario_plugins/network_chaos/network_chaos_scenario_plugin.py new file mode 100644 index 00000000..eaa0719f --- /dev/null +++ b/krkn/scenario_plugins/network_chaos/network_chaos_scenario_plugin.py @@ -0,0 +1,255 @@ +import logging +import os +import random +import time + +import yaml +from jinja2 import Environment, FileSystemLoader +from krkn_lib.k8s import KrknKubernetes +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from krkn_lib.utils import get_yaml_item_value, log_exception + +from krkn import cerberus, utils +from krkn.scenario_plugins.node_actions import common_node_functions +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class NetworkChaosScenarioPlugin(AbstractScenarioPlugin): + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + try: + with open(scenario, "r") as file: + param_lst = ["latency", "loss", "bandwidth"] + test_config = yaml.safe_load(file) + test_dict = test_config["network_chaos"] + test_duration = int(get_yaml_item_value(test_dict, "duration", 300)) + test_interface = get_yaml_item_value(test_dict, "interfaces", []) + test_node = get_yaml_item_value(test_dict, "node_name", "") + test_node_label = get_yaml_item_value( + test_dict, "label_selector", "node-role.kubernetes.io/master" + ) + test_execution = get_yaml_item_value(test_dict, "execution", "serial") + test_instance_count = get_yaml_item_value( + test_dict, "instance_count", 1 + ) + test_egress = get_yaml_item_value( + test_dict, "egress", {"bandwidth": "100mbit"} + ) + if test_node: + node_name_list = test_node.split(",") + else: + node_name_list = [test_node] + nodelst = [] + for single_node_name in node_name_list: + nodelst.extend( + common_node_functions.get_node( + single_node_name, + test_node_label, + test_instance_count, + lib_telemetry.get_lib_kubernetes(), + ) + ) + file_loader = FileSystemLoader( + os.path.abspath(os.path.dirname(__file__)) + ) + env = Environment(loader=file_loader, autoescape=True) + pod_template = env.get_template("pod.j2") + test_interface = self.verify_interface( + test_interface, + nodelst, + pod_template, + lib_telemetry.get_lib_kubernetes(), + ) + joblst = [] + egress_lst = [i for i in param_lst if i in test_egress] + chaos_config = { + "network_chaos": { + "duration": test_duration, + "interfaces": test_interface, + "node_name": ",".join(nodelst), + "execution": test_execution, + "instance_count": test_instance_count, + "egress": test_egress, + } + } + logging.info( + "Executing network chaos with config \n %s" + % yaml.dump(chaos_config) + ) + job_template = env.get_template("job.j2") + try: + for i in egress_lst: + for node in nodelst: + exec_cmd = self.get_egress_cmd( + test_execution, + test_interface, + i, + test_dict["egress"], + duration=test_duration, + ) + logging.info("Executing %s on node %s" % (exec_cmd, node)) + job_body = yaml.safe_load( + job_template.render( + jobname=i + str(hash(node))[:5], + nodename=node, + cmd=exec_cmd, + ) + ) + joblst.append(job_body["metadata"]["name"]) + api_response = ( + lib_telemetry.get_lib_kubernetes().create_job(job_body) + ) + if api_response is None: + logging.error( + "NetworkChaosScenarioPlugin Error creating job" + ) + return 1 + if test_execution == "serial": + logging.info("Waiting for serial job to finish") + start_time = int(time.time()) + self.wait_for_job( + joblst[:], + lib_telemetry.get_lib_kubernetes(), + test_duration + 300, + ) + + end_time = int(time.time()) + cerberus.publish_kraken_status( + krkn_config, + None, + start_time, + end_time, + ) + if test_execution == "parallel": + break + if test_execution == "parallel": + logging.info("Waiting for parallel job to finish") + start_time = int(time.time()) + self.wait_for_job( + joblst[:], + lib_telemetry.get_lib_kubernetes(), + test_duration + 300, + ) + end_time = int(time.time()) + cerberus.publish_kraken_status( + krkn_config, [], start_time, end_time + ) + except Exception as e: + logging.error( + "NetworkChaosScenarioPlugin exiting due to Exception %s" % e + ) + return 1 + finally: + logging.info("Deleting jobs") + self.delete_job(joblst[:], lib_telemetry.get_lib_kubernetes()) + except (RuntimeError, Exception): + scenario_telemetry.exit_status = 1 + return 1 + else: + return 0 + + def verify_interface( + self, test_interface, nodelst, template, kubecli: KrknKubernetes + ): + pod_index = random.randint(0, len(nodelst) - 1) + pod_body = yaml.safe_load(template.render(nodename=nodelst[pod_index])) + logging.info("Creating pod to query interface on node %s" % nodelst[pod_index]) + kubecli.create_pod(pod_body, "default", 300) + try: + if test_interface == []: + cmd = "ip r | grep default | awk '/default/ {print $5}'" + output = kubecli.exec_cmd_in_pod(cmd, "fedtools", "default") + test_interface = [output.replace("\n", "")] + else: + cmd = "ip -br addr show|awk -v ORS=',' '{print $1}'" + output = kubecli.exec_cmd_in_pod(cmd, "fedtools", "default") + interface_lst = output[:-1].split(",") + for interface in test_interface: + if interface not in interface_lst: + logging.error( + "NetworkChaosScenarioPlugin Interface %s not found in node %s interface list %s" + % (interface, nodelst[pod_index], interface_lst) + ) + raise RuntimeError() + return test_interface + finally: + logging.info("Deleteing pod to query interface on node") + kubecli.delete_pod("fedtools", "default") + + # krkn_lib + def get_job_pods(self, api_response, kubecli: KrknKubernetes): + controllerUid = api_response.metadata.labels["controller-uid"] + pod_label_selector = "controller-uid=" + controllerUid + pods_list = kubecli.list_pods( + label_selector=pod_label_selector, namespace="default" + ) + return pods_list[0] + + # krkn_lib + def wait_for_job(self, joblst, kubecli: KrknKubernetes, timeout=300): + waittime = time.time() + timeout + count = 0 + joblen = len(joblst) + while count != joblen: + for jobname in joblst: + try: + api_response = kubecli.get_job_status(jobname, namespace="default") + if ( + api_response.status.succeeded is not None + or api_response.status.failed is not None + ): + count += 1 + joblst.remove(jobname) + except Exception: + logging.warning("Exception in getting job status") + if time.time() > waittime: + raise Exception("Starting pod failed") + time.sleep(5) + + # krkn_lib + def delete_job(self, joblst, kubecli: KrknKubernetes): + for jobname in joblst: + try: + api_response = kubecli.get_job_status(jobname, namespace="default") + if api_response.status.failed is not None: + pod_name = self.get_job_pods(api_response, kubecli) + pod_stat = kubecli.read_pod(name=pod_name, namespace="default") + logging.error( + f"NetworkChaosScenarioPlugin {pod_stat.status.container_statuses}" + ) + pod_log_response = kubecli.get_pod_log( + name=pod_name, namespace="default" + ) + pod_log = pod_log_response.data.decode("utf-8") + logging.error(pod_log) + except Exception: + logging.warning("Exception in getting job status") + kubecli.delete_job(name=jobname, namespace="default") + + def get_egress_cmd(self, execution, test_interface, mod, vallst, duration=30): + tc_set = tc_unset = tc_ls = "" + param_map = {"latency": "delay", "loss": "loss", "bandwidth": "rate"} + for i in test_interface: + tc_set = "{0} tc qdisc add dev {1} root netem".format(tc_set, i) + tc_unset = "{0} tc qdisc del dev {1} root ;".format(tc_unset, i) + tc_ls = "{0} tc qdisc ls dev {1} ;".format(tc_ls, i) + if execution == "parallel": + for val in vallst.keys(): + tc_set += " {0} {1} ".format(param_map[val], vallst[val]) + tc_set += ";" + else: + tc_set += " {0} {1} ;".format(param_map[mod], vallst[mod]) + exec_cmd = "{0} {1} sleep {2};{3} sleep 20;{4}".format( + tc_set, tc_ls, duration, tc_unset, tc_ls + ) + return exec_cmd + + def get_scenario_types(self) -> list[str]: + return ["network_chaos_scenarios"] diff --git a/kraken/network_chaos/pod.j2 b/krkn/scenario_plugins/network_chaos/pod.j2 similarity index 100% rename from kraken/network_chaos/pod.j2 rename to krkn/scenario_plugins/network_chaos/pod.j2 diff --git a/kraken/pvc/__init__.py b/krkn/scenario_plugins/node_actions/__init__.py similarity index 100% rename from kraken/pvc/__init__.py rename to krkn/scenario_plugins/node_actions/__init__.py diff --git a/kraken/node_actions/abstract_node_scenarios.py b/krkn/scenario_plugins/node_actions/abstract_node_scenarios.py similarity index 83% rename from kraken/node_actions/abstract_node_scenarios.py rename to krkn/scenario_plugins/node_actions/abstract_node_scenarios.py index 73928375..73d3feec 100644 --- a/kraken/node_actions/abstract_node_scenarios.py +++ b/krkn/scenario_plugins/node_actions/abstract_node_scenarios.py @@ -1,15 +1,18 @@ import sys import logging import time -import kraken.invoke.command as runcommand -import kraken.node_actions.common_node_functions as nodeaction +import krkn.invoke.command as runcommand +import krkn.scenario_plugins.node_actions.common_node_functions as nodeaction from krkn_lib.k8s import KrknKubernetes + # krkn_lib class abstract_node_scenarios: kubecli: KrknKubernetes + def __init__(self, kubecli: KrknKubernetes): self.kubecli = kubecli + # Node scenario to start the node def node_start_scenario(self, instance_kill_count, node, timeout): pass @@ -47,16 +50,19 @@ class abstract_node_scenarios: try: logging.info("Starting stop_kubelet_scenario injection") logging.info("Stopping the kubelet of the node %s" % (node)) - runcommand.run("oc debug node/" + node + " -- chroot /host systemctl stop kubelet") + runcommand.run( + "oc debug node/" + node + " -- chroot /host systemctl stop kubelet" + ) nodeaction.wait_for_unknown_status(node, timeout, self.kubecli) logging.info("The kubelet of the node %s has been stopped" % (node)) logging.info("stop_kubelet_scenario has been successfuly injected!") except Exception as e: logging.error( - "Failed to stop the kubelet of the node. Encountered following " "exception: %s. Test Failed" % (e) + "Failed to stop the kubelet of the node. Encountered following " + "exception: %s. Test Failed" % (e) ) logging.error("stop_kubelet_scenario injection failed!") - sys.exit(1) + raise e # Node scenario to stop and start the kubelet def stop_start_kubelet_scenario(self, instance_kill_count, node, timeout): @@ -65,25 +71,28 @@ class abstract_node_scenarios: self.node_reboot_scenario(instance_kill_count, node, timeout) logging.info("stop_start_kubelet_scenario has been successfully injected!") - # Node scenario to restart the kubelet def restart_kubelet_scenario(self, instance_kill_count, node, timeout): for _ in range(instance_kill_count): try: logging.info("Starting restart_kubelet_scenario injection") logging.info("Restarting the kubelet of the node %s" % (node)) - runcommand.run("oc debug node/" + node + " -- chroot /host systemctl restart kubelet &") + runcommand.run( + "oc debug node/" + + node + + " -- chroot /host systemctl restart kubelet &" + ) nodeaction.wait_for_not_ready_status(node, timeout, self.kubecli) nodeaction.wait_for_ready_status(node, timeout, self.kubecli) logging.info("The kubelet of the node %s has been restarted" % (node)) logging.info("restart_kubelet_scenario has been successfuly injected!") except Exception as e: logging.error( - "Failed to restart the kubelet of the node. Encountered following " "exception: %s. Test Failed" % (e) + "Failed to restart the kubelet of the node. Encountered following " + "exception: %s. Test Failed" % (e) ) logging.error("restart_kubelet_scenario injection failed!") - sys.exit(1) - + raise e # Node scenario to crash the node def node_crash_scenario(self, instance_kill_count, node, timeout): @@ -92,13 +101,17 @@ class abstract_node_scenarios: logging.info("Starting node_crash_scenario injection") logging.info("Crashing the node %s" % (node)) runcommand.invoke( - "oc debug node/" + node + " -- chroot /host " "dd if=/dev/urandom of=/proc/sysrq-trigger" + "oc debug node/" + node + " -- chroot /host " + "dd if=/dev/urandom of=/proc/sysrq-trigger" ) logging.info("node_crash_scenario has been successfuly injected!") except Exception as e: - logging.error("Failed to crash the node. Encountered following exception: %s. " "Test Failed" % (e)) + logging.error( + "Failed to crash the node. Encountered following exception: %s. " + "Test Failed" % (e) + ) logging.error("node_crash_scenario injection failed!") - sys.exit(1) + raise e # Node scenario to check service status on helper node def node_service_status(self, node, service, ssh_private_key, timeout): diff --git a/kraken/node_actions/alibaba_node_scenarios.py b/krkn/scenario_plugins/node_actions/alibaba_node_scenarios.py similarity index 75% rename from kraken/node_actions/alibaba_node_scenarios.py rename to krkn/scenario_plugins/node_actions/alibaba_node_scenarios.py index 47c2f226..b9ce0f49 100644 --- a/kraken/node_actions/alibaba_node_scenarios.py +++ b/krkn/scenario_plugins/node_actions/alibaba_node_scenarios.py @@ -1,13 +1,22 @@ import sys import time import logging -import kraken.node_actions.common_node_functions as nodeaction +import krkn.scenario_plugins.node_actions.common_node_functions as nodeaction import os import json from aliyunsdkcore.client import AcsClient -from aliyunsdkecs.request.v20140526 import DescribeInstancesRequest, DeleteInstanceRequest -from aliyunsdkecs.request.v20140526 import StopInstanceRequest, StartInstanceRequest, RebootInstanceRequest -from kraken.node_actions.abstract_node_scenarios import abstract_node_scenarios +from aliyunsdkecs.request.v20140526 import ( + DescribeInstancesRequest, + DeleteInstanceRequest, +) +from aliyunsdkecs.request.v20140526 import ( + StopInstanceRequest, + StartInstanceRequest, + RebootInstanceRequest, +) +from krkn.scenario_plugins.node_actions.abstract_node_scenarios import ( + abstract_node_scenarios, +) from krkn_lib.k8s import KrknKubernetes @@ -46,12 +55,12 @@ class Alibaba: "variables/credentials are correct" ) logging.error(response) - sys.exit(1) + raise RuntimeError(response) return instance_list return [] except Exception as e: logging.error("ERROR while trying to get list of instances " + str(e)) - sys.exit(1) + raise e # Get the instance ID of the node def get_instance_id(self, node_name): @@ -59,8 +68,16 @@ class Alibaba: for vm in vm_list: if node_name == vm["InstanceName"]: return vm["InstanceId"] - logging.error("Couldn't find vm with name " + str(node_name) + ", you could try another region") - sys.exit(1) + logging.error( + "Couldn't find vm with name " + + str(node_name) + + ", you could try another region" + ) + raise RuntimeError( + "Couldn't find vm with name " + + str(node_name) + + ", you could try another region" + ) # Start the node instance def start_instances(self, instance_id): @@ -72,9 +89,10 @@ class Alibaba: logging.info("ECS instance with id " + str(instance_id) + " started") except Exception as e: logging.error( - "Failed to start node instance %s. Encountered following " "exception: %s." % (instance_id, e) + "Failed to start node instance %s. Encountered following " + "exception: %s." % (instance_id, e) ) - sys.exit(1) + raise e # https://partners-intl.aliyun.com/help/en/doc-detail/93110.html # Stop the node instance @@ -86,8 +104,11 @@ class Alibaba: self._send_request(request) logging.info("Stop %s command submit successfully.", instance_id) except Exception as e: - logging.error("Failed to stop node instance %s. Encountered following " "exception: %s." % (instance_id, e)) - sys.exit(1) + logging.error( + "Failed to stop node instance %s. Encountered following " + "exception: %s." % (instance_id, e) + ) + raise e # Terminate the node instance def release_instance(self, instance_id, force_release=True): @@ -99,9 +120,10 @@ class Alibaba: logging.info("ECS Instance " + str(instance_id) + " released") except Exception as e: logging.error( - "Failed to terminate node instance %s. Encountered following " "exception: %s." % (instance_id, e) + "Failed to terminate node instance %s. Encountered following " + "exception: %s." % (instance_id, e) ) - sys.exit(1) + raise e # Reboot the node instance def reboot_instances(self, instance_id, force_reboot=True): @@ -113,9 +135,10 @@ class Alibaba: logging.info("ECS Instance " + str(instance_id) + " rebooted") except Exception as e: logging.error( - "Failed to reboot node instance %s. Encountered following " "exception: %s." % (instance_id, e) + "Failed to reboot node instance %s. Encountered following " + "exception: %s." % (instance_id, e) ) - sys.exit(1) + raise e def get_vm_status(self, instance_id): @@ -132,7 +155,8 @@ class Alibaba: return "Unknown" except Exception as e: logging.error( - "Failed to get node instance status %s. Encountered following " "exception: %s." % (instance_id, e) + "Failed to get node instance status %s. Encountered following " + "exception: %s." % (instance_id, e) ) return None @@ -142,7 +166,9 @@ class Alibaba: status = self.get_vm_status(instance_id) while status != "Running": status = self.get_vm_status(instance_id) - logging.info("ECS %s is still not running, sleeping for 5 seconds" % instance_id) + logging.info( + "ECS %s is still not running, sleeping for 5 seconds" % instance_id + ) time.sleep(5) time_counter += 5 if time_counter >= timeout: @@ -156,11 +182,15 @@ class Alibaba: status = self.get_vm_status(instance_id) while status != "Stopped": status = self.get_vm_status(instance_id) - logging.info("Vm %s is still stopping, sleeping for 5 seconds" % instance_id) + logging.info( + "Vm %s is still stopping, sleeping for 5 seconds" % instance_id + ) time.sleep(5) time_counter += 5 if time_counter >= timeout: - logging.info("Vm %s is still not stopped in allotted time" % instance_id) + logging.info( + "Vm %s is still not stopped in allotted time" % instance_id + ) return False return True @@ -170,7 +200,9 @@ class Alibaba: time_counter = 0 while statuses and statuses != "Released": statuses = self.get_vm_status(instance_id) - logging.info("ECS %s is still being released, waiting 10 seconds" % instance_id) + logging.info( + "ECS %s is still being released, waiting 10 seconds" % instance_id + ) time.sleep(10) time_counter += 10 if time_counter >= timeout: @@ -180,9 +212,10 @@ class Alibaba: logging.info("ECS %s is released" % instance_id) return True + # krkn_lib class alibaba_node_scenarios(abstract_node_scenarios): - def __init__(self,kubecli: KrknKubernetes): + def __init__(self, kubecli: KrknKubernetes): self.alibaba = Alibaba() # Node scenario to start the node @@ -191,7 +224,9 @@ class alibaba_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_start_scenario injection") vm_id = self.alibaba.get_instance_id(node) - logging.info("Starting the node %s with instance ID: %s " % (node, vm_id)) + logging.info( + "Starting the node %s with instance ID: %s " % (node, vm_id) + ) self.alibaba.start_instances(vm_id) self.alibaba.wait_until_running(vm_id, timeout) nodeaction.wait_for_ready_status(node, timeout, self.kubecli) @@ -199,10 +234,11 @@ class alibaba_node_scenarios(abstract_node_scenarios): logging.info("node_start_scenario has been successfully injected!") except Exception as e: logging.error( - "Failed to start node instance. Encountered following " "exception: %s. Test Failed" % (e) + "Failed to start node instance. Encountered following " + "exception: %s. Test Failed" % (e) ) logging.error("node_start_scenario injection failed!") - sys.exit(1) + raise e # Node scenario to stop the node def node_stop_scenario(self, instance_kill_count, node, timeout): @@ -210,36 +246,48 @@ class alibaba_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_stop_scenario injection") vm_id = self.alibaba.get_instance_id(node) - logging.info("Stopping the node %s with instance ID: %s " % (node, vm_id)) + logging.info( + "Stopping the node %s with instance ID: %s " % (node, vm_id) + ) self.alibaba.stop_instances(vm_id) self.alibaba.wait_until_stopped(vm_id, timeout) logging.info("Node with instance ID: %s is in stopped state" % vm_id) nodeaction.wait_for_unknown_status(node, timeout, self.kubecli) except Exception as e: - logging.error("Failed to stop node instance. Encountered following exception: %s. " "Test Failed" % e) + logging.error( + "Failed to stop node instance. Encountered following exception: %s. " + "Test Failed" % e + ) logging.error("node_stop_scenario injection failed!") - sys.exit(1) + raise e # Might need to stop and then release the instance # Node scenario to terminate the node def node_termination_scenario(self, instance_kill_count, node, timeout): for _ in range(instance_kill_count): try: - logging.info("Starting node_termination_scenario injection by first stopping instance") + logging.info( + "Starting node_termination_scenario injection by first stopping instance" + ) vm_id = self.alibaba.get_instance_id(node) self.alibaba.stop_instances(vm_id) self.alibaba.wait_until_stopped(vm_id, timeout) - logging.info("Releasing the node %s with instance ID: %s " % (node, vm_id)) + logging.info( + "Releasing the node %s with instance ID: %s " % (node, vm_id) + ) self.alibaba.release_instance(vm_id) self.alibaba.wait_until_released(vm_id, timeout) logging.info("Node with instance ID: %s has been released" % node) - logging.info("node_termination_scenario has been successfully injected!") + logging.info( + "node_termination_scenario has been successfully injected!" + ) except Exception as e: logging.error( - "Failed to release node instance. Encountered following exception:" " %s. Test Failed" % (e) + "Failed to release node instance. Encountered following exception:" + " %s. Test Failed" % (e) ) logging.error("node_termination_scenario injection failed!") - sys.exit(1) + raise e # Node scenario to reboot the node def node_reboot_scenario(self, instance_kill_count, node, timeout): @@ -251,11 +299,14 @@ class alibaba_node_scenarios(abstract_node_scenarios): self.alibaba.reboot_instances(instance_id) nodeaction.wait_for_unknown_status(node, timeout, self.kubecli) nodeaction.wait_for_ready_status(node, timeout, self.kubecli) - logging.info("Node with instance ID: %s has been rebooted" % (instance_id)) + logging.info( + "Node with instance ID: %s has been rebooted" % (instance_id) + ) logging.info("node_reboot_scenario has been successfully injected!") except Exception as e: logging.error( - "Failed to reboot node instance. Encountered following exception:" " %s. Test Failed" % (e) + "Failed to reboot node instance. Encountered following exception:" + " %s. Test Failed" % (e) ) logging.error("node_reboot_scenario injection failed!") - sys.exit(1) + raise e diff --git a/kraken/node_actions/aws_node_scenarios.py b/krkn/scenario_plugins/node_actions/aws_node_scenarios.py similarity index 77% rename from kraken/node_actions/aws_node_scenarios.py rename to krkn/scenario_plugins/node_actions/aws_node_scenarios.py index 6894e620..c715a3e8 100644 --- a/kraken/node_actions/aws_node_scenarios.py +++ b/krkn/scenario_plugins/node_actions/aws_node_scenarios.py @@ -2,10 +2,13 @@ import sys import time import boto3 import logging -import kraken.node_actions.common_node_functions as nodeaction -from kraken.node_actions.abstract_node_scenarios import abstract_node_scenarios +import krkn.scenario_plugins.node_actions.common_node_functions as nodeaction +from krkn.scenario_plugins.node_actions.abstract_node_scenarios import ( + abstract_node_scenarios, +) from krkn_lib.k8s import KrknKubernetes + class AWS: def __init__(self): self.boto_client = boto3.client("ec2") @@ -28,10 +31,9 @@ class AWS: logging.info("EC2 instance: " + str(instance_id) + " started") except Exception as e: logging.error( - "Failed to start node instance %s. Encountered following " "exception: %s." % (instance_id, e) + "Failed to start node instance %s. Encountered following " + "exception: %s." % (instance_id, e) ) - # removed_exit - # sys.exit(1) raise RuntimeError() # Stop the node instance @@ -40,9 +42,10 @@ class AWS: self.boto_client.stop_instances(InstanceIds=[instance_id]) logging.info("EC2 instance: " + str(instance_id) + " stopped") except Exception as e: - logging.error("Failed to stop node instance %s. Encountered following " "exception: %s." % (instance_id, e)) - # removed_exit - # sys.exit(1) + logging.error( + "Failed to stop node instance %s. Encountered following " + "exception: %s." % (instance_id, e) + ) raise RuntimeError() # Terminate the node instance @@ -52,10 +55,9 @@ class AWS: logging.info("EC2 instance: " + str(instance_id) + " terminated") except Exception as e: logging.error( - "Failed to terminate node instance %s. Encountered following " "exception: %s." % (instance_id, e) + "Failed to terminate node instance %s. Encountered following " + "exception: %s." % (instance_id, e) ) - # removed_exit - # sys.exit(1) raise RuntimeError() # Reboot the node instance @@ -65,10 +67,9 @@ class AWS: logging.info("EC2 instance " + str(instance_id) + " rebooted") except Exception as e: logging.error( - "Failed to reboot node instance %s. Encountered following " "exception: %s." % (instance_id, e) + "Failed to reboot node instance %s. Encountered following " + "exception: %s." % (instance_id, e) ) - # removed_exit - # sys.exit(1) raise RuntimeError() # Below functions poll EC2.Client.describe_instances() every 15 seconds @@ -80,7 +81,10 @@ class AWS: self.boto_instance.wait_until_running(InstanceIds=[instance_id]) return True except Exception as e: - logging.error("Failed to get status waiting for %s to be running %s" % (instance_id, e)) + logging.error( + "Failed to get status waiting for %s to be running %s" + % (instance_id, e) + ) return False # Wait until the node instance is stopped @@ -89,7 +93,10 @@ class AWS: self.boto_instance.wait_until_stopped(InstanceIds=[instance_id]) return True except Exception as e: - logging.error("Failed to get status waiting for %s to be stopped %s" % (instance_id, e)) + logging.error( + "Failed to get status waiting for %s to be stopped %s" + % (instance_id, e) + ) return False # Wait until the node instance is terminated @@ -98,7 +105,10 @@ class AWS: self.boto_instance.wait_until_terminated(InstanceIds=[instance_id]) return True except Exception as e: - logging.error("Failed to get status waiting for %s to be terminated %s" % (instance_id, e)) + logging.error( + "Failed to get status waiting for %s to be terminated %s" + % (instance_id, e) + ) return False # Creates a deny network acl and returns the id @@ -111,10 +121,10 @@ class AWS: except Exception as e: logging.error( "Failed to create the default network_acl: %s" - "Make sure you have aws cli configured on the host and set for the region of your vpc/subnet" % (e) + "Make sure you have aws cli configured on the host and set for the region of your vpc/subnet" + % (e) ) - # removed_exit - # sys.exit(1) + raise RuntimeError() return acl_id @@ -122,13 +132,14 @@ class AWS: def replace_network_acl_association(self, association_id, acl_id): try: logging.info("Replacing the network acl associated with the subnet") - status = self.boto_client.replace_network_acl_association(AssociationId=association_id, NetworkAclId=acl_id) + status = self.boto_client.replace_network_acl_association( + AssociationId=association_id, NetworkAclId=acl_id + ) logging.info(status) new_association_id = status["NewAssociationId"] except Exception as e: logging.error("Failed to replace network acl association: %s" % (e)) - # removed_exit - # sys.exit(1) + raise RuntimeError() return new_association_id @@ -144,10 +155,10 @@ class AWS: except Exception as e: logging.error( "Failed to describe network acl: %s." - "Make sure you have aws cli configured on the host and set for the region of your vpc/subnet" % (e) + "Make sure you have aws cli configured on the host and set for the region of your vpc/subnet" + % (e) ) - # removed_exit - # sys.exit(1) + raise RuntimeError() associations = response["NetworkAcls"][0]["Associations"] # grab the current network_acl in use @@ -165,10 +176,10 @@ class AWS: "Make sure you have aws cli configured on the host and set for the region of your vpc/subnet" % (acl_id, e) ) - # removed_exit - # sys.exit(1) + raise RuntimeError() + # krkn_lib class aws_node_scenarios(abstract_node_scenarios): def __init__(self, kubecli: KrknKubernetes): @@ -181,19 +192,23 @@ class aws_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_start_scenario injection") instance_id = self.aws.get_instance_id(node) - logging.info("Starting the node %s with instance ID: %s " % (node, instance_id)) + logging.info( + "Starting the node %s with instance ID: %s " % (node, instance_id) + ) self.aws.start_instances(instance_id) self.aws.wait_until_running(instance_id) nodeaction.wait_for_ready_status(node, timeout, self.kubecli) - logging.info("Node with instance ID: %s is in running state" % (instance_id)) + logging.info( + "Node with instance ID: %s is in running state" % (instance_id) + ) logging.info("node_start_scenario has been successfully injected!") except Exception as e: logging.error( - "Failed to start node instance. Encountered following " "exception: %s. Test Failed" % (e) + "Failed to start node instance. Encountered following " + "exception: %s. Test Failed" % (e) ) logging.error("node_start_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() # Node scenario to stop the node @@ -202,16 +217,22 @@ class aws_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_stop_scenario injection") instance_id = self.aws.get_instance_id(node) - logging.info("Stopping the node %s with instance ID: %s " % (node, instance_id)) + logging.info( + "Stopping the node %s with instance ID: %s " % (node, instance_id) + ) self.aws.stop_instances(instance_id) self.aws.wait_until_stopped(instance_id) - logging.info("Node with instance ID: %s is in stopped state" % (instance_id)) + logging.info( + "Node with instance ID: %s is in stopped state" % (instance_id) + ) nodeaction.wait_for_unknown_status(node, timeout, self.kubecli) except Exception as e: - logging.error("Failed to stop node instance. Encountered following exception: %s. " "Test Failed" % (e)) + logging.error( + "Failed to stop node instance. Encountered following exception: %s. " + "Test Failed" % (e) + ) logging.error("node_stop_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() # Node scenario to terminate the node @@ -220,7 +241,10 @@ class aws_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_termination_scenario injection") instance_id = self.aws.get_instance_id(node) - logging.info("Terminating the node %s with instance ID: %s " % (node, instance_id)) + logging.info( + "Terminating the node %s with instance ID: %s " + % (node, instance_id) + ) self.aws.terminate_instances(instance_id) self.aws.wait_until_terminated(instance_id) for _ in range(timeout): @@ -229,15 +253,17 @@ class aws_node_scenarios(abstract_node_scenarios): time.sleep(1) if node in self.kubecli.list_nodes(): raise Exception("Node could not be terminated") - logging.info("Node with instance ID: %s has been terminated" % (instance_id)) + logging.info( + "Node with instance ID: %s has been terminated" % (instance_id) + ) logging.info("node_termination_scenario has been successfuly injected!") except Exception as e: logging.error( - "Failed to terminate node instance. Encountered following exception:" " %s. Test Failed" % (e) + "Failed to terminate node instance. Encountered following exception:" + " %s. Test Failed" % (e) ) logging.error("node_termination_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() # Node scenario to reboot the node @@ -246,17 +272,21 @@ class aws_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_reboot_scenario injection" + str(node)) instance_id = self.aws.get_instance_id(node) - logging.info("Rebooting the node %s with instance ID: %s " % (node, instance_id)) + logging.info( + "Rebooting the node %s with instance ID: %s " % (node, instance_id) + ) self.aws.reboot_instances(instance_id) nodeaction.wait_for_unknown_status(node, timeout, self.kubecli) nodeaction.wait_for_ready_status(node, timeout, self.kubecli) - logging.info("Node with instance ID: %s has been rebooted" % (instance_id)) + logging.info( + "Node with instance ID: %s has been rebooted" % (instance_id) + ) logging.info("node_reboot_scenario has been successfuly injected!") except Exception as e: logging.error( - "Failed to reboot node instance. Encountered following exception:" " %s. Test Failed" % (e) + "Failed to reboot node instance. Encountered following exception:" + " %s. Test Failed" % (e) ) logging.error("node_reboot_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() diff --git a/kraken/node_actions/az_node_scenarios.py b/krkn/scenario_plugins/node_actions/az_node_scenarios.py similarity index 80% rename from kraken/node_actions/az_node_scenarios.py rename to krkn/scenario_plugins/node_actions/az_node_scenarios.py index 43e973af..6cad8c12 100644 --- a/kraken/node_actions/az_node_scenarios.py +++ b/krkn/scenario_plugins/node_actions/az_node_scenarios.py @@ -1,16 +1,15 @@ - import time import os -import kraken.invoke.command as runcommand import logging -import kraken.node_actions.common_node_functions as nodeaction -from kraken.node_actions.abstract_node_scenarios import abstract_node_scenarios +import krkn.scenario_plugins.node_actions.common_node_functions as nodeaction +from krkn.scenario_plugins.node_actions.abstract_node_scenarios import ( + abstract_node_scenarios, +) from azure.mgmt.compute import ComputeManagementClient from azure.identity import DefaultAzureCredential from krkn_lib.k8s import KrknKubernetes - class Azure: def __init__(self): logging.info("azure " + str(self)) @@ -39,9 +38,10 @@ class Azure: self.compute_client.virtual_machines.begin_start(group_name, vm_name) logging.info("vm name " + str(vm_name) + " started") except Exception as e: - logging.error("Failed to start node instance %s. Encountered following " "exception: %s." % (vm_name, e)) - # removed_exit - # sys.exit(1) + logging.error( + "Failed to start node instance %s. Encountered following " + "exception: %s." % (vm_name, e) + ) raise RuntimeError() # Stop the node instance @@ -50,9 +50,10 @@ class Azure: self.compute_client.virtual_machines.begin_power_off(group_name, vm_name) logging.info("vm name " + str(vm_name) + " stopped") except Exception as e: - logging.error("Failed to stop node instance %s. Encountered following " "exception: %s." % (vm_name, e)) - # removed_exit - # sys.exit(1) + logging.error( + "Failed to stop node instance %s. Encountered following " + "exception: %s." % (vm_name, e) + ) raise RuntimeError() # Terminate the node instance @@ -62,10 +63,10 @@ class Azure: logging.info("vm name " + str(vm_name) + " terminated") except Exception as e: logging.error( - "Failed to terminate node instance %s. Encountered following " "exception: %s." % (vm_name, e) + "Failed to terminate node instance %s. Encountered following " + "exception: %s." % (vm_name, e) ) - # removed_exit - # sys.exit(1) + raise RuntimeError() # Reboot the node instance @@ -74,13 +75,17 @@ class Azure: self.compute_client.virtual_machines.begin_restart(group_name, vm_name) logging.info("vm name " + str(vm_name) + " rebooted") except Exception as e: - logging.error("Failed to reboot node instance %s. Encountered following " "exception: %s." % (vm_name, e)) - # removed_exit - # sys.exit(1) + logging.error( + "Failed to reboot node instance %s. Encountered following " + "exception: %s." % (vm_name, e) + ) + raise RuntimeError() def get_vm_status(self, resource_group, vm_name): - statuses = self.compute_client.virtual_machines.instance_view(resource_group, vm_name).statuses + statuses = self.compute_client.virtual_machines.instance_view( + resource_group, vm_name + ).statuses status = len(statuses) >= 2 and statuses[1] return status @@ -114,12 +119,16 @@ class Azure: # Wait until the node instance is terminated def wait_until_terminated(self, resource_group, vm_name, timeout): - statuses = self.compute_client.virtual_machines.instance_view(resource_group, vm_name).statuses[0] + statuses = self.compute_client.virtual_machines.instance_view( + resource_group, vm_name + ).statuses[0] logging.info("vm status " + str(statuses)) time_counter = 0 while statuses.code == "ProvisioningState/deleting": try: - statuses = self.compute_client.virtual_machines.instance_view(resource_group, vm_name).statuses[0] + statuses = self.compute_client.virtual_machines.instance_view( + resource_group, vm_name + ).statuses[0] logging.info("Vm %s is still deleting, waiting 10 seconds" % vm_name) time.sleep(10) time_counter += 10 @@ -130,6 +139,7 @@ class Azure: logging.info("Vm %s is terminated" % vm_name) return True + # krkn_lib class azure_node_scenarios(abstract_node_scenarios): def __init__(self, kubecli: KrknKubernetes): @@ -143,19 +153,22 @@ class azure_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_start_scenario injection") vm_name, resource_group = self.azure.get_instance_id(node) - logging.info("Starting the node %s with instance ID: %s " % (vm_name, resource_group)) + logging.info( + "Starting the node %s with instance ID: %s " + % (vm_name, resource_group) + ) self.azure.start_instances(resource_group, vm_name) self.azure.wait_until_running(resource_group, vm_name, timeout) - nodeaction.wait_for_ready_status(vm_name, timeout,self.kubecli) + nodeaction.wait_for_ready_status(vm_name, timeout, self.kubecli) logging.info("Node with instance ID: %s is in running state" % node) logging.info("node_start_scenario has been successfully injected!") except Exception as e: logging.error( - "Failed to start node instance. Encountered following " "exception: %s. Test Failed" % (e) + "Failed to start node instance. Encountered following " + "exception: %s. Test Failed" % (e) ) logging.error("node_start_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() # Node scenario to stop the node @@ -164,16 +177,21 @@ class azure_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_stop_scenario injection") vm_name, resource_group = self.azure.get_instance_id(node) - logging.info("Stopping the node %s with instance ID: %s " % (vm_name, resource_group)) + logging.info( + "Stopping the node %s with instance ID: %s " + % (vm_name, resource_group) + ) self.azure.stop_instances(resource_group, vm_name) self.azure.wait_until_stopped(resource_group, vm_name, timeout) logging.info("Node with instance ID: %s is in stopped state" % vm_name) nodeaction.wait_for_unknown_status(vm_name, timeout, self.kubecli) except Exception as e: - logging.error("Failed to stop node instance. Encountered following exception: %s. " "Test Failed" % e) + logging.error( + "Failed to stop node instance. Encountered following exception: %s. " + "Test Failed" % e + ) logging.error("node_stop_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() # Node scenario to terminate the node @@ -182,7 +200,10 @@ class azure_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_termination_scenario injection") vm_name, resource_group = self.azure.get_instance_id(node) - logging.info("Terminating the node %s with instance ID: %s " % (vm_name, resource_group)) + logging.info( + "Terminating the node %s with instance ID: %s " + % (vm_name, resource_group) + ) self.azure.terminate_instances(resource_group, vm_name) self.azure.wait_until_terminated(resource_group, vm_name, timeout) for _ in range(timeout): @@ -192,14 +213,16 @@ class azure_node_scenarios(abstract_node_scenarios): if vm_name in self.kubecli.list_nodes(): raise Exception("Node could not be terminated") logging.info("Node with instance ID: %s has been terminated" % node) - logging.info("node_termination_scenario has been successfully injected!") + logging.info( + "node_termination_scenario has been successfully injected!" + ) except Exception as e: logging.error( - "Failed to terminate node instance. Encountered following exception:" " %s. Test Failed" % (e) + "Failed to terminate node instance. Encountered following exception:" + " %s. Test Failed" % (e) ) logging.error("node_termination_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() # Node scenario to reboot the node @@ -208,7 +231,10 @@ class azure_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_reboot_scenario injection") vm_name, resource_group = self.azure.get_instance_id(node) - logging.info("Rebooting the node %s with instance ID: %s " % (vm_name, resource_group)) + logging.info( + "Rebooting the node %s with instance ID: %s " + % (vm_name, resource_group) + ) self.azure.reboot_instances(resource_group, vm_name) nodeaction.wait_for_unknown_status(vm_name, timeout, self.kubecli) nodeaction.wait_for_ready_status(vm_name, timeout, self.kubecli) @@ -216,9 +242,9 @@ class azure_node_scenarios(abstract_node_scenarios): logging.info("node_reboot_scenario has been successfully injected!") except Exception as e: logging.error( - "Failed to reboot node instance. Encountered following exception:" " %s. Test Failed" % (e) + "Failed to reboot node instance. Encountered following exception:" + " %s. Test Failed" % (e) ) logging.error("node_reboot_scenario injection failed!") - # removed_exit - # sys.exit(1) - raise RuntimeError() \ No newline at end of file + + raise RuntimeError() diff --git a/kraken/node_actions/bm_node_scenarios.py b/krkn/scenario_plugins/node_actions/bm_node_scenarios.py similarity index 79% rename from kraken/node_actions/bm_node_scenarios.py rename to krkn/scenario_plugins/node_actions/bm_node_scenarios.py index 6904c0e6..27f7d35b 100644 --- a/kraken/node_actions/bm_node_scenarios.py +++ b/krkn/scenario_plugins/node_actions/bm_node_scenarios.py @@ -1,14 +1,16 @@ -import kraken.node_actions.common_node_functions as nodeaction -from kraken.node_actions.abstract_node_scenarios import abstract_node_scenarios +import krkn.scenario_plugins.node_actions.common_node_functions as nodeaction +from krkn.scenario_plugins.node_actions.abstract_node_scenarios import ( + abstract_node_scenarios, +) import logging import openshift as oc import pyipmi import pyipmi.interfaces -import sys import time import traceback from krkn_lib.k8s import KrknKubernetes + class BM: def __init__(self, bm_info, user, passwd): self.user = user @@ -22,7 +24,11 @@ class BM: # Get the ipmi or other BMC address of the baremetal node def get_bmc_addr(self, node_name): # Addresses in the config get higher priority. - if self.bm_info is not None and node_name in self.bm_info and "bmc_addr" in self.bm_info[node_name]: + if ( + self.bm_info is not None + and node_name in self.bm_info + and "bmc_addr" in self.bm_info[node_name] + ): return self.bm_info[node_name]["bmc_addr"] # Get the bmc addr from the BareMetalHost object. @@ -40,7 +46,10 @@ class BM: 'BMC addr empty for node "%s". Either fix the BMH object,' " or specify the address in the scenario config" % node_name ) - sys.exit(1) + raise RuntimeError( + 'BMC addr empty for node "%s". Either fix the BMH object,' + " or specify the address in the scenario config" % node_name + ) return bmh_object.model.spec.bmc.address def get_ipmi_connection(self, bmc_addr, node_name): @@ -69,10 +78,15 @@ class BM: "Missing IPMI BMI user and/or password for baremetal cloud. " "Please specify either a global or per-machine user and pass" ) - sys.exit(1) + raise RuntimeError( + "Missing IPMI BMI user and/or password for baremetal cloud. " + "Please specify either a global or per-machine user and pass" + ) # Establish connection - interface = pyipmi.interfaces.create_interface("ipmitool", interface_type="lanplus") + interface = pyipmi.interfaces.create_interface( + "ipmitool", interface_type="lanplus" + ) connection = pyipmi.create_connection(interface) @@ -96,14 +110,21 @@ class BM: # Wait until the node instance is running def wait_until_running(self, bmc_addr, node_name): - while not self.get_ipmi_connection(bmc_addr, node_name).get_chassis_status().power_on: + while ( + not self.get_ipmi_connection(bmc_addr, node_name) + .get_chassis_status() + .power_on + ): time.sleep(1) # Wait until the node instance is stopped def wait_until_stopped(self, bmc_addr, node_name): - while self.get_ipmi_connection(bmc_addr, node_name).get_chassis_status().power_on: + while ( + self.get_ipmi_connection(bmc_addr, node_name).get_chassis_status().power_on + ): time.sleep(1) + # krkn_lib class bm_node_scenarios(abstract_node_scenarios): def __init__(self, bm_info, user, passwd, kubecli: KrknKubernetes): @@ -116,11 +137,15 @@ class bm_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_start_scenario injection") bmc_addr = self.bm.get_bmc_addr(node) - logging.info("Starting the node %s with bmc address: %s " % (node, bmc_addr)) + logging.info( + "Starting the node %s with bmc address: %s " % (node, bmc_addr) + ) self.bm.start_instances(bmc_addr, node) self.bm.wait_until_running(bmc_addr, node) nodeaction.wait_for_ready_status(node, timeout, self.kubecli) - logging.info("Node with bmc address: %s is in running state" % (bmc_addr)) + logging.info( + "Node with bmc address: %s is in running state" % (bmc_addr) + ) logging.info("node_start_scenario has been successfully injected!") except Exception as e: logging.error( @@ -129,7 +154,7 @@ class bm_node_scenarios(abstract_node_scenarios): "an incorrect ipmi address or login" % (e) ) logging.error("node_start_scenario injection failed!") - sys.exit(1) + raise e # Node scenario to stop the node def node_stop_scenario(self, instance_kill_count, node, timeout): @@ -137,10 +162,14 @@ class bm_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_stop_scenario injection") bmc_addr = self.bm.get_bmc_addr(node) - logging.info("Stopping the node %s with bmc address: %s " % (node, bmc_addr)) + logging.info( + "Stopping the node %s with bmc address: %s " % (node, bmc_addr) + ) self.bm.stop_instances(bmc_addr, node) self.bm.wait_until_stopped(bmc_addr, node) - logging.info("Node with bmc address: %s is in stopped state" % (bmc_addr)) + logging.info( + "Node with bmc address: %s is in stopped state" % (bmc_addr) + ) nodeaction.wait_for_unknown_status(node, timeout, self.kubecli) except Exception as e: logging.error( @@ -149,7 +178,7 @@ class bm_node_scenarios(abstract_node_scenarios): "an incorrect ipmi address or login" % (e) ) logging.error("node_stop_scenario injection failed!") - sys.exit(1) + raise e # Node scenario to terminate the node def node_termination_scenario(self, instance_kill_count, node, timeout): @@ -162,7 +191,9 @@ class bm_node_scenarios(abstract_node_scenarios): logging.info("Starting node_reboot_scenario injection") bmc_addr = self.bm.get_bmc_addr(node) logging.info("BMC Addr: %s" % (bmc_addr)) - logging.info("Rebooting the node %s with bmc address: %s " % (node, bmc_addr)) + logging.info( + "Rebooting the node %s with bmc address: %s " % (node, bmc_addr) + ) self.bm.reboot_instances(bmc_addr, node) nodeaction.wait_for_unknown_status(node, timeout, self.kubecli) nodeaction.wait_for_ready_status(node, timeout, self.kubecli) @@ -176,4 +207,4 @@ class bm_node_scenarios(abstract_node_scenarios): ) traceback.print_exc() logging.error("node_reboot_scenario injection failed!") - sys.exit(1) + raise e diff --git a/kraken/node_actions/common_node_functions.py b/krkn/scenario_plugins/node_actions/common_node_functions.py similarity index 76% rename from kraken/node_actions/common_node_functions.py rename to krkn/scenario_plugins/node_actions/common_node_functions.py index 39827854..f4e47ae1 100644 --- a/kraken/node_actions/common_node_functions.py +++ b/krkn/scenario_plugins/node_actions/common_node_functions.py @@ -2,8 +2,9 @@ import time import random import logging import paramiko -import kraken.invoke.command as runcommand +import krkn.invoke.command as runcommand from krkn_lib.k8s import KrknKubernetes + node_general = False @@ -12,7 +13,10 @@ def get_node(node_name, label_selector, instance_kill_count, kubecli: KrknKubern if node_name in kubecli.list_killable_nodes(): return [node_name] elif node_name: - logging.info("Node with provided node_name does not exist or the node might " "be in NotReady state.") + logging.info( + "Node with provided node_name does not exist or the node might " + "be in NotReady state." + ) nodes = kubecli.list_killable_nodes(label_selector) if not nodes: raise Exception("Ready nodes with the provided label selector do not exist") @@ -34,12 +38,14 @@ def wait_for_ready_status(node, timeout, kubecli: KrknKubernetes): resource_version = kubecli.get_node_resource_version(node) kubecli.watch_node_status(node, "True", timeout, resource_version) + # krkn_lib # Wait until the node status becomes Not Ready def wait_for_not_ready_status(node, timeout, kubecli: KrknKubernetes): resource_version = kubecli.get_node_resource_version(node) kubecli.watch_node_status(node, "False", timeout, resource_version) + # krkn_lib # Wait until the node status becomes Unknown def wait_for_unknown_status(node, timeout, kubecli: KrknKubernetes): @@ -50,7 +56,8 @@ def wait_for_unknown_status(node, timeout, kubecli: KrknKubernetes): # Get the ip of the cluster node def get_node_ip(node): return runcommand.invoke( - "kubectl get node %s -o " "jsonpath='{.status.addresses[?(@.type==\"InternalIP\")].address}'" % (node) + "kubectl get node %s -o " + "jsonpath='{.status.addresses[?(@.type==\"InternalIP\")].address}'" % (node) ) @@ -74,15 +81,23 @@ def check_service_status(node, service, ssh_private_key, timeout): if connection is None: break except Exception as e: - logging.error("Failed to ssh to instance: %s within the timeout duration of %s: %s" % (node, timeout, e)) + logging.error( + "Failed to ssh to instance: %s within the timeout duration of %s: %s" + % (node, timeout, e) + ) for service_name in service: logging.info("Checking status of Service: %s" % (service_name)) stdin, stdout, stderr = ssh.exec_command( - "systemctl status %s | grep '^ Active' " "| awk '{print $2}'" % (service_name) + "systemctl status %s | grep '^ Active' " + "| awk '{print $2}'" % (service_name) ) service_status = stdout.readlines()[0] - logging.info("Status of service %s is %s \n" % (service_name, service_status.strip())) + logging.info( + "Status of service %s is %s \n" % (service_name, service_status.strip()) + ) if service_status.strip() != "active": - logging.error("Service %s is in %s state" % (service_name, service_status.strip())) + logging.error( + "Service %s is in %s state" % (service_name, service_status.strip()) + ) ssh.close() diff --git a/kraken/node_actions/docker_node_scenarios.py b/krkn/scenario_plugins/node_actions/docker_node_scenarios.py similarity index 67% rename from kraken/node_actions/docker_node_scenarios.py rename to krkn/scenario_plugins/node_actions/docker_node_scenarios.py index e77cce80..a2cdf116 100644 --- a/kraken/node_actions/docker_node_scenarios.py +++ b/krkn/scenario_plugins/node_actions/docker_node_scenarios.py @@ -1,17 +1,19 @@ -import kraken.node_actions.common_node_functions as nodeaction -from kraken.node_actions.abstract_node_scenarios import abstract_node_scenarios +import krkn.scenario_plugins.node_actions.common_node_functions as nodeaction +from krkn.scenario_plugins.node_actions.abstract_node_scenarios import ( + abstract_node_scenarios, +) import logging -import sys import docker from krkn_lib.k8s import KrknKubernetes + class Docker: def __init__(self): self.client = docker.from_env() - def get_container_id(self, node_name): + def get_container_id(self, node_name): container = self.client.containers.get(node_name) - return container.id + return container.id # Start the node instance def start_instances(self, node_name): @@ -27,7 +29,7 @@ class Docker: def reboot_instances(self, node_name): container = self.client.containers.get(node_name) container.restart() - + # Terminate the node instance def terminate_instances(self, node_name): container = self.client.containers.get(node_name) @@ -46,17 +48,22 @@ class docker_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_start_scenario injection") container_id = self.docker.get_container_id(node) - logging.info("Starting the node %s with container ID: %s " % (node, container_id)) + logging.info( + "Starting the node %s with container ID: %s " % (node, container_id) + ) self.docker.start_instances(node) nodeaction.wait_for_ready_status(node, timeout, self.kubecli) - logging.info("Node with container ID: %s is in running state" % (container_id)) + logging.info( + "Node with container ID: %s is in running state" % (container_id) + ) logging.info("node_start_scenario has been successfully injected!") except Exception as e: logging.error( - "Failed to start node instance. Encountered following " "exception: %s. Test Failed" % (e) + "Failed to start node instance. Encountered following " + "exception: %s. Test Failed" % (e) ) logging.error("node_start_scenario injection failed!") - sys.exit(1) + raise e # Node scenario to stop the node def node_stop_scenario(self, instance_kill_count, node, timeout): @@ -64,14 +71,21 @@ class docker_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_stop_scenario injection") container_id = self.docker.get_container_id(node) - logging.info("Stopping the node %s with container ID: %s " % (node, container_id)) + logging.info( + "Stopping the node %s with container ID: %s " % (node, container_id) + ) self.docker.stop_instances(node) - logging.info("Node with container ID: %s is in stopped state" % (container_id)) + logging.info( + "Node with container ID: %s is in stopped state" % (container_id) + ) nodeaction.wait_for_unknown_status(node, timeout, self.kubecli) except Exception as e: - logging.error("Failed to stop node instance. Encountered following exception: %s. " "Test Failed" % (e)) + logging.error( + "Failed to stop node instance. Encountered following exception: %s. " + "Test Failed" % (e) + ) logging.error("node_stop_scenario injection failed!") - sys.exit(1) + raise e # Node scenario to terminate the node def node_termination_scenario(self, instance_kill_count, node, timeout): @@ -79,16 +93,22 @@ class docker_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_termination_scenario injection") container_id = self.docker.get_container_id(node) - logging.info("Terminating the node %s with container ID: %s " % (node, container_id)) + logging.info( + "Terminating the node %s with container ID: %s " + % (node, container_id) + ) self.docker.terminate_instances(node) - logging.info("Node with container ID: %s has been terminated" % (container_id)) + logging.info( + "Node with container ID: %s has been terminated" % (container_id) + ) logging.info("node_termination_scenario has been successfuly injected!") except Exception as e: logging.error( - "Failed to terminate node instance. Encountered following exception:" " %s. Test Failed" % (e) + "Failed to terminate node instance. Encountered following exception:" + " %s. Test Failed" % (e) ) logging.error("node_termination_scenario injection failed!") - sys.exit(1) + raise e # Node scenario to reboot the node def node_reboot_scenario(self, instance_kill_count, node, timeout): @@ -96,15 +116,21 @@ class docker_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_reboot_scenario injection") container_id = self.docker.get_container_id(node) - logging.info("Rebooting the node %s with container ID: %s " % (node, container_id)) + logging.info( + "Rebooting the node %s with container ID: %s " + % (node, container_id) + ) self.docker.reboot_instances(node) nodeaction.wait_for_unknown_status(node, timeout, self.kubecli) nodeaction.wait_for_ready_status(node, timeout, self.kubecli) - logging.info("Node with container ID: %s has been rebooted" % (container_id)) + logging.info( + "Node with container ID: %s has been rebooted" % (container_id) + ) logging.info("node_reboot_scenario has been successfuly injected!") except Exception as e: logging.error( - "Failed to reboot node instance. Encountered following exception:" " %s. Test Failed" % (e) + "Failed to reboot node instance. Encountered following exception:" + " %s. Test Failed" % (e) ) logging.error("node_reboot_scenario injection failed!") - sys.exit(1) + raise e diff --git a/kraken/node_actions/gcp_node_scenarios.py b/krkn/scenario_plugins/node_actions/gcp_node_scenarios.py similarity index 66% rename from kraken/node_actions/gcp_node_scenarios.py rename to krkn/scenario_plugins/node_actions/gcp_node_scenarios.py index f2c7ece3..437a9181 100644 --- a/kraken/node_actions/gcp_node_scenarios.py +++ b/krkn/scenario_plugins/node_actions/gcp_node_scenarios.py @@ -3,28 +3,32 @@ import sys import time import logging import json -import kraken.node_actions.common_node_functions as nodeaction -from kraken.node_actions.abstract_node_scenarios import abstract_node_scenarios +import krkn.scenario_plugins.node_actions.common_node_functions as nodeaction +from krkn.scenario_plugins.node_actions.abstract_node_scenarios import ( + abstract_node_scenarios, +) from googleapiclient import discovery from oauth2client.client import GoogleCredentials -import kraken.invoke.command as runcommand from krkn_lib.k8s import KrknKubernetes + class GCP: def __init__(self): - try: + try: gapp_creds = os.getenv("GOOGLE_APPLICATION_CREDENTIALS") with open(gapp_creds, "r") as f: f_str = f.read() - self.project = json.loads(f_str)['project_id'] - #self.project = runcommand.invoke("gcloud config get-value project").split("/n")[0].strip() + self.project = json.loads(f_str)["project_id"] + # self.project = runcommand.invoke("gcloud config get-value project").split("/n")[0].strip() logging.info("project " + str(self.project) + "!") credentials = GoogleCredentials.get_application_default() - self.client = discovery.build("compute", "v1", credentials=credentials, cache_discovery=False) + self.client = discovery.build( + "compute", "v1", credentials=credentials, cache_discovery=False + ) - except Exception as e: + except Exception as e: logging.error("Error on setting up GCP connection: " + str(e)) - sys.exit(1) + raise e # Get the instance ID of the node def get_instance_id(self, node): @@ -32,7 +36,9 @@ class GCP: while zone_request is not None: zone_response = zone_request.execute() for zone in zone_response["items"]: - instances_request = self.client.instances().list(project=self.project, zone=zone["name"]) + instances_request = self.client.instances().list( + project=self.project, zone=zone["name"] + ) while instances_request is not None: instance_response = instances_request.execute() if "items" in instance_response.keys(): @@ -40,72 +46,87 @@ class GCP: if instance["name"] in node: return instance["name"], zone["name"] instances_request = self.client.zones().list_next( - previous_request=instances_request, previous_response=instance_response + previous_request=instances_request, + previous_response=instance_response, ) - zone_request = self.client.zones().list_next(previous_request=zone_request, previous_response=zone_response) + zone_request = self.client.zones().list_next( + previous_request=zone_request, previous_response=zone_response + ) logging.info("no instances ") # Start the node instance def start_instances(self, zone, instance_id): try: - self.client.instances().start(project=self.project, zone=zone, instance=instance_id).execute() + self.client.instances().start( + project=self.project, zone=zone, instance=instance_id + ).execute() logging.info("vm name " + str(instance_id) + " started") except Exception as e: logging.error( - "Failed to start node instance %s. Encountered following " "exception: %s." % (instance_id, e) + "Failed to start node instance %s. Encountered following " + "exception: %s." % (instance_id, e) ) - # removed_exit - # sys.exit(1) + raise RuntimeError() # Stop the node instance def stop_instances(self, zone, instance_id): try: - self.client.instances().stop(project=self.project, zone=zone, instance=instance_id).execute() + self.client.instances().stop( + project=self.project, zone=zone, instance=instance_id + ).execute() logging.info("vm name " + str(instance_id) + " stopped") except Exception as e: - logging.error("Failed to stop node instance %s. Encountered following " "exception: %s." % (instance_id, e)) - # removed_exit - # sys.exit(1) + logging.error( + "Failed to stop node instance %s. Encountered following " + "exception: %s." % (instance_id, e) + ) + raise RuntimeError() # Start the node instance def suspend_instances(self, zone, instance_id): try: - self.client.instances().suspend(project=self.project, zone=zone, instance=instance_id).execute() + self.client.instances().suspend( + project=self.project, zone=zone, instance=instance_id + ).execute() logging.info("vm name " + str(instance_id) + " suspended") except Exception as e: logging.error( - "Failed to suspend node instance %s. Encountered following " "exception: %s." % (instance_id, e) + "Failed to suspend node instance %s. Encountered following " + "exception: %s." % (instance_id, e) ) - # removed_exit - # sys.exit(1) + raise RuntimeError() # Terminate the node instance def terminate_instances(self, zone, instance_id): try: - self.client.instances().delete(project=self.project, zone=zone, instance=instance_id).execute() + self.client.instances().delete( + project=self.project, zone=zone, instance=instance_id + ).execute() logging.info("vm name " + str(instance_id) + " terminated") except Exception as e: logging.error( - "Failed to start node instance %s. Encountered following " "exception: %s." % (instance_id, e) + "Failed to start node instance %s. Encountered following " + "exception: %s." % (instance_id, e) ) - # removed_exit - # sys.exit(1) + raise RuntimeError() # Reboot the node instance def reboot_instances(self, zone, instance_id): try: - self.client.instances().reset(project=self.project, zone=zone, instance=instance_id).execute() + self.client.instances().reset( + project=self.project, zone=zone, instance=instance_id + ).execute() logging.info("vm name " + str(instance_id) + " rebooted") except Exception as e: logging.error( - "Failed to start node instance %s. Encountered following " "exception: %s." % (instance_id, e) + "Failed to start node instance %s. Encountered following " + "exception: %s." % (instance_id, e) ) - # removed_exit - # sys.exit(1) + raise RuntimeError() # Get instance status @@ -115,13 +136,20 @@ class GCP: i = 0 sleeper = 5 while i <= timeout: - instStatus = self.client.instances().get(project=self.project, zone=zone, instance=instance_id).execute() + instStatus = ( + self.client.instances() + .get(project=self.project, zone=zone, instance=instance_id) + .execute() + ) logging.info("Status of vm " + str(instStatus["status"])) if instStatus["status"] == expected_status: return True time.sleep(sleeper) i += sleeper - logging.error("Status of %s was not %s in %s seconds" % (instance_id, expected_status, timeout)) + logging.error( + "Status of %s was not %s in %s seconds" + % (instance_id, expected_status, timeout) + ) return False # Wait until the node instance is suspended @@ -143,7 +171,9 @@ class GCP: sleeper = 5 while i <= timeout: instStatus = ( - self.client.instances().get(project=self.project, zone=zone, instance=instance_id).execute() + self.client.instances() + .get(project=self.project, zone=zone, instance=instance_id) + .execute() ) logging.info("Status of vm " + str(instStatus["status"])) time.sleep(sleeper) @@ -164,19 +194,23 @@ class gcp_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_start_scenario injection") instance_id, zone = self.gcp.get_instance_id(node) - logging.info("Starting the node %s with instance ID: %s " % (node, instance_id)) + logging.info( + "Starting the node %s with instance ID: %s " % (node, instance_id) + ) self.gcp.start_instances(zone, instance_id) self.gcp.wait_until_running(zone, instance_id, timeout) nodeaction.wait_for_ready_status(node, timeout, self.kubecli) - logging.info("Node with instance ID: %s is in running state" % instance_id) + logging.info( + "Node with instance ID: %s is in running state" % instance_id + ) logging.info("node_start_scenario has been successfully injected!") except Exception as e: logging.error( - "Failed to start node instance. Encountered following " "exception: %s. Test Failed" % (e) + "Failed to start node instance. Encountered following " + "exception: %s. Test Failed" % (e) ) logging.error("node_start_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() # Node scenario to stop the node @@ -186,16 +220,22 @@ class gcp_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_stop_scenario injection") instance_id, zone = self.gcp.get_instance_id(node) - logging.info("Stopping the node %s with instance ID: %s " % (node, instance_id)) + logging.info( + "Stopping the node %s with instance ID: %s " % (node, instance_id) + ) self.gcp.stop_instances(zone, instance_id) self.gcp.wait_until_stopped(zone, instance_id, timeout) - logging.info("Node with instance ID: %s is in stopped state" % instance_id) + logging.info( + "Node with instance ID: %s is in stopped state" % instance_id + ) nodeaction.wait_for_unknown_status(node, timeout, self.kubecli) except Exception as e: - logging.error("Failed to stop node instance. Encountered following exception: %s. " "Test Failed" % (e)) + logging.error( + "Failed to stop node instance. Encountered following exception: %s. " + "Test Failed" % (e) + ) logging.error("node_stop_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() # Node scenario to terminate the node @@ -204,7 +244,10 @@ class gcp_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_termination_scenario injection") instance_id, zone = self.gcp.get_instance_id(node) - logging.info("Terminating the node %s with instance ID: %s " % (node, instance_id)) + logging.info( + "Terminating the node %s with instance ID: %s " + % (node, instance_id) + ) self.gcp.terminate_instances(zone, instance_id) self.gcp.wait_until_terminated(zone, instance_id, timeout) for _ in range(timeout): @@ -212,17 +255,20 @@ class gcp_node_scenarios(abstract_node_scenarios): break time.sleep(1) if node in self.kubecli.list_nodes(): - raise Exception("Node could not be terminated") - logging.info("Node with instance ID: %s has been terminated" % instance_id) + raise RuntimeError("Node could not be terminated") + logging.info( + "Node with instance ID: %s has been terminated" % instance_id + ) logging.info("node_termination_scenario has been successfuly injected!") except Exception as e: logging.error( - "Failed to terminate node instance. Encountered following exception:" " %s. Test Failed" % e + "Failed to terminate node instance. Encountered following exception:" + " %s. Test Failed" % e ) logging.error("node_termination_scenario injection failed!") - # removed_exit - # sys.exit(1) - raise RuntimeError() + + + raise e # Node scenario to reboot the node def node_reboot_scenario(self, instance_kill_count, node, timeout): @@ -230,16 +276,20 @@ class gcp_node_scenarios(abstract_node_scenarios): try: logging.info("Starting node_reboot_scenario injection") instance_id, zone = self.gcp.get_instance_id(node) - logging.info("Rebooting the node %s with instance ID: %s " % (node, instance_id)) + logging.info( + "Rebooting the node %s with instance ID: %s " % (node, instance_id) + ) self.gcp.reboot_instances(zone, instance_id) nodeaction.wait_for_ready_status(node, timeout, self.kubecli) - logging.info("Node with instance ID: %s has been rebooted" % instance_id) + logging.info( + "Node with instance ID: %s has been rebooted" % instance_id + ) logging.info("node_reboot_scenario has been successfuly injected!") except Exception as e: logging.error( - "Failed to reboot node instance. Encountered following exception:" " %s. Test Failed" % (e) + "Failed to reboot node instance. Encountered following exception:" + " %s. Test Failed" % (e) ) logging.error("node_reboot_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() diff --git a/kraken/node_actions/general_cloud_node_scenarios.py b/krkn/scenario_plugins/node_actions/general_cloud_node_scenarios.py similarity index 52% rename from kraken/node_actions/general_cloud_node_scenarios.py rename to krkn/scenario_plugins/node_actions/general_cloud_node_scenarios.py index 62419048..c0a7ac8b 100644 --- a/kraken/node_actions/general_cloud_node_scenarios.py +++ b/krkn/scenario_plugins/node_actions/general_cloud_node_scenarios.py @@ -1,11 +1,15 @@ import logging -from kraken.node_actions.abstract_node_scenarios import abstract_node_scenarios +from krkn.scenario_plugins.node_actions.abstract_node_scenarios import ( + abstract_node_scenarios, +) from krkn_lib.k8s import KrknKubernetes + class GENERAL: def __init__(self): pass + # krkn_lib class general_node_scenarios(abstract_node_scenarios): def __init__(self, kubecli: KrknKubernetes): @@ -14,16 +18,28 @@ class general_node_scenarios(abstract_node_scenarios): # Node scenario to start the node def node_start_scenario(self, instance_kill_count, node, timeout): - logging.info("Node start is not set up yet for this cloud type, " "no action is going to be taken") + logging.info( + "Node start is not set up yet for this cloud type, " + "no action is going to be taken" + ) # Node scenario to stop the node def node_stop_scenario(self, instance_kill_count, node, timeout): - logging.info("Node stop is not set up yet for this cloud type," " no action is going to be taken") + logging.info( + "Node stop is not set up yet for this cloud type," + " no action is going to be taken" + ) # Node scenario to terminate the node def node_termination_scenario(self, instance_kill_count, node, timeout): - logging.info("Node termination is not set up yet for this cloud type, " "no action is going to be taken") + logging.info( + "Node termination is not set up yet for this cloud type, " + "no action is going to be taken" + ) # Node scenario to reboot the node def node_reboot_scenario(self, instance_kill_count, node, timeout): - logging.info("Node reboot is not set up yet for this cloud type," " no action is going to be taken") + logging.info( + "Node reboot is not set up yet for this cloud type," + " no action is going to be taken" + ) diff --git a/krkn/scenario_plugins/node_actions/node_actions_scenario_plugin.py b/krkn/scenario_plugins/node_actions/node_actions_scenario_plugin.py new file mode 100644 index 00000000..c49afdaf --- /dev/null +++ b/krkn/scenario_plugins/node_actions/node_actions_scenario_plugin.py @@ -0,0 +1,219 @@ +import logging +import time + +import yaml +from krkn_lib.k8s import KrknKubernetes +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from krkn_lib.utils import get_yaml_item_value, log_exception + +from krkn import cerberus, utils +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin +from krkn.scenario_plugins.node_actions import common_node_functions +from krkn.scenario_plugins.node_actions.aws_node_scenarios import aws_node_scenarios +from krkn.scenario_plugins.node_actions.az_node_scenarios import azure_node_scenarios +from krkn.scenario_plugins.node_actions.docker_node_scenarios import ( + docker_node_scenarios, +) +from krkn.scenario_plugins.node_actions.gcp_node_scenarios import gcp_node_scenarios +from krkn.scenario_plugins.node_actions.general_cloud_node_scenarios import ( + general_node_scenarios, +) + +node_general = False + + +class NodeActionsScenarioPlugin(AbstractScenarioPlugin): + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + with open(scenario, "r") as f: + node_scenario_config = yaml.full_load(f) + for node_scenario in node_scenario_config["node_scenarios"]: + try: + node_scenario_object = self.get_node_scenario_object( + node_scenario, lib_telemetry.get_lib_kubernetes() + ) + if node_scenario["actions"]: + for action in node_scenario["actions"]: + start_time = int(time.time()) + self.inject_node_scenario( + action, + node_scenario, + node_scenario_object, + lib_telemetry.get_lib_kubernetes(), + ) + end_time = int(time.time()) + cerberus.get_status(krkn_config, start_time, end_time) + except (RuntimeError, Exception) as e: + logging.error("Node Actions exiting due to Exception %s" % e) + return 1 + else: + return 0 + + def get_node_scenario_object(self, node_scenario, kubecli: KrknKubernetes): + if ( + "cloud_type" not in node_scenario.keys() + or node_scenario["cloud_type"] == "generic" + ): + global node_general + node_general = True + return general_node_scenarios(kubecli) + if node_scenario["cloud_type"] == "aws": + return aws_node_scenarios(kubecli) + elif node_scenario["cloud_type"] == "gcp": + return gcp_node_scenarios(kubecli) + elif node_scenario["cloud_type"] == "openstack": + from krkn.scenario_plugins.node_actions.openstack_node_scenarios import ( + openstack_node_scenarios, + ) + + return openstack_node_scenarios(kubecli) + elif ( + node_scenario["cloud_type"] == "azure" + or node_scenario["cloud_type"] == "az" + ): + return azure_node_scenarios(kubecli) + elif ( + node_scenario["cloud_type"] == "alibaba" + or node_scenario["cloud_type"] == "alicloud" + ): + from krkn.scenario_plugins.node_actions.alibaba_node_scenarios import ( + alibaba_node_scenarios, + ) + + return alibaba_node_scenarios(kubecli) + elif node_scenario["cloud_type"] == "bm": + from krkn.scenario_plugins.node_actions.bm_node_scenarios import ( + bm_node_scenarios, + ) + + return bm_node_scenarios( + node_scenario.get("bmc_info"), + node_scenario.get("bmc_user", None), + node_scenario.get("bmc_password", None), + kubecli, + ) + elif node_scenario["cloud_type"] == "docker": + return docker_node_scenarios(kubecli) + else: + logging.error( + "Cloud type " + + node_scenario["cloud_type"] + + " is not currently supported; " + "try using 'generic' if wanting to stop/start kubelet or fork bomb on any " + "cluster" + ) + raise Exception( + "Cloud type " + + node_scenario["cloud_type"] + + " is not currently supported; " + "try using 'generic' if wanting to stop/start kubelet or fork bomb on any " + "cluster" + ) + + def inject_node_scenario( + self, action, node_scenario, node_scenario_object, kubecli: KrknKubernetes + ): + generic_cloud_scenarios = ("stop_kubelet_scenario", "node_crash_scenario") + # Get the node scenario configurations + run_kill_count = get_yaml_item_value(node_scenario, "runs", 1) + instance_kill_count = get_yaml_item_value(node_scenario, "instance_count", 1) + node_name = get_yaml_item_value(node_scenario, "node_name", "") + label_selector = get_yaml_item_value(node_scenario, "label_selector", "") + if action == "node_stop_start_scenario": + duration = get_yaml_item_value(node_scenario, "duration", 120) + timeout = get_yaml_item_value(node_scenario, "timeout", 120) + service = get_yaml_item_value(node_scenario, "service", "") + ssh_private_key = get_yaml_item_value( + node_scenario, "ssh_private_key", "~/.ssh/id_rsa" + ) + # Get the node to apply the scenario + if node_name: + node_name_list = node_name.split(",") + else: + node_name_list = [node_name] + for single_node_name in node_name_list: + nodes = common_node_functions.get_node( + single_node_name, label_selector, instance_kill_count, kubecli + ) + for single_node in nodes: + if node_general and action not in generic_cloud_scenarios: + logging.info( + "Scenario: " + + action + + " is not set up for generic cloud type, skipping action" + ) + else: + if action == "node_start_scenario": + node_scenario_object.node_start_scenario( + run_kill_count, single_node, timeout + ) + elif action == "node_stop_scenario": + node_scenario_object.node_stop_scenario( + run_kill_count, single_node, timeout + ) + elif action == "node_stop_start_scenario": + node_scenario_object.node_stop_start_scenario( + run_kill_count, single_node, timeout, duration + ) + elif action == "node_termination_scenario": + node_scenario_object.node_termination_scenario( + run_kill_count, single_node, timeout + ) + elif action == "node_reboot_scenario": + node_scenario_object.node_reboot_scenario( + run_kill_count, single_node, timeout + ) + elif action == "stop_start_kubelet_scenario": + node_scenario_object.stop_start_kubelet_scenario( + run_kill_count, single_node, timeout + ) + elif action == "restart_kubelet_scenario": + node_scenario_object.restart_kubelet_scenario( + run_kill_count, single_node, timeout + ) + elif action == "stop_kubelet_scenario": + node_scenario_object.stop_kubelet_scenario( + run_kill_count, single_node, timeout + ) + elif action == "node_crash_scenario": + node_scenario_object.node_crash_scenario( + run_kill_count, single_node, timeout + ) + elif action == "stop_start_helper_node_scenario": + if node_scenario["cloud_type"] != "openstack": + logging.error( + "Scenario: " + action + " is not supported for " + "cloud type " + + node_scenario["cloud_type"] + + ", skipping action" + ) + else: + if not node_scenario["helper_node_ip"]: + logging.error("Helper node IP address is not provided") + raise Exception( + "Helper node IP address is not provided" + ) + node_scenario_object.helper_node_stop_start_scenario( + run_kill_count, node_scenario["helper_node_ip"], timeout + ) + node_scenario_object.helper_node_service_status( + node_scenario["helper_node_ip"], + service, + ssh_private_key, + timeout, + ) + else: + logging.info( + "There is no node action that matches %s, skipping scenario" + % action + ) + + def get_scenario_types(self) -> list[str]: + return ["node_scenarios"] diff --git a/kraken/node_actions/openstack_node_scenarios.py b/krkn/scenario_plugins/node_actions/openstack_node_scenarios.py similarity index 79% rename from kraken/node_actions/openstack_node_scenarios.py rename to krkn/scenario_plugins/node_actions/openstack_node_scenarios.py index b4a33489..f7ce8563 100644 --- a/kraken/node_actions/openstack_node_scenarios.py +++ b/krkn/scenario_plugins/node_actions/openstack_node_scenarios.py @@ -1,11 +1,14 @@ import sys import time import logging -import kraken.invoke.command as runcommand -import kraken.node_actions.common_node_functions as nodeaction -from kraken.node_actions.abstract_node_scenarios import abstract_node_scenarios +import krkn.invoke.command as runcommand +import krkn.scenario_plugins.node_actions.common_node_functions as nodeaction +from krkn.scenario_plugins.node_actions.abstract_node_scenarios import ( + abstract_node_scenarios, +) from krkn_lib.k8s import KrknKubernetes + class OPENSTACKCLOUD: def __init__(self): self.Wait = 30 @@ -22,9 +25,10 @@ class OPENSTACKCLOUD: runcommand.invoke("openstack server start %s" % (node)) logging.info("Instance: " + str(node) + " started") except Exception as e: - logging.error("Failed to start node instance %s. Encountered following " "exception: %s." % (node, e)) - # removed_exit - # sys.exit(1) + logging.error( + "Failed to start node instance %s. Encountered following " + "exception: %s." % (node, e) + ) raise RuntimeError() # Stop the node instance @@ -33,9 +37,10 @@ class OPENSTACKCLOUD: runcommand.invoke("openstack server stop %s" % (node)) logging.info("Instance: " + str(node) + " stopped") except Exception as e: - logging.error("Failed to stop node instance %s. Encountered following " "exception: %s." % (node, e)) - # removed_exit - # sys.exit(1) + logging.error( + "Failed to stop node instance %s. Encountered following " + "exception: %s." % (node, e) + ) raise RuntimeError() # Reboot the node instance @@ -44,9 +49,10 @@ class OPENSTACKCLOUD: runcommand.invoke("openstack server reboot --soft %s" % (node)) logging.info("Instance: " + str(node) + " rebooted") except Exception as e: - logging.error("Failed to reboot node instance %s. Encountered following " "exception: %s." % (node, e)) - # removed_exit - # sys.exit(1) + logging.error( + "Failed to reboot node instance %s. Encountered following " + "exception: %s." % (node, e) + ) raise RuntimeError() # Wait until the node instance is running @@ -63,12 +69,16 @@ class OPENSTACKCLOUD: sleeper = 1 while i <= timeout: instStatus = runcommand.invoke( - "openstack server show %s | tr -d ' ' |" "grep '^|status' |" "cut -d '|' -f3 | tr -d '\n'" % (node) + "openstack server show %s | tr -d ' ' |" + "grep '^|status' |" + "cut -d '|' -f3 | tr -d '\n'" % (node) ) logging.info("instance status is %s" % (instStatus)) logging.info("expected status is %s" % (expected_status)) if instStatus.strip() == expected_status: - logging.info("instance status has reached desired status %s" % (instStatus)) + logging.info( + "instance status has reached desired status %s" % (instStatus) + ) return True time.sleep(sleeper) i += sleeper @@ -76,7 +86,9 @@ class OPENSTACKCLOUD: # Get the openstack instance name def get_openstack_nodename(self, os_node_ip): - server_list = runcommand.invoke("openstack server list | grep %s" % (os_node_ip)) + server_list = runcommand.invoke( + "openstack server list | grep %s" % (os_node_ip) + ) list_of_servers = server_list.split("\n") for item in list_of_servers: items = item.split("|") @@ -92,6 +104,7 @@ class OPENSTACKCLOUD: return node_name counter += 1 + # krkn_lib class openstack_node_scenarios(abstract_node_scenarios): def __init__(self, kubecli: KrknKubernetes): @@ -111,11 +124,11 @@ class openstack_node_scenarios(abstract_node_scenarios): logging.info("node_start_scenario has been successfully injected!") except Exception as e: logging.error( - "Failed to start node instance. Encountered following " "exception: %s. Test Failed" % (e) + "Failed to start node instance. Encountered following " + "exception: %s. Test Failed" % (e) ) logging.error("node_start_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() # Node scenario to stop the node @@ -130,10 +143,12 @@ class openstack_node_scenarios(abstract_node_scenarios): logging.info("Node with instance name: %s is in stopped state" % (node)) nodeaction.wait_for_ready_status(node, timeout, self.kubecli) except Exception as e: - logging.error("Failed to stop node instance. Encountered following exception: %s. " "Test Failed" % (e)) + logging.error( + "Failed to stop node instance. Encountered following exception: %s. " + "Test Failed" % (e) + ) logging.error("node_stop_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() # Node scenario to reboot the node @@ -150,11 +165,11 @@ class openstack_node_scenarios(abstract_node_scenarios): logging.info("node_reboot_scenario has been successfuly injected!") except Exception as e: logging.error( - "Failed to reboot node instance. Encountered following exception:" " %s. Test Failed" % (e) + "Failed to reboot node instance. Encountered following exception:" + " %s. Test Failed" % (e) ) logging.error("node_reboot_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() # Node scenario to start the node @@ -162,7 +177,9 @@ class openstack_node_scenarios(abstract_node_scenarios): for _ in range(instance_kill_count): try: logging.info("Starting helper_node_start_scenario injection") - openstack_node_name = self.openstackcloud.get_openstack_nodename(node_ip.strip()) + openstack_node_name = self.openstackcloud.get_openstack_nodename( + node_ip.strip() + ) logging.info("Starting the helper node %s" % (openstack_node_name)) self.openstackcloud.start_instances(openstack_node_name) self.openstackcloud.wait_until_running(openstack_node_name, timeout) @@ -170,11 +187,11 @@ class openstack_node_scenarios(abstract_node_scenarios): logging.info("node_start_scenario has been successfully injected!") except Exception as e: logging.error( - "Failed to start node instance. Encountered following " "exception: %s. Test Failed" % (e) + "Failed to start node instance. Encountered following " + "exception: %s. Test Failed" % (e) ) logging.error("helper_node_start_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() # Node scenario to stop the node @@ -182,27 +199,35 @@ class openstack_node_scenarios(abstract_node_scenarios): for _ in range(instance_kill_count): try: logging.info("Starting helper_node_stop_scenario injection") - openstack_node_name = self.openstackcloud.get_openstack_nodename(node_ip.strip()) + openstack_node_name = self.openstackcloud.get_openstack_nodename( + node_ip.strip() + ) logging.info("Stopping the helper node %s " % (openstack_node_name)) self.openstackcloud.stop_instances(openstack_node_name) self.openstackcloud.wait_until_stopped(openstack_node_name, timeout) logging.info("Helper node with IP: %s is in stopped state" % (node_ip)) except Exception as e: - logging.error("Failed to stop node instance. Encountered following exception: %s. " "Test Failed" % (e)) + logging.error( + "Failed to stop node instance. Encountered following exception: %s. " + "Test Failed" % (e) + ) logging.error("helper_node_stop_scenario injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() def helper_node_service_status(self, node_ip, service, ssh_private_key, timeout): try: logging.info("Checking service status on the helper node") - nodeaction.check_service_status(node_ip.strip(), service, ssh_private_key, timeout) + nodeaction.check_service_status( + node_ip.strip(), service, ssh_private_key, timeout + ) logging.info("Service status checked on %s" % (node_ip)) logging.info("Check service status is successfuly injected!") except Exception as e: - logging.error("Failed to check service status. Encountered following exception:" " %s. Test Failed" % (e)) + logging.error( + "Failed to check service status. Encountered following exception:" + " %s. Test Failed" % (e) + ) logging.error("helper_node_service_status injection failed!") - # removed_exit - # sys.exit(1) + raise RuntimeError() diff --git a/kraken/service_disruption/__init__.py b/krkn/scenario_plugins/pvc/__init__.py similarity index 100% rename from kraken/service_disruption/__init__.py rename to krkn/scenario_plugins/pvc/__init__.py diff --git a/krkn/scenario_plugins/pvc/pvc_scenario_plugin.py b/krkn/scenario_plugins/pvc/pvc_scenario_plugin.py new file mode 100644 index 00000000..d842e955 --- /dev/null +++ b/krkn/scenario_plugins/pvc/pvc_scenario_plugin.py @@ -0,0 +1,324 @@ +import logging +import random +import re +import time + +import yaml +from krkn_lib.k8s import KrknKubernetes +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from krkn_lib.utils import get_yaml_item_value, log_exception + +from krkn import cerberus, utils +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class PvcScenarioPlugin(AbstractScenarioPlugin): + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + try: + with open(scenario, "r") as f: + config_yaml = yaml.full_load(f) + scenario_config = config_yaml["pvc_scenario"] + pvc_name = get_yaml_item_value(scenario_config, "pvc_name", "") + pod_name = get_yaml_item_value(scenario_config, "pod_name", "") + namespace = get_yaml_item_value(scenario_config, "namespace", "") + target_fill_percentage = get_yaml_item_value( + scenario_config, "fill_percentage", "50" + ) + duration = get_yaml_item_value(scenario_config, "duration", 60) + + logging.info( + "Input params:\n" + "pvc_name: '%s'\n" + "pod_name: '%s'\n" + "namespace: '%s'\n" + "target_fill_percentage: '%s%%'\nduration: '%ss'" + % ( + str(pvc_name), + str(pod_name), + str(namespace), + str(target_fill_percentage), + str(duration), + ) + ) + + # Check input params + if namespace is None: + logging.error( + "PvcScenarioPlugin You must specify the namespace where the PVC is" + ) + return 1 + if pvc_name is None and pod_name is None: + logging.error( + "PvcScenarioPlugin You must specify the pvc_name or the pod_name" + ) + return 1 + if pvc_name and pod_name: + logging.info( + "pod_name will be ignored, pod_name used will be " + "a retrieved from the pod used in the pvc_name" + ) + + # Get pod name + if pvc_name: + if pod_name: + logging.info( + "pod_name '%s' will be overridden with one of " + "the pods mounted in the PVC" % (str(pod_name)) + ) + pvc = lib_telemetry.get_lib_kubernetes().get_pvc_info( + pvc_name, namespace + ) + try: + # random generator not used for + # security/cryptographic purposes. + pod_name = random.choice(pvc.podNames) # nosec + logging.info("Pod name: %s" % pod_name) + except Exception: + logging.error( + "PvcScenarioPlugin Pod associated with %s PVC, on namespace %s, " + "not found" % (str(pvc_name), str(namespace)) + ) + return 1 + + # Get volume name + pod = lib_telemetry.get_lib_kubernetes().get_pod_info( + name=pod_name, namespace=namespace + ) + + if pod is None: + logging.error( + "PvcScenarioPlugin Exiting as pod '%s' doesn't exist " + "in namespace '%s'" % (str(pod_name), str(namespace)) + ) + return 1 + + for volume in pod.volumes: + if volume.pvcName is not None: + volume_name = volume.name + pvc_name = volume.pvcName + pvc = lib_telemetry.get_lib_kubernetes().get_pvc_info( + pvc_name, namespace + ) + break + if "pvc" not in locals(): + logging.error( + "PvcScenarioPlugin Pod '%s' in namespace '%s' does not use a pvc" + % (str(pod_name), str(namespace)) + ) + return 1 + logging.info("Volume name: %s" % volume_name) + logging.info("PVC name: %s" % pvc_name) + + # Get container name and mount path + for container in pod.containers: + for vol in container.volumeMounts: + if vol.name == volume_name: + mount_path = vol.mountPath + container_name = container.name + break + logging.info("Container path: %s" % container_name) + logging.info("Mount path: %s" % mount_path) + + # Get PVC capacity and used bytes + command = "df %s -B 1024 | sed 1d" % (str(mount_path)) + command_output = ( + lib_telemetry.get_lib_kubernetes().exec_cmd_in_pod( + [command], pod_name, namespace, container_name + ) + ).split() + pvc_used_kb = int(command_output[2]) + pvc_capacity_kb = pvc_used_kb + int(command_output[3]) + logging.info("PVC used: %s KB" % pvc_used_kb) + logging.info("PVC capacity: %s KB" % pvc_capacity_kb) + + # Check valid fill percentage + current_fill_percentage = pvc_used_kb / pvc_capacity_kb + if not ( + current_fill_percentage * 100 < float(target_fill_percentage) <= 99 + ): + logging.error( + "PvcScenarioPlugin Target fill percentage (%.2f%%) is lower than " + "current fill percentage (%.2f%%) " + "or higher than 99%%" + % ( + target_fill_percentage, + current_fill_percentage * 100, + ) + ) + return 1 + # Calculate file size + file_size_kb = int( + (float(target_fill_percentage / 100) * float(pvc_capacity_kb)) + - float(pvc_used_kb) + ) + logging.debug("File size: %s KB" % file_size_kb) + + file_name = "kraken.tmp" + logging.info( + "Creating %s file, %s KB size, in pod %s at %s (ns %s)" + % ( + str(file_name), + str(file_size_kb), + str(pod_name), + str(mount_path), + str(namespace), + ) + ) + + start_time = int(time.time()) + # Create temp file in the PVC + full_path = "%s/%s" % (str(mount_path), str(file_name)) + command = "fallocate -l $((%s*1024)) %s" % ( + str(file_size_kb), + str(full_path), + ) + logging.debug("Create temp file in the PVC command:\n %s" % command) + lib_telemetry.get_lib_kubernetes().exec_cmd_in_pod( + [command], + pod_name, + namespace, + container_name, + ) + + # Check if file is created + command = "ls -lh %s" % (str(mount_path)) + logging.debug("Check file is created command:\n %s" % command) + response = lib_telemetry.get_lib_kubernetes().exec_cmd_in_pod( + [command], pod_name, namespace, container_name + ) + logging.info("\n" + str(response)) + if str(file_name).lower() in str(response).lower(): + logging.info("%s file successfully created" % (str(full_path))) + else: + logging.error( + "PvcScenarioPlugin Failed to create tmp file with %s size" + % (str(file_size_kb)) + ) + self.remove_temp_file( + file_name, + full_path, + pod_name, + namespace, + container_name, + mount_path, + file_size_kb, + lib_telemetry.get_lib_kubernetes(), + ) + return 1 + + # Calculate file size + file_size_kb = int( + (float(target_fill_percentage / 100) * float(pvc_capacity_kb)) + - float(pvc_used_kb) + ) + logging.debug("File size: %s KB" % file_size_kb) + + file_name = "kraken.tmp" + logging.info( + "Creating %s file, %s KB size, in pod %s at %s (ns %s)" + % ( + str(file_name), + str(file_size_kb), + str(pod_name), + str(mount_path), + str(namespace), + ) + ) + + start_time = int(time.time()) + # Create temp file in the PVC + full_path = "%s/%s" % (str(mount_path), str(file_name)) + command = "fallocate -l $((%s*1024)) %s" % ( + str(file_size_kb), + str(full_path), + ) + logging.debug("Create temp file in the PVC command:\n %s" % command) + lib_telemetry.get_lib_kubernetes().exec_cmd_in_pod( + [command], pod_name, namespace, container_name + ) + + # Check if file is created + command = "ls -lh %s" % (str(mount_path)) + logging.debug("Check file is created command:\n %s" % command) + response = lib_telemetry.get_lib_kubernetes().exec_cmd_in_pod( + [command], pod_name, namespace, container_name + ) + logging.info("\n" + str(response)) + if str(file_name).lower() in str(response).lower(): + logging.info( + "Waiting for the specified duration in the config: %ss" % duration + ) + time.sleep(duration) + logging.info("Finish waiting") + + self.remove_temp_file( + file_name, + full_path, + pod_name, + namespace, + container_name, + mount_path, + file_size_kb, + lib_telemetry.get_lib_kubernetes(), + ) + end_time = int(time.time()) + cerberus.publish_kraken_status(krkn_config, [], start_time, end_time) + except (RuntimeError, Exception) as e: + logging.error("PvcScenarioPlugin exiting due to Exception %s" % e) + return 1 + else: + return 0 + + # krkn_lib + def remove_temp_file( + self, + file_name, + full_path, + pod_name, + namespace, + container_name, + mount_path, + file_size_kb, + kubecli: KrknKubernetes, + ): + command = "rm -f %s" % (str(full_path)) + logging.debug("Remove temp file from the PVC command:\n %s" % command) + kubecli.exec_cmd_in_pod([command], pod_name, namespace, container_name) + command = "ls -lh %s" % (str(mount_path)) + logging.debug("Check temp file is removed command:\n %s" % command) + response = kubecli.exec_cmd_in_pod( + [command], pod_name, namespace, container_name + ) + logging.info("\n" + str(response)) + if not (str(file_name).lower() in str(response).lower()): + logging.info("Temp file successfully removed") + else: + logging.error( + "PvcScenarioPlugin Failed to delete tmp file with %s size" + % (str(file_size_kb)) + ) + raise RuntimeError() + + def to_kbytes(self, value): + if not re.match("^[0-9]+[K|M|G|T]i$", value): + logging.error( + "PvcScenarioPlugin PVC capacity %s does not match expression " + "regexp '^[0-9]+[K|M|G|T]i$'" + ) + raise RuntimeError() + unit = {"K": 0, "M": 1, "G": 2, "T": 3} + base = 1024 if ("i" in value) else 1000 + exp = unit[value[-2:-1]] + res = int(value[:-2]) * (base**exp) + return res + + def get_scenario_types(self) -> list[str]: + return ["pvc_scenarios"] diff --git a/krkn/scenario_plugins/scenario_plugin_factory.py b/krkn/scenario_plugins/scenario_plugin_factory.py new file mode 100644 index 00000000..bf945435 --- /dev/null +++ b/krkn/scenario_plugins/scenario_plugin_factory.py @@ -0,0 +1,134 @@ +import importlib +import inspect +import pkgutil +from typing import Type, Tuple, Optional +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class ScenarioPluginNotFound(Exception): + pass + + +class ScenarioPluginFactory: + + loaded_plugins: dict[str, any] = {} + failed_plugins: list[Tuple[str, str, str]] = [] + package_name = None + + def __init__(self, package_name: str = "krkn.scenario_plugins"): + self.package_name = package_name + self.__load_plugins(AbstractScenarioPlugin) + + def create_plugin(self, scenario_type: str) -> AbstractScenarioPlugin: + """ + Creates a plugin instance based on the config.yaml scenario name. + The scenario name is provided by the method `get_scenario_type` + defined by the `AbstractScenarioPlugin` abstract class that must + be implemented by all the plugins in order to be loaded correctly + + :param scenario_type: the scenario type defined in the config.yaml + e.g. `arcaflow_scenarios`, `network_scenarios`, `plugin_scenarios` + etc. + :return: an instance of the class that implements this scenario and + inherits from the AbstractScenarioPlugin abstract class + """ + if scenario_type in self.loaded_plugins: + return self.loaded_plugins[scenario_type]() + else: + raise ScenarioPluginNotFound( + f"Failed to load the {scenario_type} scenario plugin. " + f"Please verify the logs to ensure it was loaded correctly." + ) + + def __load_plugins(self, base_class: Type): + base_package = importlib.import_module(self.package_name) + for _, module_name, is_pkg in pkgutil.walk_packages( + base_package.__path__, base_package.__name__ + "." + ): + + if not is_pkg: + module = importlib.import_module(module_name) + + for name, obj in inspect.getmembers(module, inspect.isclass): + if issubclass(obj, base_class) and obj is not base_class: + is_correct, exception_message = ( + self.is_naming_convention_correct(module_name, name) + ) + if not is_correct: + self.failed_plugins.append( + (module_name, name, exception_message) + ) + continue + + cls = getattr(module, name) + instance = cls() + get_scenario_type = getattr(instance, "get_scenario_types") + scenario_types = get_scenario_type() + has_duplicates = False + for scenario_type in scenario_types: + if scenario_type in self.loaded_plugins.keys(): + self.failed_plugins.append( + ( + module_name, + name, + f"scenario type {scenario_type} defined by {self.loaded_plugins[scenario_type].__name__} " + f"and {name} and this is not allowed.", + ) + ) + has_duplicates = True + break + if has_duplicates: + continue + for scenario_type in scenario_types: + self.loaded_plugins[scenario_type] = cls + + def is_naming_convention_correct( + self, module_name: str, class_name: str + ) -> Tuple[bool, Optional[str]]: + """ + Defines the Krkn ScenarioPlugin API naming conventions + + :param module_name: the fully qualified module name that is loaded by + walk_packages + :param class_name: the plugin class name + :return: a tuple of boolean result of the check and optional error message + """ + # plugin file names must end with _scenario_plugin + if not module_name.split(".")[-1].endswith("_scenario_plugin"): + return ( + False, + "scenario plugin module file names must end with `_scenario_plugin` suffix", + ) + + if ( + "scenario" in module_name.split(".")[-2] + or "plugin" in module_name.split(".")[-2] + ): + return ( + False, + "scenario plugin folder cannot contain `scenario` or `plugin` word", + ) + + # plugin class names must be capital camel cased and end with ScenarioPlugin + if ( + class_name == "ScenarioPlugin" + or not class_name.endswith("ScenarioPlugin") + or not class_name[0].isupper() + ): + return ( + False, + "scenario plugin class name must start with a capital letter, " + "end with `ScenarioPlugin`, and cannot be just `ScenarioPlugin`.", + ) + + # plugin file name in snake case must match class name in capital camel case + if self.__snake_to_capital_camel(module_name.split(".")[-1]) != class_name: + return False, ( + "module file name must in snake case must match class name in capital camel case " + "e.g. `example_scenario_plugin` -> `ExampleScenarioPlugin`" + ) + + return True, None + + def __snake_to_capital_camel(self, snake_string: str) -> str: + return snake_string.title().replace("_", "") diff --git a/kraken/service_hijacking/__init__.py b/krkn/scenario_plugins/service_disruption/__init__.py similarity index 100% rename from kraken/service_hijacking/__init__.py rename to krkn/scenario_plugins/service_disruption/__init__.py diff --git a/krkn/scenario_plugins/service_disruption/service_disruption_scenario_plugin.py b/krkn/scenario_plugins/service_disruption/service_disruption_scenario_plugin.py new file mode 100644 index 00000000..710d0a0c --- /dev/null +++ b/krkn/scenario_plugins/service_disruption/service_disruption_scenario_plugin.py @@ -0,0 +1,345 @@ +import logging +import random +import time + +import yaml +from krkn_lib.k8s import KrknKubernetes +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from krkn_lib.utils import get_yaml_item_value, log_exception + +from krkn import cerberus, utils +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class ServiceDisruptionScenarioPlugin(AbstractScenarioPlugin): + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + try: + with open(scenario, "r") as f: + scenario_config_yaml = yaml.full_load(f) + for scenario in scenario_config_yaml["scenarios"]: + scenario_namespace = get_yaml_item_value(scenario, "namespace", "") + scenario_label = get_yaml_item_value(scenario, "label_selector", "") + if ( + scenario_namespace is not None + and scenario_namespace.strip() != "" + ): + if scenario_label is not None and scenario_label.strip() != "": + logging.error( + "ServiceDisruptionScenarioPlugin You can only have namespace or " + "label set in your namespace scenario" + ) + logging.error( + "ServiceDisruptionScenarioPlugin Current scenario config has " + "namespace '%s' and label selector '%s'" + % (scenario_namespace, scenario_label) + ) + logging.error( + "ServiceDisruptionScenarioPlugin Please set either namespace " + "to blank ('') or label_selector to blank ('') to continue" + ) + return 1 + delete_count = get_yaml_item_value(scenario, "delete_count", 1) + run_count = get_yaml_item_value(scenario, "runs", 1) + run_sleep = get_yaml_item_value(scenario, "sleep", 10) + wait_time = get_yaml_item_value(scenario, "wait_time", 30) + + logging.info( + str(scenario_namespace) + + str(scenario_label) + + str(delete_count) + + str(run_count) + + str(run_sleep) + + str(wait_time) + ) + logging.info("done") + start_time = int(time.time()) + for i in range(run_count): + killed_namespaces = {} + namespaces = ( + lib_telemetry.get_lib_kubernetes().check_namespaces( + [scenario_namespace], scenario_label + ) + ) + for j in range(delete_count): + if len(namespaces) == 0: + logging.error( + "ServiceDisruptionScenarioPlugin Couldn't delete %s namespaces, ù" + "not enough namespaces matching %s with label %s" + % ( + str(run_count), + scenario_namespace, + str(scenario_label), + ) + ) + return 1 + + selected_namespace = namespaces[ + random.randint(0, len(namespaces) - 1) + ] + logging.info( + "Delete objects in selected namespace: " + + selected_namespace + ) + try: + # delete all pods in namespace + objects = self.delete_objects( + lib_telemetry.get_lib_kubernetes(), + selected_namespace, + ) + killed_namespaces[selected_namespace] = objects + logging.info( + "Deleted all objects in namespace %s was successful" + % str(selected_namespace) + ) + except Exception as e: + logging.info( + "ServiceDisruptionScenarioPlugin Delete all " + "objects in namespace %s was unsuccessful" + % str(selected_namespace) + ) + logging.info("Namespace action error: " + str(e)) + return 1 + namespaces.remove(selected_namespace) + logging.info( + "Waiting %s seconds between namespace deletions" + % str(run_sleep) + ) + time.sleep(run_sleep) + + end_time = int(time.time()) + cerberus.publish_kraken_status( + krkn_config, [], start_time, end_time + ) + except (Exception, RuntimeError) as e: + logging.error( + "ServiceDisruptionScenarioPlugin exiting due to Exception %s" % e + ) + return 1 + else: + return 0 + + def delete_objects(self, kubecli, namespace): + + services = self.delete_all_services_namespace(kubecli, namespace) + daemonsets = self.delete_all_daemonset_namespace(kubecli, namespace) + statefulsets = self.delete_all_statefulsets_namespace(kubecli, namespace) + replicasets = self.delete_all_replicaset_namespace(kubecli, namespace) + deployments = self.delete_all_deployment_namespace(kubecli, namespace) + + objects = { + "daemonsets": daemonsets, + "deployments": deployments, + "replicasets": replicasets, + "statefulsets": statefulsets, + "services": services, + } + + return objects + + def get_list_running_pods(self, kubecli: KrknKubernetes, namespace: str): + running_pods = [] + pods = kubecli.list_pods(namespace) + for pod in pods: + pod_status = kubecli.get_pod_info(pod, namespace) + if pod_status and pod_status.status == "Running": + running_pods.append(pod) + logging.info("all running pods " + str(running_pods)) + return running_pods + + def delete_all_deployment_namespace(self, kubecli: KrknKubernetes, namespace: str): + """ + Delete all the deployments in the specified namespace + + :param kubecli: krkn kubernetes python package + :param namespace: namespace + """ + try: + deployments = kubecli.get_deployment_ns(namespace) + for deployment in deployments: + logging.info("Deleting deployment" + deployment) + kubecli.delete_deployment(deployment, namespace) + except Exception as e: + logging.error( + "Exception when calling delete_all_deployment_namespace: %s\n", + str(e), + ) + raise e + + return deployments + + def delete_all_daemonset_namespace(self, kubecli: KrknKubernetes, namespace: str): + """ + Delete all the daemonset in the specified namespace + + :param kubecli: krkn kubernetes python package + :param namespace: namespace + """ + try: + daemonsets = kubecli.get_daemonset(namespace) + for daemonset in daemonsets: + logging.info("Deleting daemonset" + daemonset) + kubecli.delete_daemonset(daemonset, namespace) + except Exception as e: + logging.error( + "Exception when calling delete_all_daemonset_namespace: %s\n", + str(e), + ) + raise e + + return daemonsets + + def delete_all_statefulsets_namespace( + self, kubecli: KrknKubernetes, namespace: str + ): + """ + Delete all the statefulsets in the specified namespace + + + :param kubecli: krkn kubernetes python package + :param namespace: namespace + """ + try: + statefulsets = kubecli.get_all_statefulset(namespace) + for statefulset in statefulsets: + logging.info("Deleting statefulsets" + statefulsets) + kubecli.delete_statefulset(statefulset, namespace) + except Exception as e: + logging.error( + "Exception when calling delete_all_statefulsets_namespace: %s\n", + str(e), + ) + raise e + + return statefulsets + + def delete_all_replicaset_namespace(self, kubecli: KrknKubernetes, namespace: str): + """ + Delete all the replicasets in the specified namespace + + :param kubecli: krkn kubernetes python package + :param namespace: namespace + """ + try: + replicasets = kubecli.get_all_replicasets(namespace) + for replicaset in replicasets: + logging.info("Deleting replicaset" + replicaset) + kubecli.delete_replicaset(replicaset, namespace) + except Exception as e: + logging.error( + "Exception when calling delete_all_replicaset_namespace: %s\n", + str(e), + ) + raise e + + return replicasets + + def delete_all_services_namespace(self, kubecli: KrknKubernetes, namespace: str): + """ + Delete all the services in the specified namespace + + + :param kubecli: krkn kubernetes python package + :param namespace: namespace + """ + try: + services = kubecli.get_all_services(namespace) + for service in services: + logging.info("Deleting services" + service) + kubecli.delete_services(service, namespace) + except Exception as e: + logging.error( + "Exception when calling delete_all_services_namespace: %s\n", + str(e), + ) + raise e + + return services + + def check_all_running_pods( + self, kubecli: KrknKubernetes, namespace_name, wait_time + ): + + timer = 0 + while timer < wait_time: + pod_list = kubecli.list_pods(namespace_name) + pods_running = 0 + for pod in pod_list: + pod_info = kubecli.get_pod_info(pod, namespace_name) + if pod_info.status != "Running" and pod_info.status != "Succeeded": + logging.info( + "Pods %s still not running or completed" % pod_info.name + ) + break + pods_running += 1 + if len(pod_list) == pods_running: + break + timer += 5 + time.sleep(5) + logging.info("Waiting 5 seconds for pods to become active") + + # krkn_lib + def check_all_running_deployment( + self, killed_namespaces, wait_time, kubecli: KrknKubernetes + ): + + timer = 0 + while timer < wait_time and killed_namespaces: + still_missing_ns = killed_namespaces.copy() + for namespace_name, objects in killed_namespaces.items(): + still_missing_obj = objects.copy() + for obj_name, obj_list in objects.items(): + if "deployments" == obj_name: + deployments = kubecli.get_deployment_ns(namespace_name) + if len(obj_list) == len(deployments): + still_missing_obj.pop(obj_name) + elif "replicasets" == obj_name: + replicasets = kubecli.get_all_replicasets(namespace_name) + if len(obj_list) == len(replicasets): + still_missing_obj.pop(obj_name) + elif "statefulsets" == obj_name: + statefulsets = kubecli.get_all_statefulset(namespace_name) + if len(obj_list) == len(statefulsets): + still_missing_obj.pop(obj_name) + elif "services" == obj_name: + services = kubecli.get_all_services(namespace_name) + if len(obj_list) == len(services): + still_missing_obj.pop(obj_name) + elif "daemonsets" == obj_name: + daemonsets = kubecli.get_daemonset(namespace_name) + if len(obj_list) == len(daemonsets): + still_missing_obj.pop(obj_name) + logging.info("Still missing objects " + str(still_missing_obj)) + killed_namespaces[namespace_name] = still_missing_obj.copy() + if len(killed_namespaces[namespace_name].keys()) == 0: + logging.info( + "Wait for pods to become running for namespace: " + + namespace_name + ) + self.check_all_running_pods(kubecli, namespace_name, wait_time) + still_missing_ns.pop(namespace_name) + killed_namespaces = still_missing_ns + if len(killed_namespaces.keys()) == 0: + return [] + + timer += 10 + time.sleep(10) + logging.info( + "Waiting 10 seconds for objects in namespaces to become active" + ) + + logging.error( + "Objects are still not ready after waiting " + str(wait_time) + "seconds" + ) + logging.error("Non active namespaces " + str(killed_namespaces)) + return killed_namespaces + + def get_scenario_types(self) -> list[str]: + return ["service_disruption_scenarios"] diff --git a/kraken/shut_down/__init__.py b/krkn/scenario_plugins/service_hijacking/__init__.py similarity index 100% rename from kraken/shut_down/__init__.py rename to krkn/scenario_plugins/service_hijacking/__init__.py diff --git a/krkn/scenario_plugins/service_hijacking/service_hijacking_scenario_plugin.py b/krkn/scenario_plugins/service_hijacking/service_hijacking_scenario_plugin.py new file mode 100644 index 00000000..781d3602 --- /dev/null +++ b/krkn/scenario_plugins/service_hijacking/service_hijacking_scenario_plugin.py @@ -0,0 +1,108 @@ +import logging +import time + +import yaml +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class ServiceHijackingScenarioPlugin(AbstractScenarioPlugin): + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + with open(scenario) as stream: + scenario_config = yaml.safe_load(stream) + + service_name = scenario_config["service_name"] + service_namespace = scenario_config["service_namespace"] + plan = scenario_config["plan"] + image = scenario_config["image"] + target_port = scenario_config["service_target_port"] + chaos_duration = scenario_config["chaos_duration"] + + logging.info( + f"checking service {service_name} in namespace: {service_namespace}" + ) + if not lib_telemetry.get_lib_kubernetes().service_exists( + service_name, service_namespace + ): + logging.error( + f"ServiceHijackingScenarioPlugin service: {service_name} not found in namespace: {service_namespace}, failed to run scenario." + ) + return 1 + try: + logging.info( + f"service: {service_name} found in namespace: {service_namespace}" + ) + logging.info(f"creating webservice and initializing test plan...") + # both named ports and port numbers can be used + if isinstance(target_port, int): + logging.info(f"webservice will listen on port {target_port}") + webservice = ( + lib_telemetry.get_lib_kubernetes().deploy_service_hijacking( + service_namespace, plan, image, port_number=target_port + ) + ) + else: + logging.info(f"traffic will be redirected to named port: {target_port}") + webservice = ( + lib_telemetry.get_lib_kubernetes().deploy_service_hijacking( + service_namespace, plan, image, port_name=target_port + ) + ) + logging.info( + f"successfully deployed pod: {webservice.pod_name} " + f"in namespace:{service_namespace} with selector {webservice.selector}!" + ) + logging.info( + f"patching service: {service_name} to hijack traffic towards: {webservice.pod_name}" + ) + original_service = ( + lib_telemetry.get_lib_kubernetes().replace_service_selector( + [webservice.selector], service_name, service_namespace + ) + ) + if original_service is None: + logging.error( + f"ServiceHijackingScenarioPlugin failed to patch service: {service_name}, namespace: {service_namespace} with selector {webservice.selector}" + ) + return 1 + + logging.info(f"service: {service_name} successfully patched!") + logging.info(f"original service manifest:\n\n{yaml.dump(original_service)}") + logging.info(f"waiting {chaos_duration} before restoring the service") + time.sleep(chaos_duration) + selectors = [ + "=".join([key, original_service["spec"]["selector"][key]]) + for key in original_service["spec"]["selector"].keys() + ] + logging.info(f"restoring the service selectors {selectors}") + original_service = ( + lib_telemetry.get_lib_kubernetes().replace_service_selector( + selectors, service_name, service_namespace + ) + ) + if original_service is None: + logging.error( + f"ServiceHijackingScenarioPlugin failed to restore original " + f"service: {service_name}, namespace: {service_namespace} with selectors: {selectors}" + ) + return 1 + logging.info("selectors successfully restored") + logging.info("undeploying service-hijacking resources...") + lib_telemetry.get_lib_kubernetes().undeploy_service_hijacking(webservice) + return 0 + except Exception as e: + logging.error( + f"ServiceHijackingScenarioPlugin scenario {scenario} failed with exception: {e}" + ) + return 1 + + def get_scenario_types(self) -> list[str]: + return ["service_hijacking_scenarios"] diff --git a/kraken/time_actions/__init__.py b/krkn/scenario_plugins/shut_down/__init__.py similarity index 100% rename from kraken/time_actions/__init__.py rename to krkn/scenario_plugins/shut_down/__init__.py diff --git a/krkn/scenario_plugins/shut_down/shut_down_scenario_plugin.py b/krkn/scenario_plugins/shut_down/shut_down_scenario_plugin.py new file mode 100644 index 00000000..ea915e32 --- /dev/null +++ b/krkn/scenario_plugins/shut_down/shut_down_scenario_plugin.py @@ -0,0 +1,151 @@ +import logging +import time +from multiprocessing.pool import ThreadPool + +import yaml +from krkn_lib.k8s import KrknKubernetes +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift + +from krkn import cerberus +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin +from krkn.scenario_plugins.node_actions.aws_node_scenarios import AWS +from krkn.scenario_plugins.node_actions.az_node_scenarios import Azure +from krkn.scenario_plugins.node_actions.gcp_node_scenarios import GCP +from krkn.scenario_plugins.node_actions.openstack_node_scenarios import OPENSTACKCLOUD + + +class ShutDownScenarioPlugin(AbstractScenarioPlugin): + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + try: + with open(scenario, "r") as f: + shut_down_config_yaml = yaml.full_load(f) + shut_down_config_scenario = shut_down_config_yaml[ + "cluster_shut_down_scenario" + ] + start_time = int(time.time()) + self.cluster_shut_down( + shut_down_config_scenario, lib_telemetry.get_lib_kubernetes() + ) + end_time = int(time.time()) + cerberus.publish_kraken_status(krkn_config, [], start_time, end_time) + return 0 + except Exception as e: + logging.error( + f"ShutDownScenarioPlugin scenario {scenario} failed with exception: {e}" + ) + return 1 + + def multiprocess_nodes(self, cloud_object_function, nodes, processes=0): + try: + # pool object with number of element + + if processes == 0: + pool = ThreadPool(processes=len(nodes)) + else: + pool = ThreadPool(processes=processes) + logging.info("nodes type " + str(type(nodes[0]))) + if type(nodes[0]) is tuple: + node_id = [] + node_info = [] + for node in nodes: + node_id.append(node[0]) + node_info.append(node[1]) + logging.info("node id " + str(node_id)) + logging.info("node info" + str(node_info)) + pool.starmap(cloud_object_function, zip(node_info, node_id)) + + else: + logging.info("pool type" + str(type(nodes))) + pool.map(cloud_object_function, nodes) + pool.close() + except Exception as e: + logging.info("Error on pool multiprocessing: " + str(e)) + + # Inject the cluster shut down scenario + # krkn_lib + def cluster_shut_down(self, shut_down_config, kubecli: KrknKubernetes): + runs = shut_down_config["runs"] + shut_down_duration = shut_down_config["shut_down_duration"] + cloud_type = shut_down_config["cloud_type"] + timeout = shut_down_config["timeout"] + processes = 0 + if cloud_type.lower() == "aws": + cloud_object = AWS() + elif cloud_type.lower() == "gcp": + cloud_object = GCP() + processes = 1 + elif cloud_type.lower() == "openstack": + cloud_object = OPENSTACKCLOUD() + elif cloud_type.lower() in ["azure", "az"]: + cloud_object = Azure() + else: + logging.error( + "Cloud type %s is not currently supported for cluster shut down" + % cloud_type + ) + + raise RuntimeError() + + nodes = kubecli.list_nodes() + node_id = [] + for node in nodes: + instance_id = cloud_object.get_instance_id(node) + node_id.append(instance_id) + logging.info("node id list " + str(node_id)) + for _ in range(runs): + logging.info("Starting cluster_shut_down scenario injection") + stopping_nodes = set(node_id) + self.multiprocess_nodes(cloud_object.stop_instances, node_id, processes) + stopped_nodes = stopping_nodes.copy() + while len(stopping_nodes) > 0: + for node in stopping_nodes: + if type(node) is tuple: + node_status = cloud_object.wait_until_stopped( + node[1], node[0], timeout + ) + else: + node_status = cloud_object.wait_until_stopped(node, timeout) + + # Only want to remove node from stopping list + # when fully stopped/no error + if node_status: + stopped_nodes.remove(node) + + stopping_nodes = stopped_nodes.copy() + + logging.info( + "Shutting down the cluster for the specified duration: %s" + % shut_down_duration + ) + time.sleep(shut_down_duration) + logging.info("Restarting the nodes") + restarted_nodes = set(node_id) + self.multiprocess_nodes(cloud_object.start_instances, node_id, processes) + logging.info("Wait for each node to be running again") + not_running_nodes = restarted_nodes.copy() + while len(not_running_nodes) > 0: + for node in not_running_nodes: + if type(node) is tuple: + node_status = cloud_object.wait_until_running( + node[1], node[0], timeout + ) + else: + node_status = cloud_object.wait_until_running(node, timeout) + if node_status: + restarted_nodes.remove(node) + not_running_nodes = restarted_nodes.copy() + logging.info("Waiting for 150s to allow cluster component initialization") + time.sleep(150) + + logging.info("Successfully injected cluster_shut_down scenario!") + + def get_scenario_types(self) -> list[str]: + return ["cluster_shut_down_scenarios"] diff --git a/kraken/zone_outage/__init__.py b/krkn/scenario_plugins/syn_flood/__init__.py similarity index 100% rename from kraken/zone_outage/__init__.py rename to krkn/scenario_plugins/syn_flood/__init__.py diff --git a/krkn/scenario_plugins/syn_flood/syn_flood_scenario_plugin.py b/krkn/scenario_plugins/syn_flood/syn_flood_scenario_plugin.py new file mode 100644 index 00000000..17e97023 --- /dev/null +++ b/krkn/scenario_plugins/syn_flood/syn_flood_scenario_plugin.py @@ -0,0 +1,139 @@ +import logging +import os +import time + +import yaml +from krkn_lib import utils as krkn_lib_utils +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class SynFloodScenarioPlugin(AbstractScenarioPlugin): + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + try: + pod_names = [] + config = self.parse_config(scenario) + if config["target-service-label"]: + target_services = ( + lib_telemetry.get_lib_kubernetes().select_service_by_label( + config["namespace"], config["target-service-label"] + ) + ) + else: + target_services = [config["target-service"]] + + for target in target_services: + if not lib_telemetry.get_lib_kubernetes().service_exists( + target, config["namespace"] + ): + logging.error(f"SynFloodScenarioPlugin {target} service not found") + return 1 + for i in range(config["number-of-pods"]): + pod_name = "syn-flood-" + krkn_lib_utils.get_random_string(10) + lib_telemetry.get_lib_kubernetes().deploy_syn_flood( + pod_name, + config["namespace"], + config["image"], + target, + config["target-port"], + config["packet-size"], + config["window-size"], + config["duration"], + config["attacker-nodes"], + ) + pod_names.append(pod_name) + + logging.info("waiting all the attackers to finish:") + did_finish = False + finished_pods = [] + while not did_finish: + for pod_name in pod_names: + if not lib_telemetry.get_lib_kubernetes().is_pod_running( + pod_name, config["namespace"] + ): + finished_pods.append(pod_name) + if set(pod_names) == set(finished_pods): + did_finish = True + time.sleep(1) + + except Exception as e: + logging.error( + f"SynFloodScenarioPlugin scenario {scenario} failed with exception: {e}" + ) + return 1 + else: + return 0 + + def parse_config(self, scenario_file: str) -> dict[str, any]: + if not os.path.exists(scenario_file): + raise Exception(f"failed to load scenario file {scenario_file}") + + try: + with open(scenario_file) as stream: + config = yaml.safe_load(stream) + except Exception: + raise Exception(f"{scenario_file} is not a valid yaml file") + + missing = [] + if not self.check_key_value(config, "packet-size"): + missing.append("packet-size") + if not self.check_key_value(config, "window-size"): + missing.append("window-size") + if not self.check_key_value(config, "duration"): + missing.append("duration") + if not self.check_key_value(config, "namespace"): + missing.append("namespace") + if not self.check_key_value(config, "number-of-pods"): + missing.append("number-of-pods") + if not self.check_key_value(config, "target-port"): + missing.append("target-port") + if not self.check_key_value(config, "image"): + missing.append("image") + if "target-service" not in config.keys(): + missing.append("target-service") + if "target-service-label" not in config.keys(): + missing.append("target-service-label") + + if len(missing) > 0: + raise Exception(f"{(',').join(missing)} parameter(s) are missing") + + if not config["target-service"] and not config["target-service-label"]: + raise Exception("you have either to set a target service or a label") + if config["target-service"] and config["target-service-label"]: + raise Exception( + "you cannot select both target-service and target-service-label" + ) + + if "attacker-nodes" and not self.is_node_affinity_correct( + config["attacker-nodes"] + ): + raise Exception("attacker-nodes format is not correct") + return config + + def check_key_value(self, dictionary, key): + if key in dictionary: + value = dictionary[key] + if value is not None and value != "": + return True + return False + + def is_node_affinity_correct(self, obj) -> bool: + if not isinstance(obj, dict): + return False + for key in obj.keys(): + if not isinstance(key, str): + return False + if not isinstance(obj[key], list): + return False + return True + + def get_scenario_types(self) -> list[str]: + return ["syn_flood_scenarios"] diff --git a/krkn/scenario_plugins/time_actions/__init__.py b/krkn/scenario_plugins/time_actions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/krkn/scenario_plugins/time_actions/time_actions_scenario_plugin.py b/krkn/scenario_plugins/time_actions/time_actions_scenario_plugin.py new file mode 100644 index 00000000..0ba97d2a --- /dev/null +++ b/krkn/scenario_plugins/time_actions/time_actions_scenario_plugin.py @@ -0,0 +1,352 @@ +import datetime +import logging +import random +import re +import time + +import yaml +from krkn_lib.k8s import KrknKubernetes +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from krkn_lib.utils import get_random_string, get_yaml_item_value, log_exception +from kubernetes.client import ApiException + +from krkn import cerberus, utils +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class TimeActionsScenarioPlugin(AbstractScenarioPlugin): + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + try: + with open(scenario, "r") as f: + scenario_config = yaml.full_load(f) + for time_scenario in scenario_config["time_scenarios"]: + start_time = int(time.time()) + object_type, object_names = self.skew_time( + time_scenario, lib_telemetry.get_lib_kubernetes() + ) + not_reset = self.check_date_time( + object_type, + object_names, + lib_telemetry.get_lib_kubernetes(), + ) + if len(not_reset) > 0: + logging.info("Object times were not reset") + end_time = int(time.time()) + cerberus.publish_kraken_status( + krkn_config, not_reset, start_time, end_time + ) + except (RuntimeError, Exception): + logging.error( + f"TimeActionsScenarioPlugin scenario {scenario} failed with exception: {e}" + ) + return 1 + else: + return 0 + + def pod_exec( + self, pod_name, command, namespace, container_name, kubecli: KrknKubernetes + ): + for i in range(5): + response = kubecli.exec_cmd_in_pod( + command, pod_name, namespace, container_name + ) + if not response: + time.sleep(2) + continue + elif ( + "unauthorized" in response.lower() + or "authorization" in response.lower() + ): + time.sleep(2) + continue + else: + break + return response + + # krkn_lib + def get_container_name( + self, pod_name, namespace, kubecli: KrknKubernetes, container_name="" + ): + + container_names = kubecli.get_containers_in_pod(pod_name, namespace) + if container_name != "": + if container_name in container_names: + return container_name + else: + logging.error( + "Container name %s not an existing container in pod %s" + % (container_name, pod_name) + ) + else: + container_name = container_names[ + # random module here is not used for security/cryptographic + # purposes + random.randint(0, len(container_names) - 1) # nosec + ] + return container_name + + def skew_node(self, node_name: str, action: str, kubecli: KrknKubernetes): + pod_namespace = "default" + status_pod_name = f"time-skew-pod-{get_random_string(5)}" + skew_pod_name = f"time-skew-pod-{get_random_string(5)}" + ntp_enabled = True + logging.info( + f'Creating pod to skew {"time" if action == "skew_time" else "date"} on node {node_name}' + ) + status_command = ["timedatectl"] + param = "2001-01-01" + skew_command = ["timedatectl", "set-time"] + if action == "skew_time": + skew_command.append("01:01:01") + else: + skew_command.append("2001-01-01") + + try: + status_response = kubecli.exec_command_on_node( + node_name, status_command, status_pod_name, pod_namespace + ) + if "Network time on: no" in status_response: + ntp_enabled = False + + logging.warning( + f'ntp unactive on node {node_name} skewing {"time" if action == "skew_time" else "date"} to {param}' + ) + self.pod_exec(skew_pod_name, skew_command, pod_namespace, None, kubecli) + else: + logging.info( + f'ntp active in cluster node, {"time" if action == "skew_time" else "date"} skewing will have no effect, skipping' + ) + except ApiException: + pass + except Exception as e: + logging.error(f"failed to execute skew command in pod: {e}") + finally: + kubecli.delete_pod(status_pod_name, pod_namespace) + if not ntp_enabled: + kubecli.delete_pod(skew_pod_name, pod_namespace) + + # krkn_lib + def skew_time(self, scenario, kubecli: KrknKubernetes): + if scenario["action"] not in ["skew_date", "skew_time"]: + raise RuntimeError(f'{scenario["action"]} is not a valid time skew action') + + if "node" in scenario["object_type"]: + node_names = [] + if "object_name" in scenario.keys() and scenario["object_name"]: + node_names = scenario["object_name"] + elif "label_selector" in scenario.keys() and scenario["label_selector"]: + node_names = kubecli.list_nodes(scenario["label_selector"]) + for node in node_names: + self.skew_node(node, scenario["action"], kubecli) + logging.info("Reset date/time on node " + str(node)) + return "node", node_names + + elif "pod" in scenario["object_type"]: + skew_command = "date --date " + if scenario["action"] == "skew_date": + skewed_date = "00-01-01" + skew_command += skewed_date + elif scenario["action"] == "skew_time": + skewed_time = "01:01:01" + skew_command += skewed_time + container_name = get_yaml_item_value(scenario, "container_name", "") + pod_names = [] + if "object_name" in scenario.keys() and scenario["object_name"]: + for name in scenario["object_name"]: + if "namespace" not in scenario.keys(): + logging.error("Need to set namespace when using pod name") + # removed_exit + # sys.exit(1) + raise RuntimeError() + pod_names.append([name, scenario["namespace"]]) + elif "namespace" in scenario.keys() and scenario["namespace"]: + if "label_selector" not in scenario.keys(): + logging.info( + "label_selector key not found, querying for all the pods " + "in namespace: %s" % (scenario["namespace"]) + ) + pod_names = kubecli.list_pods(scenario["namespace"]) + else: + logging.info( + "Querying for the pods matching the %s label_selector " + "in namespace %s" + % (scenario["label_selector"], scenario["namespace"]) + ) + pod_names = kubecli.list_pods( + scenario["namespace"], scenario["label_selector"] + ) + counter = 0 + for pod_name in pod_names: + pod_names[counter] = [pod_name, scenario["namespace"]] + counter += 1 + elif "label_selector" in scenario.keys() and scenario["label_selector"]: + pod_names = kubecli.get_all_pods(scenario["label_selector"]) + + if len(pod_names) == 0: + logging.info( + "Cannot find pods matching the namespace/label_selector, " + "please check" + ) + + raise RuntimeError() + pod_counter = 0 + for pod in pod_names: + if len(pod) > 1: + selected_container_name = self.get_container_name( + pod[0], + pod[1], + kubecli, + container_name, + ) + pod_exec_response = self.pod_exec( + pod[0], + skew_command, + pod[1], + selected_container_name, + kubecli, + ) + if pod_exec_response is False: + logging.error( + "Couldn't reset time on container %s " + "in pod %s in namespace %s" + % (selected_container_name, pod[0], pod[1]) + ) + # removed_exit + # sys.exit(1) + raise RuntimeError() + pod_names[pod_counter].append(selected_container_name) + else: + selected_container_name = self.get_container_name( + pod, scenario["namespace"], kubecli, container_name + ) + pod_exec_response = self.pod_exec( + pod, + skew_command, + scenario["namespace"], + selected_container_name, + kubecli, + ) + if pod_exec_response is False: + logging.error( + "Couldn't reset time on container " + "%s in pod %s in namespace %s" + % (selected_container_name, pod, scenario["namespace"]) + ) + # removed_exit + # sys.exit(1) + raise RuntimeError() + pod_names[pod_counter].append(selected_container_name) + logging.info("Reset date/time on pod " + str(pod[0])) + pod_counter += 1 + return "pod", pod_names + + # From kubectl/oc command get time output + def parse_string_date(self, obj_datetime): + try: + logging.info("Obj_date time " + str(obj_datetime)) + obj_datetime = re.sub(r"\s\s+", " ", obj_datetime).strip() + logging.info("Obj_date sub time " + str(obj_datetime)) + date_line = re.match( + r"[\s\S\n]*\w{3} \w{3} \d{1,} \d{2}:\d{2}:\d{2} \w{3} \d{4}[\s\S\n]*", # noqa + obj_datetime, + ) + if date_line is not None: + search_response = date_line.group().strip() + logging.info("Search response: " + str(search_response)) + return search_response + else: + return "" + except Exception as e: + logging.info("Exception %s when trying to parse string to date" % str(e)) + return "" + + # Get date and time from string returned from OC + def string_to_date(self, obj_datetime): + obj_datetime = self.parse_string_date(obj_datetime) + try: + date_time_obj = datetime.datetime.strptime( + obj_datetime, "%a %b %d %H:%M:%S %Z %Y" + ) + return date_time_obj + except Exception: + logging.info("Couldn't parse string to datetime object") + return datetime.datetime(datetime.MINYEAR, 1, 1) + + # krkn_lib + def check_date_time(self, object_type, names, kubecli: KrknKubernetes): + skew_command = "date" + not_reset = [] + max_retries = 30 + if object_type == "node": + for node_name in names: + first_date_time = datetime.datetime.utcnow() + check_pod_name = f"time-skew-pod-{get_random_string(5)}" + node_datetime_string = kubecli.exec_command_on_node( + node_name, [skew_command], check_pod_name + ) + node_datetime = self.string_to_date(node_datetime_string) + counter = 0 + while not ( + first_date_time < node_datetime < datetime.datetime.utcnow() + ): + time.sleep(10) + logging.info( + "Date/time on node %s still not reset, " + "waiting 10 seconds and retrying" % node_name + ) + + node_datetime_string = kubecli.exec_cmd_in_pod( + [skew_command], check_pod_name, "default" + ) + node_datetime = self.string_to_date(node_datetime_string) + counter += 1 + if counter > max_retries: + logging.error( + "Date and time in node %s didn't reset properly" % node_name + ) + not_reset.append(node_name) + break + if counter < max_retries: + logging.info("Date in node " + str(node_name) + " reset properly") + kubecli.delete_pod(check_pod_name) + + elif object_type == "pod": + for pod_name in names: + first_date_time = datetime.datetime.utcnow() + counter = 0 + pod_datetime_string = self.pod_exec( + pod_name[0], skew_command, pod_name[1], pod_name[2], kubecli + ) + pod_datetime = self.string_to_date(pod_datetime_string) + while not (first_date_time < pod_datetime < datetime.datetime.utcnow()): + time.sleep(10) + logging.info( + "Date/time on pod %s still not reset, " + "waiting 10 seconds and retrying" % pod_name[0] + ) + pod_datetime = self.pod_exec( + pod_name[0], skew_command, pod_name[1], pod_name[2], kubecli + ) + pod_datetime = self.string_to_date(pod_datetime) + counter += 1 + if counter > max_retries: + logging.error( + "Date and time in pod %s didn't reset properly" + % pod_name[0] + ) + not_reset.append(pod_name[0]) + break + if counter < max_retries: + logging.info("Date in pod " + str(pod_name[0]) + " reset properly") + return not_reset + + def get_scenario_types(self) -> list[str]: + return ["time_scenarios"] diff --git a/krkn/scenario_plugins/zone_outage/__init__.py b/krkn/scenario_plugins/zone_outage/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/krkn/scenario_plugins/zone_outage/zone_outage_scenario_plugin.py b/krkn/scenario_plugins/zone_outage/zone_outage_scenario_plugin.py new file mode 100644 index 00000000..c2a83ee5 --- /dev/null +++ b/krkn/scenario_plugins/zone_outage/zone_outage_scenario_plugin.py @@ -0,0 +1,102 @@ +import logging +import time + +import yaml +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from krkn_lib.utils import log_exception + +from krkn import utils +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin +from krkn.scenario_plugins.native.network import cerberus +from krkn.scenario_plugins.node_actions.aws_node_scenarios import AWS + + +class ZoneOutageScenarioPlugin(AbstractScenarioPlugin): + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + try: + with open(scenario, "r") as f: + zone_outage_config_yaml = yaml.full_load(f) + scenario_config = zone_outage_config_yaml["zone_outage"] + vpc_id = scenario_config["vpc_id"] + subnet_ids = scenario_config["subnet_id"] + duration = scenario_config["duration"] + cloud_type = scenario_config["cloud_type"] + ids = {} + acl_ids_created = [] + + if cloud_type.lower() == "aws": + cloud_object = AWS() + else: + logging.error( + "ZoneOutageScenarioPlugin Cloud type %s is not currently supported for " + "zone outage scenarios" % cloud_type + ) + return 1 + + start_time = int(time.time()) + + for subnet_id in subnet_ids: + logging.info("Targeting subnet_id") + network_association_ids = [] + associations, original_acl_id = cloud_object.describe_network_acls( + vpc_id, subnet_id + ) + for entry in associations: + if entry["SubnetId"] == subnet_id: + network_association_ids.append( + entry["NetworkAclAssociationId"] + ) + logging.info( + "Network association ids associated with " + "the subnet %s: %s" % (subnet_id, network_association_ids) + ) + acl_id = cloud_object.create_default_network_acl(vpc_id) + new_association_id = cloud_object.replace_network_acl_association( + network_association_ids[0], acl_id + ) + + # capture the orginal_acl_id, created_acl_id and + # new association_id to use during the recovery + ids[new_association_id] = original_acl_id + acl_ids_created.append(acl_id) + + # wait for the specified duration + logging.info( + "Waiting for the specified duration " "in the config: %s" % duration + ) + time.sleep(duration) + + # replace the applied acl with the previous acl in use + for new_association_id, original_acl_id in ids.items(): + cloud_object.replace_network_acl_association( + new_association_id, original_acl_id + ) + logging.info( + "Wating for 60 seconds to make sure " "the changes are in place" + ) + time.sleep(60) + + # delete the network acl created for the run + for acl_id in acl_ids_created: + cloud_object.delete_network_acl(acl_id) + + end_time = int(time.time()) + cerberus.publish_kraken_status(krkn_config, [], start_time, end_time) + except (RuntimeError, Exception): + logging.error( + f"ZoneOutageScenarioPlugin scenario {scenario} failed with exception: {e}" + ) + return 1 + else: + return 0 + + def get_scenario_types(self) -> list[str]: + return ["zone_outages_scenarios"] diff --git a/krkn/tests/__init__.py b/krkn/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/krkn/tests/test_classes/__init__.py b/krkn/tests/test_classes/__init__.py new file mode 100644 index 00000000..bd575866 --- /dev/null +++ b/krkn/tests/test_classes/__init__.py @@ -0,0 +1,21 @@ +from typing import List, Tuple + +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift + +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class WrongModuleScenarioPlugin(AbstractScenarioPlugin): + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + pass + + def get_scenario_types(self) -> list[str]: + pass diff --git a/krkn/tests/test_classes/correct_scenario_plugin.py b/krkn/tests/test_classes/correct_scenario_plugin.py new file mode 100644 index 00000000..7b3d6b6c --- /dev/null +++ b/krkn/tests/test_classes/correct_scenario_plugin.py @@ -0,0 +1,22 @@ +from typing import List, Tuple + +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift + +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class CorrectScenarioPlugin(AbstractScenarioPlugin): + + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + pass + + def get_scenario_types(self) -> list[str]: + return ["correct_scenarios", "scenarios_correct"] diff --git a/krkn/tests/test_classes/duplicated_scenario_plugin.py b/krkn/tests/test_classes/duplicated_scenario_plugin.py new file mode 100644 index 00000000..ac25849d --- /dev/null +++ b/krkn/tests/test_classes/duplicated_scenario_plugin.py @@ -0,0 +1,20 @@ +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift + +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class DuplicatedScenarioPlugin(AbstractScenarioPlugin): + + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + pass + + def get_scenario_types(self) -> list[str]: + return ["another_irrelevant_scenario", "duplicated_scenario"] diff --git a/krkn/tests/test_classes/duplicated_two_scenario_plugin.py b/krkn/tests/test_classes/duplicated_two_scenario_plugin.py new file mode 100644 index 00000000..da22380e --- /dev/null +++ b/krkn/tests/test_classes/duplicated_two_scenario_plugin.py @@ -0,0 +1,20 @@ +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift + +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class DuplicatedTwoScenarioPlugin(AbstractScenarioPlugin): + + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + pass + + def get_scenario_types(self) -> list[str]: + return ["duplicated_scenario", "irellevant_scenario"] diff --git a/krkn/tests/test_classes/example_scenario_plugin.py b/krkn/tests/test_classes/example_scenario_plugin.py new file mode 100644 index 00000000..86d64224 --- /dev/null +++ b/krkn/tests/test_classes/example_scenario_plugin.py @@ -0,0 +1,56 @@ +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +# Each plugin must extend the AbstractScenarioPlugin abstract class +# and implement its methods. Also the naming conventions must be respected +# you can refer to the documentation for the details: +# https://github.com/krkn-chaos/krkn/blob/main/docs/scenario_plugin_api.md +class ExampleScenarioPlugin(AbstractScenarioPlugin): + + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + """ + :param run_uuid: the uuid of the chaos run generated by krkn for every single run + :param scenario: the config file of the scenario that is currently executed + :param krkn_config: the full dictionary representation of the `config.yaml` + :param lib_telemetry: it is a composite object of all the + [krkn-lib](https://krkn-chaos.github.io/krkn-lib-docs/modules.html) + objects and methods needed by a krkn plugin to run. + :param scenario_telemetry: the `ScenarioTelemetry` object of the scenario that is currently executed + """ + + pass + + try: + # The scenario logic for each scenario must be placed + # here. A try-except it is needed to catch exceptions + # that may occur in this section and they shouldn't + # be propagated outside (only int return value is admitted). + + # krkn-lib KrknKubernetes object containing all the kubernetes primitives + # can be retrieved by the KrknTelemetryOpenshift object + krkn_kubernetes = lib_telemetry.get_lib_kubernetes() + + # krkn-lib KrknOpenshift object containing all the OCP primitives + # can be retrieved by the KrknTelemetryOpenshift object + krkn_openshift = lib_telemetry.get_lib_ocp() + + # if the scenario succeeds the telemetry exit status is 0 + return 0 + except Exception as e: + # if the scenario fails the telemetry exit status is 1 + return 1 + + # Reflects the scenario type defined in the config.yaml + # in the chaos_scenarios section and to which each class + # responds. + def get_scenario_types(self) -> list[str]: + return ["example_scenarios"] diff --git a/krkn/tests/test_classes/snake_case_mismatch_scenario_plugin.py b/krkn/tests/test_classes/snake_case_mismatch_scenario_plugin.py new file mode 100644 index 00000000..1638163e --- /dev/null +++ b/krkn/tests/test_classes/snake_case_mismatch_scenario_plugin.py @@ -0,0 +1,22 @@ +from typing import List, Tuple + +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift + +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class SnakeMismatchScenarioPlugin(AbstractScenarioPlugin): + + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + pass + + def get_scenario_types(self) -> list[str]: + pass diff --git a/krkn/tests/test_classes/wrong_classname_scenario_plugin.py b/krkn/tests/test_classes/wrong_classname_scenario_plugin.py new file mode 100644 index 00000000..0a6bdd12 --- /dev/null +++ b/krkn/tests/test_classes/wrong_classname_scenario_plugin.py @@ -0,0 +1,22 @@ +from typing import List, Tuple + +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift + +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class WrongClassNamePlugin(AbstractScenarioPlugin): + + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + pass + + def get_scenario_types(self) -> list[str]: + pass diff --git a/krkn/tests/test_classes/wrong_module.py b/krkn/tests/test_classes/wrong_module.py new file mode 100644 index 00000000..b63cda9f --- /dev/null +++ b/krkn/tests/test_classes/wrong_module.py @@ -0,0 +1,22 @@ +from typing import List, Tuple + +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift + +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin + + +class WrongModuleScenarioPlugin(AbstractScenarioPlugin): + + def run( + self, + run_uuid: str, + scenario: str, + krkn_config: dict[str, any], + lib_telemetry: KrknTelemetryOpenshift, + scenario_telemetry: ScenarioTelemetry, + ) -> int: + pass + + def get_scenario_types(self) -> list[str]: + pass diff --git a/krkn/tests/test_plugin_factory.py b/krkn/tests/test_plugin_factory.py new file mode 100644 index 00000000..4494ea47 --- /dev/null +++ b/krkn/tests/test_plugin_factory.py @@ -0,0 +1,110 @@ +import unittest + +from krkn.scenario_plugins.abstract_scenario_plugin import AbstractScenarioPlugin +from krkn.scenario_plugins.scenario_plugin_factory import ScenarioPluginFactory +from krkn.tests.test_classes.correct_scenario_plugin import ( + CorrectScenarioPlugin, +) + + +class TestPluginFactory(unittest.TestCase): + + def test_plugin_factory(self): + factory = ScenarioPluginFactory("krkn.tests.test_classes") + self.assertEqual(len(factory.loaded_plugins), 5) + self.assertEqual(len(factory.failed_plugins), 4) + self.assertIs( + factory.loaded_plugins["correct_scenarios"].__base__, + AbstractScenarioPlugin, + ) + self.assertTrue( + isinstance( + factory.loaded_plugins["correct_scenarios"](), CorrectScenarioPlugin + ) + ) + # soLid + self.assertTrue( + isinstance( + factory.loaded_plugins["correct_scenarios"](), AbstractScenarioPlugin + ) + ) + + self.assertTrue( + "krkn.tests.test_classes.snake_case_mismatch_scenario_plugin" + in [p[0] for p in factory.failed_plugins] + ) + self.assertTrue( + "krkn.tests.test_classes.wrong_classname_scenario_plugin" + in [p[0] for p in factory.failed_plugins] + ) + self.assertTrue( + "krkn.tests.test_classes.wrong_module" + in [p[0] for p in factory.failed_plugins] + ) + + def test_plugin_factory_naming_convention(self): + factory = ScenarioPluginFactory() + correct_module_name = "krkn.scenario_plugins.example.correct_scenario_plugin" + correct_class_name = "CorrectScenarioPlugin" + correct_class_name_no_match = "NoMatchScenarioPlugin" + wrong_module_name = "krkn.scenario_plugins.example.correct_plugin" + wrong_class_name = "WrongScenario" + wrong_folder_name_plugin = ( + "krkn.scenario_plugins.example_plugin.example_plugin_scenario_plugin" + ) + wrong_folder_name_plugin_class_name = "ExamplePluginScenarioPlugin" + wrong_folder_name_scenario = ( + "krkn.scenario_plugins.example_scenario.example_scenario_scenario_plugin" + ) + wrong_folder_name_scenario_class_name = "ExampleScenarioScenarioPlugin" + + result, message = factory.is_naming_convention_correct( + correct_module_name, correct_class_name + ) + self.assertTrue(result) + self.assertIsNone(message) + + result, message = factory.is_naming_convention_correct( + wrong_module_name, correct_class_name + ) + self.assertFalse(result) + self.assertEqual( + message, + "scenario plugin module file names must end with `_scenario_plugin` suffix", + ) + + result, message = factory.is_naming_convention_correct( + correct_module_name, wrong_class_name + ) + self.assertFalse(result) + self.assertEqual( + message, + "scenario plugin class name must start with a capital letter, " + "end with `ScenarioPlugin`, and cannot be just `ScenarioPlugin`.", + ) + + result, message = factory.is_naming_convention_correct( + correct_module_name, correct_class_name_no_match + ) + self.assertFalse(result) + self.assertEqual( + message, + "module file name must in snake case must match class name in capital camel case " + "e.g. `example_scenario_plugin` -> `ExampleScenarioPlugin`", + ) + + result, message = factory.is_naming_convention_correct( + wrong_folder_name_plugin, wrong_folder_name_plugin_class_name + ) + self.assertFalse(result) + self.assertEqual( + message, "scenario plugin folder cannot contain `scenario` or `plugin` word" + ) + + result, message = factory.is_naming_convention_correct( + wrong_folder_name_scenario, wrong_folder_name_scenario_class_name + ) + self.assertFalse(result) + self.assertEqual( + message, "scenario plugin folder cannot contain `scenario` or `plugin` word" + ) diff --git a/kraken/utils/TeeLogHandler.py b/krkn/utils/TeeLogHandler.py similarity index 100% rename from kraken/utils/TeeLogHandler.py rename to krkn/utils/TeeLogHandler.py diff --git a/kraken/utils/__init__.py b/krkn/utils/__init__.py similarity index 100% rename from kraken/utils/__init__.py rename to krkn/utils/__init__.py diff --git a/krkn/utils/functions.py b/krkn/utils/functions.py new file mode 100644 index 00000000..6f66263a --- /dev/null +++ b/krkn/utils/functions.py @@ -0,0 +1,80 @@ +import krkn_lib.utils +from krkn_lib.k8s import KrknKubernetes +from krkn_lib.models.telemetry import ScenarioTelemetry +from krkn_lib.telemetry.ocp import KrknTelemetryOpenshift +from tzlocal.unix import get_localzone + + +def populate_cluster_events( + scenario_telemetry: ScenarioTelemetry, + scenario_config: dict, + kubecli: KrknKubernetes, + start_timestamp: int, + end_timestamp: int, +): + events = [] + namespaces = __retrieve_namespaces(scenario_config, kubecli) + + if len(namespaces) == 0: + events.extend( + kubecli.collect_and_parse_cluster_events( + start_timestamp, end_timestamp, str(get_localzone()) + ) + ) + else: + for namespace in namespaces: + events.extend( + kubecli.collect_and_parse_cluster_events( + start_timestamp, + end_timestamp, + str(get_localzone()), + namespace=namespace, + ) + ) + + scenario_telemetry.set_cluster_events(events) + + +def collect_and_put_ocp_logs( + telemetry_ocp: KrknTelemetryOpenshift, + scenario_config: dict, + request_id: str, + start_timestamp: int, + end_timestamp: int, +): + if ( + telemetry_ocp.get_telemetry_config() + and telemetry_ocp.get_telemetry_config()["enabled"] + and telemetry_ocp.get_telemetry_config()["logs_backup"] + and not telemetry_ocp.get_lib_kubernetes().is_kubernetes() + ): + namespaces = __retrieve_namespaces( + scenario_config, telemetry_ocp.get_lib_kubernetes() + ) + if len(namespaces) > 0: + for namespace in namespaces: + telemetry_ocp.put_ocp_logs( + request_id, + telemetry_ocp.get_telemetry_config(), + start_timestamp, + end_timestamp, + namespace, + ) + else: + telemetry_ocp.put_ocp_logs( + request_id, + telemetry_ocp.get_telemetry_config(), + start_timestamp, + end_timestamp, + ) + + +def __retrieve_namespaces(scenario_config: dict, kubecli: KrknKubernetes) -> set[str]: + namespaces = list() + namespaces.extend(krkn_lib.utils.deep_get_attribute("namespace", scenario_config)) + namespace_patterns = krkn_lib.utils.deep_get_attribute( + "namespace_pattern", scenario_config + ) + for pattern in namespace_patterns: + namespaces.extend(kubecli.list_namespaces_by_regex(pattern)) + return set(namespaces) diff --git a/requirements.txt b/requirements.txt index d736be24..932f3bea 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ google-api-python-client==2.116.0 ibm_cloud_sdk_core==3.18.0 ibm_vpc==0.20.0 jinja2==3.1.4 -krkn-lib==3.1.2 +krkn-lib==4.0.0 lxml==5.1.0 kubernetes==28.1.0 numpy==1.26.4 diff --git a/run_kraken.py b/run_kraken.py index db8c4626..ea6ec698 100644 --- a/run_kraken.py +++ b/run_kraken.py @@ -14,24 +14,9 @@ from krkn_lib.elastic.krkn_elastic import KrknElastic from krkn_lib.models.elastic import ElasticChaosRunTelemetry from krkn_lib.models.krkn import ChaosRunOutput, ChaosRunAlertSummary from krkn_lib.prometheus.krkn_prometheus import KrknPrometheus -from tzlocal.unix import get_localzone - -import kraken.time_actions.common_time_functions as time_actions -import kraken.performance_dashboards.setup as performance_dashboards -import kraken.pod_scenarios.setup as pod_scenarios -import kraken.service_disruption.common_service_disruption_functions as service_disruption -import kraken.shut_down.common_shut_down_func as shut_down -import kraken.node_actions.run as nodeaction -import kraken.managedcluster_scenarios.run as managedcluster_scenarios -import kraken.zone_outage.actions as zone_outages -import kraken.application_outage.actions as application_outage -import kraken.pvc.pvc_scenario as pvc_scenario -import kraken.network_chaos.actions as network_chaos -import kraken.arcaflow_plugin as arcaflow_plugin -import kraken.prometheus as prometheus_plugin -import kraken.service_hijacking.service_hijacking as service_hijacking_plugin +import krkn.performance_dashboards.setup as performance_dashboards +import krkn.prometheus as prometheus_plugin import server as server -from kraken import plugins, syn_flood from krkn_lib.k8s import KrknKubernetes from krkn_lib.ocp import KrknOpenshift from krkn_lib.telemetry.k8s import KrknTelemetryKubernetes @@ -40,10 +25,15 @@ from krkn_lib.models.telemetry import ChaosRunTelemetry from krkn_lib.utils import SafeLogger from krkn_lib.utils.functions import get_yaml_item_value, get_junit_test_case -from kraken.utils import TeeLogHandler +from krkn.utils import TeeLogHandler +from krkn.scenario_plugins.scenario_plugin_factory import ( + ScenarioPluginFactory, + ScenarioPluginNotFound, +) report_file = "" + # Main function def main(cfg) -> int: # Start kraken @@ -62,31 +52,25 @@ def main(cfg) -> int: get_yaml_item_value(config["kraken"], "kubeconfig_path", "") ) kraken_config = cfg - chaos_scenarios = get_yaml_item_value( - config["kraken"], "chaos_scenarios", [] - ) + chaos_scenarios = get_yaml_item_value(config["kraken"], "chaos_scenarios", []) publish_running_status = get_yaml_item_value( config["kraken"], "publish_kraken_status", False ) port = get_yaml_item_value(config["kraken"], "port", 8081) signal_address = get_yaml_item_value( - config["kraken"], "signal_address", "0.0.0.0") - run_signal = get_yaml_item_value( - config["kraken"], "signal_state", "RUN" - ) - wait_duration = get_yaml_item_value( - config["tunings"], "wait_duration", 60 + config["kraken"], "signal_address", "0.0.0.0" ) + run_signal = get_yaml_item_value(config["kraken"], "signal_state", "RUN") + wait_duration = get_yaml_item_value(config["tunings"], "wait_duration", 60) iterations = get_yaml_item_value(config["tunings"], "iterations", 1) - daemon_mode = get_yaml_item_value( - config["tunings"], "daemon_mode", False - ) + daemon_mode = get_yaml_item_value(config["tunings"], "daemon_mode", False) deploy_performance_dashboards = get_yaml_item_value( config["performance_monitoring"], "deploy_dashboards", False ) dashboard_repo = get_yaml_item_value( - config["performance_monitoring"], "repo", - "https://github.com/cloud-bulldozer/performance-dashboards.git" + config["performance_monitoring"], + "repo", + "https://github.com/cloud-bulldozer/performance-dashboards.git", ) prometheus_url = config["performance_monitoring"].get("prometheus_url") @@ -101,9 +85,7 @@ def main(cfg) -> int: config["performance_monitoring"], "enable_metrics", False ) # elastic search - enable_elastic = get_yaml_item_value( - config["elastic"], "enable_elastic", False - ) + enable_elastic = get_yaml_item_value(config["elastic"], "enable_elastic", False) elastic_collect_metrics = get_yaml_item_value( config["elastic"], "collect_metrics", False ) @@ -112,24 +94,16 @@ def main(cfg) -> int: config["elastic"], "collect_alerts", False ) - elastic_url = get_yaml_item_value( - config["elastic"], "elastic_url", "" - ) + elastic_url = get_yaml_item_value(config["elastic"], "elastic_url", "") elastic_verify_certs = get_yaml_item_value( config["elastic"], "verify_certs", False ) - elastic_port = get_yaml_item_value( - config["elastic"], "elastic_port", 32766 - ) + elastic_port = get_yaml_item_value(config["elastic"], "elastic_port", 32766) - elastic_username = get_yaml_item_value( - config["elastic"], "username", "" - ) - elastic_password = get_yaml_item_value( - config["elastic"], "password", "" - ) + elastic_username = get_yaml_item_value(config["elastic"], "username", "") + elastic_password = get_yaml_item_value(config["elastic"], "password", "") elastic_metrics_index = get_yaml_item_value( config["elastic"], "metrics_index", "krkn-metrics" @@ -143,8 +117,6 @@ def main(cfg) -> int: config["elastic"], "telemetry_index", "krkn-telemetry" ) - - alert_profile = config["performance_monitoring"].get("alert_profile") metrics_profile = config["performance_monitoring"].get("metrics_profile") check_critical_alerts = get_yaml_item_value( @@ -152,14 +124,13 @@ def main(cfg) -> int: ) telemetry_api_url = config["telemetry"].get("api_url") - # Initialize clients - if (not os.path.isfile(kubeconfig_path) and - not os.path.isfile("/var/run/secrets/kubernetes.io/serviceaccount/token")): + if not os.path.isfile(kubeconfig_path) and not os.path.isfile( + "/var/run/secrets/kubernetes.io/serviceaccount/token" + ): logging.error( "Cannot read the kubeconfig file at %s, please check" % kubeconfig_path ) - #sys.exit(1) return 1 logging.info("Initializing client to talk to the Kubernetes cluster") @@ -175,8 +146,12 @@ def main(cfg) -> int: # request_id for telemetry is generated once here and used everywhere telemetry_request_id = f"{int(time.time())}-{run_uuid}" if config["telemetry"].get("run_tag"): - telemetry_request_id = f"{telemetry_request_id}-{config['telemetry']['run_tag']}" - telemetry_log_file = f'{config["telemetry"]["archive_path"]}/{telemetry_request_id}.log' + telemetry_request_id = ( + f"{telemetry_request_id}-{config['telemetry']['run_tag']}" + ) + telemetry_log_file = ( + f'{config["telemetry"]["archive_path"]}/{telemetry_request_id}.log' + ) safe_logger = SafeLogger(filename=telemetry_log_file) try: @@ -194,11 +169,9 @@ def main(cfg) -> int: # Set up kraken url to track signal if not 0 <= int(port) <= 65535: logging.error("%s isn't a valid port number, please check" % (port)) - #sys.exit(1) return 1 if not signal_address: logging.error("Please set the signal address in the config") - #sys.exit(1) return 1 address = (signal_address, port) @@ -223,13 +196,15 @@ def main(cfg) -> int: if connection_data: prometheus_url = connection_data.endpoint prometheus_bearer_token = connection_data.token - else: + else: # If can't make a connection, set alerts to false enable_alerts = False critical_alerts = False except Exception: - logging.error("invalid distribution selected, running openshift scenarios against kubernetes cluster." - "Please set 'kubernetes' in config.yaml krkn.platform and try again") + logging.error( + "invalid distribution selected, running openshift scenarios against kubernetes cluster." + "Please set 'kubernetes' in config.yaml krkn.platform and try again" + ) return 1 if cv != "": logging.info(cv) @@ -237,17 +212,22 @@ def main(cfg) -> int: logging.info("Cluster version CRD not detected, skipping") # KrknTelemetry init - telemetry_k8s = KrknTelemetryKubernetes(safe_logger, kubecli, config["telemetry"]) - telemetry_ocp = KrknTelemetryOpenshift(safe_logger, ocpcli, config["telemetry"]) + telemetry_k8s = KrknTelemetryKubernetes( + safe_logger, kubecli, config["telemetry"] + ) + telemetry_ocp = KrknTelemetryOpenshift( + safe_logger, ocpcli, telemetry_request_id, config["telemetry"] + ) if enable_elastic: - elastic_search = KrknElastic(safe_logger, - elastic_url, - elastic_port, - elastic_verify_certs, - elastic_username, - elastic_password - ) - else: + elastic_search = KrknElastic( + safe_logger, + elastic_url, + elastic_port, + elastic_verify_certs, + elastic_username, + elastic_password, + ) + else: elastic_search = None summary = ChaosRunAlertSummary() if enable_metrics or enable_alerts or check_critical_alerts: @@ -259,8 +239,6 @@ def main(cfg) -> int: if deploy_performance_dashboards: performance_dashboards.setup(dashboard_repo, distribution) - - # Initialize the start iteration to 0 iteration = 0 @@ -285,11 +263,44 @@ def main(cfg) -> int: chaos_output = ChaosRunOutput() chaos_telemetry = ChaosRunTelemetry() chaos_telemetry.run_uuid = run_uuid + scenario_plugin_factory = ScenarioPluginFactory() + classes_and_types: dict[str, list[str]] = {} + for loaded in scenario_plugin_factory.loaded_plugins.keys(): + if ( + scenario_plugin_factory.loaded_plugins[loaded].__name__ + not in classes_and_types.keys() + ): + classes_and_types[ + scenario_plugin_factory.loaded_plugins[loaded].__name__ + ] = [] + classes_and_types[ + scenario_plugin_factory.loaded_plugins[loaded].__name__ + ].append(loaded) + logging.info( + "📣 `ScenarioPluginFactory`: types from config.yaml mapped to respective classes for execution:" + ) + for class_loaded in classes_and_types.keys(): + if len(classes_and_types[class_loaded]) <= 1: + logging.info( + f" ✅ type: {classes_and_types[class_loaded][0]} ➡️ `{class_loaded}` " + ) + else: + logging.info( + f" ✅ types: [{', '.join(classes_and_types[class_loaded])}] ➡️ `{class_loaded}` " + ) + logging.info("\n") + if len(scenario_plugin_factory.failed_plugins) > 0: + logging.info("Failed to load Scenario Plugins:\n") + for failed in scenario_plugin_factory.failed_plugins: + module_name, class_name, error = failed + logging.error(f"⛔ Class: {class_name} Module: {module_name}") + logging.error(f"⚠️ {error}\n") # Loop to run the chaos starts here while int(iteration) < iterations and run_signal != "STOP": # Inject chaos scenarios specified in the config logging.info("Executing scenarios for iteration " + str(iteration)) if chaos_scenarios: + for scenario in chaos_scenarios: if publish_running_status: run_signal = server.get_status(address) @@ -307,183 +318,43 @@ def main(cfg) -> int: scenario_type = list(scenario.keys())[0] scenarios_list = scenario[scenario_type] if scenarios_list: - # Inject pod chaos scenarios specified in the config - if scenario_type == "pod_scenarios": + try: + scenario_plugin = scenario_plugin_factory.create_plugin( + scenario_type + ) + except ScenarioPluginNotFound: logging.error( - "Pod scenarios have been removed, please use " - "plugin_scenarios with the " - "kill-pods configuration instead." + f"impossible to find scenario {scenario_type}, plugin not found. Exiting" ) - return 1 - elif scenario_type == "arcaflow_scenarios": - failed_post_scenarios, scenario_telemetries = arcaflow_plugin.run( - scenarios_list, - telemetry_ocp, - telemetry_request_id + sys.exit(1) + + failed_post_scenarios, scenario_telemetries = ( + scenario_plugin.run_scenarios( + run_uuid, scenarios_list, config, telemetry_ocp ) - chaos_telemetry.scenarios.extend(scenario_telemetries) + ) + chaos_telemetry.scenarios.extend(scenario_telemetries) - elif scenario_type == "plugin_scenarios": - failed_post_scenarios, scenario_telemetries = plugins.run( - scenarios_list, - kraken_config, - failed_post_scenarios, - wait_duration, - telemetry_ocp, - run_uuid, - telemetry_request_id - ) - chaos_telemetry.scenarios.extend(scenario_telemetries) - # krkn_lib - elif scenario_type == "container_scenarios": - logging.info("Running container scenarios") - failed_post_scenarios, scenario_telemetries = pod_scenarios.container_run( - scenarios_list, - config, - failed_post_scenarios, - wait_duration, - telemetry_ocp, - telemetry_request_id - ) - chaos_telemetry.scenarios.extend(scenario_telemetries) - - # Inject node chaos scenarios specified in the config - # krkn_lib - elif scenario_type == "node_scenarios": - logging.info("Running node scenarios") - failed_post_scenarios, scenario_telemetries = nodeaction.run(scenarios_list, - config, - wait_duration, - telemetry_ocp, - telemetry_request_id) - chaos_telemetry.scenarios.extend(scenario_telemetries) - # Inject managedcluster chaos scenarios specified in the config - # krkn_lib - elif scenario_type == "managedcluster_scenarios": - logging.info("Running managedcluster scenarios") - managedcluster_scenarios.run( - scenarios_list, - config, - wait_duration, - kubecli - ) - - # Inject time skew chaos scenarios specified - # in the config - # krkn_lib - elif scenario_type == "time_scenarios": - logging.info("Running time skew scenarios") - failed_post_scenarios, scenario_telemetries = time_actions.run(scenarios_list, - config, - wait_duration, - telemetry_ocp, - telemetry_request_id - ) - chaos_telemetry.scenarios.extend(scenario_telemetries) - # Inject cluster shutdown scenarios - # krkn_lib - elif scenario_type == "cluster_shut_down_scenarios": - failed_post_scenarios, scenario_telemetries = shut_down.run(scenarios_list, - config, - wait_duration, - telemetry_ocp, - telemetry_request_id - ) - chaos_telemetry.scenarios.extend(scenario_telemetries) - - # Inject namespace chaos scenarios - # krkn_lib - elif scenario_type == "service_disruption_scenarios": - logging.info("Running service disruption scenarios") - failed_post_scenarios, scenario_telemetries = service_disruption.run( - scenarios_list, - config, - wait_duration, - failed_post_scenarios, - telemetry_ocp, - telemetry_request_id - ) - chaos_telemetry.scenarios.extend(scenario_telemetries) - - # Inject zone failures - elif scenario_type == "zone_outages": - logging.info("Inject zone outages") - failed_post_scenarios, scenario_telemetries = zone_outages.run(scenarios_list, - config, - wait_duration, - telemetry_ocp, - telemetry_request_id - ) - chaos_telemetry.scenarios.extend(scenario_telemetries) - # Application outages - elif scenario_type == "application_outages": - logging.info("Injecting application outage") - failed_post_scenarios, scenario_telemetries = application_outage.run( - scenarios_list, - config, - wait_duration, - telemetry_ocp, - telemetry_request_id - ) - chaos_telemetry.scenarios.extend(scenario_telemetries) - - # PVC scenarios - # krkn_lib - elif scenario_type == "pvc_scenarios": - logging.info("Running PVC scenario") - failed_post_scenarios, scenario_telemetries = pvc_scenario.run(scenarios_list, - config, - wait_duration, - telemetry_ocp, - telemetry_request_id - ) - chaos_telemetry.scenarios.extend(scenario_telemetries) - - # Network scenarios - # krkn_lib - elif scenario_type == "network_chaos": - logging.info("Running Network Chaos") - failed_post_scenarios, scenario_telemetries = network_chaos.run(scenarios_list, - config, - wait_duration, - telemetry_ocp, - telemetry_request_id - ) - elif scenario_type == "service_hijacking": - logging.info("Running Service Hijacking Chaos") - failed_post_scenarios, scenario_telemetries = service_hijacking_plugin.run(scenarios_list, - wait_duration, - telemetry_ocp, - telemetry_request_id - ) - chaos_telemetry.scenarios.extend(scenario_telemetries) - elif scenario_type == "syn_flood": - logging.info("Running Syn Flood Chaos") - failed_post_scenarios, scenario_telemetries = syn_flood.run(scenarios_list, - telemetry_ocp, - telemetry_request_id - ) - chaos_telemetry.scenarios.extend(scenario_telemetries) - - # Check for critical alerts when enabled post_critical_alerts = 0 if check_critical_alerts: - prometheus_plugin.critical_alerts(prometheus, - summary, - run_uuid, - scenario_type, - start_time, - datetime.datetime.now()) + prometheus_plugin.critical_alerts( + prometheus, + summary, + run_uuid, + scenario_type, + start_time, + datetime.datetime.now(), + ) chaos_output.critical_alerts = summary post_critical_alerts = len(summary.post_chaos_alerts) if post_critical_alerts > 0: - logging.error("Post chaos critical alerts firing please check, exiting") + logging.error( + "Post chaos critical alerts firing please check, exiting" + ) break - iteration += 1 - logging.info("") # telemetry # in order to print decoded telemetry data even if telemetry collection @@ -495,8 +366,12 @@ def main(cfg) -> int: # Cloud platform and network plugins metadata # through OCP specific APIs if distribution == "openshift": + logging.info( + "collecting OCP cluster metadata, this may take few minutes...." + ) telemetry_ocp.collect_cluster_metadata(chaos_telemetry) else: + logging.info("collecting Kubernetes cluster metadata....") telemetry_k8s.collect_cluster_metadata(chaos_telemetry) telemetry_json = chaos_telemetry.to_json() @@ -504,53 +379,82 @@ def main(cfg) -> int: chaos_output.telemetry = decoded_chaos_run_telemetry logging.info(f"Chaos data:\n{chaos_output.to_json()}") if enable_elastic: - elastic_telemetry = ElasticChaosRunTelemetry(chaos_run_telemetry=decoded_chaos_run_telemetry) - result = elastic_search.push_telemetry(elastic_telemetry, elastic_telemetry_index) + elastic_telemetry = ElasticChaosRunTelemetry( + chaos_run_telemetry=decoded_chaos_run_telemetry + ) + result = elastic_search.push_telemetry( + elastic_telemetry, elastic_telemetry_index + ) if result == -1: - safe_logger.error(f"failed to save telemetry on elastic search: {chaos_output.to_json()}") + safe_logger.error( + f"failed to save telemetry on elastic search: {chaos_output.to_json()}" + ) if config["telemetry"]["enabled"]: - logging.info(f'telemetry data will be stored on s3 bucket folder: {telemetry_api_url}/files/' - f'{(config["telemetry"]["telemetry_group"] if config["telemetry"]["telemetry_group"] else "default")}/' - f'{telemetry_request_id}') + logging.info( + f"telemetry data will be stored on s3 bucket folder: {telemetry_api_url}/files/" + f'{(config["telemetry"]["telemetry_group"] if config["telemetry"]["telemetry_group"] else "default")}/' + f"{telemetry_request_id}" + ) logging.info(f"telemetry upload log: {safe_logger.log_file_name}") try: - telemetry_k8s.send_telemetry(config["telemetry"], telemetry_request_id, chaos_telemetry) - telemetry_k8s.put_critical_alerts(telemetry_request_id, config["telemetry"], summary) + telemetry_k8s.send_telemetry( + config["telemetry"], telemetry_request_id, chaos_telemetry + ) + telemetry_k8s.put_critical_alerts( + telemetry_request_id, config["telemetry"], summary + ) # prometheus data collection is available only on Openshift if config["telemetry"]["prometheus_backup"]: - prometheus_archive_files = '' - if distribution == "openshift" : - prometheus_archive_files = telemetry_ocp.get_ocp_prometheus_data(config["telemetry"], telemetry_request_id) + prometheus_archive_files = "" + if distribution == "openshift": + prometheus_archive_files = ( + telemetry_ocp.get_ocp_prometheus_data( + config["telemetry"], telemetry_request_id + ) + ) else: - if (config["telemetry"]["prometheus_namespace"] and - config["telemetry"]["prometheus_pod_name"] and - config["telemetry"]["prometheus_container_name"]): + if ( + config["telemetry"]["prometheus_namespace"] + and config["telemetry"]["prometheus_pod_name"] + and config["telemetry"]["prometheus_container_name"] + ): try: - prometheus_archive_files = telemetry_k8s.get_prometheus_pod_data( - config["telemetry"], - telemetry_request_id, - config["telemetry"]["prometheus_pod_name"], - config["telemetry"]["prometheus_container_name"], - config["telemetry"]["prometheus_namespace"] + prometheus_archive_files = ( + telemetry_k8s.get_prometheus_pod_data( + config["telemetry"], + telemetry_request_id, + config["telemetry"]["prometheus_pod_name"], + config["telemetry"][ + "prometheus_container_name" + ], + config["telemetry"]["prometheus_namespace"], + ) ) except Exception as e: - logging.error(f"failed to get prometheus backup with exception {str(e)}") + logging.error( + f"failed to get prometheus backup with exception {str(e)}" + ) else: - logging.warning("impossible to backup prometheus," - "check if config contains telemetry.prometheus_namespace, " - "telemetry.prometheus_pod_name and " - "telemetry.prometheus_container_name") + logging.warning( + "impossible to backup prometheus," + "check if config contains telemetry.prometheus_namespace, " + "telemetry.prometheus_pod_name and " + "telemetry.prometheus_container_name" + ) if prometheus_archive_files: safe_logger.info("starting prometheus archive upload:") - telemetry_k8s.put_prometheus_data(config["telemetry"], prometheus_archive_files, telemetry_request_id) - + telemetry_k8s.put_prometheus_data( + config["telemetry"], + prometheus_archive_files, + telemetry_request_id, + ) + except Exception as e: logging.error(f"failed to send telemetry data: {str(e)}") else: logging.info("telemetry collection disabled, skipping.") - # Check for the alerts specified if enable_alerts: logging.info("Alerts checking is enabled") @@ -563,33 +467,35 @@ def main(cfg) -> int: end_time, alert_profile, elastic_colllect_alerts, - elastic_alerts_index + elastic_alerts_index, ) else: logging.error("Alert profile is not defined") return 1 - #sys.exit(1) + # sys.exit(1) if enable_metrics: - prometheus_plugin.metrics(prometheus, - elastic_search, - start_time, - run_uuid, - end_time, - metrics_profile, - elastic_collect_metrics, - elastic_metrics_index) + prometheus_plugin.metrics( + prometheus, + elastic_search, + start_time, + run_uuid, + end_time, + metrics_profile, + elastic_collect_metrics, + elastic_metrics_index, + ) if post_critical_alerts > 0: logging.error("Critical alerts are firing, please check; exiting") - #sys.exit(2) + # sys.exit(2) return 2 if failed_post_scenarios: logging.error( "Post scenarios are still failing at the end of all iterations" ) - #sys.exit(2) + # sys.exit(2) return 2 logging.info( @@ -598,13 +504,12 @@ def main(cfg) -> int: ) else: logging.error("Cannot find a config at %s, please check" % (cfg)) - #sys.exit(1) + # sys.exit(1) return 2 return 0 - if __name__ == "__main__": # Initialize the parser to read the config parser = optparse.OptionParser() @@ -623,8 +528,6 @@ if __name__ == "__main__": default="kraken.report", ) - - parser.add_option( "--junit-testcase", dest="junit_testcase", @@ -649,7 +552,11 @@ if __name__ == "__main__": (options, args) = parser.parse_args() report_file = options.output tee_handler = TeeLogHandler() - handlers = [logging.FileHandler(report_file, mode="w"), logging.StreamHandler(), tee_handler] + handlers = [ + logging.FileHandler(report_file, mode="w"), + logging.StreamHandler(), + tee_handler, + ] logging.basicConfig( level=logging.INFO, @@ -666,12 +573,16 @@ if __name__ == "__main__": junit_start_time = time.time() # checks if both mandatory options for junit are set if options.junit_testcase_path and not options.junit_testcase: - logging.error("please set junit test case description with --junit-testcase [description] option") + logging.error( + "please set junit test case description with --junit-testcase [description] option" + ) option_error = True junit_error = True if options.junit_testcase and not options.junit_testcase_path: - logging.error("please set junit test case path with --junit-testcase-path [path] option") + logging.error( + "please set junit test case path with --junit-testcase-path [path] option" + ) option_error = True junit_error = True @@ -680,17 +591,23 @@ if __name__ == "__main__": junit_normalized_path = os.path.normpath(options.junit_testcase_path) if not os.path.exists(junit_normalized_path): - logging.error(f"{junit_normalized_path} do not exists, please select a valid path") + logging.error( + f"{junit_normalized_path} do not exists, please select a valid path" + ) option_error = True junit_error = True if not os.path.isdir(junit_normalized_path): - logging.error(f"{junit_normalized_path} is a file, please select a valid folder path") + logging.error( + f"{junit_normalized_path} is a file, please select a valid folder path" + ) option_error = True junit_error = True if not os.access(junit_normalized_path, os.W_OK): - logging.error(f"{junit_normalized_path} is not writable, please select a valid path") + logging.error( + f"{junit_normalized_path} is not writable, please select a valid path" + ) option_error = True junit_error = True @@ -713,9 +630,11 @@ if __name__ == "__main__": test_suite_name="krkn-test-suite", test_case_description=options.junit_testcase, test_stdout=tee_handler.get_output(), - test_version=options.junit_testcase_version + test_version=options.junit_testcase_version, + ) + junit_testcase_file_path = ( + f"{junit_normalized_path}/junit_krkn_{int(time.time())}.xml" ) - junit_testcase_file_path = f"{junit_normalized_path}/junit_krkn_{int(time.time())}.xml" logging.info(f"writing junit XML testcase in {junit_testcase_file_path}") with open(junit_testcase_file_path, "w") as stream: stream.write(junit_testcase_xml) diff --git a/scenarios/arcaflow/cpu-hog/config.yaml b/scenarios/kube/cpu-hog/config.yaml similarity index 100% rename from scenarios/arcaflow/cpu-hog/config.yaml rename to scenarios/kube/cpu-hog/config.yaml diff --git a/scenarios/arcaflow/cpu-hog/input.yaml b/scenarios/kube/cpu-hog/input.yaml similarity index 100% rename from scenarios/arcaflow/cpu-hog/input.yaml rename to scenarios/kube/cpu-hog/input.yaml diff --git a/scenarios/arcaflow/cpu-hog/sub-workflow.yaml b/scenarios/kube/cpu-hog/sub-workflow.yaml similarity index 100% rename from scenarios/arcaflow/cpu-hog/sub-workflow.yaml rename to scenarios/kube/cpu-hog/sub-workflow.yaml diff --git a/scenarios/arcaflow/cpu-hog/workflow.yaml b/scenarios/kube/cpu-hog/workflow.yaml similarity index 100% rename from scenarios/arcaflow/cpu-hog/workflow.yaml rename to scenarios/kube/cpu-hog/workflow.yaml diff --git a/scenarios/arcaflow/io-hog/config.yaml b/scenarios/kube/io-hog/config.yaml similarity index 100% rename from scenarios/arcaflow/io-hog/config.yaml rename to scenarios/kube/io-hog/config.yaml diff --git a/scenarios/arcaflow/io-hog/input.yaml b/scenarios/kube/io-hog/input.yaml similarity index 100% rename from scenarios/arcaflow/io-hog/input.yaml rename to scenarios/kube/io-hog/input.yaml diff --git a/scenarios/arcaflow/io-hog/sub-workflow.yaml b/scenarios/kube/io-hog/sub-workflow.yaml similarity index 100% rename from scenarios/arcaflow/io-hog/sub-workflow.yaml rename to scenarios/kube/io-hog/sub-workflow.yaml diff --git a/scenarios/arcaflow/io-hog/workflow.yaml b/scenarios/kube/io-hog/workflow.yaml similarity index 100% rename from scenarios/arcaflow/io-hog/workflow.yaml rename to scenarios/kube/io-hog/workflow.yaml diff --git a/scenarios/arcaflow/memory-hog/config.yaml b/scenarios/kube/memory-hog/config.yaml similarity index 100% rename from scenarios/arcaflow/memory-hog/config.yaml rename to scenarios/kube/memory-hog/config.yaml diff --git a/scenarios/arcaflow/memory-hog/input.yaml b/scenarios/kube/memory-hog/input.yaml similarity index 100% rename from scenarios/arcaflow/memory-hog/input.yaml rename to scenarios/kube/memory-hog/input.yaml diff --git a/scenarios/arcaflow/memory-hog/sub-workflow.yaml b/scenarios/kube/memory-hog/sub-workflow.yaml similarity index 100% rename from scenarios/arcaflow/memory-hog/sub-workflow.yaml rename to scenarios/kube/memory-hog/sub-workflow.yaml diff --git a/scenarios/arcaflow/memory-hog/workflow.yaml b/scenarios/kube/memory-hog/workflow.yaml similarity index 100% rename from scenarios/arcaflow/memory-hog/workflow.yaml rename to scenarios/kube/memory-hog/workflow.yaml diff --git a/scenarios/openshift/post_action_etcd_container.py b/scenarios/openshift/post_action_etcd_container.py deleted file mode 100755 index ff39723f..00000000 --- a/scenarios/openshift/post_action_etcd_container.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -import subprocess -import logging -import time - - -def run(cmd): - try: - output = subprocess.Popen( - cmd, shell=True, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT - ) - (out, err) = output.communicate() - except Exception as e: - logging.error("Failed to run %s, error: %s" % (cmd, e)) - return out - - -i = 0 -while i < 100: - pods_running = run("oc get pods -n openshift-etcd -l app=etcd | grep -c '4/4'").rstrip() - if pods_running == "3": - break - time.sleep(5) - i += 1 - -if pods_running == str(3): - print("There were 3 pods running properly") -else: - print("ERROR there were " + str(pods_running) + " pods running instead of 3") diff --git a/scenarios/openshift/post_action_etcd_example_py.py b/scenarios/openshift/post_action_etcd_example_py.py deleted file mode 100755 index 1c7a2cf4..00000000 --- a/scenarios/openshift/post_action_etcd_example_py.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 -import subprocess -import logging - - -def run(cmd): - try: - output = subprocess.Popen( - cmd, shell=True, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT - ) - (out, err) = output.communicate() - logging.info("out " + str(out)) - except Exception as e: - logging.error("Failed to run %s, error: %s" % (cmd, e)) - return out - - -pods_running = run("oc get pods -n openshift-etcd | grep -c Running").rstrip() - -if pods_running == str(3): - print("There were 3 pods running properly") -else: - print("ERROR there were " + str(pods_running) + " pods running instead of 3") diff --git a/scenarios/openshift/post_action_namespace.py b/scenarios/openshift/post_action_namespace.py deleted file mode 100755 index 180a0ffd..00000000 --- a/scenarios/openshift/post_action_namespace.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -import subprocess -import time - - -def run(cmd): - try: - output = subprocess.Popen( - cmd, shell=True, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT - ) - (out, err) = output.communicate() - except Exception as e: - print("Failed to run %s, error: %s" % (cmd, e)) - return out - - -i = 0 -while i < 100: - projects_active = run("oc get project | grep 'ingress' | grep -c Active").rstrip() - if projects_active == "3": - break - i += 1 - time.sleep(5) - -if projects_active == str(3): - print("There were 3 projects running properly") -else: - print("ERROR there were " + str(projects_active) + " projects running instead of 3") diff --git a/scenarios/openshift/post_action_prometheus.yml b/scenarios/openshift/post_action_prometheus.yml deleted file mode 100644 index eed2687c..00000000 --- a/scenarios/openshift/post_action_prometheus.yml +++ /dev/null @@ -1,6 +0,0 @@ -# yaml-language-server: $schema=../plugin.schema.json -- id: kill-pods - config: - namespace_pattern: ^openshift-monitoring$ - label_selector: app=prometheus - krkn_pod_recovery_time: 120 \ No newline at end of file diff --git a/scenarios/openshift/post_action_regex.py b/scenarios/openshift/post_action_regex.py deleted file mode 100755 index e7530688..00000000 --- a/scenarios/openshift/post_action_regex.py +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env python3 -import logging -import re -import subprocess -import sys - -from kubernetes import client, config -from kubernetes.client.rest import ApiException - - -def list_namespaces(): - """ - List all namespaces - """ - spaces_list = [] - try: - config.load_kube_config() - cli = client.CoreV1Api() - ret = cli.list_namespace(pretty=True) - except ApiException as e: - logging.error( - "Exception when calling CoreV1Api->list_namespace: %s\n", - e - ) - for current_namespace in ret.items: - spaces_list.append(current_namespace.metadata.name) - return spaces_list - - -def check_namespaces(namespaces): - """ - Check if all the watch_namespaces are valid - """ - try: - valid_namespaces = list_namespaces() - regex_namespaces = set(namespaces) - set(valid_namespaces) - final_namespaces = set(namespaces) - set(regex_namespaces) - valid_regex = set() - if regex_namespaces: - for current_ns in valid_namespaces: - for regex_namespace in regex_namespaces: - if re.search(regex_namespace, current_ns): - final_namespaces.add(current_ns) - valid_regex.add(regex_namespace) - break - invalid_namespaces = regex_namespaces - valid_regex - if invalid_namespaces: - raise Exception( - "There exists no namespaces matching: %s" % ( - invalid_namespaces - ) - ) - return list(final_namespaces) - except Exception as e: - logging.error(str(e)) - sys.exit(1) - - -def run(cmd): - try: - output = subprocess.Popen( - cmd, - shell=True, - universal_newlines=True, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT - ) - (out, err) = output.communicate() - except Exception as e: - logging.error("Failed to run %s, error: %s", cmd, e) - return out - - -def print_running_pods(): - regex_namespace_list = ["openshift-.*"] - checked_namespaces = check_namespaces(regex_namespace_list) - pods_running = 0 - for namespace in checked_namespaces: - new_pods_running = run( - "oc get pods -n " + namespace + " | grep -c Running" - ).rstrip() - try: - pods_running += int(new_pods_running) - except Exception: - continue - print(pods_running) - - -if __name__ == '__main__': - print_running_pods() diff --git a/scenarios/openshift/post_action_regex.sh b/scenarios/openshift/post_action_regex.sh deleted file mode 100755 index 10626cc0..00000000 --- a/scenarios/openshift/post_action_regex.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash -pods="$(oc get pods -n openshift-etcd | grep -c Running)" -echo "$pods" - -if [ "$pods" -eq 3 ] -then - echo "Pods Pass" -else - # need capital error for proper error catching in run_kraken - echo "ERROR pod count $pods doesnt match 3 expected pods" -fi diff --git a/scenarios/openshift/post_action_shut_down.py b/scenarios/openshift/post_action_shut_down.py deleted file mode 100644 index a8ec7e78..00000000 --- a/scenarios/openshift/post_action_shut_down.py +++ /dev/null @@ -1,76 +0,0 @@ -#!/usr/bin/env python3 -import subprocess -import logging -import time -import yaml - - -def run(cmd): - out = "" - try: - output = subprocess.Popen( - cmd, shell=True, universal_newlines=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT - ) - (out, err) = output.communicate() - except Exception as e: - logging.info("Failed to run %s, error: %s" % (cmd, e)) - return out - - -# Get cluster operators and return yaml -def get_cluster_operators(): - operators_status = run("kubectl get co -o yaml") - status_yaml = yaml.safe_load(operators_status, Loader=yaml.FullLoader) - return status_yaml - - -# Monitor cluster operators -def monitor_cluster_operator(cluster_operators): - failed_operators = [] - for operator in cluster_operators["items"]: - # loop through the conditions in the status section to find the dedgraded condition - if "status" in operator.keys() and "conditions" in operator["status"].keys(): - for status_cond in operator["status"]["conditions"]: - # if the degraded status is not false, add it to the failed operators to return - if status_cond["type"] == "Degraded" and status_cond["status"] != "False": - failed_operators.append(operator["metadata"]["name"]) - break - else: - logging.info("Can't find status of " + operator["metadata"]["name"]) - failed_operators.append(operator["metadata"]["name"]) - # return False if there are failed operators else return True - return failed_operators - - -wait_duration = 10 -timeout = 900 -counter = 0 - -counter = 0 -co_yaml = get_cluster_operators() -failed_operators = monitor_cluster_operator(co_yaml) -while len(failed_operators) > 0: - time.sleep(wait_duration) - co_yaml = get_cluster_operators() - failed_operators = monitor_cluster_operator(co_yaml) - if counter >= timeout: - print("Cluster operators are still degraded after " + str(timeout) + "seconds") - print("Degraded operators " + str(failed_operators)) - exit(1) - counter += wait_duration - -not_ready = run("oc get nodes --no-headers | grep 'NotReady' | wc -l").rstrip() -while int(not_ready) > 0: - time.sleep(wait_duration) - not_ready = run("oc get nodes --no-headers | grep 'NotReady' | wc -l").rstrip() - if counter >= timeout: - print("Nodes are still not ready after " + str(timeout) + "seconds") - exit(1) - counter += wait_duration - -worker_nodes = run("oc get nodes --no-headers | grep worker | egrep -v NotReady | awk '{print $1}'").rstrip() -print("Worker nodes list \n" + str(worker_nodes)) -master_nodes = run("oc get nodes --no-headers | grep master | egrep -v NotReady | awk '{print $1}'").rstrip() -print("Master nodes list \n" + str(master_nodes)) -infra_nodes = run("oc get nodes --no-headers | grep infra | egrep -v NotReady | awk '{print $1}'").rstrip() -print("Infra nodes list \n" + str(infra_nodes)) diff --git a/tests/test_ingress_network_plugin.py b/tests/test_ingress_network_plugin.py index daea3f5b..6ea8f4da 100644 --- a/tests/test_ingress_network_plugin.py +++ b/tests/test_ingress_network_plugin.py @@ -1,7 +1,8 @@ import unittest import logging from arcaflow_plugin_sdk import plugin -from kraken.plugins.network import ingress_shaping + +from krkn.scenario_plugins.native.network import ingress_shaping class NetworkScenariosTest(unittest.TestCase): @@ -9,25 +10,26 @@ class NetworkScenariosTest(unittest.TestCase): def test_serialization(self): plugin.test_object_serialization( ingress_shaping.NetworkScenarioConfig( - node_interface_name={"foo": ['bar']}, + node_interface_name={"foo": ["bar"]}, network_params={ "latency": "50ms", "loss": "0.02", - "bandwidth": "100mbit" - } + "bandwidth": "100mbit", + }, ), self.fail, ) plugin.test_object_serialization( ingress_shaping.NetworkScenarioSuccessOutput( filter_direction="ingress", - test_interfaces={"foo": ['bar']}, + test_interfaces={"foo": ["bar"]}, network_parameters={ "latency": "50ms", "loss": "0.02", - "bandwidth": "100mbit" + "bandwidth": "100mbit", }, - execution_type="parallel"), + execution_type="parallel", + ), self.fail, ) plugin.test_object_serialization( @@ -45,10 +47,10 @@ class NetworkScenariosTest(unittest.TestCase): network_params={ "latency": "50ms", "loss": "0.02", - "bandwidth": "100mbit" - } + "bandwidth": "100mbit", + }, ), - run_id="network-shaping-test" + run_id="network-shaping-test", ) if output_id == "error": logging.error(output_data.error) diff --git a/tests/test_run_python_plugin.py b/tests/test_run_python_plugin.py index ded01312..a29c01a9 100644 --- a/tests/test_run_python_plugin.py +++ b/tests/test_run_python_plugin.py @@ -1,28 +1,37 @@ import tempfile import unittest -from kraken.plugins import run_python_file -from kraken.plugins.run_python_plugin import RunPythonFileInput +from krkn.scenario_plugins.native.run_python_plugin import ( + RunPythonFileInput, + run_python_file, +) class RunPythonPluginTest(unittest.TestCase): def test_success_execution(self): tmp_file = tempfile.NamedTemporaryFile() - tmp_file.write(bytes("print('Hello world!')", 'utf-8')) + tmp_file.write(bytes("print('Hello world!')", "utf-8")) tmp_file.flush() - output_id, output_data = run_python_file(params=RunPythonFileInput(tmp_file.name), run_id="test-python-plugin-success") + output_id, output_data = run_python_file( + params=RunPythonFileInput(tmp_file.name), + run_id="test-python-plugin-success", + ) self.assertEqual("success", output_id) self.assertEqual("Hello world!\n", output_data.stdout) def test_error_execution(self): tmp_file = tempfile.NamedTemporaryFile() - tmp_file.write(bytes("import sys\nprint('Hello world!')\nsys.exit(42)\n", 'utf-8')) + tmp_file.write( + bytes("import sys\nprint('Hello world!')\nsys.exit(42)\n", "utf-8") + ) tmp_file.flush() - output_id, output_data = run_python_file(params=RunPythonFileInput(tmp_file.name), run_id="test-python-plugin-error") + output_id, output_data = run_python_file( + params=RunPythonFileInput(tmp_file.name), run_id="test-python-plugin-error" + ) self.assertEqual("error", output_id) self.assertEqual(42, output_data.exit_code) self.assertEqual("Hello world!\n", output_data.stdout) -if __name__ == '__main__': +if __name__ == "__main__": unittest.main() diff --git a/tests/test_vmware_plugin.py b/tests/test_vmware_plugin.py index 058beabe..2dd7fab2 100644 --- a/tests/test_vmware_plugin.py +++ b/tests/test_vmware_plugin.py @@ -2,33 +2,25 @@ import unittest import os import logging from arcaflow_plugin_sdk import plugin -from kraken.plugins.node_scenarios.kubernetes_functions import Actions -from kraken.plugins.node_scenarios import vmware_plugin + +from krkn.scenario_plugins.native.node_scenarios import vmware_plugin +from krkn.scenario_plugins.native.node_scenarios.kubernetes_functions import Actions class NodeScenariosTest(unittest.TestCase): def setUp(self): - vsphere_env_vars = [ - "VSPHERE_IP", - "VSPHERE_USERNAME", - "VSPHERE_PASSWORD" - ] + vsphere_env_vars = ["VSPHERE_IP", "VSPHERE_USERNAME", "VSPHERE_PASSWORD"] self.credentials_present = all( env_var in os.environ for env_var in vsphere_env_vars ) def test_serialization(self): plugin.test_object_serialization( - vmware_plugin.NodeScenarioConfig( - name="test", - skip_openshift_checks=True - ), + vmware_plugin.NodeScenarioConfig(name="test", skip_openshift_checks=True), self.fail, ) plugin.test_object_serialization( - vmware_plugin.NodeScenarioSuccessOutput( - nodes={}, action=Actions.START - ), + vmware_plugin.NodeScenarioSuccessOutput(nodes={}, action=Actions.START), self.fail, ) plugin.test_object_serialization( diff --git a/utils/chaos_recommender/chaos_recommender.py b/utils/chaos_recommender/chaos_recommender.py index ac9eae80..9bf9da29 100644 --- a/utils/chaos_recommender/chaos_recommender.py +++ b/utils/chaos_recommender/chaos_recommender.py @@ -6,16 +6,17 @@ import re import sys import time import yaml + # kraken module import for running the recommender # both from the root directory and the recommender # folder -sys.path.insert(0, './') -sys.path.insert(0, '../../') +sys.path.insert(0, "./") +sys.path.insert(0, "../../") from krkn_lib.utils import get_yaml_item_value -import kraken.chaos_recommender.analysis as analysis -import kraken.chaos_recommender.prometheus as prometheus +import krkn.chaos_recommender.analysis as analysis +import krkn.chaos_recommender.prometheus as prometheus from kubernetes import config as kube_config @@ -23,28 +24,101 @@ def parse_arguments(parser): # command line options parser.add_argument("-c", "--config-file", action="store", help="Config file path") - parser.add_argument("-o", "--options", action="store_true", help="Evaluate command line options") - parser.add_argument("-n", "--namespaces", action="store", default="", nargs="+", help="Kubernetes application namespaces separated by space") - parser.add_argument("-p", "--prometheus-endpoint", action="store", default="", help="Prometheus endpoint URI") - parser.add_argument("-k", "--kubeconfig", action="store", default=kube_config.KUBE_CONFIG_DEFAULT_LOCATION, help="Kubeconfig path") - parser.add_argument("-t", "--token", action="store", default="", help="Kubernetes authentication token") - parser.add_argument("-s", "--scrape-duration", action="store", default="10m", help="Prometheus scrape duration") - parser.add_argument("-L", "--log-level", action="store", default="INFO", help="log level (DEBUG, INFO, WARNING, ERROR, CRITICAL") + parser.add_argument( + "-o", "--options", action="store_true", help="Evaluate command line options" + ) + parser.add_argument( + "-n", + "--namespaces", + action="store", + default="", + nargs="+", + help="Kubernetes application namespaces separated by space", + ) + parser.add_argument( + "-p", + "--prometheus-endpoint", + action="store", + default="", + help="Prometheus endpoint URI", + ) + parser.add_argument( + "-k", + "--kubeconfig", + action="store", + default=kube_config.KUBE_CONFIG_DEFAULT_LOCATION, + help="Kubeconfig path", + ) + parser.add_argument( + "-t", + "--token", + action="store", + default="", + help="Kubernetes authentication token", + ) + parser.add_argument( + "-s", + "--scrape-duration", + action="store", + default="10m", + help="Prometheus scrape duration", + ) + parser.add_argument( + "-L", + "--log-level", + action="store", + default="INFO", + help="log level (DEBUG, INFO, WARNING, ERROR, CRITICAL", + ) - parser.add_argument("-J", "--json-output-file", default=False, nargs="?", action="store", - help="Create output file, the path to the folder can be specified, if not specified the default folder is used") + parser.add_argument( + "-J", + "--json-output-file", + default=False, + nargs="?", + action="store", + help="Create output file, the path to the folder can be specified, if not specified the default folder is used", + ) - parser.add_argument("-M", "--MEM", nargs='+', action="store", default=[], - help="Memory related chaos tests (space separated list)") - parser.add_argument("-C", "--CPU", nargs='+', action="store", default=[], - help="CPU related chaos tests (space separated list)") - parser.add_argument("-N", "--NETWORK", nargs='+', action="store", default=[], - help="Network related chaos tests (space separated list)") - parser.add_argument("-G", "--GENERIC", nargs='+', action="store", default=[], - help="Memory related chaos tests (space separated list)") + parser.add_argument( + "-M", + "--MEM", + nargs="+", + action="store", + default=[], + help="Memory related chaos tests (space separated list)", + ) + parser.add_argument( + "-C", + "--CPU", + nargs="+", + action="store", + default=[], + help="CPU related chaos tests (space separated list)", + ) + parser.add_argument( + "-N", + "--NETWORK", + nargs="+", + action="store", + default=[], + help="Network related chaos tests (space separated list)", + ) + parser.add_argument( + "-G", + "--GENERIC", + nargs="+", + action="store", + default=[], + help="Memory related chaos tests (space separated list)", + ) parser.add_argument("--threshold", action="store", default="", help="Threshold") - parser.add_argument("--cpu-threshold", action="store", default="", help="CPU threshold") - parser.add_argument("--mem-threshold", action="store", default="", help="Memory threshold") + parser.add_argument( + "--cpu-threshold", action="store", default="", help="CPU threshold" + ) + parser.add_argument( + "--mem-threshold", action="store", default="", help="Memory threshold" + ) return parser.parse_args() @@ -60,7 +134,9 @@ def read_configuration(config_file_path): log_level = config.get("log level", "INFO") namespaces = config.get("namespaces") namespaces = re.split(r",+\s+|,+|\s+", namespaces) - kubeconfig = get_yaml_item_value(config, "kubeconfig", kube_config.KUBE_CONFIG_DEFAULT_LOCATION) + kubeconfig = get_yaml_item_value( + config, "kubeconfig", kube_config.KUBE_CONFIG_DEFAULT_LOCATION + ) prometheus_endpoint = config.get("prometheus_endpoint") auth_token = config.get("auth_token") @@ -74,9 +150,19 @@ def read_configuration(config_file_path): else: output_path = False chaos_tests = config.get("chaos_tests", {}) - return (namespaces, kubeconfig, prometheus_endpoint, auth_token, - scrape_duration, chaos_tests, log_level, threshold, - heatmap_cpu_threshold, heatmap_mem_threshold, output_path) + return ( + namespaces, + kubeconfig, + prometheus_endpoint, + auth_token, + scrape_duration, + chaos_tests, + log_level, + threshold, + heatmap_cpu_threshold, + heatmap_mem_threshold, + output_path, + ) def prompt_input(prompt, default_value): @@ -89,10 +175,7 @@ def prompt_input(prompt, default_value): def make_json_output(inputs, namespace_data, output_path): time_str = time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime()) - data = { - "inputs": inputs, - "analysis_outputs": namespace_data - } + data = {"inputs": inputs, "analysis_outputs": namespace_data} logging.info(f"Summary\n{json.dumps(data, indent=4)}") @@ -106,9 +189,16 @@ def make_json_output(inputs, namespace_data, output_path): logging.info(f"Recommendation output saved in {file}.") -def json_inputs(namespaces, kubeconfig, prometheus_endpoint, scrape_duration, - chaos_tests, threshold, heatmap_cpu_threshold, - heatmap_mem_threshold): +def json_inputs( + namespaces, + kubeconfig, + prometheus_endpoint, + scrape_duration, + chaos_tests, + threshold, + heatmap_cpu_threshold, + heatmap_mem_threshold, +): inputs = { "namespaces": namespaces, "kubeconfig": kubeconfig, @@ -117,7 +207,7 @@ def json_inputs(namespaces, kubeconfig, prometheus_endpoint, scrape_duration, "chaos_tests": chaos_tests, "threshold": threshold, "heatmap_cpu_threshold": heatmap_cpu_threshold, - "heatmap_mem_threshold": heatmap_mem_threshold + "heatmap_mem_threshold": heatmap_mem_threshold, } return inputs @@ -128,34 +218,38 @@ def json_namespace(namespace, queries, analysis_data): "queries": queries, "profiling": analysis_data[0], "heatmap_analysis": analysis_data[1], - "recommendations": analysis_data[2] + "recommendations": analysis_data[2], } return data def main(): - parser = argparse.ArgumentParser(description="Krkn Chaos Recommender Command-Line tool") + parser = argparse.ArgumentParser( + description="Krkn Chaos Recommender Command-Line tool" + ) args = parse_arguments(parser) if args.config_file is None and not args.options: - logging.error("You have to either specify a config file path or pass recommender options as command line arguments") + logging.error( + "You have to either specify a config file path or pass recommender options as command line arguments" + ) parser.print_help() sys.exit(1) if args.config_file is not None: ( - namespaces, - kubeconfig, - prometheus_endpoint, - auth_token, - scrape_duration, - chaos_tests, - log_level, - threshold, - heatmap_cpu_threshold, - heatmap_mem_threshold, - output_path - ) = read_configuration(args.config_file) + namespaces, + kubeconfig, + prometheus_endpoint, + auth_token, + scrape_duration, + chaos_tests, + log_level, + threshold, + heatmap_cpu_threshold, + heatmap_mem_threshold, + output_path, + ) = read_configuration(args.config_file) if args.options: namespaces = args.namespaces @@ -165,7 +259,12 @@ def main(): log_level = args.log_level prometheus_endpoint = args.prometheus_endpoint output_path = args.json_output_file - chaos_tests = {"MEM": args.MEM, "GENERIC": args.GENERIC, "CPU": args.CPU, "NETWORK": args.NETWORK} + chaos_tests = { + "MEM": args.MEM, + "GENERIC": args.GENERIC, + "CPU": args.CPU, + "NETWORK": args.NETWORK, + } threshold = args.threshold heatmap_mem_threshold = args.mem_threshold heatmap_cpu_threshold = args.cpu_threshold @@ -179,29 +278,46 @@ def main(): if output_path is not False: if output_path is None: output_path = "./recommender_output" - logging.info(f"Path for output file not specified. " - f"Using default folder {output_path}") + logging.info( + f"Path for output file not specified. " + f"Using default folder {output_path}" + ) if not os.path.exists(os.path.expanduser(output_path)): logging.error(f"Folder {output_path} for output not found.") sys.exit(1) logging.info("Loading inputs...") - inputs = json_inputs(namespaces, kubeconfig, prometheus_endpoint, - scrape_duration, chaos_tests, threshold, - heatmap_cpu_threshold, heatmap_mem_threshold) + inputs = json_inputs( + namespaces, + kubeconfig, + prometheus_endpoint, + scrape_duration, + chaos_tests, + threshold, + heatmap_cpu_threshold, + heatmap_mem_threshold, + ) namespaces_data = [] logging.info("Starting Analysis...") file_path, queries = prometheus.fetch_utilization_from_prometheus( - prometheus_endpoint, auth_token, namespaces, scrape_duration) + prometheus_endpoint, auth_token, namespaces, scrape_duration + ) - analysis_data = analysis(file_path, namespaces, chaos_tests, threshold, - heatmap_cpu_threshold, heatmap_mem_threshold) + analysis_data = analysis( + file_path, + namespaces, + chaos_tests, + threshold, + heatmap_cpu_threshold, + heatmap_mem_threshold, + ) for namespace in namespaces: - namespace_data = json_namespace(namespace, queries[namespace], - analysis_data[namespace]) + namespace_data = json_namespace( + namespace, queries[namespace], analysis_data[namespace] + ) namespaces_data.append(namespace_data) make_json_output(inputs, namespaces_data, output_path)