# -*- coding: utf-8 -*- import datetime import json import math import random import re from typing import Any, Dict, List from chaoslib.exceptions import ActivityFailed from chaoslib.types import Secrets from kubernetes import client from kubernetes import stream from kubernetes.stream.ws_client import ERROR_CHANNEL from kubernetes.stream.ws_client import STDOUT_CHANNEL from kubernetes.client.models.v1_pod import V1Pod from logzero import logger from chaosk8s import create_k8s_api_client __all__ = ["terminate_pods", "exec_in_pods", "delete_pods"] def terminate_pods(label_selector: str = None, name_pattern: str = None, all: bool = False, rand: bool = False, mode: str = "fixed", qty: int = 1, grace_period: int = -1, ns: str = "default", order: str = "alphabetic", secrets: Secrets = None): """ Terminate a pod gracefully. Select the appropriate pods by label and/or name patterns. Whenever a pattern is provided for the name, all pods retrieved will be filtered out if their name do not match the given pattern. If neither `label_selector` nor `name_pattern` are provided, all pods in the namespace will be selected for termination. If `all` is set to `True`, all matching pods will be terminated. Value of `qty` varies based on `mode`. If `mode` is set to `fixed`, then `qty` refers to number of pods to be terminated. If `mode` is set to `percentage`, then `qty` refers to percentage of pods, from 1 to 100, to be terminated. Default `mode` is `fixed` and default `qty` is `1`. If `order` is set to `oldest`, the retrieved pods will be ordered by the pods creation_timestamp, with the oldest pod first in list. If `rand` is set to `True`, n random pods will be terminated Otherwise, the first retrieved n pods will be terminated. If `grace_period` is greater than or equal to 0, it will be used as the grace period (in seconds) to terminate the pods. Otherwise, the default pod's grace period will be used. """ api = create_k8s_api_client(secrets) v1 = client.CoreV1Api(api) pods = _select_pods(v1, label_selector, name_pattern, all, rand, mode, qty, ns, order) body = client.V1DeleteOptions() if grace_period >= 0: body = client.V1DeleteOptions(grace_period_seconds=grace_period) for p in pods: v1.delete_namespaced_pod(p.metadata.name, ns, body=body) def exec_in_pods(cmd: str, label_selector: str = None, name_pattern: str = None, all: bool = False, rand: bool = False, mode: str = "fixed", qty: int = 1, ns: str = "default", order: str = "alphabetic", container_name: str = None, request_timeout: int = 60, secrets: Secrets = None) -> List[Dict[str, Any]]: """ Execute the command `cmd` in the specified pod's container. Select the appropriate pods by label and/or name patterns. Whenever a pattern is provided for the name, all pods retrieved will be filtered out if their name do not match the given pattern. If neither `label_selector` nor `name_pattern` are provided, all pods in the namespace will be selected for termination. If `all` is set to `True`, all matching pods will be affected. Value of `qty` varies based on `mode`. If `mode` is set to `fixed`, then `qty` refers to number of pods affected. If `mode` is set to `percentage`, then `qty` refers to percentage of pods, from 1 to 100, to be affected. Default `mode` is `fixed` and default `qty` is `1`. If `order` is set to `oldest`, the retrieved pods will be ordered by the pods creation_timestamp, with the oldest pod first in list. If `rand` is set to `True`, n random pods will be affected Otherwise, the first retrieved n pods will be used """ if not cmd: raise ActivityFailed("A command must be set to run a container") api = create_k8s_api_client(secrets) v1 = client.CoreV1Api(api) pods = _select_pods(v1, label_selector, name_pattern, all, rand, mode, qty, ns, order) exec_command = cmd.strip().split() results = [] for po in pods: logger.debug("Picked pods '{p}' for command execution {c}".format( p=po.metadata.name, c=exec_command)) if not any(c.name == container_name for c in po.spec.containers): logger.debug("Pod {p} do not have container named '{n}'".format( p=po.metadata.name, n=container_name)) continue pod_execution_result = {} # Use _preload_content to get back the raw JSON response. resp = stream.stream(v1.connect_get_namespaced_pod_exec, po.metadata.name, ns, container=container_name, command=exec_command, stderr=True, stdin=False, stdout=True, tty=False, _preload_content=False) resp.run_forever(timeout=request_timeout) err = json.loads(resp.read_channel(ERROR_CHANNEL)) out = resp.read_channel(STDOUT_CHANNEL) if err['status'] != "Success": error_code = err['details']['causes'][0]['message'] error_message = err['message'] else: error_code = 0 error_message = '' results.append(dict(pod_name=po.metadata.name, exit_code=error_code, cmd=cmd, stdout=out, stderr=error_message)) return results ############################################################################### # Internals ############################################################################### def _sort_by_pod_creation_timestamp(pod: V1Pod) -> datetime.datetime: """ Function that serves as a key for the sort pods comparison """ return pod.metadata.creation_timestamp def _select_pods(v1: client.CoreV1Api = None, label_selector: str = None, name_pattern: str = None, all: bool = False, rand: bool = False, mode: str = "fixed", qty: int = 1, ns: str = "default", order: str = "alphabetic") -> List[V1Pod]: # Fail if CoreV1Api is not instanciated if v1 is None: raise ActivityFailed("Cannot select pods. Client API is None") # Fail when quantity is less than 0 if qty < 0: raise ActivityFailed( "Cannot select pods. Quantity '{q}' is negative.".format(q=qty)) # Fail when mode is not `fixed` or `percentage` if mode not in ['fixed', 'percentage']: raise ActivityFailed( "Cannot select pods. Mode '{m}' is invalid.".format(m=mode)) # Fail when order not `alphabetic` or `oldest` if order not in ['alphabetic', 'oldest']: raise ActivityFailed( "Cannot select pods. Order '{o}' is invalid.".format(o=order)) if label_selector: ret = v1.list_namespaced_pod(ns, label_selector=label_selector) logger.debug("Found {d} pods labelled '{s}' in ns {n}".format( d=len(ret.items), s=label_selector, n=ns)) else: ret = v1.list_namespaced_pod(ns) logger.debug("Found {d} pods in ns '{n}'".format( d=len(ret.items), n=ns)) pods = [] if name_pattern: pattern = re.compile(name_pattern) for p in ret.items: if pattern.match(p.metadata.name): pods.append(p) logger.debug("Pod '{p}' match pattern".format( p=p.metadata.name)) else: pods = ret.items if order == 'oldest': pods.sort(key=_sort_by_pod_creation_timestamp) if not all: if mode == 'percentage': qty = math.ceil((qty * len(pods)) / 100) # If quantity is greater than number of pods present, cap the # quantity to maximum number of pods qty = min(qty, len(pods)) if rand: pods = random.sample(pods, qty) else: pods = pods[:qty] return pods def delete_pods(name: str, ns: str = "default", label_selector: str = "name in ({name})", secrets: Secrets = None): """ Delete pods by `name` in the namespace `ns`. The pods are deleted without a graceful period to trigger an abrupt termination. The selected resources are matched by the given `label_selector`. """ label_selector = label_selector.format(name=name) api = create_k8s_api_client(secrets) v1 = client.CoreV1Api(api) if label_selector: ret = v1.list_namespaced_pod(ns, label_selector=label_selector) else: ret = v1.list_namespaced_pod(ns) logger.debug("Found {d} pods named '{n}'".format( d=len(ret.items), n=name)) body = client.V1DeleteOptions() for p in ret.items: v1.delete_namespaced_pod(p.metadata.name, ns, body=body)