diff --git a/.env b/.env new file mode 100644 index 0000000..7f85c28 --- /dev/null +++ b/.env @@ -0,0 +1,5 @@ +#!/bin/bash + +HELM_RELEASE_NAME=runwhen-local +NAMESPACE=monitoring +WORKSPACE_OWNER_EMAIL=saurabh.yadav@infracloud.io \ No newline at end of file diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f76ecf5..313aec7 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -21,7 +21,7 @@ jobs: build-and-push-ghcr: runs-on: ubuntu-latest env: - codebundle: hello_world + codebundle: rds-mysql-conn-count steps: - name: Checkout uses: actions/checkout@v2 diff --git a/.gitignore b/.gitignore index 254ab89..df5da75 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ __pycache__ output.xml log.html -report.html \ No newline at end of file +report.html +test/sre-stack/* \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..7e25692 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "test/sre-stack"] + path = test/sre-stack + url = https://github.com/infracloudio/sre-stack.git + branch = main diff --git a/Dockerfile b/Dockerfile index 91e079b..66d0c00 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ RUN pip install -r /app/codecollection/requirements.txt # Install packages RUN apt-get update && \ - apt install -y git && \ + apt install -y git default-mysql-client && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* && \ rm -rf /var/cache/apt @@ -19,4 +19,6 @@ RUN chown 1000:0 -R $WORKDIR RUN chown 1000:0 -R /app/codecollection # Set the user to $USER -USER python \ No newline at end of file +USER python +ENV USER=python +RUN chmod -R u+x /app/codecollection \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4a931c9 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +GIT_TLD=$(shell git rev-parse --show-toplevel) +include $(GIT_TLD)/test/sre-stack/.env +include $(GIT_TLD)/.env +include $(GIT_TLD)/test/sre-stack/makefile +SRE_STACK_DIR := $(GIT_TLD)/test/sre-stack +RUNWHEN_SETUP_SCRIPT_PATH=setup/runwhen-local/setup.sh + +RUNWHEN_REQUIRED_VARS := RUNWHEN_PLATFORM_TOKEN +$(foreach var,$(RUNWHEN_REQUIRED_VARS),$(if $(value $(var)),,$(error $(var) is not set))) + +setup-sre-stack: + $(MAKE) setup -C $(SRE_STACK_DIR) + +cleanup-sre-stack: + $(MAKE) cleanup -C $(SRE_STACK_DIR) + +setup-runwhen: + $(GIT_TLD)/$(RUNWHEN_SETUP_SCRIPT_PATH) + +cleanup-runwhen: + helm uninstall ${HELM_RELEASE_NAME} -n ${NAMESPACE} + +setup-runwhen-all: setup-sre-stack setup-runwhen + +cleanup-runwhen-all: cleanup-runwhen cleanup-sre-stack diff --git a/codebundles/rds-mysql-conn-count/README.md b/codebundles/rds-mysql-conn-count/README.md new file mode 100644 index 0000000..e69de29 diff --git a/codebundles/rds-mysql-conn-count/kill-mysql-sleep-processes.sh b/codebundles/rds-mysql-conn-count/kill-mysql-sleep-processes.sh new file mode 100755 index 0000000..b340981 --- /dev/null +++ b/codebundles/rds-mysql-conn-count/kill-mysql-sleep-processes.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# MySQL connection details +# MYSQL_USER="admin" +# MYSQL_PASSWORD="docdb3421z" +# MYSQL_HOST="robotshopmysql.cn9m6m4s8zo0.us-west-2.rds.amazonaws.com" +# PROCESS_USER="shipping" +echo "MYSQL_USER $MYSQL_USER" +echo "MYSQL_PASSWORD $MYSQL_PASSWORD" +echo "MYSQL_HOST $MYSQL_HOST" +echo "PROCESS_USER $PROCESS_USER" + +# Get process list IDs +PROCESS_IDS=$(MYSQL_PWD="$MYSQL_PASSWORD" mysql -h "$MYSQL_HOST" -u "$MYSQL_USER" -N -s -e "SELECT ID FROM INFORMATION_SCHEMA.PROCESSLIST WHERE USER='shipping'") + +if [ $? -ne 0 ]; then + echo "Error connecting to MySQL" + exit 1 +fi + +for ID in $PROCESS_IDS; do + MYSQL_PWD="$MYSQL_PASSWORD" mysql -h "$MYSQL_HOST" -u "$MYSQL_USER" -e "CALL mysql.rds_kill($ID)" + echo "Terminated connection with ID $ID for user 'shipping'" +done \ No newline at end of file diff --git a/codebundles/rds-mysql-conn-count/runbook.robot b/codebundles/rds-mysql-conn-count/runbook.robot new file mode 100644 index 0000000..7619253 --- /dev/null +++ b/codebundles/rds-mysql-conn-count/runbook.robot @@ -0,0 +1,52 @@ +*** Settings *** +Documentation This taskset Kills the numbers of sleep process created in MySQL +Metadata Author IFC + +Library BuiltIn +Library RW.Core +Library RW.platform +Library RW.CLI + +Suite Setup Suite Initialization + +*** Variables *** +${MYSQL_PASSWORD_ENV} %{MYSQL_PASSWORD_ENV} + +*** Tasks *** +Run Bash File + [Documentation] Runs a bash file to kill sleep processes created in MySQL + [Tags] file script + ${rsp}= RW.CLI.Run Bash File + ... bash_file=kill-mysql-sleep-processes.sh + ... cmd_override=./kill-mysql-sleep-processes.sh + ... env=${env} + ... include_in_history=False + RW.Core.Add Pre To Report Command Stdout:\n${rsp.stdout} + RW.Core.Add Pre To Report Command Stderr:\n${rsp.stderr} + + +*** Keywords *** +Suite Initialization + ${MYSQL_PASSWORD}= RW.Core.Import Secret MYSQL_PASSWORD + ... type=string + ... description=MySQL password + ... pattern=\w* + ... example='9jZGIzNDIxego' + ${MYSQL_USER}= RW.Core.Import User Variable MYSQL_USER + ... type=string + ... description=MySQL Username + ... pattern=\w* + ... example=admin + ${MYSQL_HOST}= RW.Core.Import User Variable MYSQL_HOST + ... type=string + ... description=MySQL host endpoint + ... pattern=\w* + ... example=robotshopmysql.cn9m6m4s8zo0.us-west-2.rds.amazonaws.com + ${PROCESS_USER}= RW.Core.Import User Variable PROCESS_USER + ... type=string + ... description=mysql user which created numbers of sleep connections + ... pattern=\w* + ... example=shipping + + Set Suite Variable + ... ${env} {"MYSQL_USER":"${MYSQL_USER}", "MYSQL_PASSWORD":"${MYSQL_PASSWORD_ENV}", "MYSQL_HOST":"${MYSQL_HOST}", "PROCESS_USER":"${PROCESS_USER}"} \ No newline at end of file diff --git a/codebundles/rds-mysql-conn-count/runwhen-config/README.md b/codebundles/rds-mysql-conn-count/runwhen-config/README.md new file mode 100644 index 0000000..e269ba9 --- /dev/null +++ b/codebundles/rds-mysql-conn-count/runwhen-config/README.md @@ -0,0 +1 @@ +Placeholder for runwhen platform config. \ No newline at end of file diff --git a/codebundles/rds-mysql-conn-count/runwhen-config/runbook.yaml b/codebundles/rds-mysql-conn-count/runwhen-config/runbook.yaml new file mode 100644 index 0000000..27fdbc6 --- /dev/null +++ b/codebundles/rds-mysql-conn-count/runwhen-config/runbook.yaml @@ -0,0 +1,26 @@ +location: location-01-us-west1 +codeBundle: + repoUrl: https://github.com/infracloudio/infracloud-runwhen-codecollection.git + ref: codebundle-rds-mysql-conn + pathToRobot: codebundles/rds-mysql-conn-count/runbook.robot +configProvided: + - name: KUBERNETES_DISTRIBUTION_BINARY + value: kubectl + - name: NAMESPACE + value: default + - name: ERROR_PATTERN + value: (Error|Exception) + - name: CONTEXT + value: default + - name: SERVICE_ERROR_PATTERN + value: (Error:) + - name: SERVICE_EXCLUDE_PATTERN + value: (node_modules|opentelemetry) + - name: ANOMALY_THRESHOLD + value: '5.0' +secretsProvided: + - name: kubeconfig + workspaceKey: kubeconfig +servicesProvided: + - name: kubectl + locationServiceName: kubectl-service.shared diff --git a/codebundles/rds-mysql-conn-count/runwhen-config/sli.yaml b/codebundles/rds-mysql-conn-count/runwhen-config/sli.yaml new file mode 100644 index 0000000..aa52d79 --- /dev/null +++ b/codebundles/rds-mysql-conn-count/runwhen-config/sli.yaml @@ -0,0 +1,34 @@ +displayUnitsLong: OK +displayUnitsShort: ok +locations: + - location-01-us-west1 +description: >- + Watch RDS MySql connection count +codeBundle: + repoUrl: https://github.com/infracloudio/infracloud-runwhen-codecollection.git + ref: codebundle-rds-mysql-conn + pathToRobot: codebundles/rds-mysql-conn-count/sli.robot +# read more about intervalStrategy here: https://docs.runwhen.com/public/runwhen-platform/feature-overview/points-on-the-map-slxs/service-level-indicators-slis/interval-strategies +intervalStrategy: intermezzo +intervalSeconds: 30 +configProvided: + - name: PROMETHEUS_HOSTNAME + value: >- + http://aeccfb7ff9bfb4705b6218294a7346c3-2081802229.us-west-2.elb.amazonaws.com/prometheus/api/v1 + - name: QUERY + value: >- + aws_rds_database_connections_average{dimension_DBInstanceIdentifier="robotshopmysql"} > 1 + - name: TRANSFORM + value: RAW + - name: STEP + value: '30' + - name: DATA_COLUMN + value: '1' + - name: NO_RESULT_OVERWRITE + value: 'Yes' + - name: NO_RESULT_VALUE + value: '0' +secretsProvided: [] +servicesProvided: + - name: curl + locationServiceName: curl-service.shared \ No newline at end of file diff --git a/codebundles/rds-mysql-conn-count/runwhen-config/slo.yaml b/codebundles/rds-mysql-conn-count/runwhen-config/slo.yaml new file mode 100644 index 0000000..2b64df7 --- /dev/null +++ b/codebundles/rds-mysql-conn-count/runwhen-config/slo.yaml @@ -0,0 +1,8 @@ +codeBundle: + repoUrl: https://github.com/runwhen-contrib/rw-public-codecollection + pathToYaml: codebundles/slo-default/queries.yaml + ref: main +sloSpecType: simple-mwmb +objective: 95 +threshold: 48 +operand: lt diff --git a/codebundles/rds-mysql-conn-count/runwhen-config/slx.yaml b/codebundles/rds-mysql-conn-count/runwhen-config/slx.yaml new file mode 100644 index 0000000..c099bbc --- /dev/null +++ b/codebundles/rds-mysql-conn-count/runwhen-config/slx.yaml @@ -0,0 +1,9 @@ +statement: RDS MySql connections should be within 80% of total max connection. +alias: RDS MySql Connections Count +metricType: gauge +asMeasuredBy: Score based on promethues query +icon: Cloud +owners: + - saurabh.yadav@infracloud.io +imageURL: >- + https://storage.googleapis.com/runwhen-nonprod-shared-images/icons/kubernetes/resources/labeled/ns.svg diff --git a/codebundles/rds-mysql-conn-count/sli.robot b/codebundles/rds-mysql-conn-count/sli.robot new file mode 100644 index 0000000..c6fef95 --- /dev/null +++ b/codebundles/rds-mysql-conn-count/sli.robot @@ -0,0 +1,87 @@ +*** Settings *** +Metadata Author InfraCloud +Documentation Run a PromQL query against Prometheus instant query API, perform a provided transform, and return the result. +Force Tags Prometheus Prom PromQL Query Metric Aggregate +Suite Setup Suite Initialization +Library RW.Core +Library RW.Prometheus + +*** Variables *** +${ENV_PROMETHEUS_HOST} %{ENV_PROMETHEUS_HOST} +${ENV_QUERY} %{ENV_QUERY} + +*** Keywords *** +Suite Initialization + ${CURL_SERVICE}= RW.Core.Import Service curl + ... type=string + ... description=The selected RunWhen Service to use for accessing services within a network. + ... pattern=\w* + ... example=curl-service.shared + ... default=curl-service.shared + ${OPTIONAL_HEADERS}= RW.Core.Import Secret OPTIONAL_HEADERS + ... type=string + ... description=A json string of headers to include in the request against the Prometheus instance. This can include your token. + ... pattern=\w* + ... default="{"my-header":"my-value"}" + ... example='{"my-header":"my-value", "Authorization": "Bearer mytoken"}' + RW.Core.Import User Variable PROMETHEUS_HOSTNAME + ... type=string + ... description=The prometheus endpoint to perform requests against. + ... pattern=\w* + ... example=https://myprometheus/api/v1/ + RW.Core.Import User Variable QUERY + ... type=string + ... description=The PromQL statement used to query metrics. + ... pattern=\w* + ... example=sysdig_container_cpu_quota_used_percent > 75 or sysdig_container_memory_limit_used_percent> 75 + ... default=sysdig_container_cpu_quota_used_percent > 75 or sysdig_container_memory_limit_used_percent> 75 + RW.Core.Import User Variable TRANSFORM + ... type=string + ... enum=[Raw,Max,Average,Minimum,Sum,First,Last] + ... description=What transform method to apply to the column data. First and Last are position relative, so Last is the most recent value. Use Raw to skip transform. + ... default=Last + ... example=Last + RW.Core.Import User Variable STEP + ... type=string + ... description=The step interval in seconds requested from the Prometheus API. + ... pattern="^[0-9]*$" + ... default=30 + ... example=30 + RW.Core.Import User Variable DATA_COLUMN + ... type=string + ... description=Which column of the result data to perform aggregation on. Typically 0 is the timestamp, whereas 1 is the metric value. + ... pattern="^[0-9]*$" + ... default=1 + ... example=1 + RW.Core.Import User Variable NO_RESULT_OVERWRITE + ... type=string + ... description=Determine how to handle queries with no result data. Set to Yes to write a metric (specified below) or No to accept the null result. + ... pattern=\w* + ... enum=[Yes,No] + ... default=Yes + RW.Core.Import User Variable NO_RESULT_VALUE + ... type=string + ... description=Set the metric value that should be stored when no data result is available. + ... pattern=\d* + ... default=0 + ... example=0 + Set Suite Variable ${CURL_SERVICE} ${CURL_SERVICE} + Set Suite Variable ${OPTIONAL_HEADERS} ${OPTIONAL_HEADERS} + Set Suite Variable ${NO_RESULT_OVERWRITE} ${NO_RESULT_OVERWRITE} + Set Suite Variable ${NO_RESULT_VALUE} ${NO_RESULT_VALUE} + +*** Tasks *** +Querying Prometheus Instance And Pushing Aggregated Data + Log ${ENV_QUERY} + ${rsp}= RW.Prometheus.Query Instant + ... api_url=${ENV_PROMETHEUS_HOST} + ... query=${ENV_QUERY} + ... step=${STEP} + ... target_service=${CURL_SERVICE} + ${data}= Set Variable ${rsp["data"]} + ${metric}= RW.Prometheus.Transform Data + ... data=${data} + ... method=${TRANSFORM} + ... no_result_overwrite=${NO_RESULT_OVERWRITE} + ... no_result_value=${NO_RESULT_VALUE} + RW.Core.Push Metric ${metric} diff --git a/codebundles/rds-mysql-conn-count/test/manifests/sli-deployment.yaml b/codebundles/rds-mysql-conn-count/test/manifests/sli-deployment.yaml new file mode 100644 index 0000000..ae4aed4 --- /dev/null +++ b/codebundles/rds-mysql-conn-count/test/manifests/sli-deployment.yaml @@ -0,0 +1,38 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: rds-mysql-connection-count +spec: + replicas: 1 + selector: + matchLabels: + app: rds-mysql-connection-count + template: + metadata: + labels: + app: rds-mysql-connection-count + spec: + containers: + - name: rds-mysql-connection-count + image: 590183940259.dkr.ecr.us-west-2.amazonaws.com/runwhen:latest + command: + - "bash" + - "-c" + - "ro /app/codecollection/codebundles/rds-mysql-conn-count/runbook.robot && while true; do sleep 5; done" + ports: + - containerPort: 3000 + env: + - name: ENV_PROMETHEUS_HOST + value: "http://ab916f39fadce498ead455d91e808053-1900228415.us-west-2.elb.amazonaws.com/prometheus/api/v1" + - name: ENV_QUERY + value: "aws_rds_database_connections_average{dimension_DBInstanceIdentifier=\"robotshopmysql\"} > 1" + - name: MYSQL_USER + value: "admin" + - name: MYSQL_PASSWORD_ENV + value: "docdb3421z" + - name: MYSQL_HOST + value: "robotshopmysql.c5eo4uy8mys1.us-west-2.rds.amazonaws.com" + - name: PROCESS_USER + value: "shipping" + - name: RW_PATH_TO_ROBOT + value: "/app/codecollection/codebundles/rds-mysql-conn-count/runbook.robot" diff --git a/codebundles/rds-mysql-conn-count/test/scripts/create-msql-sleeping-connections.sh b/codebundles/rds-mysql-conn-count/test/scripts/create-msql-sleeping-connections.sh new file mode 100644 index 0000000..6a55b52 --- /dev/null +++ b/codebundles/rds-mysql-conn-count/test/scripts/create-msql-sleeping-connections.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# MySQL connection details +MYSQL_USER="shipping" +MYSQL_PASSWORD="secret" +MYSQL_HOST="robotshopmysql.c5eo4uy8mys1.us-west-2.rds.amazonaws.com" + +CONNECTIONS=30 +SLEEP_TIMEOUT=260 + +for ((i=1;i<=$CONNECTIONS;i++)) +do + MYSQL_PWD="$MYSQL_PASSWORD" mysql -h "$MYSQL_HOST" -u "$MYSQL_USER" -N -s -e "SELECT SLEEP(${SLEEP_TIMEOUT});" & +done diff --git a/codebundles/rds-mysql-conn-count/test/scripts/local-docker.sh b/codebundles/rds-mysql-conn-count/test/scripts/local-docker.sh new file mode 100644 index 0000000..515cb39 --- /dev/null +++ b/codebundles/rds-mysql-conn-count/test/scripts/local-docker.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +# Build Image +docker build --tag runwhen . --no-cache + +# Run container +docker run --rm -d -p 3000:3000 --name rds-codecollection \ +--network="host" \ +-e ENV_PROMETHEUS_HOST="http://${SRE_STACK_PROM_PUBLIC_HOST}/prometheus/api/v1" \ +-e ENV_QUERY="aws_rds_database_connections_average{dimension_DBInstanceIdentifier=\"robotshopmysql\"} > 1" \ +-e MYSQL_USER="admin" \ +-e MYSQL_PASSWORD_ENV="" \ +-e MYSQL_HOST="${SRE_STACK_RDS_MYSQL_PRIVATE_HOST}" \ +-e PROCESS_USER="shipping" \ +-e RW_PATH_TO_ROBOT="/app/codecollection/codebundles/rds-mysql-conn-count/runbook.robot" \ +runwhen:latest + +# Run SLI +docker exec rds-codecollection bash -c "ro /app/codecollection/codebundles/rds-mysql-conn-count/sli.robot && ls -R /robot_logs" + +# Run runbook +docker exec rds-codecollection bash -c "ro /app/codecollection/codebundles/rds-mysql-conn-count/runbook.robot && ls -R /robot_logs" \ No newline at end of file diff --git a/libraries/RW/CLI/CLI.py b/libraries/RW/CLI/CLI.py new file mode 100644 index 0000000..bc9a3e6 --- /dev/null +++ b/libraries/RW/CLI/CLI.py @@ -0,0 +1,346 @@ +""" +CLI Generic keyword library for running and parsing CLI stdout + +Scope: Global +""" +import re, logging, json, jmespath, os +from datetime import datetime +from robot.libraries.BuiltIn import BuiltIn + +from RW import platform +from RW.Core import Core + +# import bare names for robot keyword names +from .json_parser import * +from .stdout_parser import * +from .cli_utils import _string_to_datetime, from_json, verify_rsp, escape_str_for_exec +from .local_process import execute_local_command + +logger = logging.getLogger(__name__) + +ROBOT_LIBRARY_SCOPE = "GLOBAL" + +SHELL_HISTORY: list[str] = [] +SECRET_PREFIX = "secret__" +SECRET_FILE_PREFIX = "secret_file__" + + +def pop_shell_history() -> str: + """Deletes the shell history up to this point and returns it as a string for display. + + Returns: + str: the string of shell command history + """ + global SHELL_HISTORY + history: str = "\n".join(SHELL_HISTORY) + SHELL_HISTORY = [] + return history + + +def execute_command( + cmd: str, + service: platform.Service = None, + request_secrets: list[platform.ShellServiceRequestSecret] = None, + env: dict = None, + files: dict = None, + timeout_seconds: int = 60, +) -> platform.ShellServiceResponse: + """Handle split between shellservice command and local process discretely. + If the user provides a service, use the traditional shellservice flow. + Otherwise we fake a ShellRequest and process it locally with a local subprocess. + Somewhat hacky as we're faking ShellResponses. Revisit this. + + Args: + cmd (str): the shell command to run + service (Service, optional): the remote shellservice API to send the command to, if left empty defaults to run locally. Defaults to None. + request_secrets (List[ShellServiceRequestSecret], optional): a list of secret objects to include in the request. Defaults to None. + env (dict, optional): environment variables to set during the running of the command. Defaults to None. + files (dict, optional): a list of files to include in the environment during the command. Defaults to None. + + Returns: + ShellServiceResponse: _description_ + """ + if not service: + return execute_local_command( + cmd=cmd, + request_secrets=request_secrets, + env=env, + files=files, + timeout_seconds=timeout_seconds, + ) + else: + return platform.execute_shell_command( + cmd=cmd, + service=service, + request_secrets=request_secrets, + env=env, + files=files, + ) + + +def _create_kubernetes_remote_exec( + cmd: str, + target_service: platform.Service = None, + env: dict = None, + labels: str = "", + workload_name: str = "", + namespace: str = "", + context: str = "", + **kwargs, +) -> str: + """**DEPRECATED**""" + # if no specific workload name but labels provided, fetch the first running pod with labels + if not workload_name and labels: + request_secrets: [platform.ShellServiceRequestSecret] = ( + [] if len(kwargs.keys()) > 0 else None + ) + request_secrets = _create_secrets_from_kwargs(**kwargs) + pod_name_cmd = ( + f"kubectl get pods --field-selector=status.phase==Running -l {labels}" + + " -o jsonpath='{.items[0].metadata.name}'" + + f" -n {namespace} --context={context}" + ) + rsp = execute_command( + cmd=pod_name_cmd, + service=target_service, + request_secrets=request_secrets, + env=env, + ) + SHELL_HISTORY.append(pod_name_cmd) + cli_utils.verify_rsp(rsp) + workload_name = rsp.stdout + # use eval so that env variables are evaluated in the subprocess + cmd_template: str = f"eval $(echo \"kubectl exec -n {namespace} --context={context} {workload_name} -- /bin/bash -c '{cmd}'\")" + cmd = cmd_template + logger.info(f"Templated remote exec: {cmd}") + return cmd + + +def _create_secrets_from_kwargs(**kwargs) -> list[platform.ShellServiceRequestSecret]: + """Helper to organize dynamically set secrets in a kwargs list + + Returns: + list[platform.ShellServiceRequestSecret]: secrets objects in list form. + """ + global SECRET_PREFIX + global SECRET_FILE_PREFIX + request_secrets: list[platform.ShellServiceRequestSecret] = ( + [] if len(kwargs.keys()) > 0 else None + ) + for key, value in kwargs.items(): + if not key.startswith(SECRET_PREFIX) and not key.startswith(SECRET_FILE_PREFIX): + continue + if not isinstance(value, platform.Secret): + logger.warning( + f"kwarg secret {value} in key {key} is the wrong type, should be platform.Secret" + ) + continue + if key.startswith(SECRET_PREFIX): + request_secrets.append(platform.ShellServiceRequestSecret(value)) + elif key.startswith(SECRET_FILE_PREFIX): + request_secrets.append( + platform.ShellServiceRequestSecret(value, as_file=True) + ) + return request_secrets + + +def run_bash_file( + bash_file: str, + target_service: platform.Service = None, + env: dict = None, + include_in_history: bool = True, + cmd_override: str = "", + timeout_seconds: int = 60, + **kwargs, +) -> platform.ShellServiceResponse: + """Runs a bash file from the local file system or remotely on a shellservice. + + Args: + bash_file (str): the name of the bashfile to run + target_service (platform.Service, optional): the shellservice to use if provided. Defaults to None. + env (dict, optional): a mapping of environment variables to set for the environment. Defaults to None. + include_in_history (bool, optional): whether to include in the shell history or not. Defaults to True. + cmd_override (str, optional): the entrypoint command to use, similar to a dockerfile. Defaults to "./ platform.ShellServiceResponse: + """Executes a string of shell commands either locally or remotely on a shellservice. + + For passing through secrets securely this can be done by using kwargs with a specific naming convention: + - for files: secret_file__kubeconfig + - for secret strings: secret__mytoken + + and then to use these within your shell command use the following syntax: $${.key} which will cause the shell command to access where + the secret is stored in the environment it's running in. + + Args: + cmd (str): the string of shell commands to run, eg: ls -la | grep myfile + target_service (platform.Service, optional): the remote shellservice to run the commands on if provided, otherwise run locally if None. Defaults to None. + env (dict, optional): a mapping of environment variables to set in the environment where the shell commands are run. Defaults to None. + loop_with_items (list, optional): deprecated. Defaults to None. + run_in_workload_with_name (str, optional): deprecated. Defaults to "". + run_in_workload_with_labels (str, optional): deprecated. Defaults to "". + optional_namespace (str, optional): deprecated. Defaults to "". + optional_context (str, optional): deprecated. Defaults to "". + include_in_history (bool, optional): whether or not to include the shell commands in the total history. Defaults to True. + + Returns: + platform.ShellServiceResponse: the structured response from running the shell commands. + """ + global SHELL_HISTORY + looped_results = [] + rsp = None + logger.info( + f"Requesting command: {cmd} with service: {target_service} - None indicates run local" + ) + if run_in_workload_with_labels or run_in_workload_with_name: + cmd = _create_kubernetes_remote_exec( + cmd=cmd, + target_service=target_service, + env=env, + labels=run_in_workload_with_labels, + workload_name=run_in_workload_with_name, + namespace=optional_namespace, + context=optional_context, + **kwargs, + ) + request_secrets: [platform.ShellServiceRequestSecret] = ( + [] if len(kwargs.keys()) > 0 else None + ) + logger.info(f"Received kwargs: {kwargs}") + request_secrets = _create_secrets_from_kwargs(**kwargs) + if loop_with_items and len(loop_with_items) > 0: + for item in loop_with_items: + cmd = cmd.format(item=item) + iter_rsp = execute_command( + cmd=cmd, + service=target_service, + request_secrets=request_secrets, + env=env, + timeout_seconds=timeout_seconds, + ) + if include_in_history: + SHELL_HISTORY.append(cmd) + looped_results.append(iter_rsp.stdout) + # keep track of last rsp codes we got + # TODO: revisit how we aggregate these + rsp = iter_rsp + aggregate_stdout = "\n".join([iter_stdout for iter_stdout in looped_results]) + rsp = platform.ShellServiceResponse( + cmd=rsp.cmd, + parsed_cmd=rsp.parsed_cmd, + stdout=aggregate_stdout, + stderr=rsp.stderr, + returncode=rsp.returncode, + status=rsp.status, + body=rsp.body, + errors=rsp.errors, + ) + else: + rsp = execute_command( + cmd=cmd, + service=target_service, + request_secrets=request_secrets, + env=env, + timeout_seconds=timeout_seconds, + ) + if include_in_history: + SHELL_HISTORY.append(cmd) + if debug: + logger.info(f"shell stdout: {rsp.stdout}") + logger.info(f"shell stderr: {rsp.stderr}") + logger.info(f"shell status: {rsp.status}") + logger.info(f"shell returncode: {rsp.returncode}") + logger.info(f"shell rsp: {rsp}") + return rsp + + +def string_to_datetime(duration_str: str) -> datetime: + """ + Helper to convert readable duration strings (eg: 1d2m36s) to a datetime. + """ + return _string_to_datetime(duration_str) diff --git a/libraries/RW/CLI/__init__.py b/libraries/RW/CLI/__init__.py new file mode 100644 index 0000000..093caa0 --- /dev/null +++ b/libraries/RW/CLI/__init__.py @@ -0,0 +1,2 @@ +from .CLI import * +from .postgres_helper import k8s_postgres_query, get_password, get_user diff --git a/libraries/RW/CLI/cli_utils.py b/libraries/RW/CLI/cli_utils.py new file mode 100644 index 0000000..a8a0bc1 --- /dev/null +++ b/libraries/RW/CLI/cli_utils.py @@ -0,0 +1,172 @@ +import logging, json +from dataclasses import dataclass +from datetime import datetime +import dateutil.parser + +from robot.libraries.BuiltIn import BuiltIn +from robot.libraries import DateTime as RobotDateTime + + +from RW import platform +from RW.Core import Core + +logger = logging.getLogger(__name__) + + +def _overwrite_shell_rsp_stdout( + rsp: platform.ShellServiceResponse, + new_stdout: str, +) -> platform.ShellServiceResponse: + """Helper method to create a new shell response object + from the ShellServiceResponse dataclass which is frozen. + + Args: + rsp (platform.ShellServiceResponse): the original response + new_stdout (str): the new stdout to insert + + Returns: + platform.ShellServiceResponse: a copy of the response object with the new stdout + """ + new_rsp: platform.ShellServiceResponse = platform.ShellServiceResponse( + cmd=rsp.cmd, + parsed_cmd=rsp.parsed_cmd, + stdout=new_stdout, + stderr=rsp.stderr, + returncode=rsp.returncode, + status=rsp.status, + body=rsp.body, + errors=rsp.errors, + ) + return new_rsp + + +def verify_rsp( + rsp: platform.ShellServiceResponse, + expected_rsp_statuscodes: list[int] = [200], + expected_rsp_returncodes: list[int] = [0], + contains_stderr_ok: bool = True, +) -> None: + """Utility method to verify the ShellServieResponse is in the desired state + and raise exceptions if not. + + Args: + rsp (platform.ShellServiceResponse): the rsp to verify + expected_rsp_statuscodes (list[int], optional): the http response code returned by the process/shell service API, not the same as the bash return code. Defaults to [200]. + expected_rsp_returncodes (list[int], optional): the shell return code. Defaults to [0]. + contains_stderr_ok (bool, optional): if the presence of stderr is considered to be OK. This is expect for many CLI tools. Defaults to True. + + Raises: + ValueError: indicates the presence of an undesired value in the response object + """ + if not contains_stderr_ok and rsp.stderr: + raise ValueError(f"rsp {rsp} contains unexpected stderr {rsp.stderr}") + if rsp.status not in expected_rsp_statuscodes: + raise ValueError(f"rsp {rsp} has unexpected HTTP status {rsp.status}") + if rsp.returncode not in expected_rsp_returncodes: + raise ValueError(f"rsp {rsp} has unexpected shell return code {rsp.returncode}") + + +def _string_to_datetime(duration_str: str, date_format_str="%Y-%m-%dT%H:%M:%SZ"): + """Utility method to create a datetime from a duration string + + Args: + duration_str (str): a duration string, eg: 3d2h1s used to get a past datetime + date_format_str (str, optional): datetime format. Defaults to "%Y-%m-%dT%H:%M:%SZ". + + Returns: + datetime: the past datetime derived from the duration_str + """ + now = RobotDateTime.get_current_date(result_format=date_format_str) + time = RobotDateTime.convert_time(duration_str) + past_date = RobotDateTime.subtract_time_from_date(now, time, result_format=date_format_str) + return past_date + + +def from_json(json_str: str): + """Wrapper keyword for json loads + + Args: + json_str (str): json string blob + + Returns: + any: the loaded json object + """ + return json.loads(json_str, strict=False) + + +def to_json(json_data: any): + """Wrapper keyword for json dumps + + Args: + json_data (any): json data + + Returns: + str: the str representation of the json blob + """ + return json.dumps(json_str) + + +def filter_by_time( + list_data: list, + field_name: str, + operand: str = "filter_older_than", + duration_str: str = "30m", +): + """Utility keyword to iterate through a list of dictionaries and remove list entries where + the specified key datetime is older than the given duration string. + + Args: + list_data (list): list of dictionaries to filter + field_name (str): what key to use for comparisons + operand (str, optional): Defaults to "filter_older_than". + duration_str (str, optional): Duration string in the form of 3d2h1s. Defaults to "30m". + + Returns: + _type_: _description_ + """ + results: list = [] + time_to_filter = _string_to_datetime(duration_str) + time_to_filter = dateutil.parser.parse(time_to_filter).replace(tzinfo=None) + for row in list_data: + if field_name not in row: + continue + row_time = dateutil.parser.parse(row[field_name]).replace(tzinfo=None) + logger.info(f"types: {type(row_time)} {type(time_to_filter)}") + logger.info(f"compare: {row_time} {time_to_filter} and >=: {row_time >= time_to_filter}") + if operand == "filter_older_than": + if row_time >= time_to_filter: + results.append(row) + elif operand == "filter_newer_than": + if row_time <= time_to_filter: + results.append(row) + else: + logger.info(f"dropped: {row}") + return results + + +def escape_str_for_exec(string: str, escapes: int = 1) -> str: + """Simple helper method to escape specific characters that cause issues in the pod exec passthrough + Args: + string (str): original string for exec passthrough + Returns: + str: string with triple escaped quotes for passthrough + """ + string = string.replace('"', "\\" * escapes + '"') + return string + + +@dataclass +class IssueCheckResults: + """ + Used to keep function signatures from getting too busy when passing issue data around. + """ + + query_type: str = "" + severity: int = 4 + title: str = "" + expected: str = "" + actual: str = "" + reproduce_hint: str = "" + issue_found: bool = False + details: str = "" + next_steps: str = "" diff --git a/libraries/RW/CLI/json_parser.py b/libraries/RW/CLI/json_parser.py new file mode 100644 index 0000000..79455b1 --- /dev/null +++ b/libraries/RW/CLI/json_parser.py @@ -0,0 +1,387 @@ +import logging, json, jmespath +from string import Template +from RW import platform +from RW.Core import Core + +from . import cli_utils + +logger = logging.getLogger(__name__) +ROBOT_LIBRARY_SCOPE = "GLOBAL" + +MAX_ISSUE_STRING_LENGTH: int = 1920 + +RECOGNIZED_JSON_PARSE_QUERIES = [ + "raise_issue_if_eq", + "raise_issue_if_neq", + "raise_issue_if_lt", + "raise_issue_if_gt", + "raise_issue_if_contains", + "raise_issue_if_ncontains", +] +EXTRACT_PREFIX = "extract_path_to_var" +ASSIGN_PREFIX = "from_var_with_path" +ASSIGN_STDOUT_PREFIX = "assign_stdout_from_var" +ASSIGN_STDOUT_PREFIX = "assign_stdout_from_var" + +RECOGNIZED_FILTERS = [ + "filter_older_than", + "filter_newer_than", +] + + +def parse_cli_json_output( + rsp: platform.ShellServiceResponse, + set_severity_level: int = 4, + set_issue_expected: str = "", + set_issue_actual: str = "", + set_issue_reproduce_hint: str = "", + set_issue_title: str = "", + set_issue_details: str = "", + set_issue_next_steps: str = "", + expected_rsp_statuscodes: list[int] = [200], + expected_rsp_returncodes: list[int] = [0], + raise_issue_from_rsp_code: bool = False, + contains_stderr_ok: bool = True, + **kwargs, +) -> platform.ShellServiceResponse: + """Parser for json blob data that can raise issues to the RunWhen platform based on data found. + Queries can be performed on the data using various kwarg structures with the following syntax: + + kwarg syntax: + - extract_path_to_var__{variable_name} + - from_var_with_path__{variable1}__to__{variable2} + - assign_stdout_from_var + - {variable_name}__raise_issue_if_gt|lt|contains|ncontains|eq|neq + + Using the `__` delimiters to separate values and prefixes. + + + Args: + rsp (platform.ShellServiceResponse): _description_ + set_severity_level (int, optional): the severity of the issue if it's raised. Defaults to 4. + set_issue_expected (str, optional): what we expected in the json data. Defaults to "". + set_issue_actual (str, optional): what was actually detected in the json data. Defaults to "". + set_issue_reproduce_hint (str, optional): reproduce hints as a string. Defaults to "". + set_issue_title (str, optional): the title of the issue if raised. Defaults to "". + set_issue_details (str, optional): details on the issue if raised. Defaults to "". + set_issue_next_steps (str, optional): next steps or tasks to run based on this issue if raised. Defaults to "". + expected_rsp_statuscodes (list[int], optional): allowed http codes in the response being parsed. Defaults to [200]. + expected_rsp_returncodes (list[int], optional): allowed shell return codes in the response being parsed. Defaults to [0]. + raise_issue_from_rsp_code (bool, optional): if true, raise an issue when the response object fails validation. Defaults to False. + contains_stderr_ok (bool, optional): whether or not to fail validation of the response object when it contains stderr. Defaults to True. + + Returns: + platform.ShellServiceResponse: the unchanged response object that was parsed, for subsequent parses. + """ + # used to store manipulated data + variable_results = {} + # used to keep track of how we got the data + # TODO: transitive lookups + variable_from_path = {} + # for making api requests with raise issue + _core: Core = Core() + logger.info(f"kwargs: {kwargs}") + found_issue: bool = False + variable_results["_stdout"] = rsp.stdout + # check we've got an expected rsp + try: + cli_utils.verify_rsp(rsp, expected_rsp_statuscodes, expected_rsp_returncodes, contains_stderr_ok) + except Exception as e: + if raise_issue_from_rsp_code: + rsp_code_title = set_issue_title if set_issue_title else "Error/Unexpected Response Code" + rsp_next_steps = set_issue_next_steps if set_issue_next_steps else "View the logs of the shellservice" + rsp_code_expected = ( + set_issue_expected + if set_issue_expected + else f"The internal response of {rsp.cmd} should be within {expected_rsp_statuscodes} and the process response should be within {expected_rsp_returncodes}" + ) + rsp_code_actual = ( + set_issue_actual if set_issue_actual else f"Encountered {e} as a result of running: {rsp.cmd}" + ) + rsp_code_reproduce_hint = ( + set_issue_reproduce_hint + if set_issue_reproduce_hint + else f"Run command: {rsp.cmd} and check the return code" + ) + _core.add_issue( + severity=set_severity_level, + title=rsp_code_title, + expected=rsp_code_expected, + actual=rsp_code_actual, + reproduce_hint=rsp_code_reproduce_hint, + details=f"{set_issue_details} ({e})", + next_steps=rsp_next_steps, + ) + else: + raise e + json_data = json.loads(rsp.stdout) + # create extractions first + for key in kwargs.keys(): + kwarg_parts = key.split("__") + prefix = kwarg_parts[0] + if prefix != EXTRACT_PREFIX or len(kwarg_parts) != 2: + continue + logger.info(f"Got kwarg parts: {kwarg_parts}") + jmespath_str = kwargs[key] + varname = kwarg_parts[1] + try: + jmespath_result = jmespath.search(jmespath_str, json_data) + if jmespath_result == None: + logger.warning( + f"The jmespath extraction string: {jmespath_str} returned None for the variable: {varname} with kwarg parts: {kwarg_parts} - did a previous extract fail?" + ) + variable_results[varname] = jmespath_result + variable_from_path[varname] = jmespath_str + except Exception as e: + logger.warning( + f"Failed to extract jmespath data: {json.dumps(variable_results[varname])} with path: {jmespath_str} due to: {e}" + ) + variable_results[varname] = None + # handle var to var assignments + for key in kwargs.keys(): + kwarg_parts = key.split("__") + prefix = kwarg_parts[0] + if prefix != ASSIGN_PREFIX or len(kwarg_parts) != 4: + continue + logger.info(f"Got kwarg parts: {kwarg_parts}") + jmespath_str = kwargs[key] + from_varname = kwarg_parts[1] + if from_varname not in variable_results.keys(): + logger.warning( + f"attempted to reference from_var {from_varname} when it has not been created yet. Available vars: {variable_results.keys()}" + ) + continue + to_varname = kwarg_parts[3] + try: + if variable_results[from_varname] == None: + raise Exception( + f"Referenced variable: {from_varname} is None in {variable_results} - did a previous extract fail?" + ) + variable_results[to_varname] = jmespath.search(jmespath_str, variable_results[from_varname]) + variable_from_path[to_varname] = jmespath_str + except Exception as e: + logger.warning( + f"Failed to extract jmespath data: {json.dumps(variable_results[from_varname])} with path: {jmespath_str} due to: {e}" + ) + variable_results[to_varname] = None + variable_from_path[to_varname] = jmespath_str + # begin filtering + for key in kwargs.keys(): + kwarg_parts = key.split("__") + logger.info(f"Got kwarg parts: {kwarg_parts}") + if len(kwarg_parts) != 3: + continue + varname = kwarg_parts[0] + filter_type = kwarg_parts[1] + if filter_type not in RECOGNIZED_FILTERS: + logger.warning(f"filter: {filter_type} is not in the expected types: {RECOGNIZED_FILTERS}") + continue + filter_amount = kwarg_parts[2] + field_to_filter_on = kwargs[key] + variable_results[varname] = cli_utils.filter_by_time( + variable_results[varname], field_to_filter_on, filter_type, filter_amount + ) + # begin searching for issues + # break at first found issue + # TODO: revisit how we submit multiple chained issues + # TODO: cleanup, reorg this + issue_results = _check_for_json_issue( + rsp, + variable_from_path, + variable_results, + set_severity_level, + set_issue_expected, + set_issue_actual, + set_issue_reproduce_hint, + set_issue_title, + set_issue_details, + set_issue_next_steps, + **kwargs, + ) + if issue_results.issue_found: + known_symbols = {**kwargs, **variable_results} + issue_data = { + "title": Template(issue_results.title).safe_substitute(known_symbols), + "severity": issue_results.severity, + "expected": Template(issue_results.expected).safe_substitute(known_symbols), + "actual": Template(issue_results.actual).safe_substitute(known_symbols), + "reproduce_hint": Template(issue_results.reproduce_hint).safe_substitute(known_symbols), + "details": Template(issue_results.details).safe_substitute(known_symbols), + "next_steps": Template(issue_results.next_steps).safe_substitute(known_symbols), + } + # truncate long strings + for key, value in issue_data.items(): + if isinstance(value, str) and len(value) > MAX_ISSUE_STRING_LENGTH: + issue_data[key] = value[:MAX_ISSUE_STRING_LENGTH] + "..." + _core.add_issue(**issue_data) + # override rsp stdout for parse chaining + for key in kwargs.keys(): + kwarg_parts = key.split("__") + logger.info(f"Got kwarg parts: {kwarg_parts}") + prefix = kwarg_parts[0] + if prefix != ASSIGN_STDOUT_PREFIX or len(kwarg_parts) != 1: + continue + from_varname = kwargs[key] + if from_varname not in variable_results.keys(): + logger.warning( + f"attempted to reference from_var {from_varname} when it has not been created yet. Available vars: {variable_results.keys()}" + ) + continue + try: + variable_as_json = json.dumps(variable_results[from_varname]) + rsp = cli_utils._overwrite_shell_rsp_stdout(rsp, variable_as_json) + logger.info(f"Assigned to rsp.stdout: {variable_as_json}") + except Exception as e: + logger.error(f"Unable to assign variable: {variable_results[from_varname]} to rsp.stdout due to {e}") + return rsp + + +def _check_for_json_issue( + rsp: platform.ShellServiceResponse, + variable_from_path: dict, + variable_results: dict, + set_severity_level: int = 4, + set_issue_expected: str = "", + set_issue_actual: str = "", + set_issue_reproduce_hint: str = "", + set_issue_title: str = "", + set_issue_details: str = "", + set_issue_next_steps: str = "", + **kwargs, +) -> cli_utils.IssueCheckResults: + """Internal method to perform checks for issues in json data. + See `parse_cli_json_output` + """ + severity: int = 4 + title: str = "" + expected: str = "" + actual: str = "" + details: str = "" + next_steps: str = "" + reproduce_hint: str = "" + issue_found: bool = False + parse_queries = kwargs + query: str = "" + issue_results: cli_utils.IssueCheckResults = None + for parse_query, query_value in parse_queries.items(): + # figure out what issue we're querying for in the data + query_parts = parse_query.split("__") + prefix = query_parts[0] + if prefix == EXTRACT_PREFIX or prefix == ASSIGN_PREFIX or prefix == ASSIGN_STDOUT_PREFIX: + # skip, we've already processed these + continue + if len(query_parts) != 2: + continue + if query in RECOGNIZED_FILTERS: + # we've already processed filters + continue + query = query_parts[1] + logger.info(f"Got prefix: {prefix} and query: {query}") + if query not in RECOGNIZED_JSON_PARSE_QUERIES: + logger.info(f"Query {query} not in recognized list: {RECOGNIZED_JSON_PARSE_QUERIES}") + continue + if prefix not in variable_results.keys(): + logger.warning( + f"Variable {prefix} hasn't been defined by assignment or extract, try define it in {variable_results.keys()} first" + ) + continue + numeric_castable: bool = False + variable_value = variable_results[prefix] + variable_is_list: bool = isinstance(variable_value, list) + # precompare cast if comparing numbers + if query in ["raise_issue_if_gt", "raise_issue_if_lt"]: + try: + query_value = float(query_value) + variable_value = float(variable_value) + numeric_castable = True + except Exception as e: + logger.warning( + f"Numeric parse query requested but values not castable: {query_value} and {variable_value}" + ) + # If True/False string passed from robot layer, cast it to bool + if not numeric_castable and (query_value == "True" or query_value == "False"): + query_value = True if query_value == "True" else False + if query == "raise_issue_if_eq" and ( + str(query_value) == str(variable_value) or (variable_is_list and query_value in variable_value) + ): + issue_found = True + title = f"Value Of {prefix} Was {variable_value}" + expected = f"The parsed output {variable_value} stored in {prefix} using the path: {variable_from_path[prefix]} should not be equal to {query_value}" + actual = f"The parsed output {variable_value} stored in {prefix} using the path: {variable_from_path[prefix]} is equal to {variable_value}" + reproduce_hint = f"Run {rsp.cmd} and apply the jmespath '{variable_from_path[prefix]}' to the output" + details = f"{set_issue_details}" + next_steps = f"{set_issue_next_steps}" + elif query == "raise_issue_if_neq" and ( + str(query_value) != str(variable_value) or (variable_is_list and query_value not in variable_value) + ): + issue_found = True + title = f"Value of {prefix} Was Not {query_value}" + expected = f"The parsed output {variable_value} stored in {prefix} using the path: {variable_from_path[prefix]} should be equal to {variable_value}" + actual = f"The parsed output {variable_value} stored in {prefix} using the path: {variable_from_path[prefix]} does not contain the expected value of: {prefix}=={query_value} and is actually {variable_value}" + reproduce_hint = f"Run {rsp.cmd} and apply the jmespath '{variable_from_path[prefix]}' to the output" + details = f"{set_issue_details}" + next_steps = f"{set_issue_next_steps}" + + elif query == "raise_issue_if_lt" and numeric_castable and variable_value < query_value: + issue_found = True + title = f"Value of {prefix} Was Less Than {query_value}" + expected = f"The parsed output {variable_value} stored in {prefix} using the path: {variable_from_path[prefix]} should have a value >= {query_value}" + actual = f"The parsed output {variable_value} stored in {prefix} using the path: {variable_from_path[prefix]} found value: {variable_value} and it's less than {query_value}" + reproduce_hint = f"Run {rsp.cmd} and apply the jmespath '{variable_from_path[prefix]}' to the output" + details = f"{set_issue_details}" + next_steps = f"{set_issue_next_steps}" + + elif query == "raise_issue_if_gt" and numeric_castable and variable_value > query_value: + issue_found = True + title = f"Value of {prefix} Was Greater Than {query_value}" + expected = f"The parsed output {variable_value} stored in {prefix} using the path: {variable_from_path[prefix]} should have a value <= {query_value}" + actual = f"The parsed output {variable_value} stored in {prefix} using the path: {variable_from_path[prefix]} found value: {variable_value} and it's greater than {query_value}" + reproduce_hint = f"Run {rsp.cmd} and apply the jmespath '{variable_from_path[prefix]}' to the output" + details = f"{set_issue_details}" + next_steps = f"{set_issue_next_steps}" + + elif query == "raise_issue_if_contains" and query_value in variable_value: + issue_found = True + title = f"Value of {prefix} Contained {query_value}" + expected = f"The parsed output {variable_value} stored in {prefix} using the path: {variable_from_path[prefix]} resulted in {variable_value} and should not contain {query_value}" + actual = f"The parsed output {variable_value} stored in {prefix} using the path: {variable_from_path[prefix]} resulted in {variable_value} and it contains {query_value} when it should not" + reproduce_hint = f"Run {rsp.cmd} and apply the jmespath '{variable_from_path[prefix]}' to the output" + details = f"{set_issue_details}" + next_steps = f"{set_issue_next_steps}" + + elif query == "raise_issue_if_ncontains" and query_value not in variable_value: + issue_found = True + title = f"Value of {prefix} Did Not Contain {query_value}" + expected = f"The parsed output {variable_value} stored in {prefix} using the path: {variable_from_path[prefix]} resulted in {variable_value} and should contain {query_value}" + actual = f"The parsed output {variable_value} stored in {prefix} using the path: {variable_from_path[prefix]} resulted in {variable_value} and we expected to find {query_value} in the result" + reproduce_hint = f"Run {rsp.cmd} and apply the jmespath '{variable_from_path[prefix]}' to the output" + details = f"{set_issue_details}" + next_steps = f"{set_issue_next_steps}" + + # Explicit sets + if set_severity_level: + severity = set_severity_level + if set_issue_title: + title = set_issue_title + if set_issue_expected: + expected = set_issue_expected + if set_issue_actual: + actual = set_issue_actual + if set_issue_reproduce_hint: + reproduce_hint = set_issue_reproduce_hint + if set_issue_next_steps: + next_steps = set_issue_next_steps + if issue_found: + break + # return struct like results + return cli_utils.IssueCheckResults( + query_type=query, + severity=severity, + title=title, + expected=expected, + actual=actual, + reproduce_hint=reproduce_hint, + issue_found=issue_found, + details=details, + next_steps=next_steps, + ) diff --git a/libraries/RW/CLI/local_process.py b/libraries/RW/CLI/local_process.py new file mode 100644 index 0000000..12080b0 --- /dev/null +++ b/libraries/RW/CLI/local_process.py @@ -0,0 +1,125 @@ +""" TODO: should be incorporated into platform behaviour + Acts as interoperable layer between ShellRequest/Response and local processes - hacky +""" +import os, subprocess, shlex, glob, importlib, traceback, sys, tempfile, shutil +import logging + +from RW import platform +from RW.Core import Core + +logger = logging.getLogger(__name__) + +PWD = "." + + +def _deserialize_secrets( + request_secrets: list[platform.ShellServiceRequestSecret] = [], +) -> list: + ds_secrets = [ + {"key": ssrs.secret.key, "value": ssrs.secret.value, "file": ssrs.as_file} + for ssrs in request_secrets + ] + return ds_secrets + + +def execute_local_command( + cmd: str, + request_secrets: list[platform.ShellServiceRequestSecret] = [], + env: dict = {}, + files: dict = {}, + timeout_seconds: int = 60, +): + USER_ENV: str = os.getenv("USER", None) + # logging.info(f"Local process user detected as: {USER_ENV}") + # if not USER_ENV: + # raise Exception(f"USER environment variable not properly set, found: {USER_ENV}") + request_secrets = request_secrets if request_secrets else [] + if request_secrets: + request_secrets = _deserialize_secrets(request_secrets=request_secrets) + env = env if env else {} + files = files if files else {} + out = None + err = None + rc = -1 + parsed_cmd = None + errors = [] + tmpdir = None + run_with_env = {} + if env: + run_with_env.update(env) + try: + tmpdir = tempfile.mkdtemp(dir=PWD) + parsed_cmd = ["bash", "-c", cmd] + secret_keys = [] + for s in request_secrets: + if s["file"]: + secret_key = s["key"] + secret_keys.append(secret_key) + secret_file_path = os.path.join(tmpdir, secret_key) + with open(secret_file_path, "w") as tmp: + tmp.write(s["value"]) + run_with_env[secret_key] = secret_file_path + else: + if run_with_env.get(s["key"], None): + errors.append( + f"Secret given attempted to over-write an existing env var {s.name}" + ) + break + run_with_env[s["key"]] = s["value"] + file_paths = [] + for fname, content in files.items(): + file_path = os.path.join(tmpdir, fname) + file_paths.append(file_path) + with open(file_path, "w") as tmp: + tmp.write(content) + logger.debug( + f"running {parsed_cmd} with env {run_with_env.keys()} and files {files.keys()}, secret names {secret_keys}" + ) + # enable file permissions + if USER_ENV: + p = subprocess.run( + ["chown", USER_ENV, "."], + text=True, + capture_output=True, + timeout=timeout_seconds, + cwd=os.path.abspath(tmpdir), + ) + p = subprocess.run( + ["chmod", "-R", "u+x", "."], + text=True, + capture_output=True, + timeout=timeout_seconds, + cwd=os.path.abspath(tmpdir), + ) + p = subprocess.run( + parsed_cmd, + text=True, + capture_output=True, + timeout=timeout_seconds, + env=run_with_env, + cwd=os.path.abspath(tmpdir), + ) + out = p.stdout + err = p.stderr + rc = p.returncode + logger.debug( + f"ran {parsed_cmd} with env {run_with_env.keys()} and files {files.keys()}, secret names {secret_keys}, resulted in returncode {rc}, stdout {out}, stderr {err}" + ) + except Exception as e: + s = traceback.format_exception(*sys.exc_info()) + msg = f"Exception while running {parsed_cmd} with env {run_with_env.keys()} and files {files.keys()}, secret names {secret_keys}: {type(e)}: {e}\n{s}" + errors.append(msg) + rc = -1 + logger.error(msg) + finally: + shutil.rmtree(tmpdir, ignore_errors=True) + proc_data: dict = { + "cmd": cmd, + # TODO: Fix odd key name + "parsedCmd": parsed_cmd, + "stdout": out, + "stderr": err, + "returncode": rc, + "errors": errors, + } + return platform.ShellServiceResponse.from_json(proc_data) diff --git a/libraries/RW/CLI/postgres_helper.py b/libraries/RW/CLI/postgres_helper.py new file mode 100644 index 0000000..21490a5 --- /dev/null +++ b/libraries/RW/CLI/postgres_helper.py @@ -0,0 +1,284 @@ +# Helper functions for simplifying calls to postgres workloads in kubernetes + +import re, logging, json, jmespath, os, yaml +from datetime import datetime +from robot.libraries.BuiltIn import BuiltIn + +from RW import platform +from RW.Core import Core + +from .CLI import run_cli + +logger = logging.getLogger(__name__) + +PASSWORD_KEYS: list[str] = [ + "PGPASSWORD", + "PGPASSWORD_SUPERUSER", +] +USER_KEYS: list[str] = [ + "PGUSER", + "PGUSER_SUPERUSER", +] + +# TODO: iterate on this first draft and create well-refined db model for applications in the k8s application model +# TODO: support non-postgres database lookup for app-level troubleshooting + +# simple global to cache first pass and reduce kubectl calls +implicit_auth_enabled: bool = False + + +def get_password( + context: str, + namespace: str, + kubeconfig: platform.Secret, + env: dict = {}, + labels: str = "", + workload_name: str = "", + container_name: str = "", +) -> platform.Secret: + if labels: + labels = f"-l {labels}" + rsp: platform.ShellServiceResponse = run_cli( + cmd=f"kubectl --context {context} -n {namespace} get all {labels} -oyaml", + secret_file__kubeconfig=kubeconfig, + env=env, + ) + if rsp.returncode == 0 and rsp.stdout: + manifest = yaml.safe_load(rsp.stdout) + pod_spec = {} + if "items" in manifest: + manifest = manifest["items"][ + 0 + ] # user more specific labels if the first result is incorrect + if manifest["kind"] == "StatefulSet" or manifest["kind"] == "Deployment": + pod_spec = manifest["spec"]["template"]["spec"] + if manifest["kind"] == "Pod": + pod_spec = manifest["spec"] + secret_name: str = "" + secret_key: str = "" + secret_value: str = "" + for container in pod_spec["containers"]: + if secret_name or secret_value: + break + if "env" in container: + for container_env in container["env"]: + if container_env["name"] in PASSWORD_KEYS: + if "valueFrom" in container_env: + secret_name = container_env["valueFrom"]["secretKeyRef"][ + "name" + ] + secret_key = container_env["valueFrom"]["secretKeyRef"][ + "key" + ] + elif "value" in container_env: + secret_value = container_env["value"] + secret_key = container_env["name"] + break + if secret_value: + return platform.Secret(secret_key, secret_value) + elif secret_key and secret_name: + rsp: platform.ShellServiceResponse = run_cli( + cmd=f'kubectl --context {context} -n {namespace} get secret/{secret_name} -ojsonpath="{{.data.{secret_key}}}" | base64 -d', + secret_file__kubeconfig=kubeconfig, + env=env, + debug=False, + ) + if rsp.returncode == 0 and rsp.stdout: + return platform.Secret(secret_key, rsp.stdout) + return None + + +def _test_implicit_auth( + base_cmd: str, + username: platform.Secret, + kubeconfig: platform.Secret, + env: dict = {}, +) -> bool: + # if we've already tested it, imediately return true to save on future calls + global implicit_auth_enabled + if implicit_auth_enabled: + return implicit_auth_enabled + test_qry: str = "SELECT 1;" + rsp: platform.ShellServiceResponse = run_cli( + cmd=f'{base_cmd} psql -U ${username.key} -c "{test_qry}"', + secret__username=username, + secret_file__kubeconfig=kubeconfig, + env=env, + ) + if rsp.returncode == 0: + logger.info(f"Implicit auth test stdout: {rsp.stdout}") + implicit_auth_enabled = True + return implicit_auth_enabled + return False + + +def get_user( + context: str, + namespace: str, + kubeconfig: platform.Secret, + env: dict = {}, + labels: str = "", + workload_name: str = "", + container_name: str = "", +) -> platform.Secret: + if labels: + labels = f"-l {labels}" + rsp: platform.ShellServiceResponse = run_cli( + cmd=f"kubectl --context {context} -n {namespace} get all {labels} -oyaml", + secret_file__kubeconfig=kubeconfig, + env=env, + ) + if rsp.returncode == 0 and rsp.stdout: + manifest = yaml.safe_load(rsp.stdout) + if "items" in manifest: + manifest = manifest["items"][ + 0 + ] # user more specific labels if the first result is incorrect + pod_spec = {} + if manifest["kind"] == "StatefulSet" or manifest["kind"] == "Deployment": + pod_spec = manifest["spec"]["template"]["spec"] + if manifest["kind"] == "Pod": + pod_spec = manifest["spec"] + secret_name: str = "" + secret_key: str = "" + secret_value: str = "" + for container in pod_spec["containers"]: + if secret_name or secret_value: + break + if "env" in container: + for container_env in container["env"]: + if container_env["name"] in USER_KEYS: + if "valueFrom" in container_env: + secret_name = container_env["valueFrom"]["secretKeyRef"][ + "name" + ] + secret_key = container_env["valueFrom"]["secretKeyRef"][ + "key" + ] + elif "value" in container_env: + secret_value = container_env["value"] + secret_key = container_env["name"] + break + if secret_value: + logger.info(f"user env: {secret_key}={secret_value}") + return platform.Secret(secret_key, secret_value) + elif secret_key and secret_name: + rsp: platform.ShellServiceResponse = run_cli( + cmd=f'kubectl --context {context} -n {namespace} get secret/{secret_name} -ojsonpath="{{.data.{secret_key}}}" | base64 -d', + secret_file__kubeconfig=kubeconfig, + env=env, + debug=False, + ) + if rsp.returncode == 0 and rsp.stdout: + return platform.Secret(secret_key, rsp.stdout) + return None + + +# TODO: implement to support application-level checks +def get_database( + context: str, + namespace: str, + kubeconfig: platform.Secret, + env: dict = {}, + labels: str = "", + workload_name: str = "", + container_name: str = "", +) -> str: + return "" + + +def _get_workload_fqn( + context: str, + namespace: str, + kubeconfig: platform.Secret, + env: dict = {}, + labels: str = "", +) -> str: + if labels: + labels = f"-l {labels}" + fqn: str = "" + if labels: + rsp: platform.ShellServiceResponse = run_cli( + cmd=f"kubectl --context {context} -n {namespace} get all {labels} -oname", + secret_file__kubeconfig=kubeconfig, + env=env, + ) + if rsp.returncode == 0 and rsp.stdout: + fqn = rsp.stdout.strip() + return fqn + + +# TODO: support dynamic db name for app data +def k8s_postgres_query( + query: str, + context: str, + namespace: str, + kubeconfig: platform.Secret, + binary_name: str = "kubectl", + env: dict = {}, + labels: str = "", + workload_name: str = "", + container_name: str = "", + database_name: str = "postgres", + opt_flags: str = "", +) -> platform.ShellServiceResponse: + cnf: str = "" + if container_name: + cnf = f"-c {container_name}" + if not workload_name: + workload_name = _get_workload_fqn( + context=context, + namespace=namespace, + kubeconfig=kubeconfig, + env=env, + labels=labels, + ) + if not workload_name: + raise Exception(f"Could not find workload name, got {workload_name} instead") + cli_base: str = f"{binary_name} --context {context} -n {namespace} exec {workload_name} {cnf} --" + logger.info(f"Created cli base: {cli_base}") + username: platform.Secret = get_user( + context=context, + namespace=namespace, + kubeconfig=kubeconfig, + env=env, + labels=labels, + workload_name=workload_name, + container_name=container_name, + ) + rsp: platform.ShellServiceResponse = None + if _test_implicit_auth( + base_cmd=cli_base, + username=username, + kubeconfig=kubeconfig, + env=env, + ): + logger.info(f"Implicit auth test succeeded") + psql_config: str = ( + f"psql {opt_flags} -qAt -U ${username.key} -d {database_name}" + ) + rsp: platform.ShellServiceResponse = run_cli( + cmd=f'{cli_base} {psql_config} -c "{query}"', + secret__username=username, + secret_file__kubeconfig=kubeconfig, + env=env, + ) + else: + password: platform.Secret = get_password( + context=context, + namespace=namespace, + kubeconfig=kubeconfig, + env=env, + labels=labels, + workload_name=workload_name, + container_name=container_name, + ) + psql_config: str = f'PGPASSWORD="${password.key}" psql {opt_flags} -qAt -U ${username.key} -d {database_name}' + rsp: platform.ShellServiceResponse = run_cli( + cmd=f"{cli_base} bash -c '{psql_config} -c \"{query}\"'", + secret__username=username, + secret__password=password, + secret_file__kubeconfig=kubeconfig, + env=env, + ) + return rsp diff --git a/libraries/RW/CLI/stdout_parser.py b/libraries/RW/CLI/stdout_parser.py new file mode 100644 index 0000000..a338add --- /dev/null +++ b/libraries/RW/CLI/stdout_parser.py @@ -0,0 +1,366 @@ +""" +CLI Generic keyword library for running and parsing CLI stdout + +Scope: Global +""" +import re, logging +from string import Template +from RW import platform +from RW.Core import Core + +from . import cli_utils + +ROBOT_LIBRARY_SCOPE = "GLOBAL" + + +logger = logging.getLogger(__name__) + +MAX_ISSUE_STRING_LENGTH: int = 1920 + +RECOGNIZED_STDOUT_PARSE_QUERIES = [ + "raise_issue_if_eq", + "raise_issue_if_neq", + "raise_issue_if_lt", + "raise_issue_if_gt", + "raise_issue_if_contains", + "raise_issue_if_ncontains", +] + + +def parse_cli_output_by_line( + rsp: platform.ShellServiceResponse, + lines_like_regexp: str = "", + issue_if_no_capture_groups: bool = False, + set_severity_level: int = 4, + set_issue_expected: str = "", + set_issue_actual: str = "", + set_issue_reproduce_hint: str = "", + set_issue_title: str = "", + set_issue_details: str = "", + set_issue_next_steps: str = "", + expected_rsp_statuscodes: list[int] = [200], + expected_rsp_returncodes: list[int] = [0], + contains_stderr_ok: bool = True, + raise_issue_if_no_groups_found: bool = True, + raise_issue_from_rsp_code: bool = False, + **kwargs, +) -> platform.ShellServiceResponse: + """A parser that executes platform API requests as it traverses the provided stdout by line. + This allows authors to 'raise an issue' for a given line in stdout, providing valuable information for troubleshooting. + + For each line traversed, the parser will check the contents using a variety of functions based on the kwargs provided + with the following structure: + + __raise_issue_= + + the following capture groups are always set: + - _stdout: the entire stdout contents + - _line: the current line being parsed + + example: _line__raise_issue_if_contains=Error + This will raise an issue to the platform if any _line contains the string "Error" + + - parsing needs to be performed on a platform.ShellServiceResponse object (contains the stdout) + + To set the payload of the issue that will be submitted to the platform, you can use the various + set_issue_* arguments. + + Args: + rsp (platform.ShellServiceResponse): The structured response from a previous command + lines_like_regexp (str, optional): the regexp to use to create capture groups. Defaults to "". + issue_if_no_capture_groups (bool, optional): raise an issue if no contents could be parsed to groups. Defaults to False. + set_severity_level (int, optional): The severity of the issue, with 1 being the most critical. Defaults to 4. + set_issue_expected (str, optional): A explanation for what we expected to see for a healthy state. Defaults to "". + set_issue_actual (str, optional): What we actually found that's unhealthy. Defaults to "". + set_issue_reproduce_hint (str, optional): Steps to reproduce the problem if applicable. Defaults to "". + set_issue_title (str, optional): The title of the issue. Defaults to "". + set_issue_details (str, optional): Further details or explanations for the issue. Defaults to "". + set_issue_next_steps (str, optional): A next_steps query for the platform to infer suggestions from. Defaults to "". + expected_rsp_statuscodes (list[int], optional): Acceptable http codes in the response object. Defaults to [200]. + expected_rsp_returncodes (list[int], optional): Acceptable shell return codes in the response object. Defaults to [0]. + contains_stderr_ok (bool, optional): If it's acceptable for the response object to contain stderr contents. Defaults to True. + raise_issue_if_no_groups_found (bool, optional): Defaults to True. + raise_issue_from_rsp_code (bool, optional): Switch to raise issue or actual exception depending on response object codes. Defaults to False. + + Returns: + platform.ShellServiceResponse: The response object used. Typically unchanged but the stdout can be + overrided by using the kwarg: assign_stdout_from_var= + """ + _core: Core = Core() + issue_count: int = 0 + capture_groups: dict = {} + stdout: str = rsp.stdout + capture_groups["_stdout"] = stdout + logger.info(f"stdout: {rsp.stdout}") + logger.info(lines_like_regexp) + logger.info(f"kwargs: {kwargs}") + squelch_further_warnings: bool = False + first_issue: dict = {} + # check we've got an expected rsp + try: + cli_utils.verify_rsp(rsp, expected_rsp_statuscodes, expected_rsp_returncodes, contains_stderr_ok) + except Exception as e: + if raise_issue_from_rsp_code: + rsp_code_title = set_issue_title if set_issue_title else "Error/Unexpected Response Code" + rsp_code_expected = ( + set_issue_expected + if set_issue_expected + else f"The internal response of {rsp.cmd} should be within {expected_rsp_statuscodes} and the process response should be within {expected_rsp_returncodes}" + ) + rsp_code_actual = ( + set_issue_actual if set_issue_actual else f"Encountered {e} as a result of running: {rsp.cmd}" + ) + rsp_code_reproduce_hint = ( + set_issue_reproduce_hint + if set_issue_reproduce_hint + else f"Run command: {rsp.cmd} and check the return code" + ) + _core.add_issue( + severity=set_severity_level, + title=rsp_code_title, + expected=rsp_code_expected, + actual=rsp_code_actual, + reproduce_hint=rsp_code_reproduce_hint, + details=f"{set_issue_details} ({e})", + next_steps=set_issue_next_steps, + ) + issue_count += 1 + else: + raise e + # begin line processing + for line in rsp.stdout.split("\n"): + if not line: + continue + capture_groups["_line"] = line + # attempt to create capture groups and values + regexp_results = {} + if lines_like_regexp: + regexp_results = re.match(rf"{lines_like_regexp}", line) + if regexp_results: + regexp_results = regexp_results.groupdict() + logger.info(f"regexp results: {regexp_results}") + if issue_if_no_capture_groups and (not regexp_results or len(regexp_results.keys()) == 0): + _core.add_issue( + severity=set_severity_level, + title="No Capture Groups Found With Supplied Regex", + expected=f"Expected to create capture groups from line: {line} using regexp: {lines_like_regexp}", + actual=f"Actual result: {regexp_results}", + reproduce_hints=f"Try apply the regex: {lines_like_regexp} to lines produced by the command: {rsp.parsed_cmd}", + details=f"{set_issue_details}", + next_steps=f"{set_issue_next_steps}", + ) + issue_count += 1 + continue + parse_queries = kwargs + # if valid regexp results and we got 1 or more capture groups, append + if regexp_results and isinstance(regexp_results, dict) and len(regexp_results.keys()) > 0: + capture_groups = {**regexp_results, **capture_groups} + # begin processing kwarg queries + for parse_query, query_value in parse_queries.items(): + severity: int = 4 + title: str = "" + expected: str = "" + actual: str = "" + reproduce_hint: str = "" + details: str = "" + next_steps: str = "" + query_parts = parse_query.split("__") + if len(query_parts) != 2: + if not squelch_further_warnings: + logger.warning(f"Could not parse query: {parse_query}") + squelch_further_warnings = True + continue + prefix = query_parts[0] + query = query_parts[1] + logger.info(f"Got prefix: {prefix} and query: {query}") + if query not in RECOGNIZED_STDOUT_PARSE_QUERIES: + logger.info(f"Query {query} not in recognized list: {RECOGNIZED_STDOUT_PARSE_QUERIES}") + continue + if prefix in capture_groups.keys(): + numeric_castable: bool = False + capture_group_value = capture_groups[prefix] + # precompare cast + if query in ["raise_issue_if_gt", "raise_issue_if_lt"]: + try: + query_value = float(query_value) + capture_group_value = float(capture_group_value) + numeric_castable = True + except Exception as e: + logger.warning( + f"Numeric parse query requested but values not castable: {query_value} and {capture_group_value}" + ) + # process applicable query + if query == "raise_issue_if_eq" and query_value == capture_group_value: + severity = set_severity_level + title = ( + f"Value Of {prefix} ({capture_group_value}) Was {query_value}" + if not set_issue_title + else set_issue_title, + ) + expected = ( + f"The parsed output {line} with regex: {lines_like_regexp} with the capture group: {prefix} should not be equal to {capture_group_value}" + if not set_issue_expected + else set_issue_expected, + ) + actual = ( + f"The parsed output {line} with regex: {lines_like_regexp} contains {prefix}=={capture_group_value} and should not be equal to {capture_group_value}" + if not set_issue_actual + else set_issue_actual + ) + reproduce_hint = ( + f"Run {rsp.cmd} and apply the regex {lines_like_regexp} per line" + if not set_issue_reproduce_hint + else set_issue_reproduce_hint + ) + details = f"{set_issue_details}" + next_steps = f"{set_issue_next_steps}" + issue_count += 1 + elif query == "raise_issue_if_neq" and query_value != capture_group_value: + severity = set_severity_level + title = ( + f"Value Of {prefix} ({capture_group_value}) Was Not {query_value}" + if not set_issue_title + else set_issue_title + ) + expected = ( + f"The parsed output {line} with regex: {lines_like_regexp} with the capture group: {prefix} should be equal to {capture_group_value}" + if not set_issue_expected + else set_issue_expected + ) + actual = ( + f"The parsed output {line} with regex: {lines_like_regexp} does not contain the expected value of: {prefix}=={capture_group_value}" + if not set_issue_actual + else set_issue_actual + ) + reproduce_hint = ( + f"Run {rsp.cmd} and apply the regex {lines_like_regexp} per line" + if not set_issue_reproduce_hint + else set_issue_reproduce_hint + ) + details = f"{set_issue_details}" + next_steps = f"{set_issue_next_steps}" + issue_count += 1 + elif query == "raise_issue_if_lt" and numeric_castable and capture_group_value < query_value: + severity = set_severity_level + title = ( + f"Value of {prefix} ({capture_group_value}) Was Less Than {query_value}" + if not set_issue_title + else set_issue_title + ) + expected = ( + f"The parsed output {line} with regex: {lines_like_regexp} should have a value >= {query_value}" + if not set_issue_expected + else set_issue_expected + ) + actual = ( + f"The parsed output {line} with regex: {lines_like_regexp} found value: {capture_group_value} and it's less than {query_value}" + if not set_issue_actual + else set_issue_actual + ) + reproduce_hint = ( + f"Run {rsp.cmd} and apply the regex {lines_like_regexp} per line" + if not set_issue_reproduce_hint + else set_issue_reproduce_hint + ) + details = f"{set_issue_details}" + next_steps = f"{set_issue_next_steps}" + issue_count += 1 + elif query == "raise_issue_if_gt" and numeric_castable and capture_group_value > query_value: + severity = set_severity_level + title = ( + f"Value of {prefix} ({capture_group_value}) Was Greater Than {query_value}" + if not set_issue_title + else set_issue_title + ) + expected = ( + f"The parsed output {line} with regex: {lines_like_regexp} should have a value <= {query_value}" + if not set_issue_expected + else set_issue_expected + ) + actual = ( + f"The parsed output {line} with regex: {lines_like_regexp} found value: {capture_group_value} and it's greater than {query_value}" + if not set_issue_actual + else set_issue_actual + ) + reproduce_hint = ( + f"Run {rsp.cmd} and apply the regex {lines_like_regexp} per line" + if not set_issue_reproduce_hint + else set_issue_reproduce_hint + ) + details = f"{set_issue_details}" + next_steps = f"{set_issue_next_steps}" + issue_count += 1 + elif query == "raise_issue_if_contains" and query_value in capture_group_value: + severity = set_severity_level + title = ( + f"Value of {prefix} ({variable_value}) Contained {query_value}" + if not set_issue_title + else set_issue_title + ) + expected = ( + f"The parsed output {line} with regex: {lines_like_regexp} resulted in {capture_group_value} and should not contain {query_value}" + if not set_issue_expected + else set_issue_expected + ) + actual = ( + f"The parsed output {line} with regex: {lines_like_regexp} resulted in {capture_group_value} and it contains {query_value} when it should not" + if not set_issue_actual + else set_issue_actual + ) + reproduce_hint = ( + f"Run {rsp.cmd} and apply the regex {lines_like_regexp} per line" + if not set_issue_reproduce_hint + else set_issue_reproduce_hint + ) + details = f"{set_issue_details}" + next_steps = f"{set_issue_next_steps}" + issue_count += 1 + elif query == "raise_issue_if_ncontains" and query_value not in capture_group_value: + severity = set_severity_level + title = ( + f"Value of {prefix} ({variable_value}) Did Not Contain {query_value}" + if not set_issue_title + else set_issue_title + ) + expected = ( + f"The parsed output {line} with regex: {lines_like_regexp} resulted in {capture_group_value} and should contain {query_value}" + if not set_issue_expected + else set_issue_expected + ) + actual = ( + f"The parsed output {line} with regex: {lines_like_regexp} resulted in {capture_group_value} and we expected to find {query_value} in the result" + if not set_issue_actual + else set_issue_actual + ) + reproduce_hint = ( + f"Run {rsp.cmd} and apply the regex {lines_like_regexp} per line" + if not set_issue_reproduce_hint + else set_issue_reproduce_hint + ) + details = f"{set_issue_details}" + next_steps = f"{set_issue_next_steps}" + issue_count += 1 + if title and len(first_issue.keys()) == 0: + known_symbols = {**kwargs, **capture_groups} + first_issue = { + "title": Template(title).safe_substitute(known_symbols), + "severity": severity, + "expected": Template(expected).safe_substitute(known_symbols), + "actual": Template(actual).safe_substitute(known_symbols), + "reproduce_hint": Template(reproduce_hint).safe_substitute(known_symbols), + "details": Template(details).safe_substitute(known_symbols), + "next_steps": Template(next_steps).safe_substitute(known_symbols), + } + else: + logger.info(f"Prefix {prefix} not found in capture groups: {capture_groups.keys()}") + continue + if first_issue: + # truncate long strings + for key, value in first_issue.items(): + if isinstance(value, str) and len(value) > MAX_ISSUE_STRING_LENGTH: + first_issue[key] = value[:MAX_ISSUE_STRING_LENGTH] + "..." + # aggregate count into title + if issue_count > 1: + first_issue["title"] += f" and {issue_count-1} more" + _core.add_issue(**first_issue) + return rsp diff --git a/libraries/RW/Prometheus/Prometheus.py b/libraries/RW/Prometheus/Prometheus.py new file mode 100644 index 0000000..ea5d9f5 --- /dev/null +++ b/libraries/RW/Prometheus/Prometheus.py @@ -0,0 +1,278 @@ +import requests +import logging +import urllib +import json +import dateutil.parser + +from datetime import datetime, timedelta +from RW import platform + +logger = logging.getLogger(__name__) + +class Prometheus: + """ + Keyword Integration for the Prometheus HTTP API which can be used to fetch data from a Prometheus instance. + Implemented according to https://prometheus.io/docs/prometheus/latest/querying/api/ + """ + ROBOT_LIBRARY_SCOPE = "GLOBAL" + + def _query(self, url, target_service: platform.Service=None, optional_headers: platform.Secret=None, params=None, timeout=30): + """ + API request method wrapped by other public query methods. + """ + if target_service: + # if a runwhen service is provided, pass an equivalent curl to it instead + # If optional_headers are provided + rsp = self._query_with_service( + url=url, + params=params, + optional_headers=optional_headers, + target_service=target_service + ) + else: + # else we assume the prometheus instance is public + headers = { + "content-type":"application/json", + } + if optional_headers: + optional_headers = json.loads(optional_headers.value) + headers.update(optional_headers) + rsp = requests.get(url, headers=headers, params=params, timeout=timeout) + else: + rsp = requests.get(url, params=params, timeout=timeout) + + if rsp.status_code != 200: + raise ValueError(f"Received HTTP code {rsp.status_code} in response {rsp} against url {url} and params {params}") + rsp = rsp.json() + if "status" not in rsp or "data" not in rsp: + raise ValueError(f"Response received is malformed {rsp} against url {url} and params {params}") + if rsp["status"] == "error": + raise ValueError(f"API responded with error {rsp} against url {url} and params {params}") + return rsp + + def _secret_to_curl_headers(self, optional_headers: platform.Secret) -> platform.Secret: + header_list = [] + headers = { + "content-type":"application/json", + } + headers.update(json.loads(optional_headers.value)) + for k,v in headers.items(): + header_list.append(f"-H \"{k}: {v}\"") + optional_headers: platform.Secret = platform.Secret(key=optional_headers.key, val=" ".join(header_list)) + return optional_headers + + def _create_curl(self, url, optional_headers: platform.Secret=None, params=None) -> str: + """ + Helper method to generate a curl string equivalent to a Requests object (roughly) + Note that headers are inserted as a $variable to be substituted in the location service by an environment variable. + This is identified by the secret.key + """ + if params: + params = f"?{urllib.parse.urlencode(params, quote_via=urllib.parse.quote)}" + else: + params = "" + # we use eval so that the location service evaluates the secret headers as multiple tokens + if optional_headers: + curl = f"eval $(echo \"curl -X GET ${optional_headers.key} '{url}{params}'\")" + else: + curl = f"eval $(echo \"curl -X GET '{url}{params}'\")" + return curl + + def _query_with_service( + self, url: str, + target_service: platform.Service, + optional_headers: platform.Secret=None, + params=None, + ) -> dict: + """ + Passes a curl string over to a RunWhen location service which handles the request and returns the stdout. + """ + curl_str: str = self._create_curl(url, optional_headers, params=params) + if optional_headers: + optional_headers = self._secret_to_curl_headers(optional_headers=optional_headers) + request_optional_headers = platform.ShellServiceRequestSecret(optional_headers) + rsp = platform.execute_shell_command( + cmd=curl_str, + service=target_service, + request_secrets=[request_optional_headers] + ) + else: + rsp = platform.execute_shell_command( + cmd=curl_str, + service=target_service, + ) + + if rsp.status != 200: + raise ValueError(f"Received HTTP status of {rsp.status} from response {rsp}") + if rsp.returncode > 0: + raise ValueError(f"Recieved return code of {rsp.returncode} from response {rsp}") + rsp = json.loads(rsp.stdout) + return rsp + + def query_instant( + self, + api_url, + query, + step: str=None, + target_service: platform.Service=None, + optional_headers: platform.Secret=None, + point_in_time = None + ): + """ + Performs a query against the prometheus instant API for metrics with a single data point. + + Examples: + | ${rsp}= | RW.Prometheus.Instant Query | ${PROM_HOSTNAME} | ${OPTIONAL_HEADERS} | ${PROM_QUERY} | + | ${rsp}= | RW.Prometheus.Instant Query | https://my-prometheus/prometheus/api/v1/ | {"opt-header": "value"} | my_metric_name | + + Return Value: + | prometheus_response: dict | + """ + if point_in_time == None: + point_in_time=datetime.now() + time = f"{point_in_time.isoformat()}Z" + api_url = f"{api_url}/query" + params = { + "query":f"{query}", + "time": f"{time}", + } + if step: + params["step"] = step + return self._query(api_url, target_service=target_service, optional_headers=optional_headers, params=params) + + def query_range( + self, + api_url, + query, + target_service: platform.Service=None, + optional_headers: platform.Secret=None, + step="30s", + seconds_in_past=60, + start=None, + end=None, + use_unix_seconds:bool=False + ): + """ + Performs a query against the prometheus Range API for metric data containing lists of data points. + + Examples: + | ${rsp}= | RW.Prometheus.Range Query | ${PROM_HOSTNAME} | ${PROM_QUERY} | ${OPTIONAL_HEADERS} | + | ${rsp}= | RW.Prometheus.Range Query | https://my-prometheus/prometheus/api/v1/ | {"opt-header": "value"} | my_metric_name | step=30 | seconds_in_past=600 | + + Return Value: + | prometheus_response: dict | + """ + api_url = f"{api_url}/query_range" + if start: + start = f"{start.isoformat()}Z" + else: + start = f"{(datetime.now() - timedelta(seconds=int(seconds_in_past))).isoformat()}Z" + if end: + end = f"{end.isoformat()}Z" + else: + end = f"{datetime.now().isoformat()}Z" + if use_unix_seconds: + start = f"{int(dateutil.parser.parse(start).timestamp())}" + end = f"{int(dateutil.parser.parse(end).timestamp())}" + params = { + "query":f"{query}", + "start": f"{start}", + "end": f"{end}", + "step": f"{step}", + } + return self._query(api_url, target_service=target_service, optional_headers=optional_headers, params=params) + + def list_labels(self, api_url, target_service: platform.Service=None, optional_headers: platform.Secret=None): + """ + Performs a query against the prometheus labels API that provides a list of all labels under the organization. + + Examples: + | ${rsp}= | RW.Prometheus.List Labels | ${PROM_HOSTNAME} | ${OPTIONAL_HEADERS} | + | ${rsp}= | RW.Prometheus.List Labels | https://my-prometheus/prometheus/api/v1/ | {"opt-header": "value"} | + + Return Value: + | prometheus_response: dict | + """ + api_url = f"{api_url}/labels" + params = {} + return self._query(api_url, target_service=target_service, optional_headers=optional_headers, params=params) + + def query_label(self, api_url, label, target_service: platform.Service=None, optional_headers: platform.Secret=None): + """ + Performs a query against the prometheus labels API that provides a list of all values under a label. + + Examples: + | ${rsp}= | RW.Prometheus.List Labels | ${PROM_HOSTNAME} | ${OPTIONAL_HEADER} | + | ${rsp}= | RW.Prometheus.List Labels | https://my-prometheus/prometheus/api/v1/ | {"opt-header": "value"} | my_label | + + Return Value: + | prometheus_response: dict | + """ + api_url = f"{api_url}/label/{label}/values" + return self._query(api_url, target_service=target_service, optional_headers=optional_headers) + + def transform_data( + self, + data, + method: str, + no_result_overwrite: bool=False, + no_result_value: float=0, + column_index=1, + metric_name=None + ): + """ + A helper method which can parse and transform data from a Prometheus API response. + In the below examples, ${data} is typically referencing ${rsp["data"]} from + an above API request. + The First and Last options are position relative, so Last is the most recent metric value. + + Examples: + ${transform}= RW.Prometheus.transform Data ${data} Average + + Return Value: + | transform_value: float | + """ + column_index = int(column_index) + if "result" not in data or len(data["result"]) == 0: + if no_result_overwrite: + return no_result_value + else: + raise ValueError(f"Empty metric results {data}") + metric_index = None + # find index of metric in results if name provided + if metric_name: + for i, metric in enumerate(data["result"]): + if metric["__name__"] == metric_name: + metric_index = i + # else assume first + else: + metric_index = 0 + # TODO: make configurable + first_data_point = 0 + + if metric_index != 0 and not metric_index: + raise ValueError(f"Could not identify metric index for {metric_name} in data {data}") + + if "value" in data["result"][metric_index]: + metric_data = data["result"][metric_index]["value"] + if method == "Raw": + return metric_data[column_index] + elif "values" in data["result"][metric_index]: + metric_data = data["result"][metric_index]["values"] + if method == "Raw": + return metric_data[first_data_point][column_index] + column = [float(row[column_index]) for row in metric_data] + if method == "Max": + return max(column) + elif method == "Average": + return sum(column) / len(column) + elif method == "Minimum": + return min(column) + elif method == "Sum": + return sum(column) + elif method == "First": + return column[0] + elif method == "Last": + return column[-1] + else: + raise ValueError(f"Invalid transform method {method} provided for aggregation on list") diff --git a/libraries/RW/Prometheus/__init__.py b/libraries/RW/Prometheus/__init__.py new file mode 100644 index 0000000..2bbf46e --- /dev/null +++ b/libraries/RW/Prometheus/__init__.py @@ -0,0 +1 @@ +from .Prometheus import Prometheus \ No newline at end of file diff --git a/libraries/RW/Prometheus/robot_tests/query.robot b/libraries/RW/Prometheus/robot_tests/query.robot new file mode 100644 index 0000000..4777bdd --- /dev/null +++ b/libraries/RW/Prometheus/robot_tests/query.robot @@ -0,0 +1,104 @@ +*** Settings *** +Library RW.Prometheus +Library RW.Core +Suite Setup Suite Initialization + +*** Keywords *** +Suite Initialization + RW.Core.Import Service curl + Set Suite Variable ${PROM_HOSTNAME} %{PROM_HOSTNAME} + Set Suite Variable ${PROM_QUERY} %{PROM_QUERY} + # Set Suite Variable ${PROM_TEST_LABEL} %{PROM_TEST_LABEL} + # Set Suite Variable ${PROM_AGGR_QUERY} %{PROM_AGGR_QUERY} + ${PROM_OPT_HEADERS}= Evaluate RW.platform.Secret("optional_headers", """%{PROM_OPT_HEADERS}""") + Set Suite Variable ${PROM_OPT_HEADERS} ${PROM_OPT_HEADERS} + +*** Tasks *** +Instant Query + ${rsp}= RW.Prometheus.Query Instant api_url=${PROM_HOSTNAME} query=${PROM_QUERY} optional_headers=${PROM_OPT_HEADERS} + Log ${rsp} + +Instant Query With Service + ${rsp}= RW.Prometheus.Query Instant + ... api_url=${PROM_HOSTNAME} + ... query=${PROM_QUERY} + ... optional_headers=${PROM_OPT_HEADERS} + ... target_service=${curl} + ${transform}= RW.Prometheus.Transform Data + ... data=${rsp} + ... method=Last + ... no_result_value=0.0 + ... no_result_overwrite=Yes + Log ${rsp} + +Range Query + ${rsp}= RW.Prometheus.Query Range api_url=${PROM_HOSTNAME} query=${PROM_QUERY} optional_headers=${PROM_OPT_HEADERS} + ... step=30s + ... seconds_in_past=36000 + Log ${rsp} + +Labels Query + ${rsp}= RW.Prometheus.List Labels api_url=${PROM_HOSTNAME} optional_headers=${PROM_OPT_HEADERS} + Log ${rsp} + +Label Values Query + ${rsp}= RW.Prometheus.Query Label api_url=${PROM_HOSTNAME} label=${PROM_TEST_LABEL} optional_headers=${PROM_OPT_HEADERS} + Log ${rsp} + +Get Range Data And Average + ${rsp}= RW.Prometheus.Query Range api_url=${PROM_HOSTNAME} query=${PROM_QUERY} optional_headers=${PROM_OPT_HEADERS} + ... step=30s + ... seconds_in_past=36000 + ${data}= Set Variable ${rsp["data"]} + ${transform}= RW.Prometheus.Transform Data ${data} Average + ... no_result_value=0.0 + ... no_result_overwrite=Yes + Log ${rsp} + Log ${transform} + +Get Range Data And Sum + ${rsp}= RW.Prometheus.Query Range api_url=${PROM_HOSTNAME} query=${PROM_QUERY} optional_headers=${PROM_OPT_HEADERS} + ... step=30s + ... seconds_in_past=36000 + ${data}= Set Variable ${rsp["data"]} + ${transform}= RW.Prometheus.Transform Data ${data} Sum + ... no_result_value=0.0 + ... no_result_overwrite=Yes + Log ${rsp} + Log ${transform} + +Get Range Data And Get Most Recent + ${rsp}= RW.Prometheus.Query Range api_url=${PROM_HOSTNAME} query=${PROM_QUERY} optional_headers=${PROM_OPT_HEADERS} + ... step=30s + ... seconds_in_past=36000 + ${data}= Set Variable ${rsp["data"]} + # The last value in the ordered list is the most recent prom data value + ${transform}= RW.Prometheus.Transform Data ${data} Last + ... no_result_value=0.0 + ... no_result_overwrite=Yes + Log ${rsp} + Log ${transform} + +Run Transform Query With Step + ${rsp}= RW.Prometheus.Query Instant api_url=${PROM_HOSTNAME} query=${PROM_AGGR_QUERY} optional_headers=${PROM_OPT_HEADERS} + ... step=30s + # ... seconds_in_past=36000 + ${data}= Set Variable ${rsp["data"]} + # The last value in the ordered list is the most recent prom data value + ${transform}= RW.Prometheus.Transform Data ${data} Raw + ... no_result_value=0.0 + ... no_result_overwrite=Yes + Log ${rsp} + Log ${transform} + +Run Transform Query Without Step + ${rsp}= RW.Prometheus.Query Instant api_url=${PROM_HOSTNAME} query=${PROM_AGGR_QUERY} optional_headers=${PROM_OPT_HEADERS} + # ... step=30s + # ... seconds_in_past=36000 + ${data}= Set Variable ${rsp["data"]} + # The last value in the ordered list is the most recent prom data value + ${transform}= RW.Prometheus.Transform Data ${data} Raw + ... no_result_value=0.0 + ... no_result_overwrite=Yes + Log ${rsp} + Log ${transform} diff --git a/requirements.txt b/requirements.txt index e61531f..f5b6899 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ robotframework>=4.1.2 ruamel.base>=1.0.0 ruamel.yaml>=0.17.20 -requests>=2 \ No newline at end of file +requests>=2 +python-dateutil +jmespath +pyyaml \ No newline at end of file diff --git a/setup/runwhen-local/helm/values.yaml b/setup/runwhen-local/helm/values.yaml new file mode 100644 index 0000000..1072ad2 --- /dev/null +++ b/setup/runwhen-local/helm/values.yaml @@ -0,0 +1,172 @@ +# RunWhen Local is intended to run with a single replica, as multiple instances +# will continue to perform discovery against your clusters +replicaCount: 1 + +image: + repository: ghcr.io/runwhen-contrib/runwhen-local + pullPolicy: Always + # Overrides the image tag whose default is the chart appVersion. + tag: "latest" + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + + +serviceAccountRoles: + namespaceRole: + enabled: false + namespaces: [] + rules: + - apiGroups: [""] + resources: ["*"] + verbs: ["get", "watch", "list"] + clusterRoleView: + enabled: true + advancedClusterRole: + enabled: false + rules: [] + # - apiGroups: [""] + # resources: ["pods", "pods/log", "events", "configmaps", "services", "replicationcontrollers"] + # verbs: ["get", "watch", "list"] + # - apiGroups: ["batch"] + # resources: ["*"] + # verbs: ["get", "watch", "list"] + + +podAnnotations: {} + + +service: + type: ClusterIP + port: 8081 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: Prefix + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "1024Mi" + cpu: "1" + +nodeSelector: + workload: "o11y" + +tolerations: + - key: "o11y" + value: "true" + effect: "NoSchedule" + +affinity: {} + +# RunWhen Local requires a kubeconfig to discover resouces from 1 or more +# clusters. +# +# Using inClusterAuth works for the local cluster only. +# If discoverying multiple clusters, set inClusterAuth.enabled=false +# and mount a secret with a kubeconfig that contains all +# cluster contexts to be discovered + +discoveryKubeconfig: + inClusterAuth: + enabled: true + secretProvided: + enabled: false + # secretName: runwhen-local-kubeconfig + # secretKey: kubeconfig + # secretPath: kubeconfig + +### RunWhen Local Runtime Configuration +# autoRun: start discovery on deployment, and re-run discovery evey discoveryInterval seconds +# also supports upload configuration for continuous updates of RunWhen Platform Workspace +# upload is disabled by default; requires a valid uploadInfo.yaml +autoRun: + discoveryInterval: 14400 # seconds to wait until a new discovery + uploadEnabled: false # upload configuration to RunWhen Platform + uploadMergeMode: "keep-uploaded" # 'keep-uploaded' or 'keep-existing' + + +# terminal: Enable or disable the in-browser terminal (unauthentication connection to container shell) +terminal: + disabled: true + +###### Upload configuration +# The upload configuration applies only to users to leverage RunWhen Local as an +# onboarding tool into RunWhen Platform (https://www.runwhen.com for more details) +# When uploading discovered resources and configuration information to RunWhen Platform, +# workspaceName, token, workspaceOwnerEmail, defaultLocation and papiURL are mandtatory +# can can be obtained immediately after creating a RunWhen Platform workspace. + +uploadInfo: + workspaceName: ifc-sre-stack + token: undefined + workspaceOwnerEmail: tester@my-company.com + papiURL: https://papi.beta.runwhen.com + defaultLocation: location-01-us-west1 + + +###### workspaceInfo +# Currently this holds details such as custom variables & upload configuration +# Note: This section will soon be split into two separate configuration files +# +###### Custom Variables +# Custom variables are used to help tailor the generated content when we can't +# automatically infer from the discovered resources (such as specifying a prometheus +# provider, cloud provider, or kubernetes distribution & binary (mostly for oc vs +# kubectl users)) + +workspaceInfo: + defaultLocation: undefined + workspaceName: undefined + workspaceOwnerEmail: tester@my-company.com + defaultLOD: 2 # This setting will discover all namespaces not specified in namespaceLODs with the greatest level of detail/depth + namespaceLODs: # Specific discovery depth overrides on a per-namespace basis. 0 = ignore, 1 = simple/basic discovery, 2 = detailed discovery + kube-system: 0 + kube-public: 0 + kube-node-lease: 0 + custom: + kubernetes_distribution: Kubernetes + kubernetes_distribution_binary: kubectl + + # Possible values are gcp, aws, azure, none + # cloud_provider: gcp + + # Possible values are gmp, promql, none + # prometheus_provider: gmp + + # If using GCP and wanting to use GCP integrations + # gcp_project_id: undefined + + #### RunWhen Platform only + # Secret names are used when integrating the RunWhen Platform + # with your enviornment. Configuration data will reference these + # secret names - and so secrets uploaded to RunWhen Platform must + # be given the same name + kubeconfig_secret_name: kubeconfig + # gcp_ops_suite_sa: ops-suite-sa + diff --git a/setup/runwhen-local/istio/vs.yaml b/setup/runwhen-local/istio/vs.yaml new file mode 100644 index 0000000..4711783 --- /dev/null +++ b/setup/runwhen-local/istio/vs.yaml @@ -0,0 +1,21 @@ +apiVersion: networking.istio.io/v1alpha3 +kind: VirtualService +metadata: + name: runwhen-local-vs + namespace: monitoring +spec: + hosts: + - "*" + gateways: + - robot-shop/robotshop-gateway + http: + - match: + - uri: + exact: /runwhen + - uri: + prefix: /runwhen/ + route: + - destination: + host: runwhen-local.monitoring.svc.cluster.local + port: + number: 8081 diff --git a/setup/runwhen-local/setup.sh b/setup/runwhen-local/setup.sh new file mode 100755 index 0000000..825b05b --- /dev/null +++ b/setup/runwhen-local/setup.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +GIT_TLD=`git rev-parse --show-toplevel` +source ${GIT_TLD}/.env + +VALUES_FILE=${GIT_TLD}/setup/runwhen-local/helm/values.yaml + +if [ -z "${RUNWHEN_PLATFORM_TOKEN}" ]; then + echo "RUNWHEN_PLATFORM_TOKEN is not set in environment variables. Please export RUNWHEN_PLATFORM_TOKEN." + exit 1 +fi + +helm repo add runwhen-contrib https://runwhen-contrib.github.io/helm-charts +helm repo update + +# Install the RunWhen Local helm release +helm upgrade --install ${HELM_RELEASE_NAME} runwhen-contrib/runwhen-local \ + --set uploadInfo.token=${RUNWHEN_PLATFORM_TOKEN} \ + --set uploadInfo.workspaceOwnerEmail=${WORKSPACE_OWNER_EMAIL} \ + -f ${VALUES_FILE} -n ${NAMESPACE} + +# Install RunWhen Istio Virtual Service +kubectl apply -f ${GIT_TLD}/setup/runwhen-local/istio/vs.yaml \ No newline at end of file diff --git a/test/sre-stack b/test/sre-stack new file mode 160000 index 0000000..a14f401 --- /dev/null +++ b/test/sre-stack @@ -0,0 +1 @@ +Subproject commit a14f40196c3865f255cc45269839158dc88bd89a