From e23467faaf4b4e5033344d7e414faa606f97df46 Mon Sep 17 00:00:00 2001 From: Sergey Ivanov Date: Thu, 14 Nov 2024 14:54:04 +0500 Subject: [PATCH] Intitial commit --- .github/workflows/build.yml | 32 +++ .gitignore | 79 +++++ Readme.md | 270 ++++++++++++++++++ build.sh | 10 + buildConfig.yaml | 4 + description.yaml | 9 + docker/Dockerfile | 50 ++++ docker/docker-entrypoint.sh | 5 + docker/libraries.py | 201 +++++++++++++ docker/requirements.txt | 5 + docker/status_provisioner.py | 131 +++++++++ .../images/status-provisioner.drawio | 114 ++++++++ .../images/status-provisioner.drawio.png | Bin 0 -> 38319 bytes 13 files changed, 910 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 build.sh create mode 100644 buildConfig.yaml create mode 100644 description.yaml create mode 100644 docker/Dockerfile create mode 100644 docker/docker-entrypoint.sh create mode 100644 docker/libraries.py create mode 100644 docker/requirements.txt create mode 100644 docker/status_provisioner.py create mode 100644 documentation/images/status-provisioner.drawio create mode 100644 documentation/images/status-provisioner.drawio.png diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..db72edc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,32 @@ +name: Build Artifacts +on: + push: + branches: + - '**' + +jobs: + multiplatform_build: + strategy: + fail-fast: false + matrix: + component: + - name: deployment-status-provisioner + file: docker/Dockerfile + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push + uses: docker/build-push-action@v5 + with: + no-cache: true + context: ${{ matrix.component.dir }} + file: ${{ matrix.component.file }} + platforms: linux/amd64,linux/arm64 + push: false + tags: ${{ matrix.component.name }} + provenance: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0307310 --- /dev/null +++ b/.gitignore @@ -0,0 +1,79 @@ +.idea/ +# Temporary Build Files +build/_output +build/_test +# Created by https://www.gitignore.io/api/go,vim,emacs,visualstudiocode +### Emacs ### +# -*- mode: gitignore; -*- +*~ +\#*\# +/.emacs.desktop +/.emacs.desktop.lock +*.elc +auto-save-list +tramp +.\#* +# Org-mode +.org-id-locations +*_archive +# flymake-mode +*_flymake.* +# eshell files +/eshell/history +/eshell/lastdir +# elpa packages +/elpa/ +# reftex files +*.rel +# AUCTeX auto folder +/auto/ +# cask packages +.cask/ +dist/ +# Flycheck +flycheck_*.el +# server auth directory +/server/ +# projectiles files +.projectile +projectile-bookmarks.eld +# directory configuration +.dir-locals.el +# saveplace +places +# url cache +url/cache/ +# cedet +ede-projects.el +# smex +smex-items +# company-statistics +company-statistics-cache.el +# anaconda-mode +anaconda-mode/ +### Go ### +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +# Test binary, build with 'go test -c' +*.test +# Output of the go coverage tool, specifically when used with LiteIDE +*.out +### Vim ### +# swap +.sw[a-p] +.*.sw[a-p] +# session +Session.vim +# temporary +.netrwhist +# auto-generated tag files +tags +### VisualStudioCode ### +.vscode/* +.history +# End of https://www.gitignore.io/api/go,vim,emacs,visualstudiocode +*.iml \ No newline at end of file diff --git a/Readme.md b/Readme.md index 8b13789..488d1f7 100644 --- a/Readme.md +++ b/Readme.md @@ -1 +1,271 @@ +This guide provides information about the usage of the Deployment Status Provisioner. +Topics covered in this section: + +[[_TOC_]] + +# Overview + +Deployment Status Provisioner is a component for providing the overall service status in DP/App Deployer jobs. + +![status-provisioner](/documentation/images/status-provisioner.drawio.png) + +# Common information + +`Deployment Status Provisioner` is a component for providing the overall service status in DP/App Deployer jobs. It is +used to receive statuses from all required service resources and specify the final result to a preselected resource from +where the DP and App Deployers read the status. + +First of all, `Deployment Status Provisioner` checks readiness status of resources specified in `MONITORED_RESOURCES` +parameter. If all resources are successfully started, the status condition displays the following message: + +``` +All components are in ready status. +``` + +If some resources are not started in the allotted time, status condition contains `RESOURCE_NAME component is not ready` +message for each unready resource, where `RESOURCE_NAME` is the name of monitored resource. + +Then `Deployment Status Provisioner` checks the result of integration tests if it is necessary. If the integration tests +fail, the status condition outputs a message from the `INTEGRATION_TESTS_RESOURCE` status. If they do not complete in +the allotted time, you will see `Integration tests have not completed in INTEGRATION_TESTS_TIMEOUT seconds` message +in the status condition. If the integration tests complete successfully, the status condition displays +`Integration tests are successfully completed` message. + +You also can find information about monitored resources and array with failed resources in the pod logs. + +# Usage + +To use `Deployment Status Provisioner` you need to create a resource inside your Helm chart, which creates Pod with the +latest image of `Deployment Status Provisioner` and the following parameters: + +The `INITIAL_WAIT` parameter specifies the time in seconds that the `Deployment Status Provisioner` waits before starting +to check readiness status for monitored components. It is important for `upgrade` process. The default value is `30`. + +The `MONITORED_RESOURCES` parameter specifies the comma-separated list of resources that should be monitored by +`Deployment Status Provisioner`. Each resource description should consist of **two** parts separated by space: resource +kind and its name. There is ability to monitor readiness status only for the following resource kinds: + +* `DaemonSet` +* `Deployment` +* `Job` +* `StatefulSet` + +For example, if you have Stateful Set with name `consul-server`, its description should look like `StatefulSet consul-server`. +A complete example for this parameter would be `Deployment consul-backup-daemon, DaemonSet consul, StatefulSet consul-server, Job consul-server-acl-init`. +This parameter is mandatory and does not have default value. + +The `MONITORED_CUSTOM_RESOURCES` parameter specifies the comma-separated list of custom resources that should be monitored by `Deployment Status Provisioner`. Each resource description should consist of **six** or **seven** parts separated by space: + +* `group` is the group of custom resource. It is required. For example, `netcracker.com`. +* `version` is the version of custom resource. It is required. For example, `v1`. +* `plural` is the custom resource's plural name. It is required. For example, `opensearchservices`. +* `name` is the custom resource's name. It is required. For example, `opensearch`. +* `expression` is the JSONPath (query language for JSON) expression to get custom resource status. It is required. For example, you need to get `type` field value from the following custom resource status if `reason` field is equal to `ReconcileCycleStatus`: + + ```yaml + status: + conditions: + - lastTransitionTime: 2024-02-27 10:06:13.746985042 +0000 UTC m=+199.958634385 + message: The deployment readiness status check is successful + reason: ReconcileCycleStatus + status: 'True' + type: Successful + - lastTransitionTime: 2024-02-27 10:06:08.714381731 +0000 UTC m=+194.926031082 + message: Component pods are ready + reason: ComponentReadinessStatus + status: 'True' + type: Ready + disasterRecoveryStatus: + mode: '' + status: '' + ``` + + In that case required expression looks like `$.status.conditions[?(@.reason=='ReconcileCycleStatus')].type`. If you need to get status from a specific field (for example, `component.status`) in the following custom resource: + + ``` + apiVersion: netcracker.com/v1 + kind: ComponentService + metadata: + creationTimestamp: '2024-02-27T10:02:51Z' + generation: 1 + name: component + namespace: component-service + spec: + global: + podReadinessTimeout: 700 + waitForPodsReady: true + component: + replicas: 3 + resources: + limits: + cpu: 500m + memory: 1024Mi + requests: + cpu: 100m + memory: 1024Mi + status: Success + ``` + + you can specify `$.spec.component.status` expression. For more information, refer to [Python JSONPath Next-Generation](https://github.com/h2non/jsonpath-ng/blob/master/README.rst). + +* `successful condition` is the value that should be considered as successfully processed custom resource. It is required. For example, `Successful`. +* `failed condition` is the value that should be considered as inability to process the custom resource. It is optional. If it is not specified, `Deployment Status Provisioner` will try to find `successful condition` before time runs out (`CR_PROCESSING_TIMEOUT`). For example, `Failed`. + +A complete example for this parameter would be as follows: + +``` +netcracker.com v1 opensearchservices opensearch $.status.conditions[?(@.reason=='ReconcileCycleStatus')].type Successful Failed, netcracker.com v1 customservices name $.spec.status.type Ready +``` + +The `RESOURCE_TO_SET_STATUS` parameter specifies the characteristics of the resource to set the final status of the cluster. +This parameter value should consist of **four** parts separated by space: resource group, version, plural and its name. +For example, if you want to write down the status to `Job` named `consul-status-provisioner`, the value should look +like `batch v1 jobs consul-status-provisioner`. +This parameter is mandatory and does not have default value. + +The `NAMESPACE` parameter specifies the namespace in OpenShift/Kubernetes where all the monitored resources and resource +to set status are located. This parameter is mandatory and does not have default value. + +The `CONDITION_REASON` parameter specifies the name of the condition reason that is used when setting the status condition +for the `RESOURCE_TO_SET_STATUS` resource. For example, `ConsulServiceReadinessStatus`. The default value is `ServiceReadinessStatus`. + +The `SUCCESSFUL_CONDITION_TYPE` parameter specifies the condition type that is used when setting the successful status +condition for the `RESOURCE_TO_SET_STATUS` resource. For example, `Success`. The default value is `Successful`. + +The `FAILED_CONDITION_TYPE` parameter specifies the condition type that is used when setting the failed status condition +for the `RESOURCE_TO_SET_STATUS` resource. For example, `Fail`. The default value is `Failed`. + +The `POD_READINESS_TIMEOUT` parameter specifies the timeout in seconds that the `Deployment Status Provisioner` waits for +each of the monitored resources to be ready or completed. The default value is `300`. + +The `CR_PROCESSING_TIMEOUT` parameter specifies the timeout in seconds the `Deployment Status Provisioner` waits for each of the monitored custom resources to have `successful` or `failed` status. The default value is `300`. + +The `INTEGRATION_TESTS_RESOURCE` parameter specifies the characteristics of the resource which the status of +integration tests execution is stored in. This parameter value should consist of **four** parts separated by space: +resource group, version, plural and its name. For example, if you want to read the integration tests status from `Deployment` +named `consul-integration-tests-runner`, the value should look like `apps v1 deployments consul-integration-tests-runner`. +This parameter should be specified only if you want the result of the integration tests to get inside the final cluster +status. + +The `INTEGRATION_TESTS_CONDITION_REASON` parameter specifies the name of the condition reason which meets the condition +with the result of the integration tests in the `INTEGRATION_TESTS_RESOURCE` resource. The default value is `IntegrationTestsExecutionStatus`. +This parameter is meaningless without `INTEGRATION_TESTS_RESOURCE` parameter. + +The `INTEGRATION_TESTS_SUCCESSFUL_CONDITION_TYPE` parameter specifies the condition type which corresponds to the successful +result of the integration tests in the status condition of the `INTEGRATION_TESTS_RESOURCE` resource. The default value +is `Ready`. +This parameter is meaningless without `INTEGRATION_TESTS_RESOURCE` parameter. + +The `INTEGRATION_TESTS_TIMEOUT` parameter specifies the timeout in seconds that the `Deployment Status Provisioner` waits for +successful or failed status condition in the `INTEGRATION_TESTS_RESOURCE` resource. The default value is `300`. +This parameter is meaningless without `INTEGRATION_TESTS_RESOURCE` parameter. + +The `TREAT_STATUS_AS_FIELD` parameter specifies whether resource status should be treated as field. It is necessary when initially `RESOURCE_TO_SET_STATUS` does not have `Status` sub-resource. In that case status is set as a field to chosen resource. For example, it may be applicable for some of custom resources. The default value +is `False`. + +# Example + +`Deployment Status Provisioner` job with only required environment variables looks like the follows: + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: my-status-provisioner + labels: + app.kubernetes.io/instance: {{ .Release.Name }} +spec: + template: + metadata: + name: my-status-provisioner + labels: + component: status-provisioner + spec: + restartPolicy: Never + serviceAccountName: my-status-provisioner + containers: + - name: status-provisioner + image: artifactorycn.netcracker.com:17008/product/prod.platform.streaming_deployment-status-provisioner:master_latest + imagePullPolicy: "Always" + env: + - name: NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: MONITORED_RESOURCES + value: "Deployment backup-daemon, StatefulSet server, Job server-acl-init" + - name: RESOURCE_TO_SET_STATUS + value: "batch v1 jobs my-status-provisioner" + resources: + requests: + memory: "50Mi" + cpu: "50m" + limits: + memory: "50Mi" + cpu: "50m" +``` +**NOTE:** You cannot use artifactory Docker images in your Helm templates due to external environments, it is necessary to use `DeploymentDescriptor` values and to add deployment status provisioner to dependencies. For example: + +```yaml + - type: find-latest-deployment-descriptor + repo: PROD.Platform.Streaming/deployment-status-provisioner + location: 0.0.16 + docker-image-id: timestamp + deploy-param: deploymentStatusProvisioner +``` + +You should also create `Service Account`, `Role Binding` and `Role` with permissions that allow `Deployment Status Provisioner` +to work with your monitored resources. + +`Deployment Status Provisioner` role should allow to `get` statuses for all resources that are specified in the `MONITORED_RESOURCES` +parameter. In addition, the role should give permissions to `get` and `patch` status for the resource from `RESOURCE_TO_SET_STATUS` +parameter. So, according to the configured `Deployment Status Provisioner` job, the role should look like this: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: my-status-provisioner +rules: + - apiGroups: + - apps + resources: + - deployments/status + - statefulsets/status + verbs: + - get + - apiGroups: + - batch + resources: + - jobs/status + verbs: + - get + - patch +``` + +And the following `deployment-configuration.json` can be used: +```yaml +{ + "statusPolling":{ + "resourceType": "job.batch", + "resourceName": "my-status-provisioner", + "statusPath": "$.status.conditions[?(@.type=='Successful')]", + "statusPathFail": "$.status.conditions[?(@.type=='Failed')]", + "timeout": "${ CUSTOM_TIMEOUT_MIN ? CUSTOM_TIMEOUT_MIN : '10' }" + } +} +``` + +The example of status subresource: +```yaml +status: + conditions: + - lastTransitionTime: 2023-10-31 07:45:28.487195606 +0000 UTC m=+74.412108827 + message: The deployment readiness status check is successful + reason: ServiceReadinessStatus + status: 'True' + type: Successful +``` + +A complete example can be found in [Consul Service Templates](https://git.netcracker.com/PROD.Platform.Streaming/consul-service/-/tree/master/charts/helm/consul-service/templates/status-provisioner). \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..ea91cb4 --- /dev/null +++ b/build.sh @@ -0,0 +1,10 @@ +DOCKER_FILE="docker/Dockerfile" + +echo "Build deployment status provisioner image" +for docker_image_name in ${DOCKER_NAMES}; do + docker build \ + --file=${DOCKER_FILE} \ + --pull \ + -t ${docker_image_name} \ + . +done \ No newline at end of file diff --git a/buildConfig.yaml b/buildConfig.yaml new file mode 100644 index 0000000..e51abcb --- /dev/null +++ b/buildConfig.yaml @@ -0,0 +1,4 @@ +type: image +builders: + - docker: + file: docker/Dockerfile diff --git a/description.yaml b/description.yaml new file mode 100644 index 0000000..deca31c --- /dev/null +++ b/description.yaml @@ -0,0 +1,9 @@ +build: + networkRules: nc.product.dtrust + env: + type: microservice + version: generic-1.0 +publication: + docker: + - latest + - timestamp \ No newline at end of file diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..463222c --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,50 @@ +FROM python:3.10.14-alpine3.20 + +ENV STATUS_PROVISIONER_HOME=/opt/provisioner \ + PYTHONUNBUFFERED=1 + + +COPY docker/requirements.txt ${STATUS_PROVISIONER_HOME}/requirements.txt +COPY docker/docker-entrypoint.sh / +COPY docker/*.py ${STATUS_PROVISIONER_HOME}/ + +RUN set -x && apk add --upgrade --no-cache bash python3 apk-tools wget sed + +# Install kubectl - it is required for vault-service-status-provisioner-cleanup job +ARG KUBECTL_VERSION="v1.30.1" +RUN set -x \ + && wget \ + --no-check-certificate \ + -nv \ + -O "/usr/local/bin/kubectl" \ + "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" \ + && chmod +x "/usr/local/bin/kubectl" + +# Upgrade all tools to avoid vulnerabilities +RUN set -x && apk upgrade --no-cache --available + +#Add unprivileged user +RUN set -x \ + && addgroup -S -g 1000 provisioner \ + && adduser -s /bin/bash -S -G provisioner -u 1000 provisioner \ + && addgroup provisioner root + +RUN set -x \ + && python3 -m ensurepip \ + && rm -r /usr/lib/python*/ensurepip \ + && pip3 install --upgrade pip setuptools==70.0.0 \ + && pip3 install -r ${STATUS_PROVISIONER_HOME}/requirements.txt \ + && rm -rf /var/cache/apk/* + +RUN set -x \ + && for path in \ + /docker-entrypoint.sh \ + ; do \ + chmod +x "$path"; \ + chgrp 0 "$path"; \ + done + +WORKDIR ${STATUS_PROVISIONER_HOME} + +USER 1000:0 +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100644 index 0000000..6dd7263 --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +sleep ${INITIAL_WAIT:-30} +echo "Status Provisioner have started calculating the state of the cluster" +exec python ${STATUS_PROVISIONER_HOME}/status_provisioner.py $@ \ No newline at end of file diff --git a/docker/libraries.py b/docker/libraries.py new file mode 100644 index 0000000..c059ca4 --- /dev/null +++ b/docker/libraries.py @@ -0,0 +1,201 @@ +from datetime import datetime + +import kubernetes +import urllib3 +from kubernetes.client import V1ComponentCondition, V1ComponentStatus + +DEFAULT_TIMEOUT = '300' + + +class ConditionReason: + DEFAULT = 'ServiceReadinessStatus' + INTEGRATION_TESTS_DEFAULT = 'IntegrationTestsExecutionStatus' + + +class ConditionType: + FAILED = 'Failed' + IN_PROGRESS = 'In Progress' + READY = 'Ready' + SUCCESSFUL = 'Successful' + + +class ConditionStatus: + FALSE = 'False' + TRUE = 'True' + + +class CustomResource(object): + + def __init__(self, custom_resource: str): + parts = custom_resource.strip().split() + if len(parts) != 4: + raise Exception(f'The description of specified resource must contain 4 parts: ' + f'group, version, plural and name. But [{custom_resource}] is received.') + self.group = parts[0] + self.version = parts[1] + self.plural = parts[2] + self.name = parts[3] + + def __str__(self): + return f'{self.group}/{self.version} {self.plural} {self.name}' + + +def get_kubernetes_api_client(config_file=None, context=None, persist_config=True): + try: + kubernetes.config.load_incluster_config() + return kubernetes.client.ApiClient() + except kubernetes.config.ConfigException: + return kubernetes.config.new_client_from_config(config_file=config_file, + context=context, + persist_config=persist_config) + + +class KubernetesLibrary(object): + + def __init__(self, + namespace: str, + resource_to_set_status=None, + config_file=None, + context=None, + persist_config=True): + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + self.k8s_api_client = get_kubernetes_api_client(config_file=config_file, + context=context, + persist_config=persist_config) + self.k8s_apps_v1_client = kubernetes.client.AppsV1Api(self.k8s_api_client) + self.k8s_batch_v1_client = kubernetes.client.BatchV1Api(self.k8s_api_client) + self.custom_objects_api = kubernetes.client.CustomObjectsApi(self.k8s_api_client) + self.namespace = namespace + if resource_to_set_status: + self.status_resource = CustomResource(resource_to_set_status) + + def delete_job(self, name): + self.k8s_batch_v1_client.delete_namespaced_job(name, self.namespace, propagation_policy='Background') + + def is_resource_ready(self, resource_type: str, name: str) -> bool: + if resource_type == 'daemonset': + return self.is_daemon_set_ready(name) + elif resource_type == 'deployment': + return self.is_deployment_ready(name) + elif resource_type == 'job': + return self.is_job_succeeded(name) + elif resource_type == 'statefulset': + return self.is_stateful_set_ready(name) + else: + raise Exception(f'The type [{resource_type}] is not supported yet.') + + def is_daemon_set_ready(self, name: str) -> bool: + daemon_set = self.k8s_apps_v1_client.read_namespaced_daemon_set_status(name, self.namespace) + return (daemon_set.status.desired_number_scheduled == daemon_set.status.number_ready + and daemon_set.status.desired_number_scheduled == daemon_set.status.updated_number_scheduled) + + def is_deployment_ready(self, name: str) -> bool: + deployment = self.k8s_apps_v1_client.read_namespaced_deployment_status(name, self.namespace) + return (deployment.status.replicas == deployment.status.ready_replicas + and deployment.status.replicas == deployment.status.updated_replicas) + + def is_job_succeeded(self, name: str) -> bool: + job = self.k8s_batch_v1_client.read_namespaced_job_status(name, self.namespace) + return job.status.succeeded == 1 + + def is_stateful_set_ready(self, name: str) -> bool: + stateful_set = self.k8s_apps_v1_client.read_namespaced_stateful_set_status(name, self.namespace) + return (stateful_set.status.replicas == stateful_set.status.ready_replicas + and stateful_set.status.replicas == stateful_set.status.updated_replicas) + + def get_custom_resource(self, resource: CustomResource): + return self.custom_objects_api.get_namespaced_custom_object(resource.group, resource.version, self.namespace, + resource.plural, resource.name) + + def get_custom_resource_status_condition(self, resource: CustomResource, condition_reason: str) -> dict: + resource_status = self.custom_objects_api.get_namespaced_custom_object_status(resource.group, resource.version, + self.namespace, resource.plural, + resource.name) + conditions = resource_status['status'].get('conditions') + if conditions: + for i, condition in enumerate(conditions): + if condition.get('reason') == condition_reason: + return condition + return {} + + def update_custom_resource_status_condition(self, new_condition: dict): + resource_status = self.custom_objects_api.get_namespaced_custom_object_status(self.status_resource.group, + self.status_resource.version, + self.namespace, + self.status_resource.plural, + self.status_resource.name) + status = resource_status.get('status') + if not status: + status = {} + resource_status['status'] = status + conditions = status.get('conditions') + if not conditions: + conditions = [] + is_condition_found = False + for i, condition in enumerate(conditions): + if (condition.get('reason') == new_condition['reason'] + or condition.get('reason') is None and condition.get('message') == new_condition['reason']): + conditions[i] = new_condition + is_condition_found = True + break + if not is_condition_found: + conditions.append(new_condition) + + resource_status['status']['conditions'] = conditions + self.custom_objects_api.patch_namespaced_custom_object_status(self.status_resource.group, + self.status_resource.version, + self.namespace, + self.status_resource.plural, + self.status_resource.name, + resource_status) + + def update_custom_resource_status_as_field(self, new_condition: dict): + custom_resource = self.custom_objects_api.get_namespaced_custom_object(self.status_resource.group, + self.status_resource.version, + self.namespace, + self.status_resource.plural, + self.status_resource.name) + status = custom_resource.get('status', None) + if status: + conditions = status.get('conditions', []) + else: + conditions = [] + is_condition_found = False + new_condition = V1ComponentCondition( + type=new_condition['type'], + status=new_condition['status'], + message=new_condition['reason'] + ) + for i, condition in enumerate(conditions): + if condition.get('message') == new_condition.message: + conditions[i] = new_condition + is_condition_found = True + break + if not is_condition_found: + conditions.append(new_condition) + + status = V1ComponentStatus(conditions=conditions) + custom_resource['status'] = status + self.custom_objects_api.patch_namespaced_custom_object(self.status_resource.group, + self.status_resource.version, + self.namespace, + self.status_resource.plural, + self.status_resource.name, + custom_resource) + + +class Condition(object): + + def __init__(self, reason: str, successful_type): + self.reason = reason + self.successful_type = successful_type + + def get_condition(self, type: str, message: str): + return { + 'type': type, + 'status': ConditionStatus.TRUE if type == self.successful_type else ConditionStatus.FALSE, + 'lastTransitionTime': datetime.utcnow().isoformat()[:-3] + 'Z', + 'reason': self.reason, + 'message': message + } diff --git a/docker/requirements.txt b/docker/requirements.txt new file mode 100644 index 0000000..a26b668 --- /dev/null +++ b/docker/requirements.txt @@ -0,0 +1,5 @@ +cachetools==5.0.0 +PyYAML==6.0.1 +certifi==2023.7.22 +kubernetes==12.0.1 +jsonpath-ng==1.6.1 \ No newline at end of file diff --git a/docker/status_provisioner.py b/docker/status_provisioner.py new file mode 100644 index 0000000..277feb1 --- /dev/null +++ b/docker/status_provisioner.py @@ -0,0 +1,131 @@ +import os +import time + +from jsonpath_ng.ext import parse + +from libraries import KubernetesLibrary, Condition, ConditionReason, ConditionType, CustomResource, DEFAULT_TIMEOUT + + +def get_resources_statuses(resources: str, kubernetes_library: KubernetesLibrary) -> []: + timeout = int(os.getenv('POD_READINESS_TIMEOUT', DEFAULT_TIMEOUT)) + statuses = [] + resources_list = resources.split(',') if resources else [] + for resource in resources_list: + resource = resource.strip() + print(f'Processing [{resource}] resource') + parts = resource.split() + if len(parts) != 2: + raise Exception(f'Resource description must contain 2 parts: type and name. ' + f'But [{resource}] is received.') + resource_type = parts[0].lower() + resource_name = parts[1] + start_time = time.time() + message = f'[{resource_name}] component is not ready.' + while start_time + timeout > time.time(): + if kubernetes_library.is_resource_ready(resource_type, resource_name): + message = '' + break + time.sleep(5) + statuses.append(message) + return statuses + + +def get_custom_resources_statuses(custom_resources: str, kubernetes_library: KubernetesLibrary) -> []: + timeout = int(os.getenv('CR_PROCESSING_TIMEOUT', DEFAULT_TIMEOUT)) + statuses = [] + resources_list = custom_resources.split(',') if custom_resources else [] + for resource in resources_list: + resource = resource.strip() + parts = resource.split() + if len(parts) != 6 and len(parts) != 7: + raise Exception(f'Resource description must contain 6 or 7 parts. But [{resource}] is received.') + expression = parts[4] + successful_condition = parts[5] + failed_condition = parts[6] if len(parts) == 7 else None + custom_resource = CustomResource(resource.split(expression)[0]) + print(f'Processing [{custom_resource}] custom resource') + + message = f'[{custom_resource}] custom resource does not have successful condition after {timeout} seconds.' + jsonpath_expression = parse(expression) + start_time = time.time() + while start_time + timeout > time.time(): + cr = kubernetes_library.get_custom_resource(custom_resource) + match = jsonpath_expression.find(cr) + if match: + if match[-1].value == successful_condition: + message = '' + break + if failed_condition and match[-1].value == failed_condition: + message = (f'Processing status of [{custom_resource}] custom resource is {failed_condition}. ' + f'For more details, check custom resource status.') + break + time.sleep(5) + statuses.append(message) + return statuses + + +def get_integration_tests_status(integration_tests_resource: str, kubernetes_library: KubernetesLibrary) -> str: + integration_tests_condition_reason = os.getenv('INTEGRATION_TESTS_CONDITION_REASON', + ConditionReason.INTEGRATION_TESTS_DEFAULT) + integration_tests_successful_condition_type = os.getenv('INTEGRATION_TESTS_SUCCESSFUL_CONDITION_TYPE', + ConditionType.READY) + integration_tests_timeout = int(os.getenv('INTEGRATION_TESTS_TIMEOUT', DEFAULT_TIMEOUT)) + + resource = CustomResource(integration_tests_resource) + print(f'Processing integration tests status from [{resource.name}] resource') + start_time = time.time() + while start_time + integration_tests_timeout > time.time(): + condition = kubernetes_library.get_custom_resource_status_condition(resource, + integration_tests_condition_reason) + if condition and condition.get('type') != ConditionType.IN_PROGRESS: + return '' if condition.get('type') == integration_tests_successful_condition_type else condition.get( + 'message') + time.sleep(5) + return f'Integration tests have not completed in {integration_tests_timeout} seconds.' + + +if __name__ == '__main__': + monitored_resources = os.getenv('MONITORED_RESOURCES') + monitored_custom_resources = os.getenv('MONITORED_CUSTOM_RESOURCES') + namespace = os.getenv('NAMESPACE') + resource_to_set_status = os.getenv('RESOURCE_TO_SET_STATUS') + treat_status_as_field = os.getenv('TREAT_STATUS_AS_FIELD', False) + if (monitored_resources or monitored_custom_resources) and namespace and resource_to_set_status: + condition_reason = os.getenv('CONDITION_REASON', ConditionReason.DEFAULT) + successful_condition_type = os.getenv('SUCCESSFUL_CONDITION_TYPE', ConditionType.SUCCESSFUL) + failed_condition_type = os.getenv('FAILED_CONDITION_TYPE', ConditionType.FAILED) + kubernetes_library = KubernetesLibrary(namespace, resource_to_set_status) + condition_library = Condition(condition_reason, successful_condition_type) + successful_status_message = 'All components are in ready status.' + + # Update status condition with 'In Progress' state + status_condition = condition_library.get_condition(ConditionType.IN_PROGRESS, + 'Computing of cluster state is in progress') + if treat_status_as_field: + kubernetes_library.update_custom_resource_status_as_field(status_condition) + else: + kubernetes_library.update_custom_resource_status_condition(status_condition) + + # Calculates statuses of resources specified in MONITORED_RESOURCES parameter. + received_statuses = get_resources_statuses(monitored_resources, kubernetes_library) + + # Calculates statuses of custom resources specified in MONITORED_CUSTOM_RESOURCES parameter. + received_statuses.extend(get_custom_resources_statuses(monitored_custom_resources, kubernetes_library)) + + # Receive the results of running integration tests + integration_tests_resource = os.getenv('INTEGRATION_TESTS_RESOURCE') + if integration_tests_resource: + successful_status_message = f'{successful_status_message} Integration tests are successfully completed.' + integration_tests_status = get_integration_tests_status(integration_tests_resource, kubernetes_library) + received_statuses.append(integration_tests_status) + + # Update status condition with final state + received_statuses = list(filter(None, received_statuses)) + print(f'Failed components statuses are {received_statuses}') + condition_type = failed_condition_type if len(received_statuses) else successful_condition_type + condition_message = ' '.join(received_statuses) if len(received_statuses) else successful_status_message + status_condition = condition_library.get_condition(condition_type, condition_message) + if treat_status_as_field: + kubernetes_library.update_custom_resource_status_as_field(status_condition) + else: + kubernetes_library.update_custom_resource_status_condition(status_condition) diff --git a/documentation/images/status-provisioner.drawio b/documentation/images/status-provisioner.drawio new file mode 100644 index 0000000..688731b --- /dev/null +++ b/documentation/images/status-provisioner.drawio @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/documentation/images/status-provisioner.drawio.png b/documentation/images/status-provisioner.drawio.png new file mode 100644 index 0000000000000000000000000000000000000000..897ed95ca75fbc5a09fbca3713028a13d04bc891 GIT binary patch literal 38319 zcmd>m2RzmL|G$})tPsj7qU>?3>`}-H85udY4#ytZA*&M-Dl41HRyJitvc-`VvI!xZ z|N9u-?)u*Q{r$i9`@7%o?>_FW&*y#KpZ9w0=j-*p!K%ua3Gu1%(a_Kc<*&$GLqkK4 z1^?yZ9s?~12UltE7rMi>%Tj1Lt*58a&@Qby%4$1WyP8?TOwpKlr4OE%c(^R!4vtK` zGE6)?Mz*#bCYDC#_D0qY95$wopb7kL4L7kgvotk1=)=Rs!^_Ui&CVsL!6m@NE4lv% zzYqrxKd;I}PdFe4@&IercyYE;72x27fMyoOD;i2ynRukZGtBaqDfsKMsqrm1 z>Jw=Td$=`dk>lnT;s8VM)?W@K;q!w{%FnK~MoAMD3M7pA4FaLp8=VJT&0qweUy zr)zPr4`)+*2TQok;kJ1WhC_Yq=w@qr&}w4pY-tQ8G z7PNo3Ofpq5vOFY% zkfJg~Mo8wWwvMeVx6o~fkfF27FV}Ij8f(|f_C~fAN^lcX7#PXK^4nz$V_ z@CgYWG??2TO>;OaszKfI_Tf&r!7fqfVD4mTV(M`4?O{hpI2`6^Y5Vg=W4MitsqrC8 z9CkFaw@2~8{>dCRnZaR)n>%c<1)TJ=9;gO&VRoWDWI_slz zJe&t`;XnWF_tUWdE?56Q!`+5S*(;eFal;*qZBztQ+2ypHudpATnX?hh>4;zVY3$(W zc68kKa3>p+eKmlRSr-dSM^klMBf$Q=GA@9>K%0f5H4MCQgGZocVA60H+#bB!z->S$ zDH9_Hi~Y}0zaNkYh?t}4Av^xYHuszU@mc?L;+IW<>^s`Kfm1&mk%#Z_oDTV$8*=#W z@)Ni4@E*2V{J<%^M;(n0In@07*eJgLGgJh94w>K|uOFp{f59uiNuWbf{YUgMH38Cb zNG`a&qXpa?Zes+K{i#*z=M?;@&s8`akZ+%ER;G@QZik|7o+%i_^G3jy*V&M`=kA4KR}S5Yu{hX z<8Shay(!Gd(bD}$`Wz& z*#bELBsuyWJei~V$(Y*0;BKb&;C+8Uz^Vsh9{hYj!=Hxd`8n786LQbQ!)sz>Drja5 zCUtPMhu<i9?-&D|q@3*2BK^`bC1oCf+T6hEw!;d4{3I3XLzZtgh9rpYCge`~u z?GL0=fJ6T;(2R%wkcK}<+kXhn4!zeOXr}IH&CW30l3@}!)Aisw?bhdW!M$_!BX7!(zL&?tX#_=!sTs2^sLj?ztkZ9evw z(s`ss|H!{OO5Yp_)c<}umK%`j7u5P2J6NW!mX4_W&c0s;o^^go?|`?TvOB03lot3G zCwKmG$nalkGk66K1D_*q`wMJ_(9cya&ygS;P5C#s7)Lb|f0f1f#rWTk=by7k|7kAe zVa)a`lkqD}14?F4dHf%$ZGN6zKdiU;jS?Iqn58+Ypv@RWXTad_N}?vk9&^aM-)n?^utA{YZeO|oy(Yv@>|27oD`)fV)D~t7S@s0m&rHMxY?H__Ay#J-;N=FCqgX#Gd$$#!F{FC{Bzem;J zFLS5gk&z#Kg`=bRoBIm?yWuJ@wcl$AQU1X9{J`&c1HT=w3ha;ZZwpxeL^a}H9@wI| z>yK{?fIk1yT63OXC)%~_LAC!s_AIZW;sM~g{ScP?`EbMD6cjESLHDm3pj=1WgA@oT z`{tKo;+Fv}KYEBDF>>(!hv?Pe)sw@Kfs#6O?QM;Ix=iugW##)4^Jm<>-*qp!gg~Xh zkI3pF$oB7XHrY`#qXK~cWZ#14*Dxx$qXq*Jt0U@W*1`2Ehy6<^`+NNXJpCU8$tI>| zMo#-B@qeCif6K7_-1Hq#;jfKqd4A!?@*e?8KUk{o(Z}E0RPi1`(LYuB9EG(&N$lSm z0k}ED$l4Yt0UM|T>aU}l3{d-h6?JrD>FDAEN_G5rF@ooJE=D*Ug7PS-`8#sb|4aq` zsOt95_J6L_cz*$&@CqF2m>=2fi1q&FZ1!s#cOa-g5FT9N16LV;4-Nal&i#nAp)S4r zarxN4Uqx_pf6q%ELSOtx#_&h#9R_nh67PthM~39D$iD0cvA=V*@+iRl=??3U9K8SQ z(S6&49}YhNQ~|U@-PFv83rt5tqeqjMk<@TCn2*PMbiF=mEsVK<>SXF5#)SuVs#MrF zuupTbkiEYAdgCrLV=M-BUlDq~rDlaHye&&tVSoV%iNCm5d@m!kxW z7%M_)++CS_HGT72d3U8#OxfB_@A4wrPS57^nJw?OEOjT9(#Z(^^SG=XSY{{67&^vk zKS`ir&7HT!4^^^@nVnx?y*(BD`X19PE_4j<48AWK?)nup=-An-G|%rKk%AYe$Z$W? zp$93|OrC~DF5)#TO3*W7(L=Z&v*Zr?BVCyS?dj1lyhweJ>+)SSW%LjVW&AQM3^y=F zX3g)-%J4Erb@aFl zF|RsgAZ>Tw?38tt@pA_EJ-)Oh;HaoZsc>GQ)oXDNUOt|YrK8E7*B5p#=}uu=T0OyJ zNLz;ZrbcS?Nj4gH?~Qg|;>PnB*FzB}!$%)PIC{+!1eDJ|VQvXvu^q(`$=F>Zq=eRv zzL5_h^WLqWOvIPmg!Nu#70_)d#FrV`n?ovfGc|wRyHUK8TQsj{t0o%J%X&%KZWJ=S z*+qBFGw3$ABrG`ry@&{d{x#MM3L49%yii2f9-HCA0+aQc?+xH;%yl>r?R)y2jTA3Kgfuh{TDLGw`1hDjtU#*Rnk& z0<_L2iB%CBl3!b^uB^GI|M8Q;$TMhuL^3@lT55d_hDPD_?1#vT#puVFs? z(6LM1tZUG2%tx)Zs6`RWbc!`N40#|tM(lYs zN^(g{NgXB3K}ve&Cw7kcnvnP)Qy;512V@((Wc=E-=f%b!grGaAh6Ch@)weDP!5Wkr z4P?&AjGKcvh!V(L#YD3coB@&(k%Wfp1J+LuL6>-$r4WQrD)g=Lk=Wgrxi3G-92%4? zGWhF9r67csJqr#7{ipNjL5M^?_7?fI;@%4SYQRmaV22KKP9oCDB|No z3x=mX{}}@v#R6RPX!+befyv-|@b7If(e1c!0@OzmuYJ(!8qjD%fb@dzrocH&*-3Gr zK0-ss#2}`@bY;OueMJBCG8#1HEnX?=qrcx|-$>TSIlbjgO-+-Hx7VbJM9=La68$iH zq#dg%1qN&}C1G>!2FaI?VQFimzCNYlhdK%|(oe&RL5MI3HO&<)Wuftt!gixFD`zB$ z!2aHWgSaVqE(EQPS*5)6gLp-OG9`O9czveP2h?CvhNV;v?Unv^4_9H zD=}tJ`0by#JD|nfV}g~s^BA@%8(5?te0;*NKFKYUFh48sL&LzDdxW!sffWauYM)#= z3D_w)oy!60haJ()#wz5ZJ=GdtgCkl|HCkDN-?~q5y z$)llTsC8cMrw@M&KFJd%l?5`DIbC;?R0cdeZK_ixM%?8`!+?H8udp$E{k+@n+QlL$c@uxK(kPE1p{A?xEqCI0XjIP8QMV1zyyY1sSqq@n;o zjY8XNr0qvX!&@74plEP^0^b2WP(v{K}+zqAt7({ z3qRz#9B)O)Cpm&JC$X1U`P{MdGNm2J0VA3#)d#cCpgc9u73yl zNQDsti-*hK)VQi7ITIa9hQ0c^7!Rz;IEWDJfm}xebHIW^N$yS=SZM6kl8#Suyf-9S z;u(F=d`&oyf#cX_lmIiox}!=BjXZw~JVH8%X+S%EL7t4hOdgli7t zg%_t=dWVxc)zUQ56ih!>Sn@iRrO`BX}Aypq~i zFW)~+?&-QIpc1-!CG_g6y%gSOF#S}6LxHZf5wL1gLtx2>yw?n)aHeF*}*Pm~= zUPz-scyq9)HRn`Z(%gNOP~3sUd3&w;!LH+y$_&2m*h=B6Y#CZ*HY+v-Hb*v-uZ7QJ zPvWb^t~FkY$ZhlV^rye?WqVx7%`s%1s<`jDVVg!3r`TR#=0c%j}46)0;DlxoN zd}f^O6h%O(`XrvO?&kV6tIi6%eB71 ze#q41WeR6A8AvZmhLOeQR&9nbUwtGId0r*GOIZYQ;*9Xg>CK{2J1n39VFrmktL*W} zyv3ENj#}z`HEiGd^rAh?alrxttCh1U7DL?|bwn;ySHqQIanQ679DbKU1EYMipideAWb84Fy*Q`D}#A&1z^(2*vft`4DuPmCdSHN@o_=udy^G|Fe zU$3#Bq{xRsQ_4oVtJ3;;3+fA~kWl+~4~!y&Jl6B3Zc9p{doI;e*1UX{uD3T?QTkO- zyzP3r?n|r9Y8Hk`f1m-#zCIwRD)l%)Y%7aR&vto~7(Bcw}J*UjXSmOA{ z!idrO)ykq77iez4Y%e*@WCG$N+&LvHsTZOHS>OY%S{m}sBWn}l^+@KDuj zrdfUHHWR-bY-ca$*W>`1PVNwVhlsp@waqI49lIyjOhI^_J@#xMQD`NtGS4&i7ikUX z7_h#pae``d9gD(|(JeaW@v`n~UkIOHB>y<_G^(&otbbGVY>*N~zT1{JZL;C^C}nr{ zjp9ZMQflYR^eh5TtOJDy)mRhzS@8{KA8cKMWt|hv>aeJwU{-o^Za1hhNL@LpBjxgC z3IqjCK8lm!xHljI1}jXWn^P$(L+%S5gD%7DyNd$2=ZI}daYa`8OpaZxmy4bhYh1p> zyb~YT92kK`@9A1Is=Lah%w7xkVsgYI{bjI=TEynWDBxuYtQY z@)rKQ&Q*G4s4=(Pt@7ykIKse7%Y{atUi(I@&_T&~zHu24RGPQZ=4WcVa+H21HJWyu zLvnwPNG^q8-LX#@Pd<^~B+D|C*ze&dak}o94WCjov;He4#c14)jk&@$|Eau}4h>Z1 zbz1@#&?|`;B_wbN>f{2ic`O6NIC%mcgX+}RgqBvVjP@=Sadzbpa|I7WiW?$|F&s+4 zt>jvIMq<2~Z^(ctJP%`e=m-2kk6;N{L6B0KNS+b&6K8MviU*j4rT2LyU% zb=F_cy2g}U(0Eb>coX#wLyqo*`5jDAVsyzJe06sV-zSPE-X)}A#tMY*eo)G-_!>eS zPig3(HaDHOXnPa=+E*aH4+Ju1f`AXOOpxDklBTsm%3(IUIHY<|?y2=~l|B~PV&e5| zUA&HszBVs2h&xMsG_d>HrzDAekdM@q;%Kr_cA7*@p?WulsL73F@_BC61uXM9WPW$K z3%u9bb8EghYgu^iy78^Rwt4!E%fQ*$&6iQyI|mG~Az?5cv8&OTGCOrO+r^Z?ckc=& z#+UgD&0CTK)D@=-nmI1UxHLzzOWA%Y89PBO?3j5ycA6(!_oYG!5@Iv($Ua}+_6>Hz ztn`~m;DKx7?Jr7Q=^k<_x!v>BlBxTx-kh|_MDHr852xK4?l=rO^#;4>K1y{NayxH2 zpXYvGAJ!5pAdtnA+gyW)?(L#Oeqw-_S&)s(b;%}Ogy$*>8g1#DSgTqC_D{-JYfj+murCyQHPSTeatX7mW!R*DD|(K;zmqsdy2W zR&6QTT@!)~quRSq_-9SkInj1{y?3-mpbj)%*}d1c9ZK`{c22Xi(TH1q)S?7jsZ31& z^v&XUjA43$s5>cq!n_>TH9Mk$;7Bd_gwZ}%L=UmKPWE*MgkDn*-o+6?Q<}nuHSJcW z)SigA*z}M#$8csO?rl#doba}CmNluxemZR_J1ytH>)l8rj&b&5OL0M$YrRiY*XYja zhNZE$^^apMyVA!iZaTC);g_u2Q4cEmb(&n z`OFD{dqHw%taqd>j_?B2?(SN3#!!AFC#BC5ugA8vlx~u*kD-<~0sLg5=K;g;T3-y0 z+{ZTfqkx$G6~lFFc}sLvXb-dFMHVX5i(OxQm4re0^`~AC0lpNIIG&7OO)KZonx>W_ z2PL{JfbF!dUhLT#vqB&-BIi&@9WH=}T9$1J)r+DtBF?xj)Hej!gv(ePYZJ>nKY4iX z%o?iqoZvunl0E@e!hu?eObS)bpEh{TE-v16`j!l<)AZ&%8W~ z@hf05SuyM>iN=FPZRD}6n_4M7;Uh7J&wO}3`K*D92z;sy=&`!h>`!CUNPwY69OQ@O zqLfM5$P->q_W@ExaI4-r1a&-g8yKn^_v1_S(D3s+;`Ki(;sd>y#!J;*n+ceqJ41zT zl-RtO^;vDA&E|9k+QZt6=UTa6(nD0g)*vYG&G?`szQ7m^Scq{va+>W?Q&Wqu%~&GD zv{8Q;C_cCn#ffHm%e=3^H0z>WPRS^~fF?aB2-d?6$!u3s`mD3_+Q&+Ffu%F11_$5h zRH7BxjWsYn^}%Ec2jNo<6NoBeik)vsmUfunQ1e+xq{tyiuix)AsU1-f@GPFjP%Q!M zbNVZ@@4m4pLc30#*iOfF)5o<#cl=$HXz|VF5tTN~pv;@?hRlrA6i={y@1Yhj;3kuT zofowAZT{jH`dBf~GPQZgnO#2WD?tGa5QZ8#_3J>3YI75ki;y(b2C0lMYeMD>M*E|c z9$eaX8?D2Ov|{9JHjk(&K|5(v)R;ki2KVvd0x0V{(-bXh{P9M&5tgI1i2_5eDVF>Z z(JGtRPa|q%$J~_YPeA=FihN=$fcsycjgfLW>{}X>SImjt(%Y*u_^a85)d^4dZ%rtQ z6p;2UkeroUzn;5)_#2|)uz5cC&_MBK$}@|VcSCCY2xn?HdeY+0z?p;GbRELEFVB?iM%znUs?Cd#S1$nKX^dOAm^1vUnlo%{?i-3tQ#3^ zKv$9o4`$yG7|XdlE@zz2TGlhTS>Uy^*)mPm~o`aJ;YdSyR)T>`p@7%)QTOxHV-!6dQF+Y$j zK1tIi5NMe{Dm+$IkT_`R5kabGg&)mIW34SiT~DZGX6YUE^s7+tARJ))V-&f zKeFixm;FN6RFx+ZhW%a#qGedf{prT3&$nI-8ld3|Jn8)qVBZG}^ZOp00SmKjy{Lps zZUwe!OU*qM>3T%Any1%?Pscs|GIHgXS;IZ5WP*w|UiWWv-Me93!uCCHO}lTRQ_#28 zO4SO;JYUU9%nx@{(i0$P17?X#E#EGq`vNh*jnUML| zK>Os~ZY7V>x1(A zNf5%t*^UK;0|3NBNV=E5V*6`zccHn89q$U2d0dkI&^#f=HCq!*>yJh9IyBQ#i67cZ z@7YWh3i~)RW{67E-t*qu8m^DM_V{A?#lDaGE|>Om13LgbW|T|-*rDY#iX-kR5Snsb}kJluaHg4fD8cr5OOu zei}G?OS~K{u~W5yM3fztM(3;R<&K@^v;O!nT{&I>P=L_=N^<4Z4hu;`pyq>3%A{|d zgc{$aw&COTs(Se4!*fdVlPB8&%<7rNIef9nNguI{q3WE!VdutL$J}?32x=@=jZ4gB zZlQq*-z*eC7(QrN%V2m+(#Qm{n)d=?0oE_juLy7@b6fyRwU^vIpWrHmg%2Aj=_tw3 zBX1P&7Ta0r8ujFnS2Q@`^o~Hn4IbDwV^W6ZxVO90og|I(L;y~^6mM;H2YJq#DTX}d-_@h^@d6?EN-f>4YTSq$ zaA##xW3Lj&BCi%^+?Rq~GEyX$Lcw2#iVMhm>5?vFnNbIx@KWsq?8*BgU!EpO2Ob;L zo11BKM7IE`njqN>fTOI8hH^6N!oDr^t4MD~T^pA4oO;%vWg1s|rr)JAPQK6*xo)J9 zVunDryi^W!2qwHy$!{gY>s*ngZhq`+<5)umo%5PmYaFXhz=VMN-3Xwon6M_xW^+q=+NGGzK3v9V4E1afw3 z-h0I*AYn6|k*GY@fFN^-B7!=6Zd7~1Wsf1c6Pr=LPAR&Un&+Bo>QJw#)$Sk2d~N60 zs^z;B^{LUsZp4~aE^<3Hz&vevatz7$7&bRr7TS`Nau*#k5rA>dx=0I=Yb+PgXLmoTq z5xELc-d}D_7g^q-Wre^#6f`bwWqB?7(GiPJZH(R+!WqBOm~w?UmL>C65pZ6vq*%_g zp<;R9>x9})adiPJHy!4^okAQZ>@?%wR|L1-n3o&7|I)fYdR?qsT}3oXTW_7AHh9fu z{T;)Qak(2ZDwrY`08Dz?{Kbwi&KFdc<9dUs;naMa0@MPq(}ZV4G_-lyt{KyvR@_n8 zJl{}el;8LA>q(=XW#RB1_Ptndt@Rw~$DM=1U&hT*=uo`d#N(Re*64jta^qXjay6q^ za9acvX6OGH$zGR)WE0#@=0BI5rWn)N8ZRNz5m2sSbO4|4e8llAgL{^!#+0(`;sD=9QAJ z^}D`YWaak`V$GDCaC@>V?-E)jlwzk#$J+=fkrB0l>KJ1U1tX%TKXf)Jwe6mMMuWh_ zKq4B5RA1S;F5LISMDEO+cfQniT;AvtD0Y&B4UiHDd0SetF7<^0pv-@O2al2#=S1E7 zqB*e*-a46JOeNa`SyMuVv>s-B+;3xTJ!6+smF?{2x#QS2?{mmIZMI zF2@U~hwQEoI%XZgn{KjMqnDgS-a}qn^EEHc+(DLFOnPa~Fh@E*@WdI?FMQ&WIYCns z%Y|@*nL9|=Bntb-Ri0ZRESb|?8G!>|ncrfOA1hjn&3=CNe~we-}} zpbJA?13cOJ0}fEBL_r9u>UPCt#j5q|=w7S#51S@x6M^?%%JZNf0yLzJJsmIlQ7r&U zTTZz|8O-e|gO74W*%XHPB6} z{Yt%$5P{+hkOlp)L)f^y`R8D$1vMwWN+y;$-9U!d}Ssb z0tTsmED76Yw$ehK!3N55g;pGSH~^GbXxyqf6CNnO(J*tF*6rK_u{Q_`yl-^^C=&xM z>3F{`YjC?OU1>D@e0dj$+t0Xpmtd;V?Siu;Z>G142ji)*Vqm?elGCkij5uovoO?#8 zmh_V!MpQtm@;SYbCrCK7G1WE18GYtINkI|PSOkRa@(|@qOtsUy{xmJmn8F{m0`D%J z6lENH9!_XcEZ?&1i*JBO6tVW*1{;+7^c~>ao2Aawdt=DC!kGyK7K+TTN2Z*Hyd>!8%o&s1|}iRCJu* z!@XxAFkJnP&#Kdt^@)ubf*G>~BOmW1d_8Jmuv`mRKkNFnjJ`*AE^Sjq(X`05PN`Sm zO@##6RIymTMo=*Ap!Iu=2A`LC`nmx@g9)pDVdb{I{Gzax@e3y@Ncy>9lrbj9aw=5e z5(ho-dTs48hm4@+w>tOD7N)FrX1C}u-sUlks}fo=j?1$0w}Ukhptz{ZXm(|*_Da%86!Q#9sU8i#BeqS2b8O1ki)>%u)jIu=GV^fBTjUPb zszd`NTRrYSunRIO7O#EKS6W3DR6zhNZ8P{nC?2Ib9&^iCx*trya`~8m?!%R{uyXit zl?H$*pNM(h_OD#*DKZQcuY7s`8a**Bx;{xq3#CEZ#aI4lzAV$1k>5as#-39!QW* zArrj=dQ5)!&Z~0g`3pAWr$lcTVOQD>2RH_bFPYYeUVfMtc}UTR;9zbaV}$JiESLNX zUsYygZc(tYJ)kDC&W@sEGW7=OU=wM@{OdyHbS)4ad!LF+s!=az)vM@yPO*vQ5Ugbof_^K;I zOyvQecAu5?k?7{VskRjhu_g_?)E9rf+}S$xcKR?mKN=S3TFQ{&dDIoKq9|tcOL6

$GKWg(GV-H0}IJv?^!r$-w^V;(EEubG0lSdT{QHCIa2+vhp;5Ri!R87l5zJu z9p1*vm#^b#62wI%P^CbQnW=2$wJ+xMR7VP*%a;Ld1aJSKg~8BvmWXQ*f+Ndy0-&)i zG^mh?RDTnhZ|t}+9@AB1*_8=E!hEV*IW&xSgu$4>Eu>4`YACRN9G^krb(|6OmUccA zn~+XSucT}AfqgLIbl@<`e%a|T3~l1&FEuE!gW%Tu(G)enQe?5Qu}7V-?5@}taKDD{ zt^kc&$9&>>tvek9WT?+h9;I@}Wv46M*FrAB1pOZ^s3__J#D;*9<76=*9}L;Pyhsm8 z=%`NyNgmh}5R5i(!WEWlU56^%P$-qm{l2i;MB-Q(4n+6U1DKwBEJiC$#Ch%tf|{Sy zT@Z3!03^Xqr=wN~$;Dr;0*3Ka4Z^H}GgslKm?WQn`WSBNY;_aLPGw)LN%R8UgcwoV z9qQ(Cn7C?pk*~V1tH*>9a)~(?HP)*fT<=Y=&q-X=UbXm(YzB4X%2_#HEn89Kf*DCG zQOfb$Rxc`hT@7S^skyoH#w%D7ND2iN3IL)hvS9*dXMhCj<3n$vj4`TEpt^$nCw-`V z`<>%@HkZQt-kdT`1Dad}Q1E222Ct`mZr8CHI2I;AY%d|-gRa?s2 zB|3#>MF6&HtT%i6EuIgg+>7Spt&g^&wN|?AWF0 z2@(6xEJ~+@d=qwCx*Qk6-7`U2xwEI#t;_7T&Bk(;7b>!8i4(jS<ESf&*W+n)5P*MQWxA{?gP{4L(V)>;5$3;KjPI8&#Ub*?(TTkP}0)rrb+f(#7+zS1ONTUr^W4W3hqzh)tKk1pZ1>$2ZY zXxknG?MmYO=6kJtBlJgNUdISs^8}x}W7&WEFn1{AXx}9$R5(0 z7jrA5oMyV0=3Vyn+C~pI5jhY$Oumy@SDpe}A!U)Sl9-@L&5sSP<(I`^(wo#JFM|9A&$gpPo6^W9#IVSh2VuO{5b zs#3qXRBy%S&KjcJlZ}r;F;S8*Jq+n@D_AFJFR>UDHMk3a{87kB^>7=$p~O)wv*lu> zmZ4|VI<~`hV1RR@x;`Tr&F;50jJ!!Y_fD~J&9zemxS{Rs`O@`%?uS(t&u*g2tAW~$ zu)Jsu#p%Y8TGn2QNp*e4>AJhw&ttT9?kjAGhj)OYNupkE5a~kM_}iWtMS)05Dz!20 z_Dt4jC=SlO7y9MVIi(ZShP_q$(Z^2=(RS5G+GissTwg&gogNb_Zfq1YhfS=EH@J7<#ItflHr)r^YqOs=$HhW&ka@FLLl|%9#C`S(+b%r` zIVdH_uQzQ@XvvAn2{#6 z9w6nEZ_vwb8T67ai5Q-&4l7V90Tw^8{nZBBy9Z2){zM{)*B(O8`k{XoJMJ)vBNU2_ zgOwSLxB$_7cAcDNF)mK+);8}D&VwBGmtwnDol=@Ic}kNwXCtAtP7Zw3=c$<4_08ul z7)NNV!Mdk}V+Sli_DzL-I=6aPm`Qk!0%Xo-r(X6Nbm@Ne3JtRH)Qu-#A;ZAJz=&P= z@CrU`AT#;!;ls~_Az-%*B5i$*Q{1m0pZ(&O5nd)ITVjO`<03{p$y8{ar9EW`+(fS?zkv}!87Dy0;0?FtqqSQLMWxZx^wUtgd)xTNr5E7xdc`QiD$9B1N<{;optQ@JXy}B_a zM-U0GdKERRItidOe-Wkg8|;i_tJ5p&nU_HQoGis^$TH5H!dOgpz~khPk)a<8@FMNR zUPURw2Dm!NajKU~r;Ul-NDvfQn%%6pMHU@W12jD#2y73MfJsTmYY_q~m_f5&>1INo zXW7MU*BCiy2@(${0>}m|l5Vz^%K_t}egcI(KkT9BI&gbL*bOSQ&$@UNf}m!&v};-j zRU9yJ30RY{z|JRF_yh#9Cc2ZY@%)N)oHTXJUJeuKZ;5FHiY`n9fN)gWQVGK&O|dOO zG(aR9co0w0DolY!rV)7fP8rzJS5lx_42;8pyV%lrIgB=~)M2uWWnAnc2G#(tbnpO3 zDV9==zw<@&)$?C0<-}U2WvQaN{jtchJ7-Wl_wibqeHtk$GE}1@1Ho!8CJKt~ek$*i zJ3%MrQDU2c9R+ISLSSCKC{WlN<(?!J;b2tWUS;$mp)xdklnQ+Db1i1|`M|_YP@)2C z)sL9IATGdl`S^9bxqin|UYHKu44&O8SXX6+mHOATK|afA@VosLedX zdZ8`>u|y5Z7Edu;SX4ljzI11*oy`v%GY6PyBA;5!Ike^m4|aOsg)mqxO56n4(x{xp zD;sUcZ3SYQ>MO~#QK;exQ7{ix^06?i@1W-<*u!$@xfx&Ndhs#v7{+Av^~zI_9~>P} zva`#7OFhQ|+(?lLhD?Lk1>9#t_sbjSF@4!@yb5wDUhazp$G)Lz%%>>n2oFsE!+c3VuV`wJ`^zOYZK z@4YcVnOT~c=cZ~Ht?0cAVHYG`XLhl~X<<2{xLQqG4&_+;e8cwrF?DG`R@ZP*=pjL< z&oE>14MXj*=I#@9%rjIHTdWvqX=z37Yc{%hH+{`7fUNZ;ms8lw_kD5%*W)=A+EF15 zoplCN`%_6wYSG(#PE+mZ{u1;NSlbjNgPY0z!Kf%IWxfat6+K}dJ5KziNFnNiFDdT1 zqanfpfC+ls!`cK%Nq@7=mFXPTH;4&pDt!N|ui+#e*_@w^qHmqBLDhGIUMSOLb9Z7t z94loEKttCm2^Ye^cpFAXnTtFH@?||eJFS#&RGaz1)S2dJ^BGKw-RdgAhF8SA$Cv?# z*Dhs1j2i}OeJ}M`G%@++=$D7wz!tnIA@Bx~u86-OL4OGpt^~ZLW_P2{yVc7vqC6HV zyrDpox_vIsbqPAtlYPT#5n%JlAVV3n2S?`l0vum?wl_D!tmO$6rm)947x2EWbn`mR zXgt$>`OGdp(+3wFLmRt??2)*)7`sk^e@~A7Elj8*e!4Lx}L6f5{IM+q~idasLLpz{Ymf69}sys;tH$Bv)k= zr}TnB0ns(s`nY%J%(eQCz62}ZC8w*;f}VL!Z0czDF*S~LkkGL5%zcgYY=m%7q>^8} zu0hOBu^MYrmZi7lr3}Hq+D;8F!#MkTn5!?UffE$NVBPI|7BU!wIEnT0Jq^4f;`Y-5 z?Im*+v5nOAt$Iqst2;Mzg)h0XGR{?vr+y*IvhAFo7uaF3U)cO?Nlb!4=^#T2= zvk}n`*dkjC)Y!#eDmm#XUsNM!V)fcC3v;ZQ=}iXrd1Ogfv6P?6hEjB=DN;fyWzw+( zbU$?w>tfo}F%QaHAe%DdImSyw0> znxZQY&e|ne*!P-VoafpNN1!U_dyf%rNZ$Mb6P&*sm01hLu=xh-3`TSns0BWeZ%hrE z8z@yT$=_UC;2f~z*;MLDGVU{xy0~`P74C0{@LH%`OCK;xhelfJ3A2@2vno86>6x{c z^zi|=7bvOFhi(pnXxf&ao+;6%-=gTMO*+Wwbz(EI-vQZ6Hm97>H3-u4xm>L&SuD&Q zGD9n0MvrA_YPU0!pj0M2E=DrxbWBxHmZ__xdsk53>G6xRksm;2Iw`Oqg5(pSzSkAU zF9W4gbf4rxVY(Q%kZ!GVh|lj{`L^b8c3JRMxm@*JHz%X{!1KvlB&AG4E2_(0RovkV zSyNldy?d9`Q##^0ZqGdW;QV=4HOGSQOS$1>^f}Iw4vei;iF!#=R}fWanYSg>#9s$S z#9oPkn*GYxOd6~TP%ZD{;><$D;OmQt(+MIZORxyySlmf;B|jeyPzA!l`nfRVHPE+p znviYkJD_$Tee;;W6U{FFRv#aFGy!S~sTX*Q%#({FwE-f8c%aVR00Wme&&8&lR|#dX zNX^ODov^Nen+MLILurNMwfAdd5?qS@SVw>DzqsYN=0?6NPgwqK2+mv7z!1v#m5 z29N~Db>fWc zm<4@4R;ubEQ&ik~UP=X%?vMjB(PR79As$V!m##K#1;bhz{T_PUL={$#550-}f}x_L zLfpG^nOg^_dC4=@$K-B;{C<)BxWbYR(K%u+rcV`n_i}TZoU^a$=um+QDn-8MRiBiq zS|t)zdflug+Ix3m3+!&tqGQ+uGgPl)tqX?%^j!^@!&OgNoO$M_RW`3T3JR~<@U2>% z(P;=pVtEdFBR?}hjL0s#`;txd7jug4u8)n*DS@NKfD;j(i}hKy@?xcL73ne&+w}l7 zeE_cDLchzo?06R9k$3}4c$wSyaIp$L)shG*-n$U^5L-=Bgv4tij2Wir?HtNU8ukyd z51wm2kN%eZR2WQNDI}@Cwtz##=>+UP0sq7NXrJSr6I$cnrn>@fuS^+P8+LG);|c1U z&KNYO#Te**flN8Vd*6SSYyS4phbw0*EJV1D`NCHV0b-1=s*@lERi~JXjBg;3yh8se zz=xMwsdCq_th_tssa-eK<>YwxFQudJDMQ1H90hMH;S=M~ES5ix%OwoY&5iD`7%;ES z-CC=8Q*?a5A{|R@$m&M}{9#9CQi6UObg)N2tT%CrP^~!iB*is}Jvbzo}B;mN30GcWf2g zf(biLsrDp$O*-_eR4+?|BuB_5BUAV^Ii`x}>ce$N(`oKHGdK+x zT3j?4d?kRnXWph`oQ?@E-TfcYZ%~)$k`yoS`$#YKq-N%Ha~)$DI}7x zdy6`TrvQ{CyYC2EGob4WJed4ob#p#wUt9LYf&`&Da7XmR zjzh<&bza~D2RWT!-&Gej!n*!KTM|{L@rAD#4>}gDUGWvvO-z7mallV!KlkYaf;q7B zxfbUI78c^%v$m0-$kU>plN0Q#^=My_LZ znH!k`NvjiS>k}YJ)^Y}g%&H2%!4g~lggK&YJbPF6yw)A$XU3BYZKwjGzd%0w`Ali8PE&@3@#V9@O{*$io~^Yny_G_TwXN zR-+s6(w(%3Wo#T`E7B30S3yXZVaICkYdm-dPwJHBEYuIC4@bu-=4a{9O=d>wV;@qF_efbbHf zEeE~+AqkIe{*DRavRW>_n)i_CI@z1xNaTAjljD`E8RG2^g;dhXX;>Rg$IR6#6=%9W zc@1WP>!bleUaw})GW1-izS_*8CBocH4mFkv13om0G;O#)Afs~7v6CTZ1FK1hkWROh zxYwG)jobkhv*U~{fc;MLJ4LS5e-9oBbW zNh4wMl&wrT-;(CCTksg%l~ya;ZRlZ*Kvu)yJzl%L*~ML(y#}OXr?^t@Z4L{>73i>$ zGTz`Q9{k#+LABvv?p~lw5j9QLECcDQ9iIAFu^LV=3FE>H}#lmaSo)r^c7vUmH#GE16Q$%C$-+j{ZF z4A;CB{d)0BffwHnJ*PB&a-^`X&6TnUC?y`CBC^C`^>~LjO zZN1i9Poq-%0owB1Zebl0?M$nlXG|aAU9T_)`{1?#_Sf>_b~N6;&dJlG2s}xX^LC@% zC5X{6X1KJz$8+%N zo$DptuHqSzTM-F$=_Q{TjMN%&;%BZsAadV$Y(+vy3$BUZ6WJh)4`|%*iMfvM9a+60 z?ww#StKTs-V{gN%`7o~LwNqjJ_zS-^<<3=gv6T-vLJNxHq%;{(Z}wbtU8d`Fbp@W) zpB;26gQmQSvK9i`!@G}!9_B?RkA`+i74`oGxOs3h&?0{%2?RJ{BRGWv_B2!r2KkxJ<}shJ3WvWHs_5t~ZrV?mXfs$uxLp54GzckDYx| zeDZn}Z`y1IW&X%*O@hm1Z@+n#X50}hU~0gqMaQ{zd{7tifa!)Ek7>U&$mr}n01EuBh466pnUTx;S2`#FZBS&zEZnlTJyQdDKxs-)h=tArl zK6}S-lF}@6QjhN7>F29upEcLe7S^I9i1cus)rQQFj&48K$|iPKh%@o=>2jKKM3ma4 zWX(5uGC6s_D@_H8sroZ2*&#iB>8xs+0{JGWmYfFoUIxJW?^kV1&@2h;ZRglkf1sdQ zjjeXT6dz1z>6?r@51AHU?}wdPD*bsQIBr_oe3 zQI!xDpbpf2?#3j-Z!7slNN6p;6B`tpzV`w&xQP<^d#~e!94tOJM$|kJ0(AjaE5PYU zE~hhy*Mt?s7cP;GM{%s^ke{5?A%DEr2Df2zxhty$Lkhq9&~_EvfO^|m`Yeuv{pG@p z`RPma?2;2r8~CkaF*ACucBN?e38$RK4CR*QH(fK3-U^h6iO<;04a0^uK?rauB^Dd& zj&sTxA$xxi+{zW2wIq|BSH>L?8oS3k^{rq}=^}*r3Xg1oTNbbKqD^o{o-36(($F=H~);X+Lnik~IObx?k7dB?)nrgcO zb9SGjr)f1D##4b57d1-2q!t()6x{7vO(@82Znm@aN46@b!|ZziyG?Jo?eE{QBlD!; zm?;O+vVugiC)lb2OwH{hh4b3CVIq0PtJOWXeY$q*GUNWzXAo2B++9EV6xKZ>*XVKH52Nfse;^?7TEi@EM-IioT>a*fCXw z(769~`+7()O$`e@c6Vf0*i%k+#wc|9vHa~Yony~T&8H%TBhIWg;j&Yn=?Nll+HUQz zO;hOSq{S(EMR|E$COF3sWFK1AA9lpw|8DUlHv;EJk+fh%ocEs4Iaa07nUGJuznln&7r+2)3`VqI}dF=LOiX5MW>J$0z)`OCJl5K~=Yh7q6pa5#9Z zvJWqw7~K19K7q;RXi0caMAyvy8e*HF%UGY@ zgA8I{xfYfJ@KK#VMW9fS0VMC`CckF06L?uVi*Kue*6y^0*I39T753{h#?C5lH%x7S zKee#BC~4g-<;xdU{eXkL?Q|Wp7a_4$>3B)gIb`W#WgFWV@E}uz&DTC95|kRZN62*g@6C5l>elmB&tDdtAFK^TukZ*N zwzf9=?YVVf#t$7zzuU?Sh2_1Q3)3NP;7zhBDbL=vuUWOIu1_EYI`lZI{2C^sF9I{! z`vLXJpqih7@%lJ%pu3X^l|+h%4nFA&ZmNtb^Rm_>J!0`zVKiWi`MjrYG5}6^Z92tQ zYzTymr}>9$E&HCiclbleofP5BseCbS*TZ#7iD>iHTl&Lv6>^9$5|KtpRUTN3-Al^a z>iU&q_qFz(L#jXW>?QXL0lW-8&yPH2SgsN)a;Ko2HP-_6lBZR-S>ty~M?E}u<*oOM zDiApy0?nE(wx}K*)Q}+S!UNg9mFQ&n>{5oHnxUA(DtY)W%x zX%Id>ff(vGo0VY9oJ@9)3D0FjDY1+Vg3^jjg!6>E^|_bcSs~wiRrhz+g-vqYNEgBc zLLJL^-=*sk!1U9i3Fy-Hf`%K0ff#z}jq%enIE01cs)E)>M=2-KT=vM0ClBL!WbYbY z)V`&WDvBw)W_nFTf@{5@2>HU)7rd5Fw;sN1yT?`0uqcq2z(2o50u9s~7R2`H1cQOd z4*nn6Zb+B$__!JvDeYM(C@I2mePwCxTy$Ia&HfXk@^Y5}XdDZ&^BqfVW_sxY3RCn} z73+F3Vsn}-xd{I6^n$a7LCXKs4DoKzW!Kq?V4qe+Yu=u|+)+|-7@ zR0RZ<{&90L3llD}03BP20q1Ppo*(x_MsFPslwFy%b((hvm&7k?+QdF3Q$_YCDabYr zwx{hvl+4NhrW~kZVMr5h{L&5q6mmjzGf>{KEMpqb#TD|>Yw;`BebLlZP5|3ZY?xyi z1H-<#`BcZ#Gw#stxNDwE<)jQyVp^3{4l)zGjvW-?ep4k)Gn5v~=qoW<-NEJg6F^Fs+%A@Zr}z>W z4ZbcO>t9pm<*5hjFcUqs`T`N&Il4;dafO`I`Q>-yLc*6^;hzsWhFf+2W!+Be2S}_9Vd{r8 zDHGP(22NiUhc_Pa4_lTwgaWjLE4!i>1frwB7bfG|!|!v9(uqzxNk+2@;|uos&QA}l z&WT{hv|md+CR+$|yK{Qv_aoTzB6s$#4K{%HDb1tR5lT`SxNwf|!)H@5*Lbz$HX=-;`GeH5cLD80 zY;SR6XP%MPAUCu7c&Hn|Bu=o0z>DR?TT4myGW!cUoljV(M>z7`$&KxFhA>P%y5$!Kx( z4-hAX=?xJ*oS+z%**3AbG*jF{?n$$YC7RX&A049p#oLI&O^izDBG@Z+C+B~@RjYUp zqBM4t4Z*bJ34jQQRatEB4O45-v#G|}dN2Ay0dA(tHK5%-e4m26-ygP8(&(!LxBp>= zw}mJ~F0nJyuDz}Qf*^c8t>h;M=4y*C5u@MISLdw`lK{EqKOiyfG{p+iOTLVc?nrv5 z^wX>=^Tz!l;)lVnR_cXQz7V)zP^2it*ZzKNW9tgA%_ zX%52Kfrke&z;cx=efE{#dvIW`v|yxfEzFzTD;J`Q5g)4bBoycHiC)YJiGk)8BhWs+ z!iNOynq~#PfP8F1mn^u;y9>;IIkVW{LG=2oNv)@YwGkdER@ytY&j!XeCmJ8@L4nRr zu=LR^&ze95ZcDSDW#>ZK@XwFZ5R-sZ+D|+snrIU7qRcY0SMy#7wf#T%d2w9Vqy|>b zOU2`~tAK0N_c(v0YF}Ds8HEzot4DlYKkMY~q$Ls?W^ARZYawsl^U7qXs{^Vo?{5TxTh!zg@CAKuu{Iyppn0_NX)n&DJof**w=5q|-PA^;4@4X%WNl z{LKQY%ck#u4PU-jwjD{kX@t3iWoufMN_)JP=d8-E>M;_!oAE+X`@qGS-nmSU#JzKZ zoP#BK4y4zwwNDZ0$UnIt;5A-bc)R38SBHBRLP5E4666Cl@+wgb5W`f*%o`D`tPF@e zGqb57=f4>IMcN5?`J2>fQ~yH_B`Qf0b4TjY*5Tp>S#h_?FXD#*6#~{P*2;~?v#7W8F+m8R8-zE#Yo26c$6b%y!BQ?#I z5rHC~LNRd0{|qo#b?-r&K)#$-Zxw<9P*+}5p*FnC5^zvmkiLI8oPj<8g*i%b7Fvth z8>-y@q=)gtp%Pz0zrfkx`x30@Qe3p4G5mF8m;2jNxGv{1g~S=ZGcX^lI(?MvmLo@8 z;qB@DTgWkOtS6;{l+VLaG1gZzRN%IXUU>hG18BNNEmS{~-Y$i|@80F5&KoS`I5Oor zw#QYp%)JLuo-;$F;moMb`#Jj~3)xGxXqB84pW|KcjFma?>*DU)-X#E7jgf#re1Q3y)w|KF z=oUY#6~R1mPrwx2*J> zf{PEJ?l=L;Gk*B503uG!XKQ(^V7q-iR8(5NNV-)nn4G`TQ0K9^G*ZjO>8xF-whTr@Xnrl6JnI)cIm(?LxQlU-uEZ*xr|kE8m0R%H!RK6FHou z_r~i&%e<%a4fl9{g=7U&Q6f){(u{(#+JGtW0F|@5RBNR`1tM!9r8_3ZE-sQSuO-#| zTA3=+)A?5FCP^T>73AJ|P`z$U4R&Gn9s!HH_`_f(rL0e<;WYv5(u3>-l zBap?SPj4K5z97D-vk@E;6cRfhuNk33^xKn`*Ki2*G8c*1D^B=*9SL%H9Z5rZ&Cm7} z3q1s$AvcnujrMzIXM$Sj2{U2?QAer_9ekN($7Jr!b+z~Lb%gs4`>{K54~FC1(@q&b6@>$@An zbTgp$(k2!HCCWC;4LQsaC1W&~d&Gy_Z;J5vE=aba`>a0b&3;3eC^;E5el*46(|<5| zcD3JWo+m3B=m=%@_7uHd4CT`DXFDQE}IX&QQ|rB9%|~1$439 zHMK7v@$Ar89Q;B!oBgazt(tLITsbNUflDoy2pj#PADK;G#`Xw4FR)Rs|6 zO!DSJH(>lX-(}8gpJnP18nWDTo84$Sk8BOCaG%vlQ7{X8yZ~8BO>yD|fp{j*CcZbl z*AgIVJDhuw?_obIj)Azxx%58sO)EIobk$d%x9RqEBhi5mS3&!?u5;o*lC?m0@VQiJp0aIUIEV(fKuM!pt+!hPBL_$iT@Bpg%a|MO9v)MP4lI=;_%^%QR#1f+v|bY_3eFgn zo5RkwXN^Dpp}A;Q9BO!wQhMAycq)cIyo)c?+&H86XNDI! zrVT_N-fg_Tf$QYmzcVR8SOA)Pfw0wo%8ES9Uaowq$Nnif2GFe=Jh||9h2xEm1s#_l fpY8waVS9nb9>{{