From aa416506e6b1b178c5d51035b4743ffecd228228 Mon Sep 17 00:00:00 2001
From: Christopher Lo <46541035+topher-lo@users.noreply.github.com>
Date: Wed, 1 May 2024 00:49:03 +0000
Subject: [PATCH 01/41] chore!: Drop cdk stack
---
aws/stack.py | 647 -------------------------------------------
aws_cdk_app.py | 16 --
docs/deployment.mdx | 80 +-----
requirements-cdk.txt | 1 -
4 files changed, 7 insertions(+), 737 deletions(-)
delete mode 100644 aws/stack.py
delete mode 100644 aws_cdk_app.py
delete mode 100644 requirements-cdk.txt
diff --git a/aws/stack.py b/aws/stack.py
deleted file mode 100644
index eb6494ba9..000000000
--- a/aws/stack.py
+++ /dev/null
@@ -1,647 +0,0 @@
-import os
-
-from aws_cdk import Duration, RemovalPolicy, Stack
-from aws_cdk import aws_ec2 as ec2
-from aws_cdk import aws_ecs as ecs
-from aws_cdk import aws_efs as efs
-from aws_cdk import aws_elasticloadbalancingv2 as elbv2
-from aws_cdk import aws_iam as iam
-from aws_cdk import aws_logs as logs
-from aws_cdk import aws_route53 as route53
-from aws_cdk import aws_secretsmanager as secretsmanager
-from aws_cdk.aws_certificatemanager import Certificate
-from aws_cdk.aws_route53_targets import LoadBalancerTarget
-from constructs import Construct
-
-TRACECAT__APP_ENV = os.environ["TRACECAT__APP_ENV"]
-AWS_ECR__API_IMAGE_URI = os.environ["AWS_ECR__API_IMAGE_URI"]
-AWS_ECR__SCHEDULER_IMAGE_URI = os.environ["AWS_ECR__SCHEDULER_IMAGE_URI"]
-AWS_SECRET__ARN = os.environ["AWS_SECRET__ARN"]
-AWS_ROUTE53__HOSTED_ZONE_ID = os.environ["AWS_ROUTE53__HOSTED_ZONE_ID"]
-AWS_ROUTE53__HOSTED_ZONE_NAME = os.environ["AWS_ROUTE53__HOSTED_ZONE_NAME"]
-AWS_ACM__CERTIFICATE_ARN = os.environ["AWS_ACM__CERTIFICATE_ARN"]
-AWS_ACM__API_CERTIFICATE_ARN = os.environ["AWS_ACM__API_CERTIFICATE_ARN"]
-AWS_ACM__RUNNER_CERTIFICATE_ARN = os.environ["AWS_ACM__RUNNER_CERTIFICATE_ARN"]
-
-if TRACECAT__APP_ENV == "production":
- CPU = 512
- MEMORY_LIMIT_MIB = 1024
-else:
- CPU = 256
- MEMORY_LIMIT_MIB = 512
-
-
-class TracecatEngineStack(Stack):
- def __init__(self, scope: Construct, id: str, **kwargs) -> None:
- super().__init__(scope, id, **kwargs)
-
- # Create cluster
- vpn_name = f"tracecat-vpc-{TRACECAT__APP_ENV}"
- cluster_name = f"tracecat-ecs-cluster-{TRACECAT__APP_ENV}"
- vpc = ec2.Vpc(self, "Vpc", vpc_name=vpn_name)
- cluster = ecs.Cluster(self, "Cluster", cluster_name=cluster_name, vpc=vpc)
- cluster.add_default_cloud_map_namespace(
- name="tracecat.local", vpc=vpc, use_for_service_connect=True
- )
-
- # Get hosted zone and certificate (created from AWS console)
- hosted_zone = route53.HostedZone.from_hosted_zone_attributes(
- self,
- "HostedZone",
- hosted_zone_id=AWS_ROUTE53__HOSTED_ZONE_ID,
- zone_name=AWS_ROUTE53__HOSTED_ZONE_NAME,
- )
- cert = Certificate.from_certificate_arn(
- self, "Certificate", AWS_ACM__CERTIFICATE_ARN
- )
- api_cert = Certificate.from_certificate_arn(
- self, "ApiCertificate", certificate_arn=AWS_ACM__API_CERTIFICATE_ARN
- )
- runner_cert = Certificate.from_certificate_arn(
- self, "RunnerCertificate", certificate_arn=AWS_ACM__RUNNER_CERTIFICATE_ARN
- )
-
- ### Environment variables
- if TRACECAT__APP_ENV == "production":
- shared_env = {
- "TRACECAT__APP_ENV": TRACECAT__APP_ENV,
- # Use http and internal DNS for internal communication
- "TRACECAT__API_URL": "https://api.tracecat-engine.com",
- "TRACECAT__RUNNER_URL": "https://runner.tracecat-engine.com",
- }
- elif TRACECAT__APP_ENV == "staging":
- shared_env = {
- "TRACECAT__APP_ENV": TRACECAT__APP_ENV,
- # # Use http and internal DNS for internal communication
- # "TRACECAT__API_URL": "http://api.tracecat.local:8000",
- # "TRACECAT__RUNNER_URL": "http://runner.tracecat.local:8001",
- "TRACECAT__API_URL": "https://api.staging.tracecat-engine.com",
- "TRACECAT__RUNNER_URL": "https://runner.staging.tracecat-engine.com",
- }
- else:
- shared_env = {"TRACECAT__APP_ENV": TRACECAT__APP_ENV}
-
- # API env vars
- api_env = {
- "API_MODULE": "tracecat.api.app:app",
- "SUPABASE_JWT_ALGORITHM": "HS256",
- **shared_env,
- }
-
- # Runner env vars
- runner_env = {
- "API_MODULE": "tracecat.runner.app:app",
- "PORT": "8001",
- **shared_env,
- }
-
- ### Secrets
- tracecat_secret = secretsmanager.Secret.from_secret_complete_arn(
- self, "Secret", secret_complete_arn=AWS_SECRET__ARN
- )
- shared_secrets = {
- "RABBITMQ_URI": ecs.Secret.from_secrets_manager(
- tracecat_secret, field="rabbitmq-uri"
- ),
- "TRACECAT__DB_ENCRYPTION_KEY": ecs.Secret.from_secrets_manager(
- tracecat_secret, field="db-encryption-key"
- ),
- "TRACECAT__DB_URI": ecs.Secret.from_secrets_manager(
- tracecat_secret, field="db-uri"
- ),
- "TRACECAT__SERVICE_KEY": ecs.Secret.from_secrets_manager(
- tracecat_secret, field="service-key"
- ),
- "TRACECAT__SIGNING_SECRET": ecs.Secret.from_secrets_manager(
- tracecat_secret, field="signing-secret"
- ),
- }
- api_secrets = {
- **shared_secrets,
- "SUPABASE_JWT_SECRET": ecs.Secret.from_secrets_manager(
- tracecat_secret, field="supabase-jwt-secret"
- ),
- "OPENAI_API_KEY": ecs.Secret.from_secrets_manager(
- tracecat_secret, field="openai-api-key"
- ),
- }
- runner_secrets = {
- **shared_secrets,
- "OPENAI_API_KEY": ecs.Secret.from_secrets_manager(
- tracecat_secret, field="openai-api-key"
- ),
- "RESEND_API_KEY": ecs.Secret.from_secrets_manager(
- tracecat_secret, field="resend-api-key"
- ),
- }
-
- ### Security Groups
- # 1. Create ALB security group
- alb_security_group = ec2.SecurityGroup(
- self,
- "AlbSecurityGroup",
- vpc=vpc,
- description="Security group for ALB",
- )
- alb_security_group.add_ingress_rule(
- peer=ec2.Peer.any_ipv4(),
- connection=ec2.Port.tcp(443),
- description="Allow HTTPS traffic from the Internet",
- )
- alb_security_group.add_ingress_rule(
- peer=ec2.Peer.any_ipv4(),
- connection=ec2.Port.tcp(80),
- description="Allow HTTP traffic for redirection to HTTPS",
- )
-
- # 2. API and runner security groups
- api_security_group = ec2.SecurityGroup(
- self,
- "ApiSecurityGroup",
- vpc=vpc,
- description="Security group for API service",
- )
- runner_security_group = ec2.SecurityGroup(
- self,
- "RunnerSecurityGroup",
- vpc=vpc,
- description="Security group for Runner service",
- )
-
- # 3. API and runner ingress rules
- api_security_group.add_ingress_rule(
- peer=alb_security_group,
- connection=ec2.Port.tcp(8000),
- description="Allow HTTP traffic from the ALB to API",
- )
- runner_security_group.add_ingress_rule(
- peer=alb_security_group,
- connection=ec2.Port.tcp(8001),
- description="Allow HTTP traffic from the ALB to Runner",
- )
- # Internal communication rules
- api_security_group.add_ingress_rule(
- peer=runner_security_group,
- connection=ec2.Port.tcp(8000),
- description="Allow traffic from Runner to API",
- )
- runner_security_group.add_ingress_rule(
- peer=api_security_group,
- connection=ec2.Port.tcp(8001),
- description="Allow traffic from API to Runner",
- )
-
- # 4. Scheduler Security Group
- scheduler_security_group = ec2.SecurityGroup(
- self,
- "SchedulerSecurityGroup",
- vpc=vpc,
- description="Security group for Scheduler service",
- )
- # Assume Scheduler listens on a port, let's say 8002
- # Allow Scheduler to receive traffic from API and Runner
- scheduler_security_group.add_ingress_rule(
- peer=api_security_group,
- connection=ec2.Port.tcp(8002),
- description="Allow traffic from API to Scheduler on port 8002",
- )
- scheduler_security_group.add_ingress_rule(
- peer=runner_security_group,
- connection=ec2.Port.tcp(8002),
- description="Allow traffic from Runner to Scheduler on port 8002",
- )
-
- # 5. EFS security group
- shared_efs_security_group = ec2.SecurityGroup(
- self,
- "SharedEfsSecurityGroup",
- vpc=vpc,
- description="Security group for EFS accessible by API and Runner",
- )
- # Allow NFS traffic from API and Runner to the EFS
- shared_efs_security_group.add_ingress_rule(
- peer=api_security_group,
- connection=ec2.Port.tcp(2049),
- description="Allow NFS traffic from API service",
- )
- shared_efs_security_group.add_ingress_rule(
- peer=runner_security_group,
- connection=ec2.Port.tcp(2049),
- description="Allow NFS traffic from Runner service",
- )
-
- # Create shared EFS for API and Runner
- shared_file_system = efs.FileSystem(
- self,
- "SharedFileSystem",
- vpc=vpc,
- performance_mode=efs.PerformanceMode.GENERAL_PURPOSE,
- throughput_mode=efs.ThroughputMode.BURSTING,
- security_group=shared_efs_security_group,
- )
- # Define EFS access point for apiuser
- efs_access_point = shared_file_system.add_access_point(
- "AccessPoint",
- path="/apiuser",
- create_acl=efs.Acl(owner_uid="1001", owner_gid="1001", permissions="0755"),
- posix_user=efs.PosixUser(uid="1001", gid="1001"),
- )
- # Create Volume
- volume_name = f"TracecatVolume-{TRACECAT__APP_ENV}"
- shared_volume = ecs.Volume(
- name=volume_name,
- efs_volume_configuration=ecs.EfsVolumeConfiguration(
- file_system_id=shared_file_system.file_system_id,
- transit_encryption="ENABLED",
- authorization_config=ecs.AuthorizationConfig(
- access_point_id=efs_access_point.access_point_id
- ),
- ),
- )
-
- # Task execution IAM role (used across API and runner)
- logs_group_prefix = f"arn:aws:logs:{self.region}:{self.account}:log-group:"
- if TRACECAT__APP_ENV == "production":
- logs_group_pattern = f"{logs_group_prefix}/ecs/tracecat-*:*"
- else:
- logs_group_pattern = (
- f"{logs_group_prefix}/ecs/tracecat-{TRACECAT__APP_ENV}*:*"
- )
-
- execution_role = iam.Role(
- self,
- "ExecutionRole",
- role_name=f"TracecatFargateServiceExecutionRole-{TRACECAT__APP_ENV}",
- assumed_by=iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
- )
- iam.Policy(
- self,
- "ExecutionRolePolicy",
- statements=[
- iam.PolicyStatement(
- effect=iam.Effect.ALLOW,
- actions=["logs:CreateLogStream", "logs:PutLogEvents"],
- resources=[logs_group_pattern],
- ),
- iam.PolicyStatement(
- effect=iam.Effect.ALLOW,
- actions=[
- "ecr:BatchCheckLayerAvailability",
- "ecr:GetDownloadUrlForLayer",
- "ecr:BatchGetImage",
- "ecr:GetAuthorizationToken",
- ],
- resources=[
- f"arn:aws:ecr:{self.region}:{self.account}:repository/tracecat",
- f"arn:aws:ecr:{self.region}:{self.account}:repository/tracecat-scheduler",
- ],
- ),
- iam.PolicyStatement(
- effect=iam.Effect.ALLOW,
- actions=["ecr:GetAuthorizationToken"],
- # Note: ecr:GetAuthorizationToken requires access on the service level, not specific repositories
- resources=["*"],
- ),
- iam.PolicyStatement(
- effect=iam.Effect.ALLOW,
- actions=[
- "secretsmanager:GetSecretValue",
- "secretsmanager:DescribeSecret",
- ],
- resources=[AWS_SECRET__ARN],
- ),
- ],
- roles=[execution_role],
- )
-
- # Task role
- task_role = iam.Role(
- self,
- "TaskRole",
- role_name=f"TracecatTaskRole-{TRACECAT__APP_ENV}",
- assumed_by=iam.ServicePrincipal("ecs-tasks.amazonaws.com"),
- )
- iam.Policy(
- self,
- "TaskRolePolicy",
- statements=[
- iam.PolicyStatement(
- effect=iam.Effect.ALLOW,
- actions=[
- "elasticfilesystem:ClientMount",
- "elasticfilesystem:ClientWrite",
- "elasticfilesystem:DescribeFileSystems",
- "elasticfilesystem:DescribeMountTargets",
- "elasticfilesystem:DescribeMountTargetSecurityGroups",
- ],
- resources=[
- f"arn:aws:elasticfilesystem:{self.region}:{self.account}:file-system/{shared_file_system.file_system_id}"
- ],
- ),
- ],
- roles=[task_role],
- )
-
- # Set up a log group
- log_group_name = "/ecs/tracecat"
- if TRACECAT__APP_ENV != "production":
- log_group_name = f"{log_group_name}-{TRACECAT__APP_ENV}"
- log_group = logs.LogGroup(
- self,
- "TracecatLogGroup",
- log_group_name=log_group_name,
- removal_policy=RemovalPolicy.DESTROY,
- )
-
- ### Tracecat API Fargate Service
- # Task definition
- api_task_definition = ecs.FargateTaskDefinition(
- self,
- "ApiTaskDefinition",
- execution_role=execution_role,
- task_role=task_role,
- cpu=CPU,
- memory_limit_mib=MEMORY_LIMIT_MIB,
- )
- # Volume
- api_task_definition.add_volume(
- name=volume_name,
- efs_volume_configuration=shared_volume.efs_volume_configuration,
- )
- # Container
- api_container = api_task_definition.add_container( # noqa
- "ApiContainer",
- image=ecs.ContainerImage.from_registry(AWS_ECR__API_IMAGE_URI),
- cpu=CPU,
- memory_limit_mib=MEMORY_LIMIT_MIB,
- environment=api_env,
- secrets=api_secrets,
- port_mappings=[
- ecs.PortMapping(
- container_port=8000,
- name="api",
- app_protocol=ecs.AppProtocol.http,
- )
- ],
- logging=ecs.LogDrivers.aws_logs(
- stream_prefix="tracecat-api", log_group=log_group
- ),
- )
- api_container.add_mount_points(
- ecs.MountPoint(
- container_path="/home/apiuser/.tracecat",
- read_only=False,
- source_volume=volume_name,
- )
- )
- # ECS service
- api_ecs_service = ecs.FargateService(
- self,
- "TracecatApiFargateService",
- cluster=cluster,
- service_name="tracecat-api",
- # Attach the security group to your ECS service
- task_definition=api_task_definition,
- security_groups=[api_security_group],
- service_connect_configuration=ecs.ServiceConnectProps(
- services=[
- ecs.ServiceConnectService(
- port_mapping_name="api", idle_timeout=Duration.minutes(15)
- )
- ]
- ),
- )
- # API target group
- api_target_group = elbv2.ApplicationTargetGroup(
- self,
- "TracecatApiTargetGroup",
- target_type=elbv2.TargetType.IP,
- port=8000,
- protocol=elbv2.ApplicationProtocol.HTTP,
- vpc=cluster.vpc,
- health_check=elbv2.HealthCheck(
- path="/health",
- interval=Duration.seconds(120),
- timeout=Duration.seconds(10),
- healthy_threshold_count=5,
- unhealthy_threshold_count=2,
- ),
- )
- api_target_group.add_target(
- api_ecs_service.load_balancer_target(
- container_name="ApiContainer", container_port=8000
- )
- )
-
- ### Tracecat Runner Fargate Service#
- # Task definition
- runner_task_definition = ecs.FargateTaskDefinition(
- self,
- "RunnerTaskDefinition",
- execution_role=execution_role,
- task_role=task_role,
- cpu=CPU,
- memory_limit_mib=MEMORY_LIMIT_MIB,
- )
- # Volume
- runner_task_definition.add_volume(
- name=volume_name,
- efs_volume_configuration=shared_volume.efs_volume_configuration,
- )
- # Container
- runner_container = runner_task_definition.add_container( # noqa
- "RunnerContainer",
- image=ecs.ContainerImage.from_registry(AWS_ECR__API_IMAGE_URI),
- cpu=CPU,
- memory_limit_mib=MEMORY_LIMIT_MIB,
- environment=runner_env,
- secrets=runner_secrets,
- port_mappings=[
- ecs.PortMapping(
- container_port=8001,
- name="runner",
- app_protocol=ecs.AppProtocol.http,
- )
- ],
- logging=ecs.LogDrivers.aws_logs(
- stream_prefix="tracecat-runner", log_group=log_group
- ),
- )
- runner_container.add_mount_points(
- ecs.MountPoint(
- container_path="/home/apiuser/.tracecat",
- read_only=False,
- source_volume=volume_name,
- )
- )
- # ECS service
- runner_ecs_service = ecs.FargateService(
- self,
- "TracecatRunnerFargateService",
- cluster=cluster,
- service_name="tracecat-runner",
- task_definition=runner_task_definition,
- # Attach the security group to your ECS service
- security_groups=[runner_security_group],
- service_connect_configuration=ecs.ServiceConnectProps(
- services=[
- ecs.ServiceConnectService(
- port_mapping_name="runner", idle_timeout=Duration.minutes(15)
- )
- ]
- ),
- )
- # Runner target group
- runner_target_group = elbv2.ApplicationTargetGroup(
- self,
- "TracecatRunnerTargetGroup",
- target_type=elbv2.TargetType.IP,
- port=8001,
- protocol=elbv2.ApplicationProtocol.HTTP,
- vpc=cluster.vpc,
- health_check=elbv2.HealthCheck(
- path="/health",
- interval=Duration.seconds(120),
- timeout=Duration.seconds(10),
- healthy_threshold_count=5,
- unhealthy_threshold_count=2,
- ),
- )
- runner_target_group.add_target(
- runner_ecs_service.load_balancer_target(
- container_name="RunnerContainer", container_port=8001
- )
- )
-
- ### Scheduler Fargate Service
- # Task definition
- scheduler_task_definition = ecs.FargateTaskDefinition(
- self,
- "SchedulerTaskDefinition",
- execution_role=execution_role,
- task_role=task_role,
- cpu=CPU,
- memory_limit_mib=MEMORY_LIMIT_MIB,
- )
- # Container
- scheduler_container = scheduler_task_definition.add_container( # noqa
- "SchedulerContainer",
- image=ecs.ContainerImage.from_registry(AWS_ECR__SCHEDULER_IMAGE_URI),
- cpu=CPU,
- memory_limit_mib=MEMORY_LIMIT_MIB,
- environment=shared_env,
- secrets=shared_secrets,
- port_mappings=[
- ecs.PortMapping(
- container_port=8002,
- name="scheduler",
- app_protocol=ecs.AppProtocol.http,
- )
- ],
- logging=ecs.LogDrivers.aws_logs(
- stream_prefix="tracecat-scheduler", log_group=log_group
- ),
- )
- # ECS service
- ecs.FargateService(
- self,
- "TracecatSchedulerFargateService",
- cluster=cluster,
- service_name="tracecat-scheduler",
- task_definition=scheduler_task_definition,
- security_groups=[scheduler_security_group],
- service_connect_configuration=ecs.ServiceConnectProps(
- services=[
- ecs.ServiceConnectService(
- port_mapping_name="scheduler", idle_timeout=Duration.minutes(15)
- )
- ]
- ),
- )
-
- ### Load balancer
- alb = elbv2.ApplicationLoadBalancer(
- self,
- "TracecatEngineAlb",
- vpc=cluster.vpc,
- internet_facing=True,
- load_balancer_name=f"tracecat-engine-alb-{TRACECAT__APP_ENV}",
- )
- alb.add_listener(
- # Redirect HTTP to HTTPS
- "HttpListener",
- port=80,
- default_action=elbv2.ListenerAction.redirect(
- port="443",
- protocol="HTTPS",
- host="#{host}",
- path="/#{path}",
- query="#{query}",
- permanent=True,
- ),
- )
-
- if TRACECAT__APP_ENV == "staging":
- host = f"staging.{AWS_ROUTE53__HOSTED_ZONE_NAME}"
- else:
- host = AWS_ROUTE53__HOSTED_ZONE_NAME
-
- # Main HTTPS listener
- listener = alb.add_listener(
- "DefaultHttpsListener",
- port=443,
- certificates=[cert, api_cert, runner_cert],
- default_action=elbv2.ListenerAction.fixed_response(404),
- )
- listener.add_action(
- "RootRedirect",
- priority=30,
- conditions=[elbv2.ListenerCondition.path_patterns(["/"])],
- action=elbv2.ListenerAction.redirect(
- host=f"api.{host}", # Redirect to the API subdomain
- protocol="HTTPS",
- port="443",
- path="/",
- permanent=True, # Permanent redirect
- ),
- )
-
- # Add subdomain listeners
- listener.add_action(
- "ApiTarget",
- priority=10,
- conditions=[elbv2.ListenerCondition.host_headers([f"api.{host}"])],
- action=elbv2.ListenerAction.forward(target_groups=[api_target_group]),
- )
- listener.add_action(
- "RunnerTarget",
- priority=20,
- conditions=[elbv2.ListenerCondition.host_headers([f"runner.{host}"])],
- action=elbv2.ListenerAction.forward(target_groups=[runner_target_group]),
- )
-
- # Create A record to point the hosted zone domain to the ALB
- route53.ARecord(
- self,
- "AliasRecord",
- record_name=host,
- target=route53.RecordTarget.from_alias(LoadBalancerTarget(alb)),
- zone=hosted_zone,
- )
- # Create A record for api.domain.com pointing to the ALB
- route53.ARecord(
- self,
- "ApiAliasRecord",
- record_name=f"api.{host}",
- target=route53.RecordTarget.from_alias(LoadBalancerTarget(alb)),
- zone=hosted_zone,
- )
-
- # Create A record for runner.domain.com pointing to the ALB
- route53.ARecord(
- self,
- "RunnerAliasRecord",
- record_name=f"runner.{host}",
- target=route53.RecordTarget.from_alias(LoadBalancerTarget(alb)),
- zone=hosted_zone,
- )
diff --git a/aws_cdk_app.py b/aws_cdk_app.py
deleted file mode 100644
index a04468e8a..000000000
--- a/aws_cdk_app.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import os
-
-from aws_cdk import App
-
-from aws.stack import TracecatEngineStack
-
-TRACECAT__APP_ENV = os.environ["TRACECAT__APP_ENV"]
-
-app = App()
-TracecatEngineStack(
- app,
- f"TracecatEngineStack-{TRACECAT__APP_ENV}",
- env={"region": os.environ["AWS_DEFAULT_REGION"]},
-)
-
-app.synth()
diff --git a/docs/deployment.mdx b/docs/deployment.mdx
index f815bf3bd..aef60d516 100644
--- a/docs/deployment.mdx
+++ b/docs/deployment.mdx
@@ -8,85 +8,19 @@ Only AWS is currently supported, but we plan to support Azure and GCP in the nea
## AWS
-Tracecat supports deployment to AWS using infrastructure-as-code.
-You can find the AWS CDK stack in the `aws` [directory](https://github.com/TracecatHQ/tracecat/blob/main/aws/stack.py).
+Tracecat supports deployment to AWS via CDK (cloud development kit) stack.
+For security purposes, we require self-hosted Cloud users to request access to the CDK code via founders@tracecat.com email.
Tracecat's AWS deployment includes the following resources:
- 1 VPC
- 1 AmazonMQ Broker
- 2 ECS Fargate Services
- 1 Application Load Balancer
-- 1 Elastic File System
-- Security Groups
-- Log Groups
-
-
-
- Go into AWS secrets manager and create a key-value pair type secret with the following sensitive data:
- ```
- rabbitmq-default-pass = your_rabbitmq_password
- rabbitmq-default-user = your_rabbitmq_user
- db-encryption-key = your_fernet_key
- db-uri = your_postgres_uri
- service-key = ${ openssl rand -hex 32 }
- signing-secret = ${ openssl rand -hex 32 }
- supabase-jwt-secret = your_supabase_jwt_secret
-
- # Optional
- openai-api-key= = your-openai-api-key
- resend-api-key= = your-resend-api-key
- ```
- Keep note of the created AWS secret's ARN.
-
-
- Use Route53 to create a hosted zone for your domain.
- You will also need to create an ACM certificate for your primary domain
- and subdomains `api.your-domain.com` and `runner.your-domain.com`.
-
-
- Set the following environment variables in the session that you will be deploying Tracecat from.
- These environment variables are used by the AWS CDK stack to configure the deployment.
- ```
- TRACECAT__APP_ENV
- AWS_ECR__REPOSITORY_URI
- AWS_ECR__IMAGE_TAG
- AWS_SECRET__ARN
- AWS_ROUTE53__HOSTED_ZONE_ID
- AWS_ROUTE53__HOSTED_ZONE_NAME
- AWS_ACM__CERTIFICATE_ARN
- AWS_ACM__API_CERTIFICATE_ARN
- AWS_ACM__RUNNER_CERTIFICATE_ARN
- ```
- You can define `TRACECAT__APP_ENV` as `production`, `staging`, or `development`.
-
-
-
- ```bash
- git clone git@github.com:TracecatHQ/tracecat.git
- cd tracecat
- ```
-
-
-
- You'll need to install [`npm`](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) for this step.
-
- ```bash
- npm install -g aws-cdk@2.133 && cdk --version
- pip install --upgrade pip && pip install -r requirements-cdk.txt
- ```
-
-
-
- First make sure you are [properly authenticated]() with AWS CLI and have the [appropriate permissions]() to deploy AWS CDK stacks.
- Run the following commands to deploy Tracecat to AWS:
-
- ```bash
- cdk synth --app "python3 aws_cdk_app.py"
- cdk bootstrap --app cdk.out
- cdk deploy --app cdk.out --require-approval never
- ```
-
-
+- 1 S3 Bucket
+- 1 AmazonMQ Broker
+- 1 Firewall (optional)
+- Multiple Security Groups
+- Multiple Logs Groups
## Azure
diff --git a/requirements-cdk.txt b/requirements-cdk.txt
deleted file mode 100644
index c7eb7c0c7..000000000
--- a/requirements-cdk.txt
+++ /dev/null
@@ -1 +0,0 @@
-aws-cdk-lib==2.133
From 7a0d04d1f7667f59216527ff5241890d1660d8d9 Mon Sep 17 00:00:00 2001
From: Christopher Lo <46541035+topher-lo@users.noreply.github.com>
Date: Wed, 1 May 2024 00:49:56 +0000
Subject: [PATCH 02/41] chore: Add note about python cdk
---
docs/deployment.mdx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/deployment.mdx b/docs/deployment.mdx
index aef60d516..733a55e32 100644
--- a/docs/deployment.mdx
+++ b/docs/deployment.mdx
@@ -8,8 +8,8 @@ Only AWS is currently supported, but we plan to support Azure and GCP in the nea
## AWS
-Tracecat supports deployment to AWS via CDK (cloud development kit) stack.
-For security purposes, we require self-hosted Cloud users to request access to the CDK code via founders@tracecat.com email.
+Tracecat supports deployment to AWS via a CDK (Cloud Development Kit) stack written in Python.
+For security purposes, we require self-hosted Cloud users to request access to the deployment script via founders@tracecat.com email.
Tracecat's AWS deployment includes the following resources:
- 1 VPC
From 68bee39324693044959fca1e51dbb56dd2c8e274 Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Wed, 1 May 2024 04:13:37 +0100
Subject: [PATCH 03/41] docs: Add linux docker networking fix
---
docs/installation.mdx | 53 ++++++++++++++++++++++++++++++++++++++++---
1 file changed, 50 insertions(+), 3 deletions(-)
diff --git a/docs/installation.mdx b/docs/installation.mdx
index bc6e2edcb..7372e2f7c 100644
--- a/docs/installation.mdx
+++ b/docs/installation.mdx
@@ -17,8 +17,8 @@ development and testing purposes._
### Required
-- [Docker](https://docs.docker.com/get-docker/)
-- [Docker Compose](https://docs.docker.com/compose/install/)
+- [Docker](https://docs.docker.com/get-docker/) (Docker Engine v20.10+)
+- [Docker Compose](https://docs.docker.com/compose/install/) (Compose V2)
- [ngrok](https://ngrok.com/)
- [pnpm](https://pnpm.io/installation)
@@ -110,6 +110,53 @@ If correctly set up, your .env should contain:
TRACECAT__RUNNER_URL=https://your-ngrok-domain.ngrok-free.app
```
+### Linux users
+
+If you are using Linux, you may encounter issues around being unable to resolve `host.docker.internal` from within the Docker containers.
+
+To resolve this, you can add the following parameter to each of the services in the `docker-compose.yaml` file, as suggested by this [StackOverflow post](https://stackoverflow.com/a/67158212).
+
+For example:
+
+```yaml
+services:
+ api:
+ build: .
+ container_name: api
+ ports:
+ - "8000:8000"
+ volumes:
+ - ./tracecat:/app/tracecat
+ - app-storage:/var/lib/tracecat
+ environment:
+ API_MODULE: "tracecat.api.app:app"
+ # Shared
+ LOG_LEVEL: ${LOG_LEVEL}
+ RABBITMQ_URI: ${RABBITMQ_URI}
+ TRACECAT__APP_ENV: ${TRACECAT__APP_ENV}
+ TRACECAT__DB_ENCRYPTION_KEY: ${TRACECAT__DB_ENCRYPTION_KEY}
+ TRACECAT__DB_URI: ${TRACECAT__DB_URI}
+ TRACECAT__SERVICE_KEY: ${TRACECAT__SERVICE_KEY}
+ TRACECAT__SIGNING_SECRET: ${TRACECAT__SIGNING_SECRET}
+ TRACECAT__API_URL: ${TRACECAT__API_URL}
+ TRACECAT__RUNNER_URL: ${TRACECAT__RUNNER_URL}
+ # Auth
+ CLERK_FRONTEND_API_URL: ${CLERK_FRONTEND_API_URL}
+ TRACECAT__DISABLE_AUTH: ${TRACECAT__DISABLE_AUTH}
+ # Integrations
+ OPENAI_API_KEY: ${OPENAI_API_KEY}
+ restart: unless-stopped
+ depends_on:
+ rabbitmq:
+ condition: service_healthy
+ networks:
+ - internal-network
+ extra_hosts:
+ - "host.docker.internal:host-gateway"
+# Do the same for the rest of the services
+# ...
+```
+
### Start Tracecat services
Finally, start Tracecat using `docker compose`:
@@ -166,4 +213,4 @@ This allows Docker containers to reference the Docker host machine.
For example, if you have a service `api` running in a container with port mapping `1234:8000` (mapping of port 1234 on the Docker host and 8000 in the container), from inside the Docker network you should be able to reference it using `http://api:8000`.
-We are working on a more robust solution to this issue - we appreciate your patience!
+If you're using Linux, please refer to the [Linux users](#linux-users) section. On MacOS and Windows WSL, you shouldn't have this issue. If you do, please refer to the [Docker networking docs](https://docs.docker.com/network/) or reach out to us on [Discord](https://discord.gg/n3GF4qxFU8) for help.
From 995ced102e718c90f3b2aaf22fe2b76a620390eb Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Wed, 1 May 2024 23:44:31 +0100
Subject: [PATCH 04/41] docs: Fix typoe
---
README.md | 21 ++++++++++-----------
SECURITY.md | 2 +-
2 files changed, 11 insertions(+), 12 deletions(-)
diff --git a/README.md b/README.md
index 302984dff..ee5143185 100644
--- a/README.md
+++ b/README.md
@@ -9,23 +9,23 @@
- ![License](https://img.shields.io/badge/License-Apache%202.0-blue?style=for-the-badge&logo=apache)
- ![Commit Activity](https://img.shields.io/github/commit-activity/m/TracecatHQ/tracecat?style=for-the-badge&logo=github)
- [![Docs](https://img.shields.io/badge/Docs-available-blue?style=for-the-badge&logoColor=white)](https://docs.tracecat.com)
+![License](https://img.shields.io/badge/License-Apache%202.0-blue?style=for-the-badge&logo=apache)
+![Commit Activity](https://img.shields.io/github/commit-activity/m/TracecatHQ/tracecat?style=for-the-badge&logo=github)
+[![Docs](https://img.shields.io/badge/Docs-available-blue?style=for-the-badge&logoColor=white)](https://docs.tracecat.com)
- ![Next.js](https://img.shields.io/badge/next.js-%23000000.svg?style=for-the-badge&logo=next.js&logoColor=white)
- ![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=for-the-badge&logo=fastapi)
- [![Pydantic v2](https://img.shields.io/endpoint?style=for-the-badge&url=https://raw.githubusercontent.com/pydantic/pydantic/main/docs/badge/v2.json)](https://docs.pydantic.dev/latest/contributing/#badges)
- [![Discord](https://img.shields.io/discord/1212548097624903681.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/n3GF4qxFU8)
+![Next.js](https://img.shields.io/badge/next.js-%23000000.svg?style=for-the-badge&logo=next.js&logoColor=white)
+![FastAPI](https://img.shields.io/badge/FastAPI-005571?style=for-the-badge&logo=fastapi)
+[![Pydantic v2](https://img.shields.io/endpoint?style=for-the-badge&url=https://raw.githubusercontent.com/pydantic/pydantic/main/docs/badge/v2.json)](https://docs.pydantic.dev/latest/contributing/#badges)
+[![Discord](https://img.shields.io/discord/1212548097624903681.svg?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/n3GF4qxFU8)
-*Disclaimer: Tracecat is currently in public alpha. If you'd like to use Tracecat in production, please reach out to us on Discord or founders@tracecat.com!*
-*Want to take Tracecat for a spin? Try out our [tutorials](https://docs.tracecat.com/quickstart) with [Tracecat Cloud](https://platform.tracecat.com) or [self-hosted](https://docs.tracecat.com/installation).*
+_Disclaimer: Tracecat is currently in public alpha. If you'd like to use Tracecat in production, please reach out to us on Discord or founders@tracecat.com!_
+_Want to take Tracecat for a spin? Try out our [tutorials](https://docs.tracecat.com/quickstart) with [Tracecat Cloud](https://platform.tracecat.com) or [self-hosted](https://docs.tracecat.com/installation)._
[Tracecat](https://tracecat.com) is an open source automation platform for security teams. We're building the features of Tines / Splunk SOAR with:
@@ -43,7 +43,6 @@ We also support [self-hosted](https://docs.tracecat.com/installation) Tracecat.
![autocomplete_gif](https://github.com/TracecatHQ/tracecat/assets/46541035/52b822a9-fbd5-4f08-a4ec-54e8fd1b8f02)
-
## Getting started
Let's automate a phishing email investigation, collect evidence, and generate a remediation plan using AI.
@@ -132,7 +131,7 @@ Here are a few integrations on our roadmap:
Please do not file GitHub issues or post on our public forum for security vulnerabilities, as they are public!
-Infisical takes security issues very seriously. If you have any concerns about Tracecat or believe you have uncovered a vulnerability, please get in touch via the e-mail address security@tracecat.com. In the message, try to provide a description of the issue and ideally a way of reproducing it. The security team will get back to you as soon as possible.
+Tracecat takes security issues very seriously. If you have any concerns about Tracecat or believe you have uncovered a vulnerability, please get in touch via the e-mail address security@tracecat.com. In the message, try to provide a description of the issue and ideally a way of reproducing it. The security team will get back to you as soon as possible.
Note that this security address should be used only for undisclosed vulnerabilities. Please report any security problems to us before disclosing it publicly.
diff --git a/SECURITY.md b/SECURITY.md
index a76dd94b6..f71d9128c 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -8,6 +8,6 @@ We always recommend using the latest version of Tracecat to ensure you get all s
Please do not file GitHub issues or post on our public forum for security vulnerabilities, as they are public!
-Infisical takes security issues very seriously. If you have any concerns about Tracecat or believe you have uncovered a vulnerability, please get in touch via the e-mail address security@tracecat.com. In the message, try to provide a description of the issue and ideally a way of reproducing it. The security team will get back to you as soon as possible.
+Tracecat takes security issues very seriously. If you have any concerns about Tracecat or believe you have uncovered a vulnerability, please get in touch via the e-mail address security@tracecat.com. In the message, try to provide a description of the issue and ideally a way of reproducing it. The security team will get back to you as soon as possible.
Note that this security address should be used only for undisclosed vulnerabilities. Please report any security problems to us before disclosing it publicly.
From 79f248a98d5571014f7df70e0d5ec68ef1ad7cc2 Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Thu, 2 May 2024 02:44:03 +0100
Subject: [PATCH 05/41] fix(engine): Wrong pydantic return type for
get_workflow
---
tracecat/runner/workflows.py | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/tracecat/runner/workflows.py b/tracecat/runner/workflows.py
index 2ff3dca4b..81ef0b251 100644
--- a/tracecat/runner/workflows.py
+++ b/tracecat/runner/workflows.py
@@ -1,5 +1,5 @@
from functools import cached_property
-from typing import Any, Self
+from typing import Any, Literal, Self
from uuid import uuid4
from pydantic import BaseModel, ConfigDict, Field, validator
@@ -26,6 +26,7 @@ class Workflow(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
id: str = Field(default_factory=lambda: uuid4().hex)
title: str
+ status: Literal["online", "offline"]
adj_list: dict[str, list[str]]
actions: dict[str, ActionSubclass]
owner_id: str
@@ -113,6 +114,7 @@ def from_response(cls, response: WorkflowResponse) -> Self:
adj_list=adj_list,
actions=actions,
owner_id=response.owner_id,
+ status=response.status,
)
From ef89cbbbec47d0390829cb298d322c04a0599ddc Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Thu, 2 May 2024 03:03:56 +0100
Subject: [PATCH 06/41] docs: Add note about needing Node v20+
---
docs/installation.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/installation.mdx b/docs/installation.mdx
index 7372e2f7c..58cbe23d7 100644
--- a/docs/installation.mdx
+++ b/docs/installation.mdx
@@ -20,7 +20,7 @@ development and testing purposes._
- [Docker](https://docs.docker.com/get-docker/) (Docker Engine v20.10+)
- [Docker Compose](https://docs.docker.com/compose/install/) (Compose V2)
- [ngrok](https://ngrok.com/)
-- [pnpm](https://pnpm.io/installation)
+- [pnpm](https://pnpm.io/installation) (with Node v20+)
### Optional
From 6298cddc55ce68f254071a53d75e3c9fa35531c3 Mon Sep 17 00:00:00 2001
From: Chris Lo <46541035+topher-lo@users.noreply.github.com>
Date: Wed, 1 May 2024 21:33:22 -0700
Subject: [PATCH 07/41] docs: Remove phone option in bug report
---
.github/ISSUE_TEMPLATE/bug_report.md | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index dd84ea782..8a31e5ba7 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -27,12 +27,7 @@ If applicable, add screenshots to help explain your problem.
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22]
-
-**Smartphone (please complete the following information):**
- - Device: [e.g. iPhone6]
- - OS: [e.g. iOS8.1]
- - Browser [e.g. stock browser, safari]
- - Version [e.g. 22]
+ - Docker version
**Additional context**
Add any other context about the problem here.
From 42481aa1ccf0ae224997fe9948cb8267b5da7fb9 Mon Sep 17 00:00:00 2001
From: Christopher Lo <46541035+topher-lo@users.noreply.github.com>
Date: Thu, 2 May 2024 04:45:49 +0000
Subject: [PATCH 08/41] ci: Consolidate all fastapi services into single
Dockerfile
---
Dockerfile.scheduler | 54 ---------------------
docker-compose.yaml | 3 +-
tests/test_scheduler.py | 2 +-
tracecat/{scheduler.py => scheduler/app.py} | 0
4 files changed, 3 insertions(+), 56 deletions(-)
delete mode 100644 Dockerfile.scheduler
rename tracecat/{scheduler.py => scheduler/app.py} (100%)
diff --git a/Dockerfile.scheduler b/Dockerfile.scheduler
deleted file mode 100644
index 56b6bc91f..000000000
--- a/Dockerfile.scheduler
+++ /dev/null
@@ -1,54 +0,0 @@
-FROM python:3.12-slim-bookworm
-
-ARG LANCEDB_CONFIG_DIR=/var/lib/tracecat/lancedb
-
-ENV HOST=0.0.0.0
-ENV PORT=8002
-ENV TRACECAT_DIR=/var/lib/tracecat
-ENV LANCEDB_CONFIG_DIR=/var/lib/tracecat/lancedb
-
-EXPOSE $PORT
-
-# Install necessary packages, including acl
-RUN apt-get update && \
- apt-get install -y acl && \
- rm -rf /var/lib/apt/lists/*
-
-# Copy and run the script to install additional packages
-COPY scripts/install-packages.sh .
-RUN chmod +x install-packages.sh && \
- ./install-packages.sh && \
- rm install-packages.sh
-
-COPY scripts/auto-update.sh ./auto-update.sh
-RUN chmod +x auto-update.sh && \
- ./auto-update.sh && \
- rm auto-update.sh
-
-# Create the apiuser with a specific UID/GID,
-# pre-create required directories, and set the correct permissions
-RUN groupadd -g 1001 apiuser && \
- useradd -m -u 1001 -g apiuser apiuser && \
- mkdir -p $TRACECAT_DIR && \
- chown -R apiuser:apiuser $TRACECAT_DIR && \
- chmod -R 755 $TRACECAT_DIR && \
- setfacl -d -m u:apiuser:rwx $TRACECAT_DIR
-
-# Set the working directory inside the container
-WORKDIR /app
-
-# Change to the non-root user
-USER apiuser
-
-# Copy the application files into the container and set ownership
-COPY --chown=apiuser:apiuser ./tracecat /app/tracecat
-COPY --chown=apiuser:apiuser ./pyproject.toml /app/pyproject.toml
-COPY --chown=apiuser:apiuser ./requirements.txt /app/requirements.txt
-COPY --chown=apiuser:apiuser ./README.md /app/README.md
-COPY --chown=apiuser:apiuser ./LICENSE /app/LICENSE
-
-# Install the Python dependencies
-RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt
-
-# Command to run the application
-CMD ["sh", "-c", "python3 -m uvicorn tracecat.scheduler:app --host $HOST --port $PORT --reload"]
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 61c4d920d..074759af9 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -66,7 +66,7 @@ services:
scheduler:
build:
context: .
- dockerfile: Dockerfile.scheduler
+ dockerfile: Dockerfile
container_name: scheduler
ports:
- "8002:8000"
@@ -74,6 +74,7 @@ services:
- ./tracecat:/app/tracecat
- app-storage:/var/lib/tracecat
environment:
+ API_MODULE: "tracecat.scheduler.app:app"
# Shared
LOG_LEVEL: ${LOG_LEVEL}
RABBITMQ_URI: ${RABBITMQ_URI}
diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py
index 5ed63cd1a..d7dd97adf 100644
--- a/tests/test_scheduler.py
+++ b/tests/test_scheduler.py
@@ -25,7 +25,7 @@
)
from tracecat.config import TRACECAT__RUNNER_URL
from tracecat.db import TRACECAT__DB_URI, Workflow, WorkflowSchedule
-from tracecat.scheduler import app, engine, start_scheduler
+from tracecat.scheduler.app import app, engine, start_scheduler
TEST_SCHEDULER_INTERVAL_SECONDS = 10
TEST_WORKFLOW_RUN_TIMEOUT = 40 # seconds
diff --git a/tracecat/scheduler.py b/tracecat/scheduler/app.py
similarity index 100%
rename from tracecat/scheduler.py
rename to tracecat/scheduler/app.py
From 6be2500fdd3998afff1705c1aa37b0d71b799a16 Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Thu, 2 May 2024 12:36:50 +0100
Subject: [PATCH 09/41] fix(engine): Get Resource.updated_at working (#120)
---
tracecat/db.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tracecat/db.py b/tracecat/db.py
index bf893e743..02675dcdf 100644
--- a/tracecat/db.py
+++ b/tracecat/db.py
@@ -81,7 +81,7 @@ class Resource(SQLModel):
sa_type=TIMESTAMP(timezone=True), # UTC Timestamp
sa_column_kwargs={
"server_default": text("(now() AT TIME ZONE 'utc'::text)"),
- "server_onupdate": text("(now() AT TIME ZONE 'utc'::text)"),
+ "onupdate": text("(now() AT TIME ZONE 'utc'::text)"),
"nullable": False,
},
)
From af5f28294d236fd464719a4e2aead5516726023b Mon Sep 17 00:00:00 2001
From: Christopher Lo <46541035+topher-lo@users.noreply.github.com>
Date: Thu, 2 May 2024 17:56:32 +0000
Subject: [PATCH 10/41] ci: Add gitleaks to pre-commit
---
.pre-commit-config.yaml | 4 ++++
.pre-commit-hooks.yaml | 6 ------
2 files changed, 4 insertions(+), 6 deletions(-)
delete mode 100644 .pre-commit-hooks.yaml
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index bf91ece7b..7b494a9cc 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -20,3 +20,7 @@ repos:
args:
- --fix
- id: ruff-format
+- repo: https://github.com/gitleaks/gitleaks
+ rev: v8.18.2 # Specify the desired version of Gitleaks
+ hooks:
+ - id: gitleaks
diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml
deleted file mode 100644
index d8f7e0e2a..000000000
--- a/.pre-commit-hooks.yaml
+++ /dev/null
@@ -1,6 +0,0 @@
-- id: trufflehog
- name: TruffleHog
- description: Detect secrets in your data with TruffleHog.
- entry: trufflehog git file://. --since-commit HEAD --only-verified --fail
- language: golang
- pass_filenames: false
From 85a2eccb7c78a205a71137da8167ddae65e90af4 Mon Sep 17 00:00:00 2001
From: Christopher Lo <46541035+topher-lo@users.noreply.github.com>
Date: Thu, 2 May 2024 18:52:27 +0000
Subject: [PATCH 11/41] ci: Remove scheduler image workflow
---
.github/workflows/publish-images.yml | 34 ----------------------------
1 file changed, 34 deletions(-)
diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml
index e263f59a4..39dcdbdf7 100644
--- a/.github/workflows/publish-images.yml
+++ b/.github/workflows/publish-images.yml
@@ -51,37 +51,3 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-
- publish-scheduler-image:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Log in to the Container registry
- uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
- with:
- registry: ${{ env.REGISTRY }}
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Extract semver metadata (tags, labels) for Docker
- id: meta
- uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
- with:
- images: ${{ env.REGISTRY }}/${{ env.SCHEDULER_IMAGE_NAME }}
- tags: |
- type=ref,event=branch
- type=ref,event=pr
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
- type=sha
-
- - name: Build and push Docker image
- uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
- with:
- context: .
- file: Dockerfile.scheduler
- push: true
- tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
From b4353d01637dc42a01e569e8c0102bafa53e24c5 Mon Sep 17 00:00:00 2001
From: Christopher Lo <46541035+topher-lo@users.noreply.github.com>
Date: Fri, 3 May 2024 00:12:16 +0000
Subject: [PATCH 12/41] style: Indent yaml by 2 spaces
---
.pre-commit-config.yaml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7b494a9cc..e039a5b05 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,7 +3,7 @@
default_language_version:
python: python3.12
repos:
-- repo: https://github.com/pre-commit/pre-commit-hooks
+ - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
# - id: check-added-large-files
@@ -13,14 +13,14 @@ repos:
- --unsafe
- id: end-of-file-fixer
- id: trailing-whitespace
-- repo: https://github.com/charliermarsh/ruff-pre-commit
+ - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.1.2
hooks:
- id: ruff
args:
- --fix
- id: ruff-format
-- repo: https://github.com/gitleaks/gitleaks
+ - repo: https://github.com/gitleaks/gitleaks
rev: v8.18.2 # Specify the desired version of Gitleaks
hooks:
- id: gitleaks
From 5c716c5042c93a333f4228ed4faa6ddf15094eed Mon Sep 17 00:00:00 2001
From: Ikko Eltociear Ashimine
Date: Sat, 4 May 2024 01:33:10 +0900
Subject: [PATCH 13/41] docs: Spelling in installation (#121)
---
docs/installation.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/installation.mdx b/docs/installation.mdx
index 58cbe23d7..b8899cee4 100644
--- a/docs/installation.mdx
+++ b/docs/installation.mdx
@@ -191,7 +191,7 @@ docker compose down --remove-orphans
## Authentication
-So far, we've shown how to run Tracecat locally without authentication and runnning with a default user.
+So far, we've shown how to run Tracecat locally without authentication and running with a default user.
If you want to enable authentication, you can set the `TRACECAT__DISABLE_AUTH` environment variable to `false` or `0` in the `.env` file. This requires you to have a Clerk account and set up the Clerk environment variables in `.env`.
From 0b25bb7ee95de191702b73c6ac1765cb88049ec2 Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Thu, 2 May 2024 16:18:28 +0100
Subject: [PATCH 14/41] feat(engine): Add context logger
---
tracecat/contexts.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/tracecat/contexts.py b/tracecat/contexts.py
index b080943fc..3afd35dba 100644
--- a/tracecat/contexts.py
+++ b/tracecat/contexts.py
@@ -1,5 +1,6 @@
from __future__ import annotations
+import logging
from contextvars import ContextVar
from typing import TYPE_CHECKING
@@ -15,3 +16,4 @@
ctx_mq_channel_pool: ContextVar[Pool[Channel]] = ContextVar(
"mq_channel_pool", default=None
)
+ctx_logger: ContextVar[logging.Logger] = ContextVar("logger", default=None)
From 3fb682a6a744d3fc7ccbd7b7a45ff096f13ca31b Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Thu, 2 May 2024 16:20:06 +0100
Subject: [PATCH 15/41] refactor(engine): Replace run actions custom logger
with context logger
---
tracecat/runner/actions.py | 127 ++++++++++++++++++-------------------
tracecat/runner/app.py | 13 ++--
2 files changed, 70 insertions(+), 70 deletions(-)
diff --git a/tracecat/runner/actions.py b/tracecat/runner/actions.py
index bf055489d..9e2f03915 100644
--- a/tracecat/runner/actions.py
+++ b/tracecat/runner/actions.py
@@ -49,7 +49,7 @@
from tracecat.concurrency import CloudpickleProcessPoolExecutor
from tracecat.config import HTTP_MAX_RETRIES
-from tracecat.contexts import ctx_session_role
+from tracecat.contexts import ctx_logger, ctx_session_role
from tracecat.db import create_vdb_conn
from tracecat.integrations import registry
from tracecat.llm import DEFAULT_MODEL_TYPE, ModelType, async_openai_call
@@ -82,8 +82,6 @@
if TYPE_CHECKING:
from tracecat.runner.workflows import Workflow
-logger = standard_logger(__name__)
-
T = TypeVar("T", str, list[Any], dict[str, Any])
@@ -96,6 +94,10 @@
ACTION_RUN_ID_PREFIX = "ar"
+def _get_logger() -> logging.Logger:
+ return ctx_logger.get() or standard_logger(__name__)
+
+
def action_key_to_id(action_key: str) -> str:
return action_key.split(".")[0]
@@ -391,8 +393,8 @@ async def start_action_run(
action_run_status_store: dict[str, ActionRunStatus],
# Dynamic data
pending_timeout: float | None = None,
- custom_logger: logging.Logger | None = None,
) -> None:
+ logger = _get_logger()
try:
await emit_create_action_run_event(action_run)
ar_id = action_run.id
@@ -400,8 +402,7 @@ async def start_action_run(
upstream_deps_ar_ids = action_run.upstream_dependencies(
workflow=workflow_ref, action_key=action_key
)
- custom_logger = custom_logger or logger
- custom_logger.debug(
+ logger.debug(
f"Action run {ar_id} waiting for dependencies {upstream_deps_ar_ids}."
)
@@ -417,7 +418,7 @@ async def start_action_run(
upstream_deps_ar_ids, action_result_store
)
- custom_logger.debug(f"Running action {ar_id!r}. Trail {action_trail.keys()}.")
+ logger.debug(f"Running action {ar_id!r}. Trail {action_trail.keys()}.")
action_run_status_store[ar_id] = ActionRunStatus.RUNNING
action_ref = workflow_ref.actions[action_key]
await emit_update_action_run_event(action_run, status="running")
@@ -432,7 +433,6 @@ async def start_action_run(
result = await run_action(
action_run_id=action_run.id,
workflow_id=workflow_ref.id,
- custom_logger=custom_logger,
action_trail=action_trail,
action_run_kwargs=action_run.run_kwargs,
**action_ref.model_dump(),
@@ -446,21 +446,19 @@ async def start_action_run(
# The schema is { : , ...}
action_trail = action_trail | {ar_id: result}
action_result_store[ar_id] = action_trail
- custom_logger.debug(
- f"Action run {ar_id!r} completed with trail: {action_trail}."
- )
+ logger.debug(f"Action run {ar_id!r} completed with trail: {action_trail}.")
except TimeoutError as e:
error_msg = f"Action run {ar_id} timed out waiting for dependencies {upstream_deps_ar_ids}."
- custom_logger.error(error_msg, exc_info=e)
+ logger.error(error_msg, exc_info=e)
run_status = "failure"
except asyncio.CancelledError as e:
error_msg = f"Action run {ar_id!r} was cancelled."
- custom_logger.warning(error_msg, exc_info=e)
+ logger.warning(error_msg, exc_info=e)
run_status = "canceled"
except Exception as e:
error_msg = f"Action run {ar_id!r} failed with error: {e}."
- custom_logger.error(error_msg, exc_info=e)
+ logger.error(error_msg, exc_info=e)
run_status = "failure"
finally:
if action_run_status_store[ar_id] != ActionRunStatus.SUCCESS:
@@ -475,11 +473,11 @@ async def start_action_run(
# Handle downstream dependencies
if run_status != "success":
- custom_logger.warning(f"Action run {ar_id!r} stopping due to failure.")
+ logger.warning(f"Action run {ar_id!r} stopping due to failure.")
return
- custom_logger.debug(f"Remaining action runs: {running_jobs_store.keys()}")
+ logger.debug(f"Remaining action runs: {running_jobs_store.keys()}")
if not result.should_continue:
- custom_logger.info(f"Action run {ar_id!r} stopping due to stop signal.")
+ logger.info(f"Action run {ar_id!r} stopping due to stop signal.")
return
try:
downstream_deps_ar_ids = action_run.downstream_dependencies(
@@ -496,7 +494,7 @@ async def start_action_run(
)
)
except Exception as e:
- custom_logger.error(
+ logger.error(
f"Action run {ar_id!r} failed to broadcast results to downstream dependencies.",
exc_info=e,
)
@@ -507,15 +505,15 @@ async def run_webhook_action(
method: str,
# Common
action_run_kwargs: dict[str, Any] | None = None,
- custom_logger: logging.Logger = logger,
) -> dict[str, Any]:
"""Run a webhook action."""
- custom_logger.debug("Perform webhook action")
- custom_logger.debug(f"{url = }")
- custom_logger.debug(f"{method = }")
+ logger = _get_logger()
+ logger.debug("Perform webhook action")
+ logger.debug(f"{url = }")
+ logger.debug(f"{method = }")
# The payload provided to the webhook action in the HTTP request
action_run_kwargs = action_run_kwargs or {}
- custom_logger.debug(f"{action_run_kwargs = }")
+ logger.debug(f"{action_run_kwargs = }")
# TODO: Perform whitelist/filter step here using the url and method
return {
"output": action_run_kwargs,
@@ -555,14 +553,14 @@ async def run_http_request_action(
payload: dict[str, str | bytes],
# Common
action_run_kwargs: dict[str, Any] | None = None,
- custom_logger: logging.Logger = logger,
) -> dict[str, Any]:
"""Run an HTTP request action."""
- custom_logger.debug("Perform HTTP request action")
- custom_logger.debug(f"{url = }")
- custom_logger.debug(f"{method = }")
- custom_logger.debug(f"{headers = }")
- custom_logger.debug(f"{payload = }")
+ logger = _get_logger()
+ logger.debug("Perform HTTP request action")
+ logger.debug(f"{url = }")
+ logger.debug(f"{method = }")
+ logger.debug(f"{headers = }")
+ logger.debug(f"{payload = }")
try:
async with httpx.AsyncClient() as client:
@@ -574,9 +572,7 @@ async def run_http_request_action(
)
response.raise_for_status()
except httpx.HTTPStatusError as e:
- custom_logger.error(
- f"HTTP request failed with status {e.response.status_code}."
- )
+ logger.error(f"HTTP request failed with status {e.response.status_code}.")
raise
return parse_http_response_data(response)
@@ -586,10 +582,10 @@ async def run_conditional_action(
condition_rules: dict[str, Any],
# Common
action_run_kwargs: dict[str, Any] | None = None,
- custom_logger: logging.Logger = logger,
) -> dict[str, Any]:
"""Run a conditional action."""
- custom_logger.debug(f"Run conditional rules {condition_rules}.")
+ logger = _get_logger()
+ logger.debug(f"Run conditional rules {condition_rules}.")
rule = ConditionRuleValidator.validate_python(condition_rules)
rule_match = rule.evaluate()
return {
@@ -610,12 +606,12 @@ async def run_llm_action(
llm_kwargs: dict[str, Any] | None = None,
# Common
action_run_kwargs: dict[str, Any] | None = None,
- custom_logger: logging.Logger = logger,
) -> dict[str, Any]:
"""Run an LLM action."""
- custom_logger.debug("Perform LLM action")
- custom_logger.debug(f"{message = }")
- custom_logger.debug(f"{response_schema = }")
+ logger = _get_logger()
+ logger.debug("Perform LLM action")
+ logger.debug(f"{message = }")
+ logger.debug(f"{response_schema = }")
llm_kwargs = llm_kwargs or {}
@@ -664,14 +660,14 @@ async def run_send_email_action(
provider: Literal["resend"] = "resend",
# Common
action_run_kwargs: dict[str, Any] | None = None,
- custom_logger: logging.Logger = logger,
) -> dict[str, Any]:
"""Run a send email action."""
- custom_logger.debug("Perform send email action")
- custom_logger.debug(f"{sender = }")
- custom_logger.debug(f"{recipients = }")
- custom_logger.debug(f"{subject = }")
- custom_logger.debug(f"{body = }")
+ logger = _get_logger()
+ logger.debug("Perform send email action")
+ logger.debug(f"{sender = }")
+ logger.debug(f"{recipients = }")
+ logger.debug(f"{subject = }")
+ logger.debug(f"{body = }")
if provider == "resend":
email_provider = ResendMailProvider(
@@ -682,7 +678,7 @@ async def run_send_email_action(
)
else:
msg = "Email provider not recognized"
- custom_logger.warning(f"{msg}: {provider!r}")
+ logger.warning(f"{msg}: {provider!r}")
email_response = {
"status": "error",
"message": msg,
@@ -698,7 +694,7 @@ async def run_send_email_action(
await email_provider.send()
except httpx.HTTPError as exc:
msg = "Failed to post email to provider"
- custom_logger.error(msg, exc_info=exc)
+ logger.error(msg, exc_info=exc)
email_response = {
"status": "error",
"message": msg,
@@ -710,7 +706,7 @@ async def run_send_email_action(
}
except (EmailBouncedError, EmailNotFoundError) as exc:
msg = exc.args[0]
- custom_logger.warning(msg=msg, exc_info=exc)
+ logger.warning(msg=msg, exc_info=exc)
email_response = {
"status": "warning",
"message": msg,
@@ -752,8 +748,8 @@ async def run_open_case_action(
tags: ListModel[Tag] | None = None,
# Common
action_run_kwargs: dict[str, Any] | None = None,
- custom_logger: logging.Logger = logger,
) -> dict[str, str | dict[str, str] | None]:
+ logger = _get_logger()
db = create_vdb_conn()
tbl = db.open_table("cases")
role = ctx_session_role.get()
@@ -773,11 +769,11 @@ async def run_open_case_action(
suppression=suppression,
tags=tags,
)
- custom_logger.info(f"Sinking case: {case = }")
+ logger.info(f"Sinking case: {case = }")
try:
await asyncio.to_thread(tbl.add, [case.flatten()])
except Exception as e:
- custom_logger.error("Failed to add case to LanceDB.", exc_info=e)
+ logger.error("Failed to add case to LanceDB.", exc_info=e)
raise
return {"output": case.model_dump(), "output_type": "dict"}
@@ -788,12 +784,12 @@ async def run_integration_action(
params: dict[str, Any] | None = None,
# Common
action_run_kwargs: dict[str, Any] | None = None,
- custom_logger: logging.Logger = logger,
) -> dict[str, Any]:
"""Run an integration action."""
- custom_logger.debug("Perform integration action")
- custom_logger.debug(f"{qualname = }")
- custom_logger.debug(f"{params = }")
+ logger = _get_logger()
+ logger.debug("Perform integration action")
+ logger.debug(f"{qualname = }")
+ logger.debug(f"{params = }")
params = params or {}
@@ -818,7 +814,6 @@ async def run_action(
title: str,
action_trail: dict[str, ActionRunResult],
action_run_kwargs: dict[str, Any] | None = None,
- custom_logger: logging.Logger = logger,
**action_kwargs: Any,
) -> ActionRunResult:
"""Run an action.
@@ -837,20 +832,21 @@ async def run_action(
- transform: Apply a transformation to the data.
"""
- custom_logger.debug(f"{"*" * 10} Running action {"*" * 10}")
- custom_logger.debug(f"{key = }")
- custom_logger.debug(f"{title = }")
- custom_logger.debug(f"{type = }")
- custom_logger.debug(f"{action_run_kwargs = }")
- custom_logger.debug(f"{action_kwargs = }")
- custom_logger.debug(f"{"*" * 20}")
+ logger = _get_logger()
+ logger.debug(f"{"*" * 10} Running action {"*" * 10}")
+ logger.debug(f"{key = }")
+ logger.debug(f"{title = }")
+ logger.debug(f"{type = }")
+ logger.debug(f"{action_run_kwargs = }")
+ logger.debug(f"{action_kwargs = }")
+ logger.debug(f"{"*" * 20}")
action_runner = _ACTION_RUNNER_FACTORY[type]
action_trail_json = {
result.action_slug: result.output for result in action_trail.values()
}
- custom_logger.debug(f"Before template eval: {action_trail_json = }")
+ logger.debug(f"Before template eval: {action_trail_json = }")
action_kwargs_with_secrets = await evaluate_templated_secrets(
templated_fields=action_kwargs
)
@@ -867,19 +863,18 @@ async def run_action(
action_run_id=action_run_id, workflow_id=workflow_id
)
- custom_logger.debug(f"{processed_action_kwargs = }")
+ logger.debug(f"{processed_action_kwargs = }")
try:
# The return value from each action runner call should be more or less what
# the user can expect to see in the action trail. This makes it very clear
# what the action is doing and what the output is.
output = await action_runner(
- custom_logger=custom_logger,
action_run_kwargs=action_run_kwargs,
**processed_action_kwargs,
)
except Exception as e:
- custom_logger.error(f"Error running action {title} with key {key}.", exc_info=e)
+ logger.error(f"Error running action {title} with key {key}.", exc_info=e)
raise
# Leave dunder keys inside as a form of execution context
diff --git a/tracecat/runner/app.py b/tracecat/runner/app.py
index 2e68764c7..0df7bd80f 100644
--- a/tracecat/runner/app.py
+++ b/tracecat/runner/app.py
@@ -52,7 +52,12 @@
from tracecat.auth import AuthenticatedAPIClient, Role, authenticate_service
from tracecat.config import TRACECAT__API_URL, TRACECAT__APP_ENV
-from tracecat.contexts import ctx_mq_channel_pool, ctx_session_role, ctx_workflow
+from tracecat.contexts import (
+ ctx_logger,
+ ctx_mq_channel_pool,
+ ctx_session_role,
+ ctx_workflow,
+)
from tracecat.logger import standard_logger
from tracecat.messaging import use_channel_pool
from tracecat.runner.actions import (
@@ -381,13 +386,14 @@ async def run_workflow(
"""
# TODO: Move some of these into ContextVars
workflow_run_id = uuid4().hex
+ run_logger = standard_logger(f"wfr-{workflow_run_id}")
+ ctx_logger.set(run_logger)
try:
await emit_create_workflow_run_event(
workflow_id=workflow_id, workflow_run_id=workflow_run_id
)
- run_logger = standard_logger(f"wfr-{workflow_run_id}")
workflow = await get_workflow(workflow_id)
- logger.info(f"Set workflow context for user {workflow.owner_id}")
+ run_logger.info(f"Set workflow context for user {workflow.owner_id}")
ctx_workflow.set(workflow)
# Initial state
@@ -436,7 +442,6 @@ async def run_workflow(
running_jobs_store=running_jobs_store,
action_result_store=action_result_store,
action_run_status_store=action_run_status_store,
- custom_logger=run_logger,
pending_timeout=120,
)
)
From 591ccf4f220166fcece43dde07e5d145dec64145 Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Thu, 2 May 2024 16:23:02 +0100
Subject: [PATCH 16/41] refactor(engine): Rename subclass to variant
---
tracecat/runner/actions.py | 6 +++---
tracecat/runner/llm.py | 2 +-
tracecat/runner/workflows.py | 4 ++--
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/tracecat/runner/actions.py b/tracecat/runner/actions.py
index 9e2f03915..9bbffe797 100644
--- a/tracecat/runner/actions.py
+++ b/tracecat/runner/actions.py
@@ -61,7 +61,7 @@
)
from tracecat.runner.llm import (
TaskFields,
- TaskFieldsSubclass,
+ TaskFieldsVariant,
generate_pydantic_json_response_schema,
get_system_context,
)
@@ -280,7 +280,7 @@ class LLMAction(Action):
message: str
# Discriminated union with str discriminators
# https://docs.pydantic.dev/latest/concepts/unions/#discriminated-unions-with-str-discriminators
- task_fields: TaskFieldsSubclass = Field(..., discriminator="type")
+ task_fields: TaskFieldsVariant = Field(..., discriminator="type")
system_context: str | None = None
model: ModelType = DEFAULT_MODEL_TYPE
response_schema: dict[str, Any] | None = None
@@ -347,7 +347,7 @@ def namespace(self) -> str:
ActionTrail = dict[str, ActionRunResult]
-ActionSubclass = (
+ActionVariant = (
WebhookAction
| HTTPRequestAction
| ConditionAction
diff --git a/tracecat/runner/llm.py b/tracecat/runner/llm.py
index 6fb05f960..716476974 100644
--- a/tracecat/runner/llm.py
+++ b/tracecat/runner/llm.py
@@ -62,7 +62,7 @@ class EnrichTaskFields(TaskFields):
type: Literal["llm.enrich"] = Field("llm.enrich", frozen=True)
-TaskFieldsSubclass = (
+TaskFieldsVariant = (
TranslateTaskFields
| ExtractTaskFields
| LabelTaskFields
diff --git a/tracecat/runner/workflows.py b/tracecat/runner/workflows.py
index 81ef0b251..d4cdc5c51 100644
--- a/tracecat/runner/workflows.py
+++ b/tracecat/runner/workflows.py
@@ -7,7 +7,7 @@
from tracecat.logger import standard_logger
from tracecat.runner.actions import (
Action,
- ActionSubclass,
+ ActionVariant,
)
from tracecat.types.api import (
ActionResponse,
@@ -28,7 +28,7 @@ class Workflow(BaseModel):
title: str
status: Literal["online", "offline"]
adj_list: dict[str, list[str]]
- actions: dict[str, ActionSubclass]
+ actions: dict[str, ActionVariant]
owner_id: str
@cached_property
From 850fe8d507afbb9cdb209a252d2a709c186e8860 Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Fri, 3 May 2024 12:09:28 -0700
Subject: [PATCH 17/41] docs: Update installation docs
---
docs/installation.mdx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/installation.mdx b/docs/installation.mdx
index b8899cee4..878b5535f 100644
--- a/docs/installation.mdx
+++ b/docs/installation.mdx
@@ -204,6 +204,8 @@ CLERK_SECRET_KEY=...
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=...
```
+Finally, you will need to add custom claims to your sessions - follow the steps in [this Clerk blog post](https://clerk.com/blog/add-onboarding-flow-for-your-application-with-clerk#add-custom-claims-to-your-session-token).
+
## Troubldshooting
### Docker networking
From e41110666ade351533a64b9fcdad899374f19bb4 Mon Sep 17 00:00:00 2001
From: Christopher Lo <46541035+topher-lo@users.noreply.github.com>
Date: Fri, 3 May 2024 21:44:30 +0000
Subject: [PATCH 18/41] feat(engine): Support cases s3 backend
---
tracecat/db.py | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/tracecat/db.py b/tracecat/db.py
index 02675dcdf..abaf8c06c 100644
--- a/tracecat/db.py
+++ b/tracecat/db.py
@@ -345,7 +345,10 @@ def create_db_engine() -> Engine:
def create_vdb_conn() -> lancedb.DBConnection:
- db = lancedb.connect(STORAGE_PATH / "vector.db")
+ if os.environ.get("LANCEDB__S3_STORAGE_PATH") is None:
+ db = lancedb.connect(STORAGE_PATH / "vector.db")
+ else:
+ db = lancedb.connect(os.environ["LANCEDB__S3_STORAGE_PATH"])
return db
From ef4faf59d05029d5c31bd122b27af9cd75f1a3c2 Mon Sep 17 00:00:00 2001
From: Christopher Lo <46541035+topher-lo@users.noreply.github.com>
Date: Fri, 3 May 2024 21:57:07 +0000
Subject: [PATCH 19/41] docs: Upload new screens
---
img/cases.gif | Bin 0 -> 2759075 bytes
img/console.gif | Bin 0 -> 1931335 bytes
img/labelling.gif | Bin 900258 -> 0 bytes
img/workflow.png | Bin 0 -> 641176 bytes
4 files changed, 0 insertions(+), 0 deletions(-)
create mode 100644 img/cases.gif
create mode 100644 img/console.gif
delete mode 100644 img/labelling.gif
create mode 100644 img/workflow.png
diff --git a/img/cases.gif b/img/cases.gif
new file mode 100644
index 0000000000000000000000000000000000000000..86ce8b0e4a53fe2a0d5f4cadcae72b219c7d4b5f
GIT binary patch
literal 2759075
zcmeF&`9D3s1zI%tRzJ`W|rlzLW4ijx19i2V8y88P23~cm`3=K_on34Qg(965T?#>vUa#l^+V{e=6~V<%6Z^ziWT
zJmcYY`t)hLQ>QO^c}KbWTnO=Pi#}^-a_-zY|Fh>V7@AzzQ63o(aUp=4ddbP^^5x6)
zpesQM0l_xcgG2ud&Iq`A^=fFy_0Z7JFfGQ78#f|$+>DC2edFfMn^7M2QBhGbv2n3l
zw_+WV;^O1tQZo}K3X^v{NJ`b#OiN3O`}X6z^~%B0=T
zdQpP`HBqt{)FE8)GrtE#H1tE->yXl!{t-cnOjQ@f*~t+ubduCA`$qxEGY=N0wn
ztHzdwhKARi)~2SW=A?w?Hv^o9?3NvGKDKuCwe9HX?(FRBI)An+nA^4E{b)~5&)X0E
zy%qhvb#?FFz3UsE9Xn1&dWOQWo^VHb$
zyJI!gW8Ix&V`Jl=KYw2SGBGj1slCzgB*(
z{^jrZz4mLz%G&R#KfiaZ{#jXBS)Kd4y0W^uw!X&RSnKEhNlyHe|KQK~=AS=*)~B}q
ze*5$H@869bTicuCqni_-H#ax8+W&3!zTVo}+Mb)<-roNA@1LORi9jF#1b~tNbJWK9
zIK_%;qrZm;0qx{L=`R!p0sa9x{|m?eVv_LxLh}DY@_$1}{*NSNCnrb&u#A$kW3yUf
zkh`=zyV;N067VYad3M#0J5r?fT^R4KF6c@lP?F^iJu7^3SL1lO=bL9m4jCI~dh!lE
zFMgM668v@i&GVA?4@og-dHb4DZnoWBty4WUWdkMD0{aK{hF_a*EOg)d)KgnA{FK&~
zEbmZPIr`jhp#0R^x+kAr_)mTR=Ki$mb3@qgubKB3+
zD`t63I8^Ff>7(Ntyj?i+6Ze9Tny-e8WLSPD#H1z3)-6sS+BP4+rtIoCK6T4P!I;Is
zuPx4`k>E)@L(zG^**mYULU6bC8hx3)E7s!UVeIUx*_U{@UmRz>DHJB2L5n|h$8y&E
zu9|h7QrWpn(=}h_bLpL5)ajo+DMyo}WW?`MR&yw`_w9N#7g_B|`*L#<)q9*RR4+Ln
zyZ?H5_^93L%E0?a30gI{JHeV?zm$N#1Pe!sk0ML&Plv1cMwP)eZkayt5)3Puf9Im#
zKdGNDzgSgksakGelfVCwNrmXhk&2chyYE@@^gJf=S+;MTEcnhB$}9)ZSRZBIx&PbI
zY@0B1=)mAw8`E;wZ&yXl(tb^?GO>GF0k_*_e15)OpRc&w^k&6~V0|kxC$L~|QgUw-
za(95xJ`baiQN!QDZ&O>vW-zG+-T&%Tecc}14Z1vC0|{(URkZ#d@;oBPpW<B<|dZ2KbR=uYIIs!y+BOec21xSRNU*_e}msHTYTE*ML*T;2-2
zI`BgHXttiz+b8uV?}-K1tbFb7t{I=Xd;ISR?dQ7OC?}~+NIymUhf(Jn%ZUu5X8yCc
zUn?s5_J3>KO7Ar-u4pfvvYyE|w0!mRD1P7`{NdG~;bVsQW%OW210~8M`Mpcz!PivY
z`fOA*7%B&V#rv5KcTBhB!(`LOa&)iQM*KOBLPIr(fUvF{+x|d5Wls%xW9$2$THT+5
zv(`gB&gQ?)mqi4WUC%04{HbFoJMgaa@&+mYjntyqLf|bGQK7-oM%$9$kQwQI{>Wu=
zd~RqM^9q0ch#7MbJ9dw4I$aOfCuGXjq0R?%^051w^bBI5!eUj62pvH;C!xuOEN4-f
zGASgL9>Mg92TgmX(^Z6h0s@pa_;MrWaI$~chZN=Uckf(7$n#9DNhi1SbAX226+1{s
zQGvY$vpU5$HXyE-AGbI1QJ3Y~9m%E~5poX9B;n(2C1m^kUr2IG+~QB>`g$%&!LFHl
zNdKlt?`bO?zdPG57Zv9pch{=EWZFyn2wz)FFxmK!NZeA+82Hw;*9p%6gctxs3ILR;
zsGl~fag)TZe>yh8fy@GiQ7?>whMe2SX#
zOPr5fD=wVTZo21cW0*nD9{X<|nqK$xgXJF!CE(BTBE1C(rkqyK6rns_l6MtmSvV{!
z+FD9jkYU+QvDPGKDcT!nxM(*V`wJPE+T(I~OvGsgq
zwEZl-V^6QrZQsf>0Ra{L#-7VUdFgEux0qk*LQblp4lNt$#THy54`~@WIR?zUow}6s
z;g*8^JBCz!yOJ;)B1R)f>my_K?nd=E;Da6r-@0!Gld4U;U@vEX_PAO9?8jxRRaqDF
zN%Gp1Xqdc9FX!O#`IyS-U@e%Y38jn62{K)fl{H#KyrK)#cHzIG)_C@`$luQTu@
zXI%5O?-p2Uk}dW*{S)zmVZalF1ILHMl4~8li}PC_&a&ii&}ONX%q!S29pj
zI!pLzY~^LZlFcLl*bB61{A`#olx>Bz2f#k;eG06l1kiSgxx*Wp@GLrSuPrlLxdwnX
zEOx08X%HoA0PA$Fq-cxFQ{LJo@I>VmYI!iC)d~UFdxF~v(xi7~m6D^&j(NFOf>lCZ
zLuCK@6w<%MOH)sa%v_ZH^~%+W-Jv2BM~v5H(NaR0Ph@9laIsKO+C?!Xq3@*$1PVHiN0I&d=vJ`#*K5fqE{3>&E-B#bE1RKHi7UlrH-~5!~PGtlg;FrdVEs_u#Ap<$fe1kdixpIlM
zvX1YG2Xk{AY?cOS
zSdyp{C*D4eR6)R{rp#rmSs<7C49or8_()YS5V?quH{vZs#L_SyQoIJ%Uo^vqRG<(l
z1B{PI#B=^$PO6buu^q)}&a%WHrE#~hqV*^7?tu6n;#Uwov0A3^vONF@uSCH$xK)?rU!ue&cZ%)#d&ib
zSa`TM2eVB?B(QOBsAxN+TQBXD7Z1J3!Mq^^O;b^Rc-#UHQ@{}7^D(_N;eI|Q<2R5Sdi%I$oT!zR`C!`YaQX$kZAmxV*A*adp8*2vlIsY?uwlEf@qY
z;)8w<5YrODku)oYLZS))Ih#xs!x-GIUw56
zNAB&0cAadDBw0o+IJW?44_Qs5n!&vHVuO&Kv%igL;?TU3IqkMjm)K^usoC;6Oqfiv&~Gd0PMS2
z$(tP7U2a32j$!EB1
zHl`sPLS+Vz4JT$!W5oFpDpVX#8H?r!1v4_U(joS2+&5>83MopJflK6q%s60G{B2zx
z*o+QD({i^309u@&?
zHbk7@di!2XrW5+LGb)>yJ_3z3OgGAQMZRHU^5Ss|EXDW5xCF-8@a}|F7OW9}B$y4m
zP78FR!ZLaG1yooa5n%(MQW@t`S+GzljLk*5yQ1t_*j_g5sa=A;wGN++KFLLvxuR29
zI1V+0#oEK-+hjIIzgqm
z)a|}uX~S#=fvjKwsB;JcfHGi$8woc&)Z(@|2ss8$jR4IW1|?9DQf#miLnwrfQpE$B
z1f(JdOd#$_lL8U&;9e#eK>|ZLPbNe_QNuW2JUEU7F5-f82*7pHUFL42H=$OZfHeaE
zc|06T1cM3C3KFnlVAai{mA{~2Y-v!}*xRGV4ZDHpbHeq{j@EGQdA)p$09noqSeO8?
zO`0%^1-$3QFVKlh{rk8N#?E+<{IvWe9|UuSX9Iv$&ErvM-Zmkx-Bc2k0IJ|2&{Qay
z1D0ba^8`+)sU(~@8`RJnIV2seUh;Et;q;_T$q^aDi<(Bc>HDji57#);z(sjv$xsGwC6B!P{N9>U23
zsD8%1dseENwuzhzhf3Lqi!Sk)!H{P+S!c
zw!njNsHhM?$b*VIOGR$8uzB&wlK^gv;qQe%SH*%=8X$%Nm{UEf<9FHNJ}Cht3^1e93d8~Fqba;-~K>DbC0ZOu**onwt#*%J6hAl6J+Qr
z$@+-(#+x{kAd_^!(AC<3;kU^J6#y`Ie~e_RS-}ObL@GLxrOaX=ms!|b?4Gfalzu8Q
zpO9t;OBE8KLta#@qfkr^qL+NWvOWoLV{aqj_GFBk6a
ziqVI`{F9L0EbKT1{v<)DV2$2TePV%)N#x&MDVp8%4oH)RFyVnu$!1`=7MX%*5R+e)OM=KU5fD1^BoXjn0emLf22=iH
z^T}B%T7(2MqD7wN!Bpvh22D4Y1UR7gG&4X6Oi%+G6v6>j;X!6}6oTfrjt8At1?jU<
zvK(+08DsMar^tfoaLQD;GzU@8-}8ZTB!mjXmnVcaVq^xhp$M*n5*6k)ic?}kQvx8J
zoCftz#*u_q!8FXyk_W*?{AV7I2GFVMHlm>~E*_KTYK_qi-mRZ`
zinup5OoWD^tX433Cp??4_YaumF{ycXm4&f1p=%6K2o)a5wHWQ+sV2Y%T;Rz#I1AjW
zc~+CmIQtEXA_(w@a#_&Ol8*T^7zZw}PDomzfE#ECQ!Y-0hc2Q$)TK^-T8$Lv?W-xU
z4WUNW5W&3w_^C5o@CPGKozy3SIaG+lNqGM|hfX|rnTOJ)qNfg;DbXBg$)_V1SS6Pz-7h=R
zRcIMm(eOkgO^AqO`DPS^>FHXuQi_&0;O0D*CK4<4*fkBFoTYvMshJPuNzS3cD}
z?nOM1&qc26ySj~!_a!1~=*ZWq-7jQPUpLA&-aw{aLF)d)e8yM33GfOfAx2lQi3Ko|
z-5uV0kQ=1RCZ>=F01E_s{pfd_8L&pQle8Ms{LlMn`uUnxGz
zL?}0*!}0J`K1NRaP8XOdL;0ksCyV+PGioJ_e87ziDZDU<3#B8w>9@D(h}uKd71X$4
z#`|mZgl+mv)hx={6J1vGSmRq}83&=k1MBc$&%{92si4m)cyR`D{}G5T0cyhq40&J^
zHUxcGcn$;peH>y>tJ7ftCmCQrIF7>xp-EtzDkOe>ep|o;6KIi!^zdd9pi6-0Ft5#1
zK};?V%K#e?Aa_eGv4q29<^$=^;IP*?5qv%u3r=9~bO=CL=5uf3BBeq#E
z1Cp=`J~V#7*o8{N(lnj$fB|5yL8S>6a(JTuA-Fl;V{xkVdvO7HgZ42h>41-|T4?(I
zkhqHGk2o7f#Jy(+iU>e0>a}_Cy=ErJr<9X*1+h03E+`3O4};qc!D)SY1`MPJ!*81e
ziJm_-iw8LeXr71=Cg2OQL_sxp_}x+*frANULpF)vP5>iL^J)fAY4^ai08M++!<b3rM>v_bk)+1(Q_BGwxtVVj0t2Q;KeR^?Ft0XcBh#^5IU=|ilN)V^1|L6&2)
zzQNa*V~FyWNAf2(Y8%>9hKvIpELSj?(1-QD(_@2LK01#1kyYHK3)vThpO!dowrD{v
zij#vn$G)V_Z8RK7;V3OYMxn~dRln2+e(i-$yO9pYb2*zyMmNzGIP2^)dI5>pTe1
z$CJj0z%p0`I>wumK^e9I@F{taU-h$kvVL|p@iH4I5Z8&Acu+C=+g!ut
zW&ynMXQb#TSOnW;k^dxGplghv;%D=?>a<1&^FV1(kpi%T7?AM5rbcEKU)=Wwv
zVK>eU$T*XIF`3>WuDM~QntJm~X$L0oRN15Suw*1e;nT>sFD1c#cY1Q1dzH@LJtF+d
z_wMEBhR(cGo-en`VK4TZlOuR#-mxN7!p&Gw5_iZ>%p;kUXj^W=5_1==FHA?f)?XEN
zw;h>HM{B*I3>6Z=UI9v;|J@Zj+pdS_*!hUf5)yVBJt8cq%^LHA;t2VFZVfe_6Of;m
zy^E&rOL$#VK;GKlC2_$~?m@_mTp))mC?twnJGY3(v063)1Btdn1m$6Vy
zzvSOnxYM&5k9G|e$TzRs$f^8JSqe{-R6&dQa~>0|3m@B|_rax?Xb89U$JlFrF-D<<
z*aII`aDFVvK3B)2K2@@(w{(i4GM54A=15M{K$;lGG+Zj6a7qcJ+#UrMvL@lT@vR!?
z5ir$Hf}0+(SLXc+)7IXv0e2@eu0JOgEwc7R;lqeoj}JPZW;86KXG7!FG_c&SnV
z^=t69$P!GfX^CSqR-I;H%>#582+TYiCnKfqqi(_g3)9OeIUGrqQUH$kzAffVgL9QDyYLN^JL#YV$sF;QN3Fj#;91YG@CAon0^gLFs~l$tJbx-7xc&k=lktndEn
zMt)$va*V^F0>$f8j)brh1n<6Qx8(QS)=P%_%vvD|I(i6D23`v(E#zJ8IAYjJ6FyC{
zLfLD`+nQIudW{lC8}T8EB=3~L&nx!%oG6J!nw`q6I0=W+7U8hMv`9HH!hqFk_;_D}
zOzCauBBYaAC`0H9wFT~#*u|;x$EbZIp&EAPe_|F55}4HHy3My{I-P{e>VTxT*=<^1
zm0+^7Q+UC>7_k5=)!={@xCcM`Usg|mj`3l{X*NCi$Gzbd-iI^cab8Zp|cyEvEz%i2F5e-S%9u8oo;I90)N)ocb@^G{QV3j
zcWp+t$>ff~)*#NqJg%x4usyJd7V`I-=}oVByC?q<&S&w4^pcpgReJI&jo`w_MvT}5G!nAa8F<6Y+0
zR1_{<@;s)s&muNNW%th(xd`3Kph~$MZL7e82hdi4m{7w)aq&j~9j_3UH?;KrAqI&EH5J_`?JVus#|K?4otcrY8?jNef_C?o
zo%(RY!(fDIl<_qgw3G{PNlY!n=T{)E{Y6VGzpR^nd9gKd*z8fYtVFjI=JoP^9S{cB
zp5AV6Ruglp@q^)=8|;WA;W*R0Ck|yo=Bjbm>%`+M#Q=2!T2d(n&TS=Uy31>ND%yK0
zm#piK=Q-wexfOOi6?vvS!cQSJrP^mO3DM@Jo3I0|9s-vJ=*F@JWCPIZqGl5dSSn-c7@9>+J!%-*Kl)
zQ{!TLxnl1=%;uKwdX@kCrETS#ND^EoCyP4(mS*Vq&gQ
z33y_CQ#HC(LVH@R`(|}IE|K6q^uC4IzQv5Ai{bH|z6DiVvRr82Pj9#{zwamXJzwk?
zztAY>4BVgEw{rMsP+0tzkoSK=j@C9FY?6Byp;HsgR`$1x-}a3^9|redzZIite}ykM
zZNP=Rw*NjZS!Pa7^+P=6CY)$H(lpgEXO7sA>u{4XvyscbLgC_IAMk_rGa+WH29CR4
zKjk<(HqNp-KfryM@ezz$M+!d3)P9id_#ijgE5A3s=dAt9K^T)e2S1v_ZxE8Wya6oCRh2NQ}dn22O6F~Z|D{@
zEhk>2>JZ}wOfm;h{-i6+X47X!R0jtvwtIN|!)>|MB=L_{Y9GmlA1O8;tzACaoOPxc
zCit$y{7Hu`i$2;9qB8e!HR2NJc}_>;+NCQBOvMKsSNk<*IxjJ*rz#N(7f4Q52i;&F
zCd#p&M5_+fe)O<$ngrnH8_ccWgQsCbH1Q#CwV^YHLq0Y`zT1NbT5G-92G75C>H5~s
ztt^nfyK~#N{nkg4V+Sv-4ppWif~iii1a5s}5Bep1nEg`W>~QGS;cIcjVVT3%i-vF1
z4*%ETCNG|Go))if!P$%+Vhj#k$$&?mjo-E&iFFx?J3A78btEBfBr$U&$z??FdkGuW
z;ja3Cs!@B)ZpAt9vup77&>gkWPt%e?u_^v>y>kCK`jTEO$ByP?j@~O8y$5q=wzkl+2S(ZvF+M=b-XKXygPIJP0@Hy?fBb{@!rAlcXQ)?tK;wE
zJUhide^5KsqULFRWW3+y^WfRfLsvf!$9*2jJjJ={Nx3>c+VT0ryW!7spC?v73npO`
zJn@MswTWrNi5Z)TS(k~qvlH`Iz2v@sTF9LEQZ(_kcH)Rn{L^L#EhQ$jK0y#KHC|Af$NOnxf!F-
z8RPgFldKuj;+g$*GiIGL=0h_U^D~xfGY8_}fqe{#zoy=)E7;7hr&>e?SvRmEvid{!+ev|LD>GymhuoM^8e$AQ?@mSj(&vdqK}IwJ4={>uP&0Z0KD0>)
zk)n_siIPhoz=R<(0tEO-=o2gs9737#B?v7LAJ0CKAa~7bYYw-@2_eXB$%z&y`O3
zA?;L;iG{nN3^5L3=K{5@!#tk-7M8595p}sm
z;uGq!&Y$48x-6iyv~9$BFNgsexwo^^?$lkIUm10Y7Arm)XHEx!x;3p3@o)T+G-Bdx
z8F2+UG5TphaM-{BVT?*GH4$Y+
z6IWi6!+w>pe;q%#7J2CofZSQ&f;3s6qDufREZ#6XN~!-c;1?BPS1(eE5(NPKVggN(
zAy0zHak9+A;x8YH(jSSI?TYF?x^{i9pRgEo?nfy=luhl1SX{d#o4D#?SAQEEb?hnP
z?YRqWc#FXw8WQX>pOQa<#O8%+Zaf(oqs595p}0hxL4;qHCjac;m_5RV%HHCx}h9Ppz^
z&fa=cvekV6diMGChT(A2qh%lOfHfJ>T*>VZySMv|w+9Yxe>C3yh0lgpN`n~NBiY-d
zCEH^!wm)@kj}J!(#uWik#_it8E#CQ=jGh>^L^&POzgf3`bLaofU;DR^@NfPpSYtgR
z^((@FehJ3?_kH2tk3au@A_RO%!SZgwvLXXVg9Bp!R-gLBjl#4_qt~+qe@g@#F9e%i
zg01&Ab(qmcKw{V`e{f*;_qM25w4{E(B44RJQFOOc0D*A$K-jv`m8HdHm2Rf;!E_z}
zU#dMBT5it*zWh?_&Cx&KU+Mo_y)SS7wO>nLe(&b8$O)HHhu`lK({aht5B$2K`LWcg
z#A#6Z$Z3m$GL0+Wzpj3BgF!-lRWGdRjMZEieiry`O?SNh%0mB>&4I<@LgzofzJGg>
z>{4m#5TzTiPIMDgn{L!T|F*6-+nv7qcvZk({rO)1<*(b{{svESPHbi>Gu90*v?ss4
zaOwMo;nK*n^8;0ZvbyJsSl51sYTY2(z><#R+$?C-ou7KL_g((6WxBH1e`5Z}XEC&x
z*6T9A$<15?&=fu{C9RY}3Y(tC6&g40|GTpOYhmE&Wr1a6_F#e2=;NXT;wJ+>oI390
z*K$zFnr50|zb@mLIGco4xR4d+B+3`sbMGR7dx5X~SQEfyP26=H92!j(XYUWJp5Qee{6mfkhQxnkeb8H9p06uMi
zKtIe1@-QNOBhPY@*`{qdRudCUef3qP?_I$6w_*OJrH4!=ecI+n_u5b&RNwPiAIJ>i(2zGu1MJ52Gm<0`zXp5FvH$6!x
zf%J&6+idnxhcL67io)t|J!?~Ej!=7$XTzq+YU;>GFnCfSdktC3qQ)qyjZ{+}Z}yFQ
zT5Q(@*&U$H#Mo$!cKwdH4MxSj|1
z(Y=q5kx0mvNqr0dEcfD`a3vw198tEO4aS}nt5`_FXoN)z2c5M4G*SFSO9v#Htw9#|
z(?i5&wn&h>+tlWH$?~dTvHBT^vTzifx`o#g*T;dlfOeo%hm%@8&LI7s2@7*l`*5+_-sEsYT}>mUFJ24pVa<99kXNN-Ndyis+GZpL&PM8i$^
z7d>%t5NT@i^`O)`Cr81`TWq^jq4H>1nsa`7rX^XeGJf7(;QHzq`g1Nt!Re)zdwze!
ztJ$-A{Y@*Jg{^a-UlNRx3AbHEgEQMJC-!DBEi~4ZGiNtUIeKcaKDwrBG3S
zD}xc
zZo%YxsHh^Br(!nzR>NPU1bb$b2){I6d!FF<%JsceR3OxHLkWAJ286{eb!l+9aB91~
zB9jy&pAOP?4_OnnO6JLBkPyC{)yhG!gKFj?@u6#etjKy>ajl>PqsCRaOlGGempJP4
z4=i%;NJsTXb<&HmtIxIpm_}hc!fa(mI-DP;m`p;P-s!P#ug4R5cnOzRX7HVhU53!0
zTaoKv(b8ADBY#&+Zq7b;kNJ*JCON|Q;uAHUx*^0Z4#G!uQ8KjDTC^(~6H*mLrFlltvK%RvZz^yY5{YyGUi0;XNNiT}W*p*QscEIWRPo
z8Vx8fXgt*~xp5X5vs+7PK{?~GVkj4^iwDJBtf8P>y4&^&JHp&ZWb{vYtVS|`b_=Lb
zj2me=dN0#d^C$tV^canHQ^
zxKB+1l4B=;i3d2MlnU7mHmvUwvCJcyOJ^SE@}bW*^$<#SE5gdL(S)AzSbOsVH2)J=
z+Ep(>i&3js`prvWp80xPzi~>~?Wko10cKWHAS?PLItgo@aP5;7`e=_lElb{BGs9ch
zyBi{=iizXYc?&x++U42SN@?Ui^MxoVHeViWi2?<;inJm;xp27=2B5=l1^F@JmDycT
zZ)WTDSVp`&k^wc4BB4(b0GV)tklzSc7|hoL)$qPa5lF%;VwR~+i}}FC=c`$acp@`O
z$c%1doXkscNwcBVEEtQ^N#;Ohv`;r7lZ@3nYzi`TMmB;&=t&U(gIo>
zGSyj0gk265KkA8DvE&%~p-Y8oTUA`NJBIHLWAW?Q8=pI`g5G%xAba>hFa8
zN-Zjzs-a;^6m&BkCgyYkb}XY66IvXNL>w7T_}wh{S8}-BTTZ(2h!oZ4Ugevd#H4ob
zJTW2<+=G<}%NutFf%hM646%Xc9fYUvIr76E(bOzwba<;O9p_(X9fMF#TZL5u!I=F
z78%{T3Xvv7Nh0mUxvqNe9yTW78IPhY4QQfAN8d?x$Z@R>u`wZzgto~F(ubztm*>>kqXu9w;+
zvysU;-N_tek{p0;>^3P!nr0<_sy-nconB<&YSDXNPeTx8#DSO-Ao}adu;b>bs~40xTj3O?e8^L0uRna{N(1uzGFK+FZLgTkmh+TxMr-`7Xp?c}
zz2R-9A0awaD}{^?d9VmUrtv)U%%
z{8((AY|O+_lG4k{-pPeUYYWl-kU(3#g`$fT?KOxzH_8avQlr-<>eprtj@hm1doC^t
z8P}rFYh@M-lF!&xDfUKxsOZ#0(Gi1!t4l>N@Im*|1h+f{t*L8woajXzkpK3*b5<>C
zi!pNLJrZ22K07Nlp|h*bEn5Ui=t_(t)2g2#0Dr3;Ryt#|N{uRd2fDUOQKmr^ouiC+
zl=F^WvqwIik{+@ZBV(o7)a_dIO}*y+Mp^pJYwc82jl)VTHG^Aomd-8mA1M}>qBUK2
zQTwxbcVb*x(GmfzmKq>^q>Sw2q1LH3%h)Kq6lDLQ7R2W$i4e+~g_ks+)mS#Aqyw_-
zRyZM}WGWVQQF;jC5{(U;kQ?F5y=BxN6wOw{9y|Z(wTe8E
z92Va)r`9HW=G=iCid5mCr8z~x6{I+8x5_1phgnGzz|uJqyTCakOUL)R;K+s^?t#%V
zLv8ZTAkL{auH1n7x}Q|zJm)(>W?XPTEemu-nv$Tz_d*ILlPGGL2a%^mN%Xep&$ZkX2jiyOGS5yL
zf22s6bG2wK63$i+R%7(opB$ZA^lJ|>wMq+*Y874a^>QzWIrlZD-!u78X-PT4yAR=-
zdgt&jAuazleZwPbo*hMlXmj9Uns0kwn6*{uVEFgbtL<%1s+PcZZa*`bWG9FU9VvO12z#>!&`fifz|2gg^P3Y~YNm-LGWHy!cY#
zNKtXcD=n3n3tFcmKa_N82gM4sB1UFwyr`?=+E$gKe_7hq5xbt;|90>rrGLeaQ+J_>
z{BDo94s~8TSwFyTq#{bRJ;FvuOFH0?FRVQo`byOLoOj2o_{wheQpawY;#UWT^0tr;Q}tqJAGEXBx-Z@O!)={@_3x3_xpF#*
z29~-XZJ&HRsypcVWYAR?c8MGo$rOV!j3aeN;-8G%)*a1yGMXG1&yBYHnAX4Z?_iVe
z_yzb!0!6wK6*{gvG5=)ZhwkLslgVFC#0X&XoMRT8Hbc^Tz#LtmvuDPrYQ}QUtZmio
z(LHmPm!@rjjG=(>OM4bV1yu{r;z%J7jSL5+jH)mBd%hM|eXZK_t!~d(b+Rt6Agvu@
zp4b+S2K|_?`tf7W&$X(b|Mu|VPx=3QF^vmA(E?Wy43-krmL0FDTfWP^6`O~=zVc79(rrjIK;@riv3Jw}ANv7u%}_<1(`
zG7)i&jkrlfCa{rdL{v5#^^k}zVWXcCF)!GdW};9RTj)IzJIuyT5``Do!as?}Odi1m
zL_DGzkJA&84B8bc(5V%TZIRJ~X;+K>M2cpA+vW39Y;Vw-YtWxF)e;wfzPzF%VXPw_
zTrHIrBy~Dy%~lUrvV3KoxC^md+j;OEk)q$LCqG;*KdGm%P_6J&Pw`K+qCk&;ct*he
zlG_I%J=S)GmK#XL81*Xab0DTtZH7{@dL(6ib4s5Sh1C;eZvD&fW6AZpHnkZ@29si;
z3faetF(K+(b_cD>w7Gaf^m8a3p|O@s%6Dao)=h|tnZ9`*MCV(KkyMOH-WC0i6jkG2
z$_=v`*JAWSVl0VmDv)3u*%-}&XIg)r84xI>NJ`hg!{*^_YMAHhFZA`>V@x@}EHkc{
z(|&7d?=yZOpb&A-O=_;_hdfjL*`}OJF<;cT~G5g({IGF;PA_xs|FSmNaK!v!JK1p~*56>3F@Ma@c&jKT3?+js8INwgX$
zX4Zw&^^U??vvQ_Pc)83w>G#LbLwH*l7bQO(-r;0N7|oVKKBU<
zcd5H{Jl-mVa&hvinJOeC@7MVm9OC+tXvsytnZsr)A%V~PEi-x!x-);LpFQXau}+3=
zR`;(&$8Ky@+6n0%stja#T?_iJJ+ve}|-vcwO>qY*G@9F
z?=^HgfC###quqjT?kjq7`0JJ6_8Z^2wMJXzdqK+VN8yq-=T6Do>d?EY2MN>ny|pO?QIU(za3hD(76R*a*NuS)plZ;cjV`VXboydRjmHVCLffKMKw?9Ll#1HWZyz#9cnN_RMJ=~MH-USUyL<`1}U`Jk|d3iB=UMcKF{$S
z_owH+j`O;%^ZdOMi750tmlR{^5Z~aem4O(}7i$5CU>le_-6{?I(Rggj{cjsVR+fL&
z5UE+F6`7HK3GsK5@NKj!u3d0!MZ%tW($!*-DbYC0PEUu)hmf=unhSS;3v|Df6iZx
zYNKs%!0RrW9*z4N)Lu}9m?&4Qr@|3i9@14IoVhJzj?W~yw;dEp>OH`|zh~^ZaMV`s
z$Z8f5{whOri(fNx(;Al|m|J8$MUW#~X8Rp3dU?wU^0+xz;GulJv!%$PM?JtldjtiKr`ZJ?Klf%Z+_`D3pG7+HL6&ITlf7l7Rq7Wj^pME?WM0-e>F2f-Da4E
zh73K`Gl;FYiOkDbrF^WnIX;ZzP3)E-?JZ2$s`HU2=(G-Z98=s))0;{nMf`(MXCL!3MP+PA;)zkcvq
zH$woma<;4iXIp&G`L^qm%YhDMn(LDe0!|<8vP!!FZy!0x7^|*>@Xcs%qi*NJ7ho@{($>lSrN_!5-k1Gt
z46P5v`uA~x+{tt%Hp=B_0U
zhdwtc-nPS+rd{M^ib-wnNvc0}IFs9?B_8$%TKlROIA&W)tscCca}lbDR1eF$15bn-
zQE#dbOk2Ujw^g%{iC1iMa9QZ9nN<#zQQ40z2I_)gq|HBp@}^51NTr&6Iu#MqTl90l
zOuyGblR2YpeJl0MR)4W<7=Q>>*(3tQw@}N2Z}eHB0@bFMP09d$5&Ab)M)7$aJ|vH
zpeqU&B?9SwVSAo^*=aNBRtRx#JTNQTIU38&Q*E4@zob3txsY$v=wK=2mJwcTgjG9b
zJk`oo=#M7|&I@6UjX*$;GgH~wvMzJ3{wcYuTRlp6--gRNALjEbRO^jBq?~G%f%z7-
zWAoRqVa@HU-?kGJk<|%f{P1JjL<@x8kcy&$8LO_lv^qeou2Lbj(UTkfHtB%!he)m4
z*IhwXRabIbwI)nHaJ6#qYFB8tY*gdfHCbM(y!ndk?NH*iv)yE3Uebw`QGLNdm{hA#
zu4A8=6#A5vbO!w!D%-Cyk0B#jd6%AJPtCo@P_I~feo4ngaFeNUg+}ed&piqHFL+c?
z_D!1oUX+!9t&1Ri>2)gKzTI31=AAswlCXmM#?z?uw9Tq9fujSgnj5Tu@u1IbUmkq?
z_jmoJ-`*}=rYL3&*@KN*C4*yZKgK6oT$0^NHz`x$SFk0}n{U*>t@dZl@i@a*3{b>Q
zJ%W41HP23zDsPhs@f-zXJn9;bG;;gJI+_ZTnW`SUgsg+LiKyj%_8W55mxnp3MF
z*+{m&!-_=b@N#et8?2%w_q)trneZes^l&`0BaKZCS0+wF4Vo?jC;dU}T#i~=6wPIe
zC@>H0Qr?fE1ia#ib|rm`xAaH88Rlv3Lglu%(=&avYLT}kE3C}P1%=ESkiYpJpJo^#
zhsq+0xI%lLv=T01LuzDIJo$Fq7g&kI=d`aI`fZ2J7R4l`yL_Lsf^OqMU~Vp&@uCQ2
zOac1V&Ppk%^x?xIp_0prnA?Jdrt=h}i)qo-k=P~OD#;N>>gt7dZ2`#vY>rVa4mlH&
zE~19FD8|)7QMm)kApoSXM2$$rJrKZK8v@Y!S}s$v)oO6u4`Qg{Mazy;MTGMQ6;6dx%nsuC9dr}wT7b`=S(3I_C4Ajo{%rKBRdeL~%
z9{Y2>Cj~v0>+!zW+*!}|SYy3<7#zV0OD|9
z2T$EMchbr3X60SEmbRJrMnlaoZ|wm$jG5tvcCWU%lXd
z76J#zl%jGK=Y!G*DFpAE;JoEz8;8l5-Q8vI7)6~|%1}JP_I~<@cv^XlSjXb?z
zytL;lsci5_IQ~xCCFNHvf&3W%bek`gueWsN?o&pPim6QJ%P#V~x*K*+$J$MkuLu~i
zGUiCb;+~Qq4c)~WGyU*`DXv}WFB%|5n*(;FJq1ho!74uVJ;F!V7f;6_N!QH+`fTdL
zq%?|iq;-m&z094i&rG&%>ONfcN(}o?7O5?B*41UToRdARoepoCXt<`Y`#WMj^wC=p
z-J#3Fm;k#*XAD!}F_G|(fMElOOv`e0UM?S>~1P`zemMoUOQZ&ZR
zCA7UvSz1VkUL9Z>aENlzMTPHdrz;a(P308yPhTBjDJALtuF5CA7m-va9(l?)iO#dj
z^adrIK5GygRWfTRd9Wy_t_ZZ@a58LGGhut8N-pkcu1by;aVzWci|C-1Q!7q8cWAIa
zEUu?R$@&)N9d`XV&kMK>1Oie
zgUVm`6L!B+&16CSx|k(qj^9R8;WbueaJN%bUWT8-D~$Y~!=`EL
zS6`+qo0)Bml?hZo5iX2~wb-6COZ%Pgw6l2O=Jwp<(ybA{lurmh`FW-Q_udZ+n&;)=Z0VcWnOrmtOCSPEo3}a44<9o}>oHV2AJGGHtoyV`O
z1=NoO8nJ;UJRngEWEBarWAhhNzB(>R?Ohz*?Y3Cr&B-)k8b&*BnUy
zyg?Cg_`bI>C*6p5Z>*B$b?uH41s?Iq&k&`0`Q)FbyT^_`>Ls2l;X$S&4(E~}0c`1(
zbp1xkncg`muSnR-IVp1+#FLAE+SC`DT=FiI?RUD6u#?~ioBO6}@p{fj>io{xUZYF14hSIrzhbPA|?Q2o#|=eLbIhdZNH#>Njmj#j-PbZElm
z^Xng~rQ?d0J0&FYQ%CFAo#)WvW5|bEzn8TF{PjhOHH0P}!DF=X
zKegmzw4`-4)QYtap42&1+@v?51Ky_|#^@OdXxaVLbJo>&i_t&zP~YdLK1J6cFvcMC
zp+WdhgDBl2aWO}d9v(^kc_dxeFgwOD@1bG-Ps1WzqtY0os}GHC{4}c4J$ftV=-mnP
zjk%*uy2l>H9DDNc*t4IO4^6-Q
zG+oy<`xRsM=b_o|PcuF}Gos@W$6Ex{20>7dC>l$YY$3{S5b=8E%CY9EE#~SQ=GuA|
z`mq*9Efyvl7DPQut5{3B7RwVGmV3^6R&KFYr&_EWT810%%Pra~*)>~-Z&*j^*~G=#
zB(>P2ZrG&j*=EPu=C#=7Z`c;;*_FoHU2UlJ4o_Mf
zo^3dE=^gKjJwDiSd}QPJnBIxW*b~z&C*E(InA3CoEx{X$VFZMs8xpK0sTh`rC&9Eoh0hJ
z770JsE9D-IJ87lw<`(DXSM2KitHp8h(Pp&!-4eI(U+z(*Yu=Nt%B9+4!NFD(o$|_G87(HzB5wb6iui_cToS^m^&BnIXTTT3;Up
z@@f84_Yb=Vn9B=Q2{q#VmEEOX@E9IXY2z?0%yC{PE3eu-r@~36#^2}E{7uUj8Y;v3
z=3?nrFeASF{k+mVa=fmxt`0M63osC9A!g@^q~NpqTpP3nL;6&ZzpgF2Njnvk)=1w$nnKll^zXG
zRE+(DOkShmHYwu6b3eDcL`6a)#ogp3(qlE|;$J>GTkKw)R)s=7Ms48)L#kx8=CJ~w
zs3**~-1$-hzTwxo;drta;96#*whO#H5k%ijZ!pxwrkB(oYABl4?Y@l-1nRsV>8V
zYF!cp%K4<8aD|1iUzGFmqPn}#Fwgw08N4mFE3!JH$)3vHrihY(WTWoL?=0yL{J$g|
z5+0i%&BxvoxyQQBezTHX19wlCQKKOKc@(v&o&ESgeqVPcDkjY$
zHPnc?JE8JR1h~MUOe?Lje^aQIg@VWVCuTY(Mj_J9$fp(J4X29_uFzgzx^a&t&PYFK
z-G8Vh)4_Y~D3}P}VbOk;DwcFhpeYSf%_gtj2y=p>lkUs+;!Us_-b?QHnoKn+xH3n}T3_9jXh_($h&%CsrX`aWUxibb9cWn$;0$7{qi2F1pK^7kaT
zPvW@wgJEYj2QMmBUku3JD(^zHeJA-o!BUOPJrjq#
zCB!5s3m*bR&rIO}LkJ#TP_0_NgMwfd)DhI=x&eyVEBnF*QClxxNgPAZNiNecLmUZf
zd@q(yY#T4=&bOb2L$A`d?
zRaIN8;jP(REVN?)pfJO!EX%^Y%XsWKfIxRiEYZ+#`?x;_M&4gZbSv*2Bk
zPrhQ;IA^@8kUK26`6aKvbB*2PCs%(S=w`8)Rj473jCBxNBnur@b>Zur$_z{TOxL;m
zJGRj;9`y`nAbzKfeHNO*A+7m@N@)9eIY~xWbHjk}zG-@ZQ~zjKG~_Q9e$Tx=$aI`8
zw#g?_@g@;Xxn+CXxRFn6CfRgpR-DFHTSC72XjpiQCBHUXt-n}x3mLKZyG!)IQ}jW@
z3K1Zym0a`Q|7|znm`innvO=tBv;4lPrxk#zrN|G=mMCVhu35-$)yS{nL~&J)_US#Y
z-A!-1`prV*JLYOYc;wLvxP5wEQ5Euajv0pU}Kc|pti!i^p6mcjY
z-%2(en!U61POLQnEk%^#Dj-3MNEbfA1Ig%j)o-_0LOcqhvFrKn`Am&4m}-AY5A8lV
zOdPa;-QtMeP1KB1KKkO8uI{B>gjUU1m*
zuBYttmVvQv8ArMScWmy;KP4ira1oV6?%#Ce89tFL+u9i-g28uVj3YWqd_@eEmyw1~
zm}pqaaBSc8s$M&CIJwT{xQyOW!FSbJ*t8!c3|@y?t+yqOo4Y&&kQ`3O{GcJx=`sUk
z`Bn0H@La@CG6Kt22hk1tZ*sPYENoAV8N`_^kz122{!{BxM^_2aC@yMvK^$Ld+)B3s
z2=Dvf+xRNkC7dmr-1!+GQrm?tPj2SUIxf$u?0N4$1PZ5ZIS$}3EVAhUtz&lS!qrWu
zL`BIKT2@E8c;sId&TYX~4th8})_tY5;qJX_$C8h>BNHh<&L>+Rtux7ZWQaPAaH$d@
zv3=q=;=`mpAf@Fhe7Q8i9@a()5%FID12=+NEMdgn@>td_Rgj40_MJR#ITh7
zYg>}{>h6^KyhsGL%Gb)?oBb}mA|9=*cCF0Vy$Xq~7lHE$Eh&CIwQE9NRf>b7*2E0b
z@S(ZWw_WV8jzvM=dJ7D>0bIsl1q$KB$6<)i`fJ{#z
zs-=h9(_a?1Z~01u$0z=n1D5PJl^e1
zivR}W!Tn0LKVLkc;}3KMdP>GxU;116#cWQ`PrMB8$z`|Xetqn4@rgmpcDD1^(8yc&
z^Bc3*5A%d{iDlcLjP%}|ydLw>-3q0M`m&f-zr1lMF2wKjV6UY0VT*@;*RAO(^36XF
zP5M~Vg?`28!~HGEzB9iTPM#^1efhGRxAHhIyf1NA3co_H`^uZ&`1-c9a4*f$i@G#=
z-<5gD%UIM}dY9&6s}{0R{6+=%&wMZ2h+?eE^!oWMkRBUfkw8h4ON097{%J~pD!n8{
zL5(Oo3Vr&YZO8IuTV-7A6<+&9+ewPJ`!gc?e*p*2=Z4MKoL)`JU>Y`NQI1Re%2gcD
z$STDL1}Au5hAI&RAQKU{-lcyyFI&(nr{c?rkrnHV{XDH18`1{P_!~lTG%IC39Fhqs
zaVZpEKi*P#gWcz`NSBUN@~{4Vqh#DAzq}87zLERtlj$PU`K7Y!FtK0eqsX?HCM9uh
zynMsT)lM`fi|^9e*b?)8e9VrhfYN_qO_2IWiCqxc_fYYZW*mlEP?SI^5p8;*U-!Ab
zm5+skWDEn67sqhrFBG#JU*9`f)@43#CBc97sL(xM?p(H4EVNf%wk_P;8Xwr!(s!Vh
zIk$FXhSg;wabHyVhIa_B`?-_Ex0AWzR2=YL^?!B~svYgw_(Sr;SxwJn22xJu$Tpn$
zEh6<{tnP$pm742I=>kT(C0!>~ji-qa
z$@tUrYMe>hFk+8O;I+~<7a>C
zeFSX)>R$~oHQwd`Uz#q3WMYoCT=d@M^(Lv=ROIF=p2wy`thS@j|qoTNK)9K+|{JU)h`6eMlu
zE8VbH5VKjnkoNUFOo>~o|4}t6@6TPx&Ig^|Yb^`XF<8XjVg~XK87rY+IicTJRVb>V
zr0`PW#P{;te!ab%+zYgBn`1&iWDJ>`zD}stes!cnPE=dYro`9IWgeS2MUr~bMLx3p
zrcfpA+$r8buD2S#L+RpTvUirPS>v2Z>cF`RtABF1{!qd1hnwYQ3ep6i%>*a;!8+h@#ko(#7$64MpA$GYtLJHAQ1NI6Q8k<{9
zN~u3>dDm3i{TwYd;_Keqc&S(q_AZo*7**Pn>V*6qLD?^lA3SQ}(w<$et0Rd&ASIt$
z63_3Tpy28!E$KfJzP50U+i(!R_hl$ctwbq7*q3_7Mc^y-WLAvoL6d{gJ?GqGt7N`~
zxD1DmWZU-cCH9}6wam5Cpx>i9OLeKZ(DIDKGBy~fFA(dwott1e{sI#>X((~E(48pc
zuJ5IPqd4@eZ8O}xW+<-lB4xnif&J@tHRX!fZ-;zNo~dyAA!>FuKd^~b#a~z@BPJhXIOw&+d&B3#iinv)1%sAa_4}bt=UCNBnpSyr~Hw#Z_>s4=zQj~r>KOWCeXukz@JO6IkKH=%B`;BoA
zF1=r!Klr3~&!;iI6Y+s}F`<1?{Xx>_q4#sizuQ;CADsV)__#In_~{qXwdBv2KJKg}
zbnbk6aKXg&ikyQwBzB85vft=U&n~QGO7@ejbxe`-
z*h`7MN*YahPKw7G%CAJ~v)#D!hPP#37OfEH#>o
z3K@&Im~zQ=y*0aG%8S$XNkguU&7}_*pAMf*df~m+T-J$P(z=v1Ox1XJ^(A9TuRduc
zEaKtykI2u4FOo)M8z0{I&iHIR@kCCh>Mrfz**O9tnVY52QuS$&YzF+@MUQBykw<;8
zJuTjI@t^u$jq=XB<7bm6D%Lh`PpEw%T}tjA$=8iN{q>7$eez_3sXjqi{k_8>iJSlK
zKDvJ{|Eu@MQ;~X|wg=?zf1{w{I8a5?asB$v*#={C-vLyYk8Pyu=shKWsEUe)jzvTuCY+rE~dl
z=N^hjL!``tG`BQc$1q8@OVj9rO$q13uh+LdH1OSWm>GQ~q3DAQ^Rrw=q0_?9v1e@7LZ;wj39o%`?Y
z#}Dzzy|QaD^kXLD>3OlX?VZm3w_IErUw?k8uS(lp@&aSxyBl9kZre3{yyE$zbf61*
zKcsITgZF3nY}(I??7YuasQ__h<-WJlxg{)#bsu;`*VPRvbkGc?!0dy86Dcl?pujYY{sMNo2d7)dyxM
zraR_L9zVD!|3go_n=h(8NdDDlC;Vm0gAziE9zyfK6Ko!#N<5OxpjkwftkNS$i;%2R
zhya0w86$=IJa)Ezgqi2UcpTIMIomxEJ&WC^i=cn-guJKBcmTAA3g$Boy{JW6h|Jld
z#RqJm>W-jixn@5&*#?$$kKAk?7dFkMDs)Bme~z4ILXxRxzcC^0xTs%bbUg`E&oves
zkb8+U?PQ^#TPDZ}D~USYT@s><4L3ijq`nBFM~W=XB7XLqE-k$%=6UKD2Qkm37ZX$44c?UM|NO
zF4LSO!oDmrsNfUACArd*8RsbZUH-^Y0Q_zw>=wt&$2?we&bkq2CTwfQx_wbZ&{CqD
z=G>ZI)O>N*R`A~P*}oQu@krE5uJJP}Q@F^T=t_eO1b>i#Hcmw+H>WnE%JZ6vay$)(mIpfS!~R@NKLI9F02
zw@A2+=6w4w%F}I^fNa2F-bJF%{kbyB#ysXBloJs9@rV5`6&p;j7HornxuzxXng=;shWXBk
zx1+(rj$BxyGlnE+epjKIU+)Hc{b|{5VMu0LAls#&j04%Nwe~=^1|M{fu0vE5bQ#F30*%Yh{+~f7#+R)ZSL^f
zI{9`!h`Axs6af0c%PhCa>cOJ!v!SN!y?a*WNwqU@YtFqB5-PW_0!?{!vvswdM^993
zBiNDj$R}4H^MHRtVP-tA83*>54RW6aRgj|Nc);mMwBIbqoeh4+g;gU;5F&L
z<`6%9NFD(=y?zxTnDl7tMnW)Zlm|)hsI20mmY9eDTEbIWLOBign_Ovo{?c#BWTo^2
z1_b_?sD0*Wa}t@qKXMOxfDO^$ioSCJ8*(7ZJVY6jKc2=v4uC0>Dzol6Po}gLqHEq^3*gc!A%Lk&UykM;y~Z?lZIq^W&IEYmn)O+<_Gyr01o8))6|2_^e)}aj=FW5Hw0{5xjqQ9=K%}Y7(XoZF9{Ml
z3k_mnf_UIvmVntT=v^ejk&FDy1K!|)IXKKQ7W~EzFqI}2#K!FNUqM%R;2&71JKzER
z@Pk?;4ZlvWo>tkmH|nasX3(@>C-}W+z+-HLK37v`R$`F`^l1XBFd-@=xWFX=uQ|8|
z31&qCJ-td0-4?#tjR>4Q{Bu7CM!Ru+1))LOM>~UkXZioqfK)cvo{KTysJ?Fi*<%IZ
zEHV8T$y>CC{pFY)Ca6mb_SFUxhJ|!t;l+d&OAe-(2dUB$K)>grQZIb_-0}n|^kEKi
zhz(ph2`(c;9PuDJ4&*W8
zp4?pma0c)+T6kZwN_(+^2CVDGf*94=!ylUVz-}a9pfeQD9r?uuDst6QvJaHvfXhT6
zgAE)$2xQLk|3?Dp@DQpzlQXh&ShxcKpO!zS^NGy|oiwO*;fk`xs{;c+)gMIAm3;@HSLAN7OGy897
z;MpqEPMSbH6H-aSglNI(H0UA#++3|r(drEZAgEl$zl7UiGpHBoIIZ2phH^VPfFhy`5oHHm2RJBOxeUbkaOLWEOD?0M6zV
zpLRxM(4b`m$QuusS0pNg{e%)J5=cf6AA%D&(0xL0&l8vxPIjAzbYsI#xEQ|X8ptmC8a4FYyIPTiC}_uwa8Q5A;D%w;
z>lxT*4y<-|@EsTV7l1G$U#(?>-8m@2Z*Uw7lhc6-<$+x#pk`R;_Ux5b;|J&O?7jFs
z<5M#z)Y&er>-<0v59%O+jptzd&|Mt&&MpBsl?P;NL+o+lUL@=rHtaeMtj@X~aOyO9
z60?qw7t}y^0;E4wW0Xl^-?+N^Y}n`rj6N4iA_Mbyow!J73YV{x%O5?*uje3i;Q5UY
zU()_C(ZMWqF^$W!!Bla%P+7=pEMyc5IV%qND+izFaiyQ7ec_HNb;PgOxT
zpThAxlsQY(h4$na&b}4P-;Z5wPaN*3sdooNI)(Rp~wQM
zlE9G5*E>1GfkJ%1=5!4JCKdobjc4~W;SMyQ_%%VDrxWJ1JzyCNq{oKg36Lo}0dNuj
zIETNLi`l36>Jae9n4QZ-{4v_A$GD=`74}J|uwyt-wpKF(HyINBqaE0l#LB4Ng3XbKZaAsuYP3vw(|^P=n+Bmp-bD)~6V?JLZe``iupyxRqP7YFTRskie8
zRC{zFK-Zoi%wmD#-YE#Jqgu&`*jY$tq*RmUjrRn^Bdw$F**H2usg;{|nv4v@iH`#i
zt_1LWkLKb_2#ZrEp%3YxK?4EU`trSRm)@K!yOBnT{}liloRUeQ4rCX-#-JK>=C{XG
zCoz?MF?8Io39;KZOjEYzZ+YiE@U<`34t%;A47WX0VRZUWTj*k5ku)KN*FFXxL}@wE
zM8FF*s+Zl5Fp(2^69eoJmEef%Q_L|TQ~S+t{zSA@bDEBEQ=Q7Kd=b6f_^GxQC9BRW
z-oMuJPd)gk>Gp$jLop?y!Z_7kX0Z7Aw@bxAf14+pss%ondNN-Q2K-XQ`a1?yfUf+$
zRbU$NV`Zmp(vVxG`3^391%fCyO-cz|Is5#0eZ*{6Hs)Y~ll;SCjo(lFvpjBpbSs74
zJMKxeg~;SaCst+7OLwH{69#|1+S7C@O51Hu{PK9PuR=?2eBp#2`=dh#_=xPvn~}n%
zG$~@QQHtq3N&b;M^W<9>ZZD`2wGpfBiA|HUjEo}S7kPEybSI^5u1HPV!82%;BQmfU688@KSV
zM}_hrB8SaYZlN|k6LWI*%%f<%;QT9XiqBjo4t1mP-gYWTJj;-Zc3Ndt-?WJ5oV_U~
zwjyvvriGUY3TP3m6~O->;ERlx6PG&XhxC7Y$4>F$aN!_znzR~vh%tGsJ`~voJ(lnLB_!4=m(rp!IA;a?~4t&)3
zq5>*O)iJlYzL)QfuaW&}{^|}Ren;iO4Xxg;sj-r6;@z96883tj?nVBfs2S$y@I!H?
zzpF%zD&|8s>aOtz+_@WFeXq2>Sz+{$%7+yffp97SOLw5+Wb)NpBE{(n*ro;M$JaLo
zANY|4!z!jZMZuT)+NS3bNt<7?O43~a;H5`#6Z8^!1i#wf+HVQoM^yTFQP7=Ec~QrwTHW0R4xZXc5Ok>Mr{Z+!C@~l!_hsODes@>3
zwr3{vYq=$(pH*O&H7^~$YJIfD5Ocst^$2JElEFTLUQz`hm_(jY@tnQ1QJawy!!wuL
z;TF2GIbvS~yYZXO+3Hzz^p~-2bUc@)u=PdpI*TZ13*e{x0*W2ibyfBx7|Q4c%V))8
z(;JC7`yHy!u1bcG#28fAbskHa%|>RiP*SxSgwv|FiX^t6ls8Z#Z7SWsiv%@mqe&{0
zt>I#q`8`Ng&{7fy(V3oM;++8+=TX#Lc+4|mRanjnU4GG`&2h$Dzzf=?iQbE%`Ah+X
zSX$|aT-b2iDS+IdBH2QAn;ZA%7K(!tH)WQ>mG|e?3>>6;Uw#LL%t^-7WgQxt%`=ct
zLM}2h4uT#R8tGMw#Q<`|)5da*$^#Ie>*(VBpTvV0^rK9UxfQ{r^vlqP%CG>$*$jT+UL$yy5ZdxM)LCMS9WkbIc875$2CQhOZ
z66HE4oyP{M*RfC}l{x8{DUkJL(K~*w8R*Xg7HS&n0}P)m%yqK0Xl)V8CuB}Ku$*eP
zM9PjY??EWvg-QaHp&kU{Re2at#al3kDx-k;Jxw<>h`O_P!f;ghP2IBZj(_nmDnNx?
z&L>3U7q|({j&&qn#4yNNk~E^4Ghg@DSfOJ%D@%&G9HI`1S1MTE1Jbi3s%Npl9t@6X
zXU1*4ds-g3IxmxEx$>0$D$GGpwY=XH5y#(^N7$i>umN_`n;dFAy{l=V^%uV7xtBS
z*p%BP0jmZo2oiG?jDN`NlIKiY@Q!9-Iv7P&OMGs-opX4fW-HfRnd}VLj0NSqQneC{
z$BKt*g^N3=nA_Onvvhth44X?h>6Ea**EaX!EuX0Nnp`faP9gJKpRWWlLM$=I$<@|R
z$}NwYQ}W}o6eKQ0T5DY<~5KsxuMl+s^4CVH8KYEm4}pyQIL?
z0xI*m(e87O`-e-r;Q&pZ(}g0W7I<;9lUXY*JOA(%&}m+@**$iq`oMF{53fF-?w>)D
zA-xh}$1(EMNJwxx0h-gS;BmWsFzp=?>c+J;KKjHiNQEMtf~Cu<`9sKzSCx(jWHf7;
zOHu?%#V&gp-NGt#<^_vpu)<9SaG;Er6i94&rnWaeOIamm#LG0QPd#n@a~MeSd$P!+
z!8$5Y;(_t_tcBc}b(Lu;3#q-D<>$Hv&X+BBp5rpM`Vzg1=oYz(a%FWk#-;^L8g>Vj
z(^UIperT!M&QxJV)2-yT$jIoD&yov&`f7!U^b@sI`HwuyV;WIt5=};sH`OOAvUxas
zs|#~W1zc-;`BCz6<|3VZ-V8Sfi#9enRhgJ{1l~obYSkFCmvKtV+}zYDb5A`ss`Kdh@W**ae|mDm`c1zR(k)NWpKMDbuH2xrx1sg74XX
zo#JAi3_s$BIz=|RrSL#C5>=uDR%;53^WKVw`QxLxd0Gt_r9yvO}{^@B9uHp7rjb1gMx!4fN3;ykwN+~Da8YKA^lS|v$>E|wou|!RUl!UnLQ4MdghgsAup<0Av@CDk
zzCYx2r`Q75TH?-to@7NK=qg6Mg@8g@Q}oI*1~ScYR=5%wpX1izJ4BlzfqHniPi=)A
z2tz$y*n#b0TrH%6F!JxL+_GRck~V>hN5X6KL~GU4F3AXP6#I%0eQIU4wQRy(Nt82f
zBHEvyd>2}y^Pv9(}vOlp^-AycaAre&>%M#GTkD<_3ieS15Zw@BFb`y_L=tO
zQ$vY>q4r#pGNwJt6$bHoF^4lJyTV!+Lk~;r+iG7l)xJn3ymtE59^*-gjhm9j^w6dfMP
zyr{Go{g0t@C&h936{3@wW32_DGM-&&E1qXOD_eeY?8{KuRN0Bu(PMk~m#fsg4y@?v
zFnG^t-W>u`_JJ(g|Yj^gRpz{qW15GDp9qz;=6uzs0`HkAm&BjBI7We!gq|v4L=6
z8SYVGiVj(pWxN!Xr14oD~2+$h^zrDQ_%
z>Ph2U6Y*O)DRn2!ZcL<E^6BSGfUgH+QSf$#)lH8VoxD{6-Q2_t=xCM+Uqf
zHNJc3CSo;4vF&K_vM)H^=l%`i*ap6K+%+iNJvh}pB;P&sgllRW*l~*P(9`lEx5M4d
z4Q>;iH>R*zvrQXM|oWpD1td
zPwH;pE8Lg!eVdW*m+t$1edfD5;w${(Ac71@
zBSQA(gF+%0*opHjz$E8LfhZC
zepuMbiNVpMth@+lDp8JVu0gdhrCK^tt$eB0dl6KdG^%X@)vl6i-$-@npdKHkp7=m@
zT%$Vuqms~p&T@e+8i6MT0?q#Aq_aOPv@u3g13e1@PgMqbH3pvU2=pEe^!X6zyB6s8
zFOZB5qR0jLYXk+D22mY@0)2ymB7%a`f=Wcqf0JyWf*0<;RP
zbzDgjTd9sXbH}?v`UVL
z=hu^-XItaHdW4*PeC6xo%Cl{^zqWOp{ZB*Sz1x}2e_x~iAzTOcM{uj%2f}(B!+L#J
zdm_U6;=}q4!hDtmGH_(z8idLMdrpOou7$n)7sf@0kI99PZ?5{OK+|M+L
zn}w`2MttpvSRIY{_F-+s7ZThC5!?fwey5U6eFpZ|I=5kZ?w8}aP2Y3BBhGE5o%>U8
zZp%H}F(fDM-BId5!2Q>u*T@2`h{)ET;2zo1bhj6cgel}M1POOXhJZbBe9ru>=6jchJG>;@(vK3UwPwd?wJB(p58b6_mvJZ&m3Y=
zJ)dc=bLCDzGl!hg<}!_fN@)^E-}OtD=MLlL-{W??*r5w@KNRVstApUalajv^_r;&=Ul
zXH*b7XGB=v_=@;%-{r~X(^EaqCM3pr7XC`ejq?nKBqY*f%HupS^aK@%=bJdM3vuy)
zD6h}IPO|BwvgQvd+797SjaP=Q&|IGlyEabw$oxK$d1B)J#>mhANCJ@PS-{Gy=LuJ3
zoGa)={@l6Tj0y?wJPDcTGgNWE)pd`2n7idjvKjHAV_Aata<*lYtLXD{?_LB__do8_
z{|Jwlx8WapCjn3V!uWl4);(7f_gbUu_OqN*ZMr?HTZfVVW9q!)p?=^$ehcSt&N-ZO
zID7Ag?S@Tdg^VH!r6NK|H|%6@Dtjea$trh8c6N4!gi1vrp|9Wl9*^H2zd!H4&*O2Q
z`F_7%&*yJFCNbqtm}XvS@_5Mm#VWgQ*`j)NCXZE}&ck_>*-z|9ZchfJ);K^P8
z&wuZ2S@_>TJ^IL<_w)CYVXI*B7$@Gi0Q1OTkGRYA90v6q^p|%+!;a|p*)DADx~p*n*T0o-#L4VoH7=M?w8~y@l+c%!=2%g%u9{PU#+)Cse=ZC83@S+%2
zL9Dt2d#(tq+fPgEmsq~F-MOPfFHXLMrxo25THQHg{0~arDpcr0#AlKX9scHS&ue
z+0|OBzN8nnjcob0J}CGmnw^izt#I4BKm4%y?!oSmpw7MBuMJZ|Y9AQkTGu~V$Db=y
z%F|J~81(Menn|RZT1(R|*X--?)sSt=Y2FLvXI2kHRj%28J5vA+TA2(TanyIR7!^JV
z6I^y|ImJtr4&TCdhWox;Nvb?2^1S$xUyaH)Uxlu$N+;=kG8EO{$)|chIHP
zyiDcQE_S&zZM@}D2|w{=JhxV7DC=x+`(%+-x8NDx;PwkY_Xdu;v7;+*N`!=6cfuNQ
zo%B1qEERf!)b?{WtxI(?ytxW@7e_q%3~w&Beg8zKeEQEIGIVS1bKdPwUkW>2zqYYN
zJ7#>5Hz^>EPiy@)IP*ef$Rl^g4{#4(Dv^Q>t`pD497fki(^G@
z$LARiHwP65H=iK#*=jK+M-{X7W6mX3n|qGH|3Mpi;4V8_96WZLMdyr4PXw47m6st*
zVdXm+-su5%(QRc;!k&i%ri|+&aGPQpSYZ&_c@b4S4`H3)zG`>am%thye9m{zbX=OTG#kSXLZwq?Uu-2`TCSHtl=W
z)-m?6!IqXYsP=WuyQ)5xr;w&b7t74;jar5YQa+k038}Bv6)k?W{@n9Y?VRuf6ZPz(
z1Fms`pW(rRdZ`{snd^%BndvGXd3@3m4t(sKHmRmm*4Jn$&Y^r%;|DnaMLYbl
zNDev9nAbd$3s=kwx6{mJe4`s~rhf&!k{(``RX8gHJMNBzs?WQD8i#peJmbW3V=28d
z6VX5m30F6*T_)%KN2`XKBJV~|dgt0F=y-=IH!kk7da+9umfdDX1xeI;3(E@RR{GYo
z@_X5PY6|bwVsLJu2C3c(dJ_diHH0pS$_s<5E+iuf~__D}!&wQQ18-Bb+lBg+AvU?-bQWbB~^@T&y7Y~*x
z0w9$)`gCk?4^i4y>;?NKJT|y8b!%RjtH(03YDq)(PJ&joe1Wzu6e*te?V>qEB6x+5
zC$f+#nncaeW2RL&1pq~_r@qlcXUXaZFWc^IfOL1r9QyGqXm@AQ$sKyKeP{8x&toL9
z?}3S`^oQaB-^@+q*07`b2
zP27e>Pj!&6@I>QZf97^mJsS7`B7
z5z^s6K;I1@U_@8K-1>GhZ|!yQV^?yV`0H_ABVVwv!$zhKL?cdShmCJuy|3Dl0Jr^1
zgxNU@iPvo__(7~V!8-khkYY&7E*NF9l!G98JUEj{bl8@UR>C)MvW>CIE-b}5vM-M$
zPv9CxTl2kWNYv@sK$^&djbXOYIxL~Ps}0jwyOamnLxL$EUgbKF0Q>BgvR^5Ja*6*<
zx`m~}E$QhRMK-a5P#Fn{4Hsj&437%l6St0|AziL;$gg?|#36|IV+Hu*ScxpwIyBke
zCLufkX2BA8Vu}#;>b^9V0E*cTuYQH~0}G~VN1u8v5pTKmOgw8Ata4DyW4zC}@E)(M
zX>%LuI>p46&mOZ-wFI~ViijaL#E5Hiq}Y+Eyj{+|GWhX_XWXQmI>^zY>8qT{tyEs0
z?|o`VsqrC&QUV3?Jz9!^(7?@Z{&ztZq(K0$ll87hZ@k?NKHws4qZHM0qwl1pZ3596
zrCNL=+EBG2+9gwhd%LwzeEP-gAd?D@`)z{%O_ui9Y`MYTZh9JSdofM)^5d2&h@>e)
z*y#u)xaw@;%M}4PR=c9{G4U>05iO@aE>ekycMjaVsQr@9cN$
z+D&43UiM!o
zUT_l}XOaq%bY4mj@Ree_+e(79Q$Wc}>KvY9nheDTuosj9n{ci{v>@0q=uhVI8)k~W
z0i2kl67bU^;F^m%U*c$Oel#4_&t*_gj&9F+~(!*iwJ0wFlbuxRt1QVhbc~n#wJY0-~hN
z-$;1dBqGd#NK125nAI?&=XF-DI
zG|?@J;18l=>IbYkNWPh%utkxB49m#%b1Eu8c(-rHNxfjoc8!NSa_RQ#A~zwn|x^6^P4)f2}&}yV@<@L(%PFNJB{2
z+HUdL5%d-~A?>n4;{SfQ%U5-y3rpgz5Y(fY;#J^;SM0uvxi?M>o6GaTGC}Zqx|NOz
zr#d+083<}ls&{8%@f3|_fYd3v>=a(06~uw=hT}lOkz~0dyjUxdYm+EI9y;>u-RlR3
z>c{hIGY$2}gd!;=Y^lPXDT@9KUI^%~vl(IO0?wae7|i5FbQ|t4;71okkI4Maj622%
zxlNLaGo@sJCZeBzEQK!9$E}v!<2_AhvG%BjkuFtd7A)-2s)gBV@>0#s`x2{MG{@+&
zC<+b>dOL=f8N)x5XOxdE&JI%$NlZAzS_*Qdmo2XOu1Typ>s)2XY!QTmN%rxOz$rFM
z$8iAy^F^Soc44=MHN~LQReFgabOx*!KvB-L&_;pvtbw|jBz}kPC-G_WWoddSngQKb
z2tYguNXO-Imd(=iQE}?*nJ6?^>kbVOmZ9W9htFajEK2KdlDi#iW*L0AYIT-3d;h80;_)~xWv(oH@jY$Z=96)Lw0NKYR*iv`RYCjUn
zI93g#qE-RvRZ`|hRw6}so}$Nn{dBcq0?ePGhNE#wTkwQ2;YaUZ6(S{Zu0~6AiB-Jk
zI9+uX2=ga+z8cp10%bvPQHV2QWGmQMzbp&PI!5K$A)OH=@^#MJu;_s}CUzFj=LUMa
zLlWRlLqdk1w^6w^0c)#3muF&Cbw0rZJXJt8g
zJS_0v-Sw0iuw1;1x0_MYMV=`bYfnLqvKL^{b5|MAW`aO8-SqpL=>L*cMd3q;v0fBU=F&^Ec;BU2k7|;PIKzZEn^fJ%F_-5qD$Gr%6bv(~eJZKQx7KYc&&2
z7g5?~#l`ojYFj|*Oel-ls*wuxNd>l`t-FWQgQrM}`@qX$MD$qEobiKr0tjw8-&GG(w-
zp(Ey5nFKci!zqn{EvvzWQJFHv$y*?Kl>Li?0z6Lg|k7xvpS<=9x8+y`mBa=IkL+!g(*73FBAbXAFJ`sD|oyA4*Us=W+@
z&Tiqw>z!ADvhk7%AuIYvmy-=#jXWr?GnsH-5*M9ztldi2+GJv*2^^c$_`PneeITZY
zj`crpglDdM`tUgr(V4|U(S!je;@`NSvad^sL)j@EYDpeNYHU;}7icu?bsR|$FPjt8
z_ttV|@Y*nVPtf!uiK5Ox4S#~DJceDE${(DoN6_@urT=L6SGXfA
zm`oKi#*ul$lGvq3>XrO=;gOIdz9LD1&@JCZ4UM%Ww2g
zEj~bbLVeV)i}^DpW`Xs=g)2QO77hi1Clm9cyHQw@_7YI9sN3~*x9}Z;P7g_=^NJ9b
zrq_Q(I-ZFcU}7@CdcH*IV8Tgb;_;;rib245-sXbG$ff8vBvy7`n2cA+q(<8H=!^j*
zQG|rtDfu$H>}ax$udH~0fLbb^z6sz_CvsHH#0133IpAfM@I14VHo>vpG)*a*!db?A
zs4E`ovY9F^FV*U1G)9*gqnkG1x7t9$WPtq+7}F~6Fvl46C4DNhgKdxoSI8&0ujtp)
z4O78zC?%Y*i0!8%CFHfk0DW`HClvET8z5bo
z(`S`|&H(zhkdF=Bx~(r)a&o;Pw>(bo852=rygzRxY)(Ny2e1x6X&Z{@7(mS?TgOd8
zpq@U9wz%^B{qhHfU~@Lgh5>IDRB2@*>&e~5HyJjr_$Jl&kNU
z6Y4aTGEm;lO~WLRcn^vFI>UH_RGCfD&ICqHlN6$XTALvGq8N`CK+V~l;ECz-08`0f
zpcaZLq267NV#?>zgldVn|CnR@Abw5i3rC`68B@(om6d-IZKd8mPZSs{Hf?qk(SC4z
zVwfUY%QSRT!b}Zf=|ps-fGEz#D3hR%1wDwT388OCvwdpKwQ48!$wryVMM=tzk+8!=
z-5!co4?#VCMc10D8%a~9I&bq4AStX@&^!cOYBCfE=G|A(M_5F
zglM!#o&66Ft|rQQ?C}PWM70U0HoJ9}vO}V!73!ZYTW)x@taxJ}X7n3eJr_fdYxTF~e?wGIFCO
z6dwyYJTU2uIU8va*cszA7Ja7Qn23$ei848{L3{C#Gp{T`Y(rIViDHmTlI|o3FUtrG
zFJJ&v18astEBMQV>&a-_M~fC))GL=%6H6hoy0fH!)>XsEZbARylOvR6D8*ruF4n4?
z41b!6CBh=#Ewc}szX0E4CW-djvzb$@5i^SB3_*EhM0I@RO849s8t2q`(=sM(vs)GF
zn!C6DCcy_i%T#S;Q8gVBe*pAb>G+M)JgorHO*}kz>lm3y0UZ(H4g|?ox~efA9Sv+O
z2dM3Ah}P0&YXLgVfJa{Ilo1B-h>E}yWYiy};0ZjLp!&cDacvhF>w2EuZZDW%NGxE;
zp8!@jHa)tf&`QTVC!bKII37be7t4f_m{9+9tPKTWoZeIj;b;b7cY>AT*3|CSO$2
zfsv$cYBWm$gbm98@Sfl4$W;nwBn`2+j}0cVw=%0H3B57E(P3byUj4-dagBmQiJifd
z(oE4Q;^Y=Z9I|qHl_23p6w&^1Y|9;PejeZU*@Q44QAFLQ-E$~P4h&UkCNjRe+f1MT>aJoL2(t+iF8VsV
z&xILdc3%&;J(kUv1^l@U6j;RaO<0biNhdCXP#zYEsX)nns!$n7bmx}^mTA2uz-3`_
z@w#bw*+@qsTIFl7y3HN=vEPMnqB*+DOrs3tRfobM<*#I8Sv!=RwN
zKeTX1AbTqjiUl2uBLEOCP98i%KA0{z=3;~b!8b{4{=}*W!{!eV01nI6g=xc
zpP2F=gb3FL8Qf;_vO?YB82#Jc?0F2XT!s*YI-^S!;09?N;YG^`{>SHVSj_48t~+$I
zeQFd76|(_6a{b@um=w
zZ@WQnsxEbeY5BB#ds#E_I&9yiLUC7V-SCF@*z8n}*@K3t?E}inyXWF%iU|kHO`pmg
z%A>L@cNdzgFDaV;`B$*j3_|d6QiDw$Zz5GvwR5pNt@aWh
zt1a|ig>+RsU;Upiw7U57T4K2MdazR?srT{LDS!IUzNe6a13zPS!^8~^Hu(cTiL7#C
z-66Qbk9tmFHQ08U$PMN!6MfaWRf_vY%fwZ2J#BwEIlq1=?U@e~;mv=eU&8Bwd-i;#
z=&oH`*%SAcey?gB{C0FweN&2duT;dfW
zoTLZF`7*)hBuv(SK5xwmNaX7F>Hwdwmdq6k*F+KdfjkE{v3|4Qv9kaK~~!f
zIcF^Av{K!d74&P+Gs8Cx`7dbk0OYQQk4G?ov;4pAQ~AR(eJ+ZbI;8oC
z1aw-ICF>4DqYJEG2Skh9HMb~DzPc(=0Y7OzSDA;;<-GXVwKbll{;W!!b@ChmWpyvgoC|5dol|xuP?tX|hmUjp
z6muYOw7m&cVKIRnH42GampM)?(SC;5M9b8Iki=Pv&Y%a&3jPUuo=!n7rNRCH5~8sQ
zW+%1+VIlQ2jb_h2jVgIAMX^r_&38HUKWrxtb$V&IE1L6yy$;?~{al@|FEKlOPnJ*GTBipK*(X%_
zN2{+CqCG&rbFSC+smbMdnJ0|163-Up~+CVjNuicfZ%^c|LD-zCg|I)J5fC0`k>yEs-6ubM>Tz_GJ54
zlP7{QORmm*`pcAVH$lp(;m$H5Y|~2|KEQL_YwO&jD44v6=D-Rmr9>omyYW%tjdvIi
zxA^w3nP-&s_e0?~=`?f&UfHxlf)0Ky*wP?l0b$G1C_rTf=@p5yI(MK94ATA$vuMqoyg_j~<`w$aZ6X9t@Swyo_4#&-iV
zKP`VU{yRJ19OYdyAGm0oR^Rq1zalN;TlSjfT+Q>Fhh62erng5#f`MLmoV}QgLgxu2F7ty!<%wa)$M}1F1~0UlzkHDYu3tMIFw3
zYh2%My-IpFm-=9@Y39_8{?AfEZN3~>GtIi|J+(uW!)bIO#t@eZpELjb@#16Nas@eznA*z
zRA-N1DyA;$d#_Fuem+FHziT%7<{hgCk-+9p3O_|Pby;q{)e&@OnTgxsGMnNm&cOk1
zj2=UXp_Dy2^u~JOHuaCKipcExppAeg39g9TZn}rHlvl6wof8*Dzg-w@7w!m4^cv>-
zoCR0S9#FYd_1L6Yy_A>@zC2j82$pDEf6gJ))&M{_Q2ThISnr6v#n3KORqn
z%!LGPB{&_eXtWd3;)6ny&dC`~w486cDE2#V=ID4$=i}cY-efVYw7v;<<-g#TXw=uLGhTsV$uMapZo0Uv#>Eh#PcDhDv&MUC%PUe6|lWCqK-gpEkVU@
zQ*JdrWcl8}MhFPW2BJ46Jh42?Jlih4wawa&XcGh?mj!Q9guGMPea6_YBRFo^unvqP
zbc`bc!C}EbGAot$2v`RTwzQw07E6Y1AJ^{7t8<Q<-H@xCv#9N
ze3Kwx$0u-}g){XOaBmQ}WGm>ECU{|7&~sbRM?=W9K|&LHZV8xS9{4lc27UVGnGj0B
zJVQtkhC&qUqa_5=DeQHhh4)u5*Vq*!8NAePB{&0{Y|(|grH)`FORuX9rxg#{ip~Uz
z3J*%c8_=I@#n#irzKx6hJhp8aHWgE5=6)R%Kjy=MZ>#SVzuOw`0U;&$?IeWkB!B^D
zav59Pzy6@iCHbmUY`)@LFLU`8K@*phkumB4evgLz+I^YQ=a4e?b}~+yGRqB;myohv
zcCtR{vgcA{ZXxA@kQ3hN?co!0_mT26JNdYD`C`Pmd7$L=U)$t!3Z#;DySJ+T#a6+R
zliD}N^N~tTc1kbPl{zMrx-`YaH6^ps(TraN-G1dsJC&Jqm4ykFk4WV=jY>nmR30K#
ze+o}CYS3A`Fyqd1`WkQW3Lp`hUEsS4--(F(@ss7SVU5H;(iXVA+R#RzGQ|-5y
zh`o~iO1>6CHOb@JpT{!?D|r+hL_U7tL4%l_nH
zF^bG~E@1NXeSU44y>?uN_Jc|76n>oyd!3x1lcmxMZMVg9mqkV>*b#~TI)1$-d%c$#
zdL5H`UHtmp_WFGp`a_fYqx=Sw_69Q<1`Cr0ANdWtn#}b*If|B8bTe!Z16!+QcsOZv
z%#Q~<;5jn!TvK>n6oKD?(EVHFagb7JhOnCjM)ixZ;uKL0MbdI0oz5ibO_7XH#v})0
zvrJ>FDdTe}6MF{}r%V&qDU(aVqzs@Kvj_>Jzz>RHPu7HGQKk`7X7^F%GzaszO!M=>
z#1xc8hJ!`Uai&GVl*JR2WvPQ@Wv1oRDa+?5t0o6bE=4$ZP$Q3mO-;&0r&|wAS&yR5
zOgfyI$vm?#b><`L>?en_>nG0qrSh?8h5mRRF3-%3sdL9D8}NA>jw~CFcd&vnPX6<@
zLRq#V?`*{d?4-`y$z|EGvZ`tV_F5;!h8cv@8NxjT*(Fb*)@TQ-cMj(S&fA|q@04}k
z_1*bP0*+qi9euJKufKD=CEyfv-YGQ8DdNs~Pfr0@F|?flIRZe|030<-NMBEZ$afc>
z2)LAfHbSFu?lb4z7qZ+xzH|R1
zaB=x8pYxXvvo0OKy95^W;BfTd%J$%$_TU%v6ms+w$@ctv-ksIp
zHTMA5u~??XJO{qX^vVG&GeIE$=r8xR!Wc%ez`xOXYd*XPlH2YfR^tGpg
z*PlCHZ_2*@a{79QpnsR6e|O8Z>LN%X9~fVJEq4WDSL8Dg;{QqT=DOp}Z`n70PT%}3
zc3|0_VLpO^eJ36E@zA2B8&8D7OP#_ibHbm_gg+OGXmW~pnG?}56VW9U
z+3ghBmlHWO6FK@PyqpY4B&)$G+#f=nP4Td?oTzUzQ9p(5|8~0n$0@S7=t>76AjOHy
z5k|;3t^R~^CAAn9O{R#