From 517b2ca70752e61453e459ae4f04c35d95ae7c8e Mon Sep 17 00:00:00 2001 From: Tal Borenstein Date: Thu, 31 Oct 2024 15:03:32 +0200 Subject: [PATCH 1/4] feat: gcp monitoring logs --- .../gcpmonitoring_provider.py | 111 +++++++++++++++++- poetry.lock | 56 ++++++++- pyproject.toml | 1 + 3 files changed, 165 insertions(+), 3 deletions(-) diff --git a/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py b/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py index b53cf3eb7..a89e050bb 100644 --- a/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py +++ b/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py @@ -2,12 +2,37 @@ PrometheusProvider is a class that provides a way to read data from Prometheus. """ +import dataclasses import datetime +import json +import logging + +import google.api_core +import google.api_core.exceptions +import google.cloud.logging +import pydantic from keep.api.models.alert import AlertDto, AlertSeverity, AlertStatus from keep.contextmanager.contextmanager import ContextManager from keep.providers.base.base_provider import BaseProvider -from keep.providers.models.provider_config import ProviderConfig +from keep.providers.models.provider_config import ProviderConfig, ProviderScope +from keep.providers.providers_factory import ProvidersFactory + + +@pydantic.dataclasses.dataclass +class GcpmonitoringProviderAuthConfig: + """GKE authentication configuration.""" + + service_account_json: str = dataclasses.field( + metadata={ + "required": True, + "description": "A service account JSON with logging viewer role", + "sensitive": True, + "type": "file", + "name": "service_account_json", + "file_type": ".json", # this is used to filter the file type in the UI + } + ) class GcpmonitoringProvider(BaseProvider): @@ -49,19 +74,78 @@ class GcpmonitoringProvider(BaseProvider): PROVIDER_DISPLAY_NAME = "GCP Monitoring" FINGERPRINT_FIELDS = ["incident_id"] + PROVIDER_SCOPES = [ + ProviderScope( + name="roles/logs.viewer", + description="Read access to GCP logging", + mandatory=True, + alias="Logs Viewer", + ), + ] def __init__( self, context_manager: ContextManager, provider_id: str, config: ProviderConfig ): super().__init__(context_manager, provider_id, config) + self._service_account_data = json.loads( + self.authentication_config.service_account_json + ) + self._client = None def validate_config(self): """ Validates required configuration for Prometheus's provider. """ # no config + self.authentication_config = GcpmonitoringProviderAuthConfig( + **self.config.authentication + ) + + def dispose(self): pass + def validate_scopes(self) -> dict[str, bool | str]: + scopes = {} + # try initializing the client to validate the scopes + try: + self.client + scopes["roles/logs.viewer"] = True + except google.api_core.exceptions.PermissionDenied: + scopes["roles/logs.viewer"] = ( + "Permission denied, make sure IAM permissions are set correctly" + ) + except Exception as e: + scopes["roles/logs.viewer"] = str(e) + return scopes + + @property + def client(self) -> google.cloud.logging.Client: + if self._client is None: + self._client = self.__generate_client() + return self._client + + def __generate_client(self) -> google.cloud.logging.Client: + if not self._client: + self._client = google.cloud.logging.Client.from_service_account_info( + self._service_account_data + ) + return self._client + + def _query(self, filter: str, timedelta_in_days=1, page_size=1000): + self.logger.info( + f"Querying GCP Monitoring with filter: {filter} and timedelta_in_days: {timedelta_in_days}" + ) + if "timestamp" not in filter: + start_time = ( + datetime.datetime.now(tz=datetime.timezone.utc) + - datetime.timedelta(days=timedelta_in_days) + ).strftime("%Y-%m-%dT%H:%M:%SZ") + filter = f'{filter} timestamp>="{start_time}"' + entries_iterator = self.client.list_entries(filter_=filter, page_size=page_size) + entries = [entry for entry in entries_iterator] + self.logger.info(f"Found {len(entries)} entries") + return entries + @staticmethod def _format_alert( event: dict, provider_instance: "BaseProvider" = None @@ -126,4 +210,27 @@ def _format_alert( if __name__ == "__main__": - pass + logging.basicConfig(level=logging.DEBUG, handlers=[logging.StreamHandler()]) + context_manager = ContextManager( + tenant_id="singletenant", + workflow_id="test", + ) + + # Get these from a secure source or environment variables + with open("sa.json") as f: + service_account_data = f.read() + + config = { + "authentication": { + "service_account_json": service_account_data, + } + } + + provider = ProvidersFactory.get_provider( + context_manager, + provider_id="gcp-demo", + provider_type="gcpmonitoring", + provider_config=config, + ) + entries = provider._query(filter='resource.type = "cloud_run_revision"') + print(entries) diff --git a/poetry.lock b/poetry.lock index 1a4527b81..5ed29a9fb 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1464,6 +1464,38 @@ pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] reauth = ["pyu2f (>=0.1.5)"] requests = ["requests (>=2.20.0,<3.0.0.dev0)"] +[[package]] +name = "google-cloud-appengine-logging" +version = "1.5.0" +description = "Google Cloud Appengine Logging API client library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_cloud_appengine_logging-1.5.0-py2.py3-none-any.whl", hash = "sha256:81e36606e13c377c4898c918542888abb7a6896837ac5f559011c7729fc63d8a"}, + {file = "google_cloud_appengine_logging-1.5.0.tar.gz", hash = "sha256:39a2df694d97981ed00ef5df541f7cfcca920a92496707557f2b07bb7ba9d67a"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" +proto-plus = ">=1.22.3,<2.0.0dev" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" + +[[package]] +name = "google-cloud-audit-log" +version = "0.3.0" +description = "Google Cloud Audit Protos" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_cloud_audit_log-0.3.0-py2.py3-none-any.whl", hash = "sha256:8340793120a1d5aa143605def8704ecdcead15106f754ef1381ae3bab533722f"}, + {file = "google_cloud_audit_log-0.3.0.tar.gz", hash = "sha256:901428b257020d8c1d1133e0fa004164a555e5a395c7ca3cdbb8486513df3a65"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.56.2,<2.0dev" +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" + [[package]] name = "google-cloud-bigquery" version = "3.24.0" @@ -1530,6 +1562,28 @@ google-auth = ">=1.25.0,<3.0dev" [package.extras] grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] +[[package]] +name = "google-cloud-logging" +version = "3.11.3" +description = "Stackdriver Logging API client library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "google_cloud_logging-3.11.3-py2.py3-none-any.whl", hash = "sha256:b8ec23f2998f76a58f8492db26a0f4151dd500425c3f08448586b85972f3c494"}, + {file = "google_cloud_logging-3.11.3.tar.gz", hash = "sha256:0a73cd94118875387d4535371d9e9426861edef8e44fba1261e86782d5b8d54f"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" +google-cloud-appengine-logging = ">=0.1.3,<2.0.0dev" +google-cloud-audit-log = ">=0.2.4,<1.0.0dev" +google-cloud-core = ">=2.0.0,<3.0.0dev" +grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev" +opentelemetry-api = ">=1.9.0" +proto-plus = {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""} +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" + [[package]] name = "google-cloud-secret-manager" version = "2.20.0" @@ -5299,4 +5353,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = ">=3.11,<3.12" -content-hash = "c021924b92984309ec0904f525c5287b51ff74b2a51017a48c714f85b802f92e" +content-hash = "d405584a8c24ff3b5e3c7f4405d24145e5b2bf7b951f5c6279977d7cd0780d5e" diff --git a/pyproject.toml b/pyproject.toml index 0fea838e2..529d284d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -94,6 +94,7 @@ scipy = "^1.14.1" networkx = "^3.3" google-auth = "2.34.0" clickhouse-driver = "^0.2.9" +google-cloud-logging = "^3.11.3" [tool.poetry.group.dev.dependencies] pre-commit = "^3.0.4" pre-commit-hooks = "^4.4.0" From 0bc1c865afdcae436ef5df5028a93b1743a6e92d Mon Sep 17 00:00:00 2001 From: Tal Borenstein Date: Thu, 31 Oct 2024 17:44:16 +0200 Subject: [PATCH 2/4] feat: gcp logging and friends --- docs/mint.json | 1 + .../documentation/gcpmonitoring-provider.mdx | 117 ++++++++++++------ .../documentation/openai-provider.mdx | 38 ++++++ .../documentation/slack-provider.mdx | 2 + docs/providers/overview.mdx | 8 ++ examples/workflows/gcp_logging_open_ai.yaml | 41 ++++++ keep-ui/public/icons/openai-icon.png | Bin 0 -> 18601 bytes .../gcpmonitoring_provider.py | 46 +++++-- keep/providers/openai_provider/__init__.py | 0 .../openai_provider/openai_provider.py | 58 +++++++++ .../slack_provider/slack_provider.py | 18 ++- 11 files changed, 272 insertions(+), 57 deletions(-) create mode 100644 docs/providers/documentation/openai-provider.mdx create mode 100644 examples/workflows/gcp_logging_open_ai.yaml create mode 100644 keep-ui/public/icons/openai-icon.png create mode 100644 keep/providers/openai_provider/__init__.py create mode 100644 keep/providers/openai_provider/openai_provider.py diff --git a/docs/mint.json b/docs/mint.json index 182404dc7..3ce28eb4b 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -164,6 +164,7 @@ "providers/documentation/netdata-provider", "providers/documentation/new-relic-provider", "providers/documentation/ntfy-provider", + "providers/documentation/openai-provider", "providers/documentation/openobserve-provider", "providers/documentation/openshift-provider", "providers/documentation/opsgenie-provider", diff --git a/docs/providers/documentation/gcpmonitoring-provider.mdx b/docs/providers/documentation/gcpmonitoring-provider.mdx index 8a31e79c0..7fc56ee5d 100644 --- a/docs/providers/documentation/gcpmonitoring-provider.mdx +++ b/docs/providers/documentation/gcpmonitoring-provider.mdx @@ -1,78 +1,113 @@ --- title: "GCP Monitoring" sidebarTitle: "GCP Monitoring Provider" -description: "GCP Monitoringing provider allows you to get alerts from Azure Monitoring via webhooks." +description: "GCP Monitoring provider allows you to get alerts and logs from GCP Monitoring via webhooks and log queries." --- ## Overview -The GCP Monitoring Provider enables seamless integration between Keep and GCP Monitoring, allowing alerts from GCP Monitoring to be directly sent to Keep through webhook configurations. This integration ensures that critical alerts are efficiently managed and responded to within Keep's platform. + +The GCP Monitoring Provider enables seamless integration between Keep and GCP Monitoring, allowing alerts from GCP Monitoring to be directly sent to Keep through webhook configurations. In addition to alerts, the provider now supports querying log entries from GCP Logging, enabling a comprehensive view of alerts and associated logs within Keep's platform. ## Connecting GCP Monitoring to Keep -To connect GCP Monitoring to Keep, you'll need to configure a webhook as a notification channel in GCP Monitoring and then link it to the desired alert policy. + +### Alert Integration via Webhook + +To connect GCP Monitoring alerts to Keep, configure a webhook as a notification channel in GCP Monitoring and link it to the desired alert policy. ### Step 1: Access Notification Channels + Log in to the Google Cloud Platform console. Navigate to **Monitoring > Alerting > Notification channels**. - - + + ### Step 2: Add a New Webhook + Within the Webhooks section, click on **ADD NEW**. - - + + ### Step 3: Configure the Webhook + In the Endpoint URL field, enter the webhook URL provided by Keep. -- For Display Name, use keep-gcpmonitoring-webhook-integration. -- Enable Use HTTP Basic Auth and input the following credentials: - - Auth Username: **api_key** - - Auth Password: **%YOURAPIKEY%** - - - + +- **Display Name**: keep-gcpmonitoring-webhook-integration +- Enable **Use HTTP Basic Auth** and input the following credentials: + - **Auth Username**: `api_key` + - **Auth Password**: `%YOURAPIKEY%` + + + ### Step 4: Save the Webhook Configuration -- Click on Save to store the webhook configuration. + +- Click **Save** to store the webhook configuration. ### Step 5: Associate the Webhook with an Alert Policy Navigate to the alert policy you wish to send notifications from to Keep. -- Click on Edit. -- Under "Notifications and name," find the Notification Channels section and select the keep-gcpmonitoring-webhook-integration channel you created. -- Save the changes by clicking on SAVE POLICY. - - - - +- Click **Edit**. +- Under "Notifications and name," find the **Notification Channels** section and select the `keep-gcpmonitoring-webhook-integration` channel you created. +- Save the changes by clicking on **SAVE POLICY**. + + + + - - + + -### Step 6: Review the alert in Keep +### Step 6: Review the Alert in Keep - - +Once the setup is complete, alerts from GCP Monitoring will start appearing in Keep. + + + +## Log Query Integration + +The GCP Monitoring Provider also supports querying logs from GCP Logging, allowing you to fetch log entries based on specific filters. This is helpful for enriching alert data with related logs or for monitoring specific events in Keep. + +### Authentication Requirements + +To enable log querying, you need to provide a service account JSON file with the `logs.viewer` role. This service account should be configured in the `authentication` section of your GCP Monitoring Provider configuration. + +### Querying Logs + +The provider’s `query` function supports filtering logs based on criteria such as resource type, severity, or specific keywords. You can specify a time range for querying logs using `timedelta_in_days`, and control the number of entries with `page_size`. + +#### Example Usage + +Here’s an example of how you might use the provider to query log entries: + +```python +query(filter='resource.type="cloud_run_revision" AND severity="ERROR"', timedelta_in_days=1) +``` + +This will return logs of severity “ERROR” related to Cloud Run revisions from the past day. + +#### Log Scopes + +To read logs, the provider requires the following IAM role: + + • roles/logs.viewer - Allows the provider to read log entries. + +#### Post Installation Validation + +To validate both alerts and logs, follow these steps: + + 1. Alert Validation: Test the webhook by triggering an alert in GCP Monitoring and confirm it appears in Keep. + 2. Log Query Validation: Execute a simple log query and verify that log entries are returned as expected. + ### Useful Links - - [GCP Monitoring Notification Channels](https://cloud.google.com/monitoring/support/notification-options) - - [GCP Monitoring Alerting](https://cloud.google.com/monitoring/alerts) + +- [GCP Monitoring Notification Channels](https://cloud.google.com/monitoring/support/notification-options) +- [GCP Monitoring Alerting](https://cloud.google.com/monitoring/alerts) diff --git a/docs/providers/documentation/openai-provider.mdx b/docs/providers/documentation/openai-provider.mdx new file mode 100644 index 000000000..cfeb18914 --- /dev/null +++ b/docs/providers/documentation/openai-provider.mdx @@ -0,0 +1,38 @@ +--- +title: "OpenAI Provider" +description: "The OpenAI Provider allows for integrating OpenAI's language models into Keep." +--- + + + The OpenAI Provider supports querying GPT language models for prompt-based + interactions. + + +## Inputs + +The OpenAI Provider supports the following functions: + +- `query`: Interact with OpenAI's models by sending prompts and receiving responses +- `model`: The model to be used, defaults to `gpt-3.5-turbo` + +## Outputs + +Currently, the OpenAI Provider outputs the response from the model based on the prompt provided. + +## Authentication Parameters + +To use the OpenAI Provider, you'll need an API Key, and optionally, an Organization ID from OpenAI. The required parameters for authentication are: + +- **api_key** (required): Your OpenAI Platform API Key. +- **organization_id** (optional): Your OpenAI Platform Organization ID. + +## Connecting with the Provider + +To connect to OpenAI, you'll need to obtain an API Key and (optionally) an Organization ID: + +1. Log in to your OpenAI account at [OpenAI Platform](https://platform.openai.com). +2. Go to the **API Keys** section. +3. Click on **Create new secret key** to generate a key for Keep. +4. (Optional) Retrieve your **Organization ID** under **Organization settings** if you’re part of multiple organizations. + +Use the generated API key in the `authentication` section of your OpenAI Provider configuration. diff --git a/docs/providers/documentation/slack-provider.mdx b/docs/providers/documentation/slack-provider.mdx index d4360c6ee..5fa125b01 100644 --- a/docs/providers/documentation/slack-provider.mdx +++ b/docs/providers/documentation/slack-provider.mdx @@ -63,6 +63,8 @@ The `notify` function take following parameters as inputs: - `message`: Required. Message text to send to Slack - `blocks`: Optional. Array of interactive components like inputs, buttons - `channel`: Optional. The channel ID to send to if using the OAuth integration. +- `thread_timestamp`: Optional. The timestamp of the thread to update if using the OAuth integration. +- `slack_timestamp`: Optional. The timestamp of the message to update if using the OAuth integration. ## Useful Links diff --git a/docs/providers/overview.mdx b/docs/providers/overview.mdx index ae455e8cf..52f6f98dd 100644 --- a/docs/providers/overview.mdx +++ b/docs/providers/overview.mdx @@ -436,6 +436,14 @@ By leveraging Keep Providers, users are able to deeply integrate Keep with the t } > + + } +> + i+H7MtZMJ^dwq2WTd$aAf-k$fbd+(Wp?>RGfJ~L;| zgel5PBEsRq0RVs~^<7LE03bhK!7yR}dno7AQ-3}nErjKS0iZSp{>=#L^PAY@yRsYr zcu@dAKo9`De3Al=0KkQ@j#ek2{nDOq%%`()*@j|QhlI<`R+pr*o(a|ArDc-f{-dT^^;pw2FkKOO z7cwPDmXa;d{>N;TPlwF9^fK*Vf$$WyMn$GZ|9>iRbn@FUNpavuoHYzY34Id7e^lZ4 zgr>LsxoQ79_Aqtj=TFKn6v)=&-Wve=qi;zJG$!*24+YZSY9J4Q`J`x0#%hrKC;0Bb zks!+SRIC^3k&SjE{U1{=eHa2cIa{CqYGayR|5!Qde?$%4$<6MGd?`}C(u9A z*v)`8^DoHV_JrrG+(_f>L!ZQ4{$|CMzOU~&-0kvDTe^t_kk{zMltAe6AN8h!TbWPy zgoio6bC|dAE(h#)TsMsa2iE(YNt$Qk@k1VCipgK8g$d;MRm+rqC9vvZeK&`fNt+VE ztcmr+B#ZnnUSdIBHk`T+24nxw9TLk=d(a;(m~Y1_q4wb6Vq7VQ+!{Xc|9Mp_E%45aO{jwXca82D`~c=a$B-VPb=iaVKUox! zu~!NBvkBh`6M3&XMjJ^s)3qZpMFH&^Ps>R!6JH`6}tOTE-)I27>VwhZm}wM$L{{e)qn3;{T1O+1$XtJ^LEl z**tHi$8Z01kjQ12^_c`xu3B4QF+baX9l{g}Tf+zY49hrf{>=&Q{U8o3-Q#QC$N7Q% z`$^o&?LXi(iR$^!;gF&`MYB+Rw8`2d3tMb(wi_vi z$r4f=O3I+}&mcYwE*d)!dfgB|)A3ib$k6kb3RF901s~dMGegIr{pIvz`C3x-cAD!l zzIZM`xlbkcEsQK)Xy@bIpLWE?oHbo*02gTl=5vxDab__6S%}hBTA+>hreGc$eEwVY z;dWL@R4i4)Q2htKkxlHmww3VYuKMO*EOna32nafJ{AwrC`w{v~T@*+yFs`g`cg2AV ze27goP_gNRd_TyOd$2FegvB-wwBE}2ROJw^Lqhb>tmH)}^O3kCvum?UWzD|{*6Il0 z{zAb4c^uudYB7QnJA+tLosw_o<8leJ9k>~l7DVI^oEWzxPkc9riTAhkdzY9u+Y|~B z>AC0nD7VE(nBweldYzu@;6#qN1FZ-z{8(M|Rk{j79{6Qi$K}kOZsL;yU55;cJxA5( z{TkvU=j)q@?x#H}OwFV9_YA|=O{rP=mK!xP?vvYb={ zKAc+AV`P*>OQM9bUm|9`luEO+YoU;CdnxRt)$jPrvcSODu+acep z(7%XO%q==!zRN=jdAZb~$x{A1g~`WX*vbAK@+ri)f5Q=zHmUzxKN&J7%(636_ofpg zNb$Nd`2NT#mqrN|QV3sx_g`w4U2HMMS)_}l!K;ix+3S|U(09-FT85n7kE_NEJIp9V zDB&v!D_Xpt$PoY+$Yay-jl@@$S3zyXPWTuKZ*_?S5gqSCcvP~hBBh>e`%{XY9nVVx zCCK3>Hc6n9SXEsB3_bu-!0jFNJU5f}QvIV!^p(O1mD7O$(Z_}A_NVbmc9;ug^>|4v z!h2}&9xr-=eKj%Uq^^wbO9x64wIWz-Pl|w#h-)Um4(n;TRTRnpgQF`7OkkpwmU`8y zf=nQffG^rky~)qU;MC;CC74(&%(?RZ@KFo^`dQ~y*zIzvX9X%RdLvRq@K>90N$2G5 zr3}E~x=S_Tu`?TSI z=e)p&ZzMLyoXj`&H!{?ymQ?0bgKGXvnnB+uI$g+mw!rwokmd<$iy zh(1N&AdT)CbSew<*;nGD(%=B!Xv;dBCKT3_DWNkk6TS-O!sI>bo2V-Wj`B0t6J^~R z1M9#XJ|Sl%4gVhL*y#xyXDV!13+~d%w(i|BiH-gx4i5W^h|P62P;fLFd>h}^eiVWg zF&r%j;Oa+_=J6p9yQ|0caf9cl4^6TvPQ1_|xtuSqCI8ds4}vTq-ZB?8?sdUz8H@_{;kC!O8xs-zNV{g2*DuV%^eALc}%ChqhjkLd-hfFnF6L z>_UE#t^&N7d-Nfzy!+LkprS;xq z-b<)%B-YGkYRQU(;+MM-2hc4-Z~C_8y_{5=0k|9dSGICrvIV+oJi0^NK9oOGv}ukZ z5gvm&+JZO|z z>T24V1pO2m7dKWZJvB*CHt7?S^n!cpc;NqSB8q1xtZonOSs(tFc(|3qtZY@gu^y19 zQ8h@v%36N(C4jXtCdqVfteVO4y#eLS7a{62m@bjn!9JHE3*7jFv)!=m~waThc$FiDinuuVBW3g z?OzA#07X5C6+0KEiO-2@UkGS04l^gOp61%fOdzzW{{}ut9fHD8cGqw~ay%N$@1^}c zx0vx+cAG!u@{$PNE(=n4TB}vSDbup< zycSN9IxZAWi=aj`)y77q_beXc)^_eYZmke=Ssmfi3f0Tnia>z~|9g(-%5SfYMjfhW zU^E29oHeD!9xTx`=b*;tcW)w#PuhK|r5S-CX|wsUk^Vr!KjN=~?z6#Z>(WebD*yauC zNuHgh!=N$InIysMhNms+0DQs2;|YEW1AF1CVJZmtj2l4WU~uGTW~vhn09PkD!|R)2 z^w38OfLLYfwKZmDS_q>l82MMM#33W|oS?1t)Z0GtP+ay@_&nE6alU8mV(*bm=}>>$ z`qbAW1%1sk`2Ml^Akq2ItBDSjsTp-?;LC%>7E}^FPA@5!Y_g3qiQV=XNxW|Tqe`5- zU5Er2e?PtX3mLY`Z+mM&pacbY^yDnLKG}meEm$f>!50+BL-lZmlcPO>!Re9I~X+?@EUPWPe0aN0rh>6Z0%h$EJI7 zhB^O)(Txpp??_ckb=yPtJ zE_-bxkVJVFQO@0DV=DN5$WInd5K~uB^~+9w;8x@@nX}wI1I$qgIc^+zmTn-knUWSL z=^%&<;UjQ{051pTBna&*S7G7FBD{akX$zDcD;wk6wEBO|e@K+{Q_O{!6g$=S>oo_TEr=(1Q(FfCkU0DCU3scUS)d%z;mhejsD*U$*}*-Xx@HsEvK1Xq(tc zHZ;DVFSCL_<0A4vDWfpunC`3+GQTGd5UYMGdh9};%p}x;SiDL5j)eJ%EDqZ2IZrp! z`~S%*>KCsLbFP9~GI&i1I@Q>uz+;8`?ayANb;w`OZkXdI=>QlGJ_R zKB)WUB5#);Q?A4F9_8$hvf)Qlj#sr64)9(UuqWul zac%3>Z#o+$A%$QHCGX*K0mUuwl43TL6ZW4*^74@QMO6PPd{Wh_gg-6DAl`QJsb&|k z4E9A{7zycs=YZ{2Hs$|AKuUrgmeLqlkkfHRQ1J7Y2(&pCBkfu33#uaG~sDmzQ z7A(BYpp87)yIw{GE}xw4NVvcZ>uK@5&$mg6VW#C9QE)Z(z1XIsD^7w&FG|s|_^dRe zgTY_}Xd)eEY2D^1(HWw5t*ogZ%q1)oUX-~Cz=^T=myKFG5`0$Pr+1hGm0hIWJ6$Vh zpv$-7htmK>?!c~r`bsCJ|M(IE@zeEqY5_S*Gm;md76LGLrty1YG?51>8uJclhmdM; z;%FUyMnsE?m^|km*1>Xw$4*yL#}?S5JvAU>l8>^GUnmCUzL6`iF;fo*+o9W7l_<(Y z6zH$l*%8k6p7cWj-eQq<- z%DPnoGE5szOTGW~jDU3pCSYI8Eoyxx-z?DaHq=D<***ntZ zQhGipc;GqVvw?Qu89+&XXLoCaAlnPgk^1eVm9OuHd~z ze2L|u4W*%{@H-W-JU0wuu>@mE2LnNWqekGK^eH8oYuIRWb8n;arAWwhIllhswpUJS z)f~%Q1RcfhiwbDqt?8s@+Ix&@IBNB$G)CQ3HPb)%sDQ-|+jk|p+8JJ7MaOO#%DHG! z{5-p4DhtCi5eE43u2KUr&2<>g4kayze>A;hnpf+-G+c!oxHIZ?u_kF{a% zwJ8*x#!~$8Fs6eI5OyQ{T807e2NsTtynxssl#j(F;x=sb>*-czQ(*(@kSL(jyhUN%ATBflP1e*{eE(WV+$)m4 zfEz~sG3kOW1+Ln_W0hbxb}ca5ExbB{?L#CJ7W&`|@T|h~X?$#I&Sr4K5NDlo6s4#%7G}t1K5qMYi z7*AlP2D)U2XjLVS+7I<4mt-F@I_7@+(@ji$u4vWUhDtkbq~#82ne`9^AV+>8Mfr2N zuDH0vekP_RRb*g@0dpATMPBezBL%#@ChKHc_pvB4gXj`?APH)0%~&27-#4CUKszcT z5b^-^0e?^wB;heP+@W|hL9J5+5-n*)s%4b;Z~@!ZGm{9W?JzA(ahnoCd6(eCsn+h8 z5hoX2-eZ}vI9VKJ!4Y{3fGU!a-d7cHLsotSK?#ZrPf2cmGlPIl!l{4ih%P0%R*8pI zBv=3=PF6;Y8CdKnul&hNlj7%IDm3=i6#kQ-@Drc7Ynr%Nl)#p*;6|$fN{FOzh%E*O z%pt&!a5r^n(QT?dU0)tcsGN1R1-=?_9!AKsPgeuB6=+#5QC&-Fj?AM5ls%`t)K5RX) zJg!j}aF?h(eP=uF`LFVnO;X#W!77|(=q6&8_;Z9)fIJDWV(;U5iyL%tvcPui>}80x z?_A9A_3zIpD~oD_FL2-6y(p&7V5)2?7?j;c8}~`)lc?bQ9S@dZsaDo8A$tB!fOLa@ zJmUjx@n;J4NXL8=!$c1dBHwJFpa`+b_^zX}I~uq2&Yh}I zwpCYW*oPPk+k)X6!%}jWcYDds>n5&<+ZU4r$@%0~7wQsMPK~>36~f+?Crgmy6~3X$ zMqRmPCgJ*x{>XBx2`;pmL4rtLag8w=ag#Xn)ju`qE;Ebm-fEz*qF}$OV>EL&hEP;G zW^VjO3+OVm6J-fEFEyDN2+@Xe^MqYC;Ej?IP1O@pq|B_`*@D4<)bb;9BNiNB1J1|) zG7hpN3#Qpl>^z{h9&fO2TZIUxM0Y*hkSaKFv&XD_9~M_**dq+_W_WZH%?6ad0)Ad- z%-ja~SY;#qg|l7;>w3JXKw%=G3THfcPvg!8>dlNw`&x8ylc>_H%G&9xNf&ZFd!zto zHyWa>@$9*VE{7W_;C4-q?C8fcvkB)&!FhvvN?$m?bbIrms&Gts%dTN+C5STAdS?<5 z>LM_L2~IpvdU|WJ^{Ne#rXfZfB^i0LeNzG#z$YD3TPt~14LJDTU5L?G@*>DT_>eK* zRsx;KBVdzvp1*Bu=NDc+kKrO&X);09NeqjEJ0{0DhKg82D+qNpvRza8@M$o71)ZTw z4dw?>Eo*`8l@hrqz=&+2p!;mRbBQEB$O!}an&dM#`4NWV&+H6&H}p!>6% z1yfqhH*aHx+*=bugmtIW*k6q|sKmw$y*Ob3Ol< z8Q1SbL^KlzD2A0-^yyV(gBv;t5&c7x*sy?>BiJwQDC+*z>{Cf!pTjLY$S}X-mOonx z#a%}Um>1%~@>}9 z8;Xg<0ofy8b)!327h)8nEVZALd%TgGEKJH>lN%)rwAP4$P=N?$IPK< z?1T@nWvfvh{kH1I)M3lDzK2J9F;A+qf1n>LqQ<>t zjXGx{S7S2t?9JpDqxf|Pzvc7Z&gy%Q?XaAyE;^8V(=Bd#KwEatjrm%;(pnBm@3te) zjyOcor-)WlSWPWGa(eXiE#AXOY}GG{!MTD78E*bH3HH^NaU@8cGEu+Zqw=EpcyJ3$ zOB3LZeCs`rp62BoDJKHoCh4kw>5$Xy)z9a3g%Z*@C#?@{8T|g+Y%t$uwj)15f!>bD zR`cTci*@9_zgjW5wW#B{cb8GUqnGH4y@N*oAMnD@2$Tf9SOshc3+oQSMQt4(QXe=p z4&Zkb*tlnIEk|%`le1mVwlIoZuA3^-9r0-h-o)QqOtRo}oT!M(uxu7&{;}51+Fn?m zWCgi5TVUYyO6CN3A6uinuF}dq$;dC7p$Rx`;|!DUs{t4~9fahcM)GTfR#z59zvGAs6nuq}<>K{!_$n`Gga%5~L@HGhYF!v~e}3 zjv*5X-Ndr=PCh9#(MExus5EnW&<@3ZpD@(%P6x+$NSmg?XBJYs5qT_wk2AQvLBxcp zD09%B*IMu&s^l-n=7+Nzk;6**mB5j;S|4LoYX&GGNeFiVQSKw^iAyaL2aQ9jD5+4W zo=fTOu5XFACndF@=!_{Y;mSUpu;gX2H~R2{76d$ zQi$U(C+>8TE3P~G4u-i?X>h}(d7!a-*Qz6Zs<_bi=xJ{bW>P0wAj`Qecy{dQyoBL` zV!AsZtF6wI z$(S8x_u-o&D`U%|F}>z;`9K?bb<^ zA2i|5D>i%fhLrr=N%jpFtwCuW#x8#Eu$NSRG`P5JGcLUI`%%+)lczeBCD&ojU!@Zi zC!&(X^fHzHAD`~lc&eZJm-S}I>Lf0XXtu3|Ik5Ur=g|#LC51@zU z>hMEV{Sz%sklnS7QaPGo0q2VoZ^(tJZ#3_DWQuU+L+mJ%Y+A1Z`#hZanA?x;os@aw z{rIt^qImqa$3+)cQFmrU481pZ(OiXDL>cMmqgi9&ho`P12w;qpS@t}P$Hh2 zB^{82?7=2))SXNq2mlGoBiR}KBu{9zWQuNcv4|KXFvGbV(Yt~jSdoD#)?qOh)k}mF zA`J9iuLS58Soqn^T6*~pHD4xXWDS#c<4>}ZtB}M^6ExnW!@c@7q+sHwK zR5DJF04^XZeDk*5`}?`VAkBLnY&Fwo9>0EPo7i44ij&Q4;P$2v#@@GVCsT&2(Q?Dg z)Hs1s;mA-QJhFJ`7M?d6~8rtvD1vWeuOvi5X#@i%nvMZ9{e-u;e(BeiyT z{-_Ye08s=*9*%j?D}J(jdhdn)@o>k42kB#Vl|V`m!hju{}A;{0lTo(x55U!!5X!wBuzbm&ImYi_Y4pk(69s1I7c1ZZyFt?ndLJ zlHL1t(%cE!;*yPO+o?^A zz+a?mOsTuM)#B1I$Pgvwwci1*($?6U;14C-wpK?qiPn)9;iD8nI7Fvp*5=RZ7fITb z+VdfUaNhuyfiC}0J(7}Y5oqfSn8r*ti>NRXL&B;k5WtmlHr8Hkp;A$~K4Ar#0j;ux z7Dvl)N1sJ($P0r;#l1qYah@DsF1`UO0}mDw7jq!U(Yx9G}m)2t@>4Vq-cbgbQ(l{C7`0z-knq z(JyV2<&R?yR4*2JUQ|PJ-$q(zc`K-NFeV+7-<9**^ZwGUp7oreNw!|A07q*D7BLtB zmaVmLATn~~xS_gWX8D_&t{J_|d3b28R7Y{+o@#cOj}Y}SDdDqH z>Egy0fOYJy1Qz)EbpoQoAu;`2AX(QRwaEsv&pZtK*zp-f_4r54>}ze{1WJHlBpTS`RNUCB_ktTaEC?YeZr+SgQ+|}vSMcR zpZmS29q|XsV=|whBL^*~6jVQtTr<91B@)tp`fl7LzjM*>-lYg15F~pjUCU~pOb@6r zuyCETt`P^t(TGNNz`G%}kMhL%_Oo1_7(Z8=ptn%)VWaY2m3edDhbhs z>x9c3r2_%pt(w=V27E*_)5L(~;Mx9XW;{dlCDalMD7#6?zbnS$PPWTC>LdOR!u2y< zk3F$ok$W(@HUEZpGbHPuKrh1{j>Apd9;_px9FN2(&v4*aUP3CTi+&TAKVj!Pm-B({_^T@$}#iON9$;>Jd4kNnMFvI{o?O z2*xQ$7|MFexd6P(R&k7IpE9-e>$clIa|)m=_+3+zG#_~xsV^|DmH{w5H^SmZ!|3inJ6+K#VK-@+t^f(==6!G~Q zmw~PAv>Dq;0jG7j6CIoYQv7?+q zuOZ*nyBIbm47f6zZjby!QZ=WoCDAOC9o7#`LDa#YCJz~OCd#lnpz4X%N*Df_GRbVT z35~eTSL+5OS)+Melq1zrC5)hNu?}CgQwY+A^x~|u))IrFc>S0&O!QIz@|;_`Ecp$v z;+R*?DRA2uH#94#13@1VuV~EbdDC{_rteDFGJ=)G`#FrF1UP2}OhLFEx%-so2!1;z z*~UZc=D0*o%8+=)qt6a0pSz}93?Z05Yvom(ZI>FM5PBKaW3l9om)|kKh=Y7v*bee) zLYNeW|6U@`CGvo>_g7ua+Y;98a(V4<%R+vt;h$Uf-FBoNBjsz*+vLxwqfGc>E#R3GXi+po0C)Vh-w>r&xbbJU zs%BNk$5=BZ2+ceSY-`UFQpJg|D zY%nH>F?UODn1Xq6h0e0cqht-W$R*g& z{{}0e6{`MlZL%-JtCFVq%^TJ%mLAtXMY+>o%w`XmhH$&Y?PAe*_t_~FUv7Knze+oq zz@FZ|({V1&TD0u?Jhy@?s=P*5oC)3GlkFp(W|hZ6AtJ2Gv%wac1%k}3U^-0i&eAnS z>@5Z*Pmjt7M4l)xbw-q^p>NvnF@g;hzi4|;I}*ymbiX3N=(v;NP8k|Y0U3F|lImka zW9-@PA(4eFvLWH8e~zZL?=53XX|vw$jG^f>_do1$=j6x2`2;&tkB@cD2?@=?Ho&h~ zwxN?)->WKXByn&{)c&rogiW!a48hkwwcABPq5?{L(#0^*ffPaIcz&=zfcaaW zP~m|2*M}=UcI>kKrV<=~R>ip;F~lyDg1{ch-uRNM^!rI?=H;s{Cq9(CHlG#6=mMsG z&&`t;%U=+&n5dR{70`_ zy6J`e8B^+&ln2GRk;SO_6@3XNM#Kz0lVkIc0rW&41D+9SNE>{z1p=83$>m>hdgvIw zXm&tK5f*YqUub;hG?%U>J38>gLE|I9Zl8tyYrLpGeg*m--??`YZ9_~cXsQE}!>08)AE!Q$GlyR`BC(6pJGFuw5BP|j>K6J0o`4^a zx-nQDHR2u1TV$w?%scTz+JFsMxUH~9EQ zeR#NTml|Uibqn^j=sA&V1LP$az8mS!3NSs99}eaK%gMpn96BB5XO2vQ*^oc()Z!H) z57X11r;Bwd90HNq9ZMhhsbv+>fVRrM>KI$P%rEh|GmU%<=Piz``#7V+k;F-^hO7wWo1Oc0f0z%uOd2sXB^FC;RH*D@n8QBT-ehL@4VxTn zTBx*O>t($Zu68qDEljWp;P%`y!FyQ?)QM#voIO7;5ICvZqT=K^w4uTEF+1Rn#GKJJ z8fq^8x#9j<*mPaNWXZV{WbFHfklSJXboeW)e-klH8O zAv%JPMMw^IRi$i2bl0h}vE7Q;xxY@)4!h-PC$8V5-T@o%FJs@(_uL@A!-lL_oyP#L zU}y929daXgeFOwxiU9=D`-tDFRyC*SEh${_4=(XBy(e=yuNMbYR;?>uA3Vm2wJnVj z!_!$0AR2FKg7l>wIqR~4r}6K9LxZg((ALN6Z2<6XjzaEN)SIiC!psNz3<;*GDHUnS z?dZ;Av(_tg0vg{ehxV_MDy>J>y$5DVlA*+Ud_{sQWv?K~$lKg@ zOw=LWKvg8J-+>PG!U7j4U=HBHQ7ZfH+Zi*FudGRGMX4dJf;qJJuIX-%hgJv-B#!UJbQj?kVgvvlS|nj%$LsFN ze}ROB#$>!oNktk7|A~wAKSSY^8TsD;ThOe(bIypJVwb3s?k##P91=)uk zF14`{TPzKxGdqsR+?FP>`ue06qNQiG*a*jP`7vED*VSiazpcy(Y63B>yUS(Jm2}ul?TWdQiz`PcEu?d z4M@Rl9Ms4o0X9{>{~bfPoRqYOZ*MIvU>TK$-6>ja8nr$uYJ?|51e?~ay@LQh*TmAn zb5fDoM{dKW_e$B;FQ2S&`yrmB>VfcUwwJWe-n^Jr`DI-`?&)@A)?)b>gnBCXR$Tj-MOBS;+ z&J9`JNdg@s!p2F(=o%bNH&U=p?V%j9N^Uu5cJBw;NWonG!ABxk%33|Tn6(-FXOM7; z0b*q;nJ0@OY3LUe3v*wS9T_h@y+p{pW+Jp?4ZnTVMDFdrXGP%7@9=@BMuWzs*Cv!I z<}6iahd0(jbLqxjp|ifASymHl&ARKWPGfgl^HqmfV3mdH32&wpwlCGKz9x>`Ev=SE zY{Cion!z7niOxY=|Hxj0-DQtSpj(tm`MwlPaEX>*sU1ppz&Loro{ux8s;<-UZ<@1k zf{+$?#GT)tMezi<)cGzss3t0|?7{d78Vsu>We&8CHE&tDSJG1`8w^Hb=5N+f{?G$A zRE*mcI^3>C5p&D<*E{~)7!`}(6QO$0WG*_TwRovcZa^j|kyPHWK*B`9W^N{pzv(>B zB!W6%YKzz|MWTbjrnZ9p!<8j0!Bf^Gz z7OEu$Zj_~2H%woYf|uyeesk;+K@)z}-f{6WB1>Y+s!bP|H}@EKZ+&o4d$-{ysOwP3 zrN$%GzPzi#RwHrD zO#ySzG#$wLyx@%UIp^Tpk-#X%yQh}9dhrMMsups-8h7EVZ}eLxaa0o89pm7}p}Pc4`V}FQufQRS zJhXStGszb8C|gHJM^V86m}!=waus@f*D@7h&D*TuVK zOf}f#Hb>lahA3@~N#h9$*Mtm0U$Fk#C18kXIRB-`;TZOJM~^_cB{`0S?woO5D7@ni z0jffPRAe(H8Mo4c6Zt0AxkI_O?f_q*b$Lu>e*0?-W#GNCPdu*AiAlI{e9X)-NZS0a z%K+9<%gq@>LE>*;TMAw)x!60|ugMWY?b@D^ghywyqsTtL{W_bPN=s=R5Z-54j>hhP z&S3k9wm=1pY7NZsKi$ilV4Pc7Vv}pds0tKAcP{Vl9q;fKf?`lJ%CBGBm+?;I-G1w{ zxicj+jEPL{{8QXNVyzsISUcOrE#k8Is)vnVw&E}2Ho}!nl8L})Ty;=9onqZG>-Bu? zf@nAYx!|6|@JkpQ)qgR?bs)9QOn$P*bta(ASU8L#v5ptV8576t{s@^teF}J!?Gy^F zufUe|eMi*FdT48Vr|gauOb3w*}3t40sT8y1-* zl_qv%rX=m5n*03V1LRk$V)KvTN(-G_M38&9i90}CyHBmokT!Nt+ zzo_ER9e01-XCW5)&~?+WPYfaJ58fXz#}H@$L=sxtoR`|*+>8iz>i9S4Wi2;VUZcxm zU|3EN!u~!Cbwg=fit{{Rs;Ror#ksB1Q~?0idkKk7OkoHhexM#ccOlgzywY`jXkr2c zQH%EZx3)Cp_;%+<)*`7;Lf(Zzes0Q{14ox63`>|7--`QE^kTwBJ+Bpj4701}wZq-B z1t=&Fx|-CVGA#pEwDpWY4zxc{Rg#n{Vhumpbe|-AoKmtV|7YFu(~2blwe*{%DQ{k# z5@swwH9}JOP1RjBw=uHpju;}ttARJ{G4W);04z3JBNF|nQY_oYrFln-;Hq6& zxtgb9evCbec2H%_$j<3H;6Xl_2t22;X(ijL|E2~_!8t@LazAUOZjT8B!!hoO1z3$G zK~yd+FnD=Cz!}FRc+MDdsS&;DaE-X0!379FH+F13-m}~9G4n*!Dfe=U7=mvr6&5t{ zScXUrPXvZXcty0%9>d!WfCpmjXal-ErhQY($Un!8^pt;wM&(77Gc3vNMF;GXf0j1_ z#Z2oUSj}Nyba*}M?|Y~R+IA4$*cCWqdXPYW?Q%|)LHPRPp=~}#Qloo+4`feic-Y3C z?iBK*@fRyfWZq2w`8}9Eg+XT!b!RyVfQx%blaxk+T)VTc$Nz^cA2BYns?$dSc{Di3 z<^fV@b*QU&rug{?BG$ypdV6a^@Z`D-LuDo~@{nUFl;1=RU}G)2=81c~$*@(OjQNKJ zl(BYejjL)a`zMm%J80IPw#0FIMBUE)v!*d{=w@Z~S4_-W7iH*ccO}&-&qSDzv%c83 z*=320dOJXmlN< zPxD%24YFMnlgL@hLgZ7OSwAN^`kafFR4lG4|LDHwvzgi49OglmL2QymVj*?u1Gk+PkW`7r^GuP_YIrckyyE7!vZVVRNXB`1 z3oH7R83pHuFmyx1$;0Z18Puc5X+$%nOfFXs`q2fMFe2w&wPOPZQT~)fBJbPBxyv5q z;dN}?ECF(V%kvFoptTzOW3zJMteiYc7XSU*mygymDKk8n<#BV4`f&E#XzmiCH}lvB zXg$Xtis8S|bAVuAc|e|{Hp`5Tl3QX`M5#3IYK-;fh#c^G%G4n)8r3k&X3 z&Nb)Jv!uK4YDc0SjQV*(e}a7Q!PR7JASP}#A2+Z8_j%j*&zA<(hYt+iu~8_`Nom8p zV#HbfGnc1qg3=nnU$CEdaly&-J-@#&NQ|zfv8zL`X&}f)96F$*Ue}}q<;~`Z1D}_2 zOPrc-hKfw~#=N4*HA>4{{!sm+K??nFCq2*e`n^uDZjW3sED0&x@PW z#WjPiw*shi20AjmrbXUJC@j;xSArq&vqAsIk?PnTGIZAzN28_ZSIaVPR}>1BQsS>e z_~t%G3VFTqnc4fii=4bpr}n;gmSZj^t@i7uy3Zid_QJ>ds0Yamm8JRMk|vGR+-GVU z_SbgQX=7es5}dE6lT)?Q)8NU^U2#Fdhlo*51 zP=Zz(x%LiI)9`x5s@avfQvpR* zCFEz0)7V4CPJW%YxKy6mcfV+OOMBxTdxk^*JC0y2Nr6{nXtXRpMwe8JTd!%+CH!ZU zP1xE`bp0O~E5JDjv$w&yxn(p+k*!v0T!k=c84y4U`w_;LBFe00P`4h2BQP#Wi zHx~m-#c~k{m~F`^9NyaPm)^ta<&>a6a>~2?{)s9;AAx6$B!58_(INLMS*<6IJe0-n z_YRK#J!Rw5$Qj%A4U=;0r4RnhjzqJy42zJDAsbhgX1(F|_D2cj7P=l5#yUm`3c^}@ z4ZnaiTvO0?9~IRTASDu=5CnJ{tXip~KKQ-R2Pk9y0(6h3GRi9ApaQv1bxFWVQwv>O zv3n4HGrrkUoU;fCZY;LitAqj-?;Zj`XGY&<%}KWLAvy0iImgUQ;^=}5-Bwl>WKGJo zsbevY{Ve3z^ub~Y7k4Wb;iH{tceKWf>)CBD#SddXS$1^?|6XCGn=+7?20KdHBzTIv zeA;|G?4(cwt9G!J?_@J?LUldbUaGqP5enUzqQJ>DR3$Tn*U%gu}D7ToZw*@d_@W*lgCYuIn1XRo4(|kF6fCs2qKEM|(ddzd{=Pr~HUgMmD%mv;| z=iwr94enYM&Wb%{H!9`UB7l(GgN#>0K90ZsEc?&aWLD_4A4xN8A5rIQL0FAEAhrN~ zK;2oAo;d*HE-Xvm^b5Kw`Poo2+R=vrL_{@;c}WXs@G^en=>sY!s7A4n#&BKU7&G+= z2BfzIE29I)@Um)Jo}54W#>X8`iQ|6keJ{gx1Hpubp9rxq;W-qt;=z8JrH!fN>MY-9 zb59ePPUL)NF<$NTA=}a3Cmo#skp{}4MMwP(U;KQ&97w51+?DdKuVPW$kJgeyAA~^k z*q6CypY$Fk9OnnjMi6}|V3SlV>s)i&`;{I(*(h7p1qE96I3LQznL0SCdAdF9v1un<=8 zw8i@(kRN6SOn2Mz8XJ5Kwcng~IRp5HOXCZi)ISZWNv`Y%^nA_$H-T^Y``rpKqR<0? z2Lyx0=pxbjNVEQ*%QPAg|Ht5Zm_PN}?f-8;;ewh%5Qq()fNQew GP5L){y82WA literal 0 HcmV?d00001 diff --git a/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py b/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py index a89e050bb..75cbe5e0f 100644 --- a/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py +++ b/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py @@ -1,7 +1,3 @@ -""" -PrometheusProvider is a class that provides a way to read data from Prometheus. -""" - import dataclasses import datetime import json @@ -19,10 +15,23 @@ from keep.providers.providers_factory import ProvidersFactory +class LogEntry(pydantic.BaseModel): + timestamp: datetime.datetime + severity: str + payload: dict | None + http_request: dict | None + payload_exists: bool = False + http_request_exists: bool = False + + @pydantic.validator("severity", pre=True) + def set_default_severity(cls, severity): + if severity is None: + return "INFO" + return severity + + @pydantic.dataclasses.dataclass class GcpmonitoringProviderAuthConfig: - """GKE authentication configuration.""" - service_account_json: str = dataclasses.field( metadata={ "required": True, @@ -93,10 +102,6 @@ def __init__( self._client = None def validate_config(self): - """ - Validates required configuration for Prometheus's provider. - """ - # no config self.authentication_config = GcpmonitoringProviderAuthConfig( **self.config.authentication ) @@ -131,7 +136,10 @@ def __generate_client(self) -> google.cloud.logging.Client: ) return self._client - def _query(self, filter: str, timedelta_in_days=1, page_size=1000): + def _query( + self, filter: str, timedelta_in_days=1, page_size=1000, raw="true", **kwargs + ): + raw = raw == "true" self.logger.info( f"Querying GCP Monitoring with filter: {filter} and timedelta_in_days: {timedelta_in_days}" ) @@ -142,7 +150,21 @@ def _query(self, filter: str, timedelta_in_days=1, page_size=1000): ).strftime("%Y-%m-%dT%H:%M:%SZ") filter = f'{filter} timestamp>="{start_time}"' entries_iterator = self.client.list_entries(filter_=filter, page_size=page_size) - entries = [entry for entry in entries_iterator] + entries = [ + ( + entry + if raw + else LogEntry( + timestamp=entry.timestamp, + severity=entry.severity, + payload=entry.payload, + http_request=entry.http_request, + payload_exists=entry.payload is not None, + http_request_exists=entry.http_request is not None, + ) + ) + for entry in entries_iterator + ] self.logger.info(f"Found {len(entries)} entries") return entries diff --git a/keep/providers/openai_provider/__init__.py b/keep/providers/openai_provider/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/keep/providers/openai_provider/openai_provider.py b/keep/providers/openai_provider/openai_provider.py new file mode 100644 index 000000000..ed107fbed --- /dev/null +++ b/keep/providers/openai_provider/openai_provider.py @@ -0,0 +1,58 @@ +import dataclasses + +import pydantic +from openai import OpenAI + +from keep.contextmanager.contextmanager import ContextManager +from keep.providers.base.base_provider import BaseProvider +from keep.providers.models.provider_config import ProviderConfig + + +@pydantic.dataclasses.dataclass +class OpenaiProviderAuthConfig: + api_key: str = dataclasses.field( + metadata={ + "required": True, + "description": "OpenAI Platform API Key", + "sensitive": True, + }, + ) + organization_id: str | None = dataclasses.field( + metadata={ + "required": False, + "description": "OpenAI Platform Organization ID", + "sensitive": False, + }, + default=None, + ) + + +class OpenaiProvider(BaseProvider): + PROVIDER_DISPLAY_NAME = "OpenAI" + + def __init__( + self, context_manager: ContextManager, provider_id: str, config: ProviderConfig + ): + super().__init__(context_manager, provider_id, config) + + def validate_config(self): + self.authentication_config = OpenaiProviderAuthConfig( + **self.config.authentication + ) + + def dispose(self): + pass + + def validate_scopes(self) -> dict[str, bool | str]: + scopes = {} + return scopes + + def _query(self, prompt, model="gpt-3.5-turbo"): + client = OpenAI( + api_key=self.authentication_config.api_key, + organization=self.authentication_config.organization_id, + ) + response = client.chat.completions.create( + model=model, messages=[{"role": "user", "content": prompt}] + ) + return response.choices[0].message.content diff --git a/keep/providers/slack_provider/slack_provider.py b/keep/providers/slack_provider/slack_provider.py index e5cafb8d4..e1bef052e 100644 --- a/keep/providers/slack_provider/slack_provider.py +++ b/keep/providers/slack_provider/slack_provider.py @@ -105,7 +105,13 @@ def oauth2_logic(**payload) -> dict: return new_provider_info def _notify( - self, message="", blocks=[], channel="", slack_timestamp="", **kwargs: dict + self, + message="", + blocks=[], + channel="", + slack_timestamp="", + thread_timestamp="", + **kwargs: dict, ): """ Notify alert message to Slack using the Slack Incoming Webhook API @@ -141,7 +147,7 @@ def _notify( elif self.authentication_config.access_token: if not channel: raise ProviderException("Channel is required (E.g. C12345)") - if slack_timestamp == "": + if slack_timestamp == "" and thread_timestamp == "": self.logger.info("Sending a new message to Slack") payload = { "channel": channel, @@ -165,9 +171,13 @@ def _notify( else blocks ), "token": self.authentication_config.access_token, - "ts": slack_timestamp, } - method = "chat.update" + if slack_timestamp: + payload["ts"] = slack_timestamp + method = "chat.update" + else: + method = "chat.postMessage" + payload["thread_ts"] = thread_timestamp response = requests.post( f"{SlackProvider.SLACK_API}/{method}", data=payload From abcc51307e60ebe8fc1f28a179b821fb14ff6f37 Mon Sep 17 00:00:00 2001 From: Tal Borenstein Date: Thu, 31 Oct 2024 17:48:29 +0200 Subject: [PATCH 3/4] chore: default route --- keep/api/api.py | 7 ++++++- .../gcpmonitoring_provider/gcpmonitoring_provider.py | 6 ------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/keep/api/api.py b/keep/api/api.py index f4552c370..161b162da 100644 --- a/keep/api/api.py +++ b/keep/api/api.py @@ -190,8 +190,13 @@ def get_app( app = FastAPI( title="Keep API", description="Rest API powering https://platform.keephq.dev and friends 🏄‍♀️", - version="0.1.0", + version=KEEP_VERSION, ) + + @app.get("/") + async def root(): + return {"message": app.description, "version": KEEP_VERSION} + app.add_middleware(RawContextMiddleware, plugins=(plugins.RequestIdPlugin(),)) app.add_middleware( GZipMiddleware, minimum_size=30 * 1024 * 1024 diff --git a/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py b/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py index 75cbe5e0f..806dcd920 100644 --- a/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py +++ b/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py @@ -23,12 +23,6 @@ class LogEntry(pydantic.BaseModel): payload_exists: bool = False http_request_exists: bool = False - @pydantic.validator("severity", pre=True) - def set_default_severity(cls, severity): - if severity is None: - return "INFO" - return severity - @pydantic.dataclasses.dataclass class GcpmonitoringProviderAuthConfig: From e880dc44bf788aae9a97954f015eae5d635ea7fc Mon Sep 17 00:00:00 2001 From: Tal Borenstein Date: Thu, 31 Oct 2024 18:08:45 +0200 Subject: [PATCH 4/4] fix: scopes --- keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py b/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py index 806dcd920..1f366c478 100644 --- a/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py +++ b/keep/providers/gcpmonitoring_provider/gcpmonitoring_provider.py @@ -107,7 +107,7 @@ def validate_scopes(self) -> dict[str, bool | str]: scopes = {} # try initializing the client to validate the scopes try: - self.client + self.client.list_entries(max_results=1) scopes["roles/logs.viewer"] = True except google.api_core.exceptions.PermissionDenied: scopes["roles/logs.viewer"] = (