diff --git a/docs/deployment/kubernetes/installation.mdx b/docs/deployment/kubernetes/installation.mdx index 9680df532..594df7c3b 100644 --- a/docs/deployment/kubernetes/installation.mdx +++ b/docs/deployment/kubernetes/installation.mdx @@ -8,23 +8,28 @@ The recommended way to install Keep on Kubernetes is via Helm Chart.

Follow these steps to set it up. -## Prerequisites +# Prerequisites -### Helm CLI +## Helm CLI See the [Helm documentation](https://helm.sh/docs/intro/install/) for instructions about installing helm. -### Ingress Controller (Optional) +## Ingress Controller (Optional) You can skip this step if: 1. You already have **ingress-nginx** installed. 2. You don't need to expose Keep to the internet/network. -#### Overview +### Overview An ingress controller is essential for managing external access to services in your Kubernetes cluster. It acts as a smart router and load balancer, allowing you to expose multiple services through a single entry point while handling SSL termination and routing rules. -**Keep works the best with** [ingress-nginx](https://github.com/kubernetes/ingress-nginx) **but you can customize the helm chart for other ingress controllers too.** + +**Keep works best with both** [ingress-nginx](https://github.com/kubernetes/ingress-nginx) **and** [HAProxy Ingress](https://haproxy-ingress.github.io/) **controllers, but you can customize the helm chart for other ingress controllers too.** + + +### Nginx Ingress Controller + #### Check ingress-nginx Installed You check if you already have ingress-nginx installed: ```bash @@ -67,6 +72,34 @@ kubectl get configmap -n ingress-nginx ingress-nginx-controller -o yaml | grep a allow-snippet-annotations: "true" ``` +### HAProxy Ingress Controller + +#### Install ingress-haproxy + +To read about more installation options, see [haproxy-ingress installation docs](https://haproxy-ingress.github.io/docs/getting-started/). + + +```bash +# simplest way to install +helm upgrade --install haproxy-ingress haproxy-ingress \ + --repo https://haproxy-ingress.github.io/charts \ + --namespace ingress-haproxy --create-namespace +``` + +Verify installation: +```bash +kubectl get ingressclass +NAME CONTROLLER PARAMETERS AGE +haproxy haproxy-ingress.github.io/controller 4h19m +``` + +Verify if controller is running: +```bash +kubectl get pods -n ingress-haproxy -l app.kubernetes.io/instance=haproxy-ingress +NAME READY STATUS RESTARTS AGE +haproxy-ingress-controller-x4n2z 1/1 Running 0 4h19m +``` + ## Installation ### With Ingress-NGINX (Recommended) @@ -79,7 +112,17 @@ helm repo add keephq https://keephq.github.io/helm-charts helm install keep keephq/keep -n keep --create-namespace ``` -### Without Ingress-NGINX (Not Recommended) +### With Ingress-HAProxy (Recommended) + +```bash +# Add the Helm repository +helm repo add keephq https://keephq.github.io/helm-charts + +# Install Keep with ingress enabled +helm install keep keephq/keep -n keep --create-namespace --set global.ingress.className=haproxy +``` + +### Without Ingress (Not Recommended) ```bash # Add the Helm repository diff --git a/keep-ui/package-lock.json b/keep-ui/package-lock.json index 6e3563b45..295bfb0fe 100644 --- a/keep-ui/package-lock.json +++ b/keep-ui/package-lock.json @@ -79,7 +79,7 @@ "convert-source-map": "^1.9.0", "cookie": "^0.7.0", "cosmiconfig": "^7.1.0", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "crypto-js": "^4.2.0", "css-unit-converter": "^1.1.2", "cssesc": "^3.0.0", @@ -10222,8 +10222,9 @@ } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "license": "MIT", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", + "integrity": "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", diff --git a/keep-ui/package.json b/keep-ui/package.json index 62d468ef5..09dedf974 100644 --- a/keep-ui/package.json +++ b/keep-ui/package.json @@ -80,7 +80,7 @@ "convert-source-map": "^1.9.0", "cookie": "^0.7.0", "cosmiconfig": "^7.1.0", - "cross-spawn": "^7.0.3", + "cross-spawn": "^7.0.5", "crypto-js": "^4.2.0", "css-unit-converter": "^1.1.2", "cssesc": "^3.0.0", diff --git a/keep/api/utils/email_utils.py b/keep/api/utils/email_utils.py index d76001495..34e708fba 100644 --- a/keep/api/utils/email_utils.py +++ b/keep/api/utils/email_utils.py @@ -33,17 +33,22 @@ class EmailTemplates(enum.Enum): FROM_EMAIL = config("SENDGRID_FROM_EMAIL", default="platform@keephq.dev") API_KEY = config("SENDGRID_API_KEY", default=None) CC = config("SENDGRID_CC", default="founders@keephq.dev") +KEEP_EMAILS_ENABLED = config("KEEP_EMAILS_ENABLED", default=False, cast=bool) def send_email( to_email: str, template_id: EmailTemplates, **kwargs, -): +) -> bool: + if not KEEP_EMAILS_ENABLED: + logger.debug("Emails are disabled, skipping sending email") + return False + # that's ok on OSS if not API_KEY: logger.debug("No SendGrid API key, skipping sending email") - return + return False message = Mail(from_email=FROM_EMAIL, to_emails=to_email) message.template_id = template_id.value @@ -55,6 +60,7 @@ def send_email( sg = SendGridAPIClient(API_KEY) sg.send(message) logger.info(f"Email sent to {to_email} with template {template_id}") + return True except Exception as e: logger.error( f"Failed to send email to {to_email} with template {template_id}: {e}" diff --git a/keep/functions/__init__.py b/keep/functions/__init__.py index 6a04469c9..503021a8e 100644 --- a/keep/functions/__init__.py +++ b/keep/functions/__init__.py @@ -115,7 +115,7 @@ def to_timestamp(dt: datetime.datetime | str = "") -> int: def datetime_compare(t1: datetime = None, t2: datetime = None) -> float: - if t1 is None or t2 is None: + if not t1 or not t2: return 0 diff = (t1 - t2).total_seconds() / 3600 return diff diff --git a/keep/iohandler/iohandler.py b/keep/iohandler/iohandler.py index 88a857aff..7f0f22fec 100644 --- a/keep/iohandler/iohandler.py +++ b/keep/iohandler/iohandler.py @@ -330,7 +330,17 @@ def _parse(self, tree): .replace("\n", "\\n") ) t = self._encode_single_quotes_in_double_quotes(t) - tree = ast.parse(t) + try: + tree = ast.parse(t) + except Exception: + # For strings where ' is used as the delimeter and we failed to escape all ' in the string + # @tb: again, this is not ideal but it's best effort... + t = ( + t.replace("('", '("') + .replace("')", '")') + .replace("',", '",') + ) + tree = ast.parse(t) else: # for strings such as "45%\n", we need to escape tree = ast.parse(token.encode("unicode_escape")) diff --git a/keep/providers/servicenow_provider/servicenow_provider.py b/keep/providers/servicenow_provider/servicenow_provider.py index c9c1ee967..f1afd1776 100644 --- a/keep/providers/servicenow_provider/servicenow_provider.py +++ b/keep/providers/servicenow_provider/servicenow_provider.py @@ -45,6 +45,25 @@ class ServicenowProviderAuthConfig: } ) + # @tb: based on this https://www.servicenow.com/community/developer-blog/oauth-2-0-with-inbound-rest/ba-p/2278926 + client_id: str = dataclasses.field( + metadata={ + "required": False, + "description": "The client ID to use OAuth 2.0 based authentication", + "sensitive": False, + }, + default="", + ) + + client_secret: str = dataclasses.field( + metadata={ + "required": False, + "description": "The client secret to use OAuth 2.0 based authentication", + "sensitive": True, + }, + default="", + ) + class ServicenowProvider(BaseTopologyProvider): """Manage ServiceNow tickets.""" @@ -65,6 +84,33 @@ def __init__( self, context_manager: ContextManager, provider_id: str, config: ProviderConfig ): super().__init__(context_manager, provider_id, config) + self._access_token = None + if ( + self.authentication_config.client_id + and self.authentication_config.client_secret + ): + url = f"{self.authentication_config.service_now_base_url}/oauth_token.do" + payload = { + "grant_type": "password", + "username": self.authentication_config.username, + "password": self.authentication_config.password, + "client_id": self.authentication_config.client_id, + "client_secret": self.authentication_config.client_secret, + } + response = requests.post( + url, + json=payload, + ) + if response.ok: + self._access_token = response.json().get("access_token") + else: + self.logger.error( + "Failed to get access token", + extra={ + "response": response.text, + "status_code": response.status_code, + }, + ) @property def service_now_base_url(self): @@ -80,15 +126,23 @@ def validate_scopes(self): try: self.logger.info("Validating ServiceNow scopes") url = f"{self.authentication_config.service_now_base_url}/api/now/table/sys_user_role?sysparm_query=user_name={self.authentication_config.username}" - response = requests.get( - url, - auth=HTTPBasicAuth( - self.authentication_config.username, - self.authentication_config.password, - ), - verify=False, - timeout=10, - ) + if self._access_token: + response = requests.get( + url, + headers={"Authorization": f"Bearer {self._access_token}"}, + verify=False, + timeout=10, + ) + else: + response = requests.get( + url, + auth=HTTPBasicAuth( + self.authentication_config.username, + self.authentication_config.password, + ), + verify=False, + timeout=10, + ) try: response.raise_for_status() @@ -131,13 +185,57 @@ def validate_config(self): **self.config.authentication ) + def _query( + self, + table_name: str, + get_incidents: bool = False, + incident_id: str = None, + **kwargs: dict, + ): + request_url = f"{self.authentication_config.service_now_base_url}/api/now/table/{table_name}" + headers = {"Content-Type": "application/json", "Accept": "application/json"} + auth = ( + ( + self.authentication_config.username, + self.authentication_config.password, + ) + if not self._access_token + else None + ) + if self._access_token: + headers["Authorization"] = f"Bearer {self._access_token}" + + response = requests.get( + request_url, + headers=headers, + auth=auth, + params=kwargs, + verify=False, + timeout=10, + ) + + if not response.ok: + self.logger.error( + f"Failed to query {table_name}", + extra={"status_code": response.status_code, "response": response.text}, + ) + return [] + + return response.json().get("result", []) + def pull_topology(self) -> list[TopologyServiceInDto]: - # TODO: in scable, we'll need to use pagination around here + # TODO: in scale, we'll need to use pagination around here headers = {"Content-Type": "application/json", "Accept": "application/json"} auth = ( - self.authentication_config.username, - self.authentication_config.password, + ( + self.authentication_config.username, + self.authentication_config.password, + ) + if not self._access_token + else None ) + if self._access_token: + headers["Authorization"] = f"Bearer {self._access_token}" topology = [] self.logger.info( "Pulling topology", extra={"tenant_id": self.context_manager.tenant_id} @@ -253,7 +351,16 @@ def dispose(self): def _notify(self, table_name: str, payload: dict = {}, **kwargs: dict): # Create ticket headers = {"Content-Type": "application/json", "Accept": "application/json"} - + auth = ( + ( + self.authentication_config.username, + self.authentication_config.password, + ) + if not self._access_token + else None + ) + if self._access_token: + headers["Authorization"] = f"Bearer {self._access_token}" # otherwise, create the ticket if not table_name: raise ProviderException("Table name is required") @@ -271,10 +378,7 @@ def _notify(self, table_name: str, payload: dict = {}, **kwargs: dict): # HTTP request response = requests.post( url, - auth=( - self.authentication_config.username, - self.authentication_config.password, - ), + auth=auth, headers=headers, data=json.dumps(payload), verify=False, @@ -302,12 +406,20 @@ def _notify(self, table_name: str, payload: dict = {}, **kwargs: dict): def _notify_update(self, table_name: str, ticket_id: str, fingerprint: str): url = f"{self.authentication_config.service_now_base_url}/api/now/table/{table_name}/{ticket_id}" headers = {"Content-Type": "application/json", "Accept": "application/json"} - response = requests.get( - url, - auth=( + auth = ( + ( self.authentication_config.username, self.authentication_config.password, - ), + ) + if self._access_token + else None + ) + if self._access_token: + headers["Authorization"] = f"Bearer {self._access_token}" + + response = requests.get( + url, + auth=auth, headers=headers, verify=False, ) diff --git a/keep/workflowmanager/workflowscheduler.py b/keep/workflowmanager/workflowscheduler.py index 5ed3d139c..fe402a32e 100644 --- a/keep/workflowmanager/workflowscheduler.py +++ b/keep/workflowmanager/workflowscheduler.py @@ -583,32 +583,30 @@ def _finish_workflow_execution( or previous_execution.status != WorkflowStatus.ERROR.value ): workflow = get_workflow_db(tenant_id=tenant_id, workflow_id=workflow_id) - self.logger.info( - f"Sending email to {workflow.created_by} for failed workflow {workflow_id}" - ) - - # send the email (commented out) try: - # from keep.api.core.config import config - # from keep.api.utils.email_utils import EmailTemplates, send_email - # TODO - should be handled - # keep_platform_url = config( - # "KEEP_PLATFORM_URL", default="https://platform.keephq.dev" - # ) - # error_logs_url = f"{keep_platform_url}/workflows/{workflow_id}/runs/{workflow_execution_id}" - # send_email( - # to_email=workflow.created_by, - # template_id=EmailTemplates.WORKFLOW_RUN_FAILED, - # workflow_id=workflow_id, - # workflow_name=workflow.name, - # workflow_execution_id=workflow_execution_id, - # error=error, - # url=error_logs_url, - # ) - # self.logger.info( - # f"Email sent to {workflow.created_by} for failed workflow {workflow_id}" - # ) - pass + from keep.api.core.config import config + from keep.api.utils.email_utils import EmailTemplates, send_email + + keep_platform_url = config( + "KEEP_PLATFORM_URL", default="https://platform.keephq.dev" + ) + error_logs_url = f"{keep_platform_url}/workflows/{workflow_id}/runs/{workflow_execution_id}" + self.logger.debug( + f"Sending email to {workflow.created_by} for failed workflow {workflow_id}" + ) + email_sent = send_email( + to_email=workflow.created_by, + template_id=EmailTemplates.WORKFLOW_RUN_FAILED, + workflow_id=workflow_id, + workflow_name=workflow.name, + workflow_execution_id=workflow_execution_id, + error=error, + url=error_logs_url, + ) + if email_sent: + self.logger.info( + f"Email sent to {workflow.created_by} for failed workflow {workflow_id}" + ) except Exception as e: self.logger.error( f"Failed to send email to {workflow.created_by} for failed workflow {workflow_id}: {e}" diff --git a/pyproject.toml b/pyproject.toml index 79267d83d..6ed27766c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "keep" -version = "0.28.10" +version = "0.29.0" description = "Alerting. for developers, by developers." authors = ["Keep Alerting LTD"] readme = "README.md"