From a6e444c9b5be71d869bd8a173335d760577de20c Mon Sep 17 00:00:00 2001 From: guoqiao <64932716+guoqiaoli1992@users.noreply.github.com> Date: Fri, 13 Nov 2020 12:09:38 -0800 Subject: [PATCH 1/3] feature: image based on emr 6.1.0 (#34) * add support for emr 6.1.0 * add more comment and avoid unncessary calls to s3 --- Makefile | 17 +-- new_images.yml | 2 +- setup.py | 2 +- .../container-bootstrap-config/bootstrap.sh | 1 + .../processing/3.0/py3/docker/Dockerfile.cpu | 100 ++++++++++++++++++ .../3.0/py3/hadoop-config/core-site.xml | 26 +++++ .../3.0/py3/hadoop-config/hdfs-site.xml | 19 ++++ .../3.0/py3/hadoop-config/spark-defaults.conf | 6 ++ .../3.0/py3/hadoop-config/spark-env.sh | 3 + .../3.0/py3/hadoop-config/yarn-site.xml | 34 ++++++ .../3.0/py3/nginx-config/default.conf | 17 +++ .../3.0/py3/nginx-config/nginx.conf | 66 ++++++++++++ .../3.0/py3/smspark-0.1-py3-none-any.whl | Bin 0 -> 32375 bytes spark/processing/3.0/py3/yum/emr-apps.repo | 7 ++ src/smspark/bootstrapper.py | 49 ++++++--- .../local/test_multinode_container.py | 4 +- test/integration/sagemaker/test_spark.py | 67 +++++++++++- .../hello-scala-spark/hello-scala-spark.sbt | 4 +- test/unit/test_bootstrapper.py | 6 +- 19 files changed, 399 insertions(+), 31 deletions(-) create mode 100644 spark/processing/3.0/py3/container-bootstrap-config/bootstrap.sh create mode 100644 spark/processing/3.0/py3/docker/Dockerfile.cpu create mode 100644 spark/processing/3.0/py3/hadoop-config/core-site.xml create mode 100644 spark/processing/3.0/py3/hadoop-config/hdfs-site.xml create mode 100644 spark/processing/3.0/py3/hadoop-config/spark-defaults.conf create mode 100644 spark/processing/3.0/py3/hadoop-config/spark-env.sh create mode 100644 spark/processing/3.0/py3/hadoop-config/yarn-site.xml create mode 100644 spark/processing/3.0/py3/nginx-config/default.conf create mode 100644 spark/processing/3.0/py3/nginx-config/nginx.conf create mode 100644 spark/processing/3.0/py3/smspark-0.1-py3-none-any.whl create mode 100644 spark/processing/3.0/py3/yum/emr-apps.repo diff --git a/Makefile b/Makefile index 7a65bc7..f7e5b5a 100644 --- a/Makefile +++ b/Makefile @@ -7,10 +7,10 @@ SHELL := /bin/sh # Set variables if testing locally ifeq ($(IS_RELEASE_BUILD),) - SPARK_VERSION := 2.4 + SPARK_VERSION := 3.0 PROCESSOR := cpu FRAMEWORK_VERSION := py37 - SM_VERSION := 0.1 + SM_VERSION := 1.0 USE_CASE := processing BUILD_CONTEXT := ./spark/${USE_CASE}/${SPARK_VERSION}/py3 AWS_PARTITION := aws @@ -84,8 +84,8 @@ test-sagemaker: install-sdk build-tests # History server tests can't run in parallel since they use the same container name. pytest -s -vv test/integration/history \ --repo=$(DEST_REPO) --tag=$(VERSION) --durations=0 \ - --spark-version=$(SPARK_VERSION) - --framework_version=$(FRAMEWORK_VERSION) \ + --spark-version=$(SPARK_VERSION) \ + --framework-version=$(FRAMEWORK_VERSION) \ --role $(ROLE) \ --image_uri $(IMAGE_URI) \ --region ${REGION} \ @@ -93,9 +93,10 @@ test-sagemaker: install-sdk build-tests # OBJC_DISABLE_INITIALIZE_FORK_SAFETY: https://github.com/ansible/ansible/issues/32499#issuecomment-341578864 OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES pytest --workers auto -s -vv test/integration/sagemaker \ --repo=$(DEST_REPO) --tag=$(VERSION) --durations=0 \ - --spark-version=$(SPARK_VERSION) - --framework_version=$(FRAMEWORK_VERSION) \ + --spark-version=$(SPARK_VERSION) \ + --framework-version=$(FRAMEWORK_VERSION) \ --role $(ROLE) \ + --account-id ${INTEG_TEST_ACCOUNT} \ --image_uri $(IMAGE_URI) \ --region ${REGION} \ --domain ${AWS_DOMAIN} @@ -104,8 +105,8 @@ test-sagemaker: install-sdk build-tests test-prod: pytest -s -vv test/integration/tag \ --repo=$(DEST_REPO) --tag=$(VERSION) --durations=0 \ - --spark-version=$(SPARK_VERSION) - --framework_version=$(FRAMEWORK_VERSION) \ + --spark-version=$(SPARK_VERSION) \ + --framework-version=$(FRAMEWORK_VERSION) \ --role $(ROLE) \ --image_uri $(IMAGE_URI) \ --region ${REGION} \ diff --git a/new_images.yml b/new_images.yml index cdf8822..c5dcdda 100644 --- a/new_images.yml +++ b/new_images.yml @@ -1,6 +1,6 @@ --- new_images: - - spark: "2.4.4" + - spark: "3.0.0" use-case: "processing" processors: ["cpu"] python: ["py37"] diff --git a/setup.py b/setup.py index 5ab6b7a..24cb4ee 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ ], setup_requires=["setuptools", "wheel"], # Be frugal when adding dependencies. Prefer Python's standard library. - install_requires = install_reqs, + install_requires=install_reqs, extras_require={ "test": test_install_reqs, diff --git a/spark/processing/3.0/py3/container-bootstrap-config/bootstrap.sh b/spark/processing/3.0/py3/container-bootstrap-config/bootstrap.sh new file mode 100644 index 0000000..e4e23bb --- /dev/null +++ b/spark/processing/3.0/py3/container-bootstrap-config/bootstrap.sh @@ -0,0 +1 @@ +echo "Not implemented" \ No newline at end of file diff --git a/spark/processing/3.0/py3/docker/Dockerfile.cpu b/spark/processing/3.0/py3/docker/Dockerfile.cpu new file mode 100644 index 0000000..a61b4f4 --- /dev/null +++ b/spark/processing/3.0/py3/docker/Dockerfile.cpu @@ -0,0 +1,100 @@ +FROM amazonlinux:2 +ARG REGION +ENV AWS_REGION ${REGION} +RUN yum clean all +RUN yum update -y +RUN yum install -y awscli bigtop-utils curl gcc gzip unzip python3 python3-setuptools python3-pip python-devel python3-devel python-psutil gunzip tar wget liblapack* libblas* libopencv* libopenblas* + +# install nginx amazonlinux:2.0.20200304.0 does not have nginx, so need to install epel-release first +RUN wget https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm +RUN yum install -y epel-release-latest-7.noarch.rpm +RUN yum install -y nginx + +RUN rm -rf /var/cache/yum + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +# http://blog.stuart.axelbrooke.com/python-3-on-spark-return-of-the-pythonhashseed +ENV PYTHONHASHSEED 0 +ENV PYTHONIOENCODING UTF-8 +ENV PIP_DISABLE_PIP_VERSION_CHECK 1 + +# Install EMR Spark/Hadoop +ENV HADOOP_HOME /usr/lib/hadoop +ENV HADOOP_CONF_DIR /usr/lib/hadoop/etc/hadoop +ENV SPARK_HOME /usr/lib/spark + +COPY yum/emr-apps.repo /etc/yum.repos.d/emr-apps.repo + +# Install hadoop / spark dependencies from EMR's yum repository for Spark optimizations. +# replace placeholder with region in repository URL +RUN sed -i "s/REGION/${AWS_REGION}/g" /etc/yum.repos.d/emr-apps.repo + +# These packages are a subset of what EMR installs in a cluster with the +# "hadoop", "spark", and "hive" applications. +# They include EMR-optimized libraries and extras. +RUN yum install -y aws-hm-client \ + aws-java-sdk \ + aws-sagemaker-spark-sdk \ + emr-goodies \ + emr-scripts \ + emr-s3-select \ + emrfs \ + hadoop \ + hadoop-client \ + hadoop-hdfs \ + hadoop-hdfs-datanode \ + hadoop-hdfs-namenode \ + hadoop-httpfs \ + hadoop-kms \ + hadoop-lzo \ + hadoop-yarn \ + hadoop-yarn-nodemanager \ + hadoop-yarn-proxyserver \ + hadoop-yarn-resourcemanager \ + hadoop-yarn-timelineserver \ + hive \ + hive-hcatalog \ + hive-hcatalog-server \ + hive-jdbc \ + hive-server2 \ + python37-numpy \ + python37-sagemaker_pyspark \ + s3-dist-cp \ + spark-core \ + spark-datanucleus \ + spark-external \ + spark-history-server \ + spark-python + + +# Point Spark at proper python binary +ENV PYSPARK_PYTHON=/usr/bin/python3 + +# Setup Spark/Yarn/HDFS user as root +ENV PATH="/usr/bin:/opt/program:${PATH}" +ENV YARN_RESOURCEMANAGER_USER="root" +ENV YARN_NODEMANAGER_USER="root" +ENV HDFS_NAMENODE_USER="root" +ENV HDFS_DATANODE_USER="root" +ENV HDFS_SECONDARYNAMENODE_USER="root" + +# Set up bootstrapping program and Spark configuration +COPY *.whl /opt/program/ +RUN /usr/bin/python3 -m pip install /opt/program/*.whl +COPY hadoop-config /opt/hadoop-config +COPY nginx-config /opt/nginx-config +COPY aws-config /opt/aws-config + +# Setup container bootstrapper +COPY container-bootstrap-config /opt/container-bootstrap-config +RUN chmod +x /opt/container-bootstrap-config/bootstrap.sh +RUN /opt/container-bootstrap-config/bootstrap.sh + +# With this config, spark history server will not run as daemon, otherwise there +# will be no server running and container will terminate immediately +ENV SPARK_NO_DAEMONIZE TRUE + +WORKDIR $SPARK_HOME + +ENTRYPOINT ["smspark-submit"] diff --git a/spark/processing/3.0/py3/hadoop-config/core-site.xml b/spark/processing/3.0/py3/hadoop-config/core-site.xml new file mode 100644 index 0000000..52db7b2 --- /dev/null +++ b/spark/processing/3.0/py3/hadoop-config/core-site.xml @@ -0,0 +1,26 @@ + + + + + + + fs.defaultFS + hdfs://nn_uri/ + NameNode URI + + + fs.s3a.aws.credentials.provider + com.amazonaws.auth.DefaultAWSCredentialsProviderChain + AWS S3 credential provider + + + fs.s3.impl + org.apache.hadoop.fs.s3a.S3AFileSystem + s3a filesystem implementation + + + fs.AbstractFileSystem.s3a.imp + org.apache.hadoop.fs.s3a.S3A + s3a filesystem implementation + + diff --git a/spark/processing/3.0/py3/hadoop-config/hdfs-site.xml b/spark/processing/3.0/py3/hadoop-config/hdfs-site.xml new file mode 100644 index 0000000..6ccfb8f --- /dev/null +++ b/spark/processing/3.0/py3/hadoop-config/hdfs-site.xml @@ -0,0 +1,19 @@ + + + + + + + dfs.datanode.data.dir + file:///opt/amazon/hadoop/hdfs/datanode + Comma separated list of paths on the local filesystem of a DataNode where it should store its\ + blocks. + + + + dfs.namenode.name.dir + file:///opt/amazon/hadoop/hdfs/namenode + Path on the local filesystem where the NameNode stores the namespace and transaction logs per\ + sistently. + + diff --git a/spark/processing/3.0/py3/hadoop-config/spark-defaults.conf b/spark/processing/3.0/py3/hadoop-config/spark-defaults.conf new file mode 100644 index 0000000..c1f1c17 --- /dev/null +++ b/spark/processing/3.0/py3/hadoop-config/spark-defaults.conf @@ -0,0 +1,6 @@ +spark.driver.extraClassPath /usr/lib/hadoop-lzo/lib/*:/usr/lib/hadoop/hadoop-aws.jar:/usr/share/aws/aws-java-sdk/*:/usr/share/aws/emr/emrfs/conf:/usr/share/aws/emr/emrfs/lib/*:/usr/share/aws/emr/emrfs/auxlib/*:/usr/share/aws/emr/goodies/lib/emr-spark-goodies.jar:/usr/share/aws/emr/security/conf:/usr/share/aws/emr/security/lib/*:/usr/share/aws/hmclient/lib/aws-glue-datacatalog-spark-client.jar:/usr/share/java/Hive-JSON-Serde/hive-openx-serde.jar:/usr/share/aws/sagemaker-spark-sdk/lib/sagemaker-spark-sdk.jar:/usr/share/aws/emr/s3select/lib/emr-s3-select-spark-connector.jar +spark.driver.extraLibraryPath /usr/lib/hadoop/lib/native:/usr/lib/hadoop-lzo/lib/native +spark.executor.extraClassPath /usr/lib/hadoop-lzo/lib/*:/usr/lib/hadoop/hadoop-aws.jar:/usr/share/aws/aws-java-sdk/*:/usr/share/aws/emr/emrfs/conf:/usr/share/aws/emr/emrfs/lib/*:/usr/share/aws/emr/emrfs/auxlib/*:/usr/share/aws/emr/goodies/lib/emr-spark-goodies.jar:/usr/share/aws/emr/security/conf:/usr/share/aws/emr/security/lib/*:/usr/share/aws/hmclient/lib/aws-glue-datacatalog-spark-client.jar:/usr/share/java/Hive-JSON-Serde/hive-openx-serde.jar:/usr/share/aws/sagemaker-spark-sdk/lib/sagemaker-spark-sdk.jar:/usr/share/aws/emr/s3select/lib/emr-s3-select-spark-connector.jar +spark.executor.extraLibraryPath /usr/lib/hadoop/lib/native:/usr/lib/hadoop-lzo/lib/native +spark.driver.host=sd_host +spark.hadoop.mapreduce.fileoutputcommitter.algorithm.version=2 diff --git a/spark/processing/3.0/py3/hadoop-config/spark-env.sh b/spark/processing/3.0/py3/hadoop-config/spark-env.sh new file mode 100644 index 0000000..1b58aa1 --- /dev/null +++ b/spark/processing/3.0/py3/hadoop-config/spark-env.sh @@ -0,0 +1,3 @@ +#EMPTY FILE AVOID OVERRIDDING ENV VARS +# Specifically, without copying the empty file, SPARK_HISTORY_OPTS will be overriden, +# spark.history.ui.port defaults to 18082, and spark.eventLog.dir defaults to local fs diff --git a/spark/processing/3.0/py3/hadoop-config/yarn-site.xml b/spark/processing/3.0/py3/hadoop-config/yarn-site.xml new file mode 100644 index 0000000..3790582 --- /dev/null +++ b/spark/processing/3.0/py3/hadoop-config/yarn-site.xml @@ -0,0 +1,34 @@ + + + + + yarn.resourcemanager.hostname + rm_hostname + The hostname of the RM. + + + yarn.nodemanager.hostname + nm_hostname + The hostname of the NM. + + + yarn.nodemanager.webapp.address + nm_webapp_address + + + yarn.nodemanager.vmem-pmem-ratio + 5 + Ratio between virtual memory to physical memory. + + + yarn.resourcemanager.am.max-attempts + 1 + The maximum number of application attempts. + + + yarn.nodemanager.env-whitelist + JAVA_HOME,HADOOP_COMMON_HOME,HADOOP_HDFS_HOME,HADOOP_CONF_DIR,YARN_HOME,AWS_CONTAINER_CREDENTIALS_RELATIVE_URI + Environment variable whitelist + + + diff --git a/spark/processing/3.0/py3/nginx-config/default.conf b/spark/processing/3.0/py3/nginx-config/default.conf new file mode 100644 index 0000000..a8a50a5 --- /dev/null +++ b/spark/processing/3.0/py3/nginx-config/default.conf @@ -0,0 +1,17 @@ +server { + listen 15050; + server_name localhost; + client_header_buffer_size 128k; + large_client_header_buffers 4 128k; + + location ~ ^/history/(.*)/(.*)/jobs/$ { + proxy_pass http://localhost:18080/history/$1/jobs/; + proxy_redirect http://localhost:18080/history/$1/jobs/ $domain_name/proxy/15050/history/$1/jobs/; + expires off; + } + + location / { + proxy_pass http://localhost:18080; + expires off; + } +} \ No newline at end of file diff --git a/spark/processing/3.0/py3/nginx-config/nginx.conf b/spark/processing/3.0/py3/nginx-config/nginx.conf new file mode 100644 index 0000000..1e3a51c --- /dev/null +++ b/spark/processing/3.0/py3/nginx-config/nginx.conf @@ -0,0 +1,66 @@ +# For more information on configuration, see: +# * Official English Documentation: http://nginx.org/en/docs/ +# * Official Russian Documentation: http://nginx.org/ru/docs/ + +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log; +pid /run/nginx.pid; + +# Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. +include /usr/share/nginx/modules/*.conf; + +events { + worker_connections 1024; +} + +http { + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Load modular configuration files from the /etc/nginx/conf.d directory. + # See http://nginx.org/en/docs/ngx_core_module.html#include + # for more information. + include /etc/nginx/conf.d/*.conf; + + server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + root /usr/share/nginx/html; + + # Load configuration files for the default server block. + include /etc/nginx/default.d/*.conf; + + location /proxy/15050 { + proxy_pass http://localhost:15050/; + } + + location ~ ^/proxy/15050/(.*) { + proxy_pass http://localhost:15050/$1; + } + + location / { + } + + error_page 404 /404.html; + location = /40x.html { + } + + error_page 500 502 503 504 /50x.html; + location = /50x.html { + } + } +} \ No newline at end of file diff --git a/spark/processing/3.0/py3/smspark-0.1-py3-none-any.whl b/spark/processing/3.0/py3/smspark-0.1-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..3d4c3d68b4b4a63526d970f5bee8aac3845753da GIT binary patch literal 32375 zcmZ^~Q;;UX)`t6a_q1)>wmogzw(V)#wr$(CZBE-Z&+Ir6=imF{R76(PMXsBwm09n4 z@?G*$Aiq!m000EQzK&ht-va>nKevAu^1tC^<798(XhpB5XJKpMtfxn3?*Rgk|NlZ7 z^Q{zyOlreQ|IO8v1pv_hE5y*w&e_S?(ZJr`#PMHPGme$hX4_q-FK9^mb5WKs=eW$~ zLlk#m~7MX8r#POziIEUf|WJH@x5ABjf#5cSv1nml!Jnq)zmbGbAAk zB?=_c2O(b)u}~ap57~_oYsb6Ovism;8K1T zDVoPLwe)5#D|F8E-U8tjF&kP8e2S12vcAa!1zNs4>U84OAOLQ@c50^o9+}Aq#N0XJ z%;IwcxCI8_SJAVW+1>xG|El!12FY_Na0j9TKhWSd(13v5=Ky*wJ$S@`T?Hu+8Ha>F zklR}Xi8`MZC?d{zAK1MUBg61KYUo3b-+(@&g_WCRHxKp!jZL!g0(EfFtlpXX)? zG`@r-yb!=^YD&f;_WKR*0;&ymTOPHo+q*Jc0i!3T==@8hCX)~IJ5igIkyqi)z{9C; zA8w;~mAxwoh`xPZ@S0#zo}7B0C+t+PPCir$fXT^)(`KK-O*DhMTa1VjgGPt2>+eX? zKlR6NAf0@@doVNNQf@hLPoo0&_ney$!G4hjLi@URB5(2nUrvzHI>sF45AhpM$Y${}qEzLOdewl4~w;1#S?k!QgCMgHmKbduTLvBu~0(KC1b%g5X;=)eo zyoiaFq+(hw&NSKmDLjEogJ`5<+#|Yza z)D3cXNs1V{WIsG|<;-ozC@KPe$#Tjg{%L_2@_;_9-_9R`D`!Ypn+OpOJC$*d$*KJv zqV$I8e@*ku#%F%97(IC~YX#1)r3#SIVivmF=#b0-NxcZs6TqVmWG~wpjG5~iJIFM- z&Q8JR5vqB1=M4J-xnCO9l#Q!{+=`EN^)zTY}06z|M zW9FE2O^TZ~;va}Z)8RGu3#`B+xDs~-NTgyr{L<}aOkych(4d3-+yk9^p-5+NMmK^E z%8Ab}CB^q<@T(snfkI94720!g^MS&-%_3l0EWe(S&G#JerF#Xp)c*)BRBR?rCTPnI z{i%!>vqD00InGAdNqD0<@x_F25!X=k1dt4b_zAUU*|Pc_HDU3vzA3C^v=D3gPKUsF zt$l-CUHLg5VejNFY$Z-UxkO_<(6I4{AI=&GX}xq?Z*^*X;;=Qj>`sQ5mrrgp?dn}j znPbEE7%|g%2Bq5Gv-C8tKihX8^&onBZl1&tB=HtE%BbtMsBYx^O$??_e|lTIJ6Y)< z7*_cd_|-}Y(cW>A+#cSZA4xQsN4O?e3u)|aE2=4q%hEY$g+Y&0I{UZ{Mftw)5MPm_ z-bj^+z_8FbCkYf49v6|MaE#j(&(W>vwOHf|5xKR3Ml#gxJ#D zYKzuZ8FwF}@{DrZqd5^PlbAHZn+OFyID6z?pEZ^3 z);N5>1l*5>2dnr^z5?ZsX;PVy!ZL!7=FZcABUkG;w(jM~*RCl$dV@aWb??(P#(l`G8keE zgku(9^xZH`8+xc!vxxQUKJkK`%~B@s+zucqvlIu~g75IEm=yjz@@ZkqGGF?g+5H z5Wg8Q_gTy4xun5P!fyAdl`tl-ZoaAO@_=$E+rN3H>QQx_eEW6=27@XYP~nbs@7(pY zOe61re-JUdR}XWmb8pQZ2GjZ|_?&|62990u$P-*k0Hr(3p7?SRm>U z{{{qQ{ny=vSn*bKVa!d@p5BKlkaN*rGD?k7LpWqTBWx1WCvss6K1S01eziB@l)479 zBobPn^#N|kBG$2D63HAv3^U8(xxWcAd#h-dzS)W;}IC3mO zrMBHpG|8)y5p-;763Y&Y*0F8{=Tq51la^fBZK?(Q?-nx`jB+q7?BJq_syTQ<_GD&= z?}p>FA2@9fTD06`fSh)f=3{tdG@1|9OGkO2x?q_v)!_AbqzFD0?1cM;BG`J zh0mAy>#1Vg5d}fVth9(~Z2HMTaAqowbL8bP91Ql~%SaHtVcv!xuF@3oxw6=ZI@tg- ztRn9CCyQIKzeV$`lUr>K;=$Mdzmy z{>5J;$^Z)8Wl;jSvm{jM-d+_|#xi(D{7wtvk0RoCYcfkeA$a2Mp7GI{DMGF9tiM|T zS^fK$Qc+8&u%~wKW%q1BP5)>(6VYE!R*QxzX^S_JkmY(;T#tV8pU6v(Ud>I{E->S* z7qVhme#U!1D3dswNoo!>up8{wp0BQ3-ZE0)ZBMu-IbuD zf0gS|ilkm0K%?!3kM6zhUAa0fvtR^7E&2vk+d4;f+dr+icrVCCw7gk>Mk+_``ijL1 zKGQ=0VewscQEN7v{kGH5h8Oi@7u355)_62!7b{-UsgL1T< z+-<_xa}&4Y^9Uuq_#NDsq1|O@#=!QjiByB- zG<*3br^!ReauH5J5-zTs*0x^#nfa?^t)AAJw$(u#xg$msb|s%N#nVe2vkuX{)<(Il0Z7t_V2H)gLfB*f&EM2cuI<6kmJD!Ydo>v1F_pS4JU3GfH2IjKmezEY~ zc*aC;iXU`A&+nN^!zaS9&a&&JKiwxIElx|b2<#?DJIET*9drBA8n*T%Hq<7E0K2M9 zsTXzNmliTQBses=1Y(5GYLI)2E zLiJPsKluombPtcx*7DoI(3!Bk^P7}xank`dOC7Hf6S1q_Pw^*${r!22)0$>2)8EGB z8J2mQrg>*APFN*diDU8V4=F|cRXw;CT@|f_GOsbTW6Ul1T<9Pmci%6N8!SO|77seK z6%G5N;~R#?`USOi4bQKUu37~h@TyNO@Mm(xk%NQpaKS%j085Z+Pk2zOJK-AtZ1U=}jK!MoM^erf-!IPx0v>2v!6?gltMot_ zz_`tE(dX3Dn$I$*e7q`SFAD(^MqXwVup*)Yl9|*-KG{y)bd@Gu9^92vW$p{twp1ZT ziN-RTHfb!K3A(J#2X-wT%*N~0?d}iX+gpg>B&ugB!eb=2KGdaCL29iO&alM7yFi?B=j-}3#OO4_>+w3apV(m$a zbz+wFYVa-be656<@O<1?Gi;2hnLB zoi>>3;O^}aV(_!=qJFE-lH1KjCr<1F9w(=cv26;I>SA~sS} zO46|U6RgFGo*LtFc5?WuYWRSSsZGUR2n$m&K^$Qbma5Y5(D?~2!WHUDZ^%W>OCM&s zkwtS_V$2$6IQ^p(>@V)Mas&#RaH6%~I>tLy*rfG>TOJ)AVy4sG+`e^N{dib^oeM&J)07gw?+~ostq796d+3g7$QCF}KO2EIuYsEM86iv_66sm^m z+-*B;0z&z4K4O+nw&|%tI^^CJp=JR+gW?eE>~2^`(t>OPWfuNOw)fTU{5i9_JNdW-B{TJIavbOk#^oBLH z>^4Qwd{%2Q7~qrTBV-fVfpn|RIl?-sZPsKXi5S8VITO}yRpBhOAn$iyxXnV;6de6+ z>wUyY?x)&!#cGE{Qf?y6JE?~=SM78oO^AAp%h8R)sZ}lZ!mAJz%7eG83pf&r7jo1` z>f4HmHMgj$Bg9buB&sE0iIBekPjs76Rq=Y=%^Oh}iU^6nLd%K>(r>JlKNIR%cR#xzK`A**k?#@Gbs zT?himWBw5Tuu@C}s>U6$3pSJG zE-1Qw!L$Q;c%h}XETHC&D-|qB2T01**wbyE?Zu{@5 z+S+>ETHCcXwYOKib2PO+=sy}d-A=&)OY*gUW}u5%AH=ZyQHQb>dF$EJ83Sij5Nrwn z?uO~M)a@#UnYq4<@zbnM6{;?X%(BAMWYbI(KwX zlIV)x8kB3hekvj`%DQyW4UIq}%-?+F@n8(wW+1i5(QdiCDkvKH*pGK?)Ol-%33#3R zV&%zSZITZahqKp<7OD7AiwluldIq^st3G|ws8#uj9nvPaGphEJX{ zmzO^-F#E$FG(~n)llcz{sY-fsCxqX=t(nq4Gf8i&Y)b`GQqJ2So2jI57ETCRn3El{ zc;6VH3SdMXWwrGJseq z6N|R*6q!hA2$^qdv|M2A2SHuTr&E0q8o%JY;)wVs?ksKW%}3V!CueOZmBKB!v_&fw zz-!DVDp@gRgvk=o_J&J&cy8+P^S5ZuBc4}O{}Vsy;|6+LyiHYftnT=ZlA2FR^ps!& zL&Eanr?>cIh(Ro@^a$s!>g4^Zi>iRErUp}cq()!7KdwccN2IKh#Hl%i$`t{dYp%YW+`81+#C_5@Rkg*ed2k0W|Adrn>Fy zO{C;9iXBu15)%xu5N1PFW%XDEl0|bOb6k1FuPJFLjPlPw-T#U@rDO z{90SvT6(&hYr0)l3dac>HY3^J&4dR91DMUAM-Kx@;5P$)7$>Zl4nWZ;$3RDUGpk-V zjis)5u)U2T-Ya!-A-7Lj_&am{`d{zh_6+LaV-;?MD;j8-V)7#3d8HU1US!8VNNO4t zXd|3BCo#dI^77jOR_jb`>=UbKxNaTfu}&(E{o!Hv?$WWSET?WN>)SoZQ;4M@@S<8}oM~VS znIMB%jiw{TLa~LX%IYK*2r>f|EGGMVmL1Mg#IW>)gc;7da6YO$;8B1ie#YQ!d#0!i zXF0djC>&b`^EcLf12$G3nk1M`ro|%N4~aT0Ge*;gZ;WBmQRo&VteFlVq*ktA677ea zyeDcL2s5MHX^HpLom)6-xhDhSMl;r9{$~?DArW~~bL(;vw_TIaM{{T6QTDfzdK%g` zRV!RZN95g*%}b>MY&1c~M0t1N4AxaC1#~zeL|TTvkvLp*-8}d@_P7j*hBz&IAq$F2 zs$}F)CGk>8P&ImSrgevdEdo5RBc8XHc3ACcJS8Kak5JB`Qtc4zOD{<9^0)awNjXEx zU!O}P-x9JlC0+9HbusAcK7NG|!f2j68Bq6V zd)k=ehEl&ZTVD%_;~0{33$0Z4Lt#W3>ws=ah9km>h!ScUg*_z0TWK`{jvtv;XvB$X zX0=Opjm&jEO6bVQjRV~B$FaCq^%0qj%LB z7Auy#R_|gtL{47oK5-Ww+C=C=sF0?WebOK`@}Gt^dQIkxt8=J*oiyQPsEPS-wTzpk?L8CMb1>LTHg)a74Vy6})D1TZhWO!`g;K!8U`|-F zTmJ5{Y(kJP)@mNMR#Zha1@^`^h9-1Az=~QO(rUt@6hiih4MK6&m$L zywEex4{Qv`*JX3RTJrM^2`L4@A3Qiu)kx`CD(Ui_Y}FCiuX06JDyG*1gGjGP2Y!7Q zY%4EgJ6f!4tbo&BHhC5i=HflF+Q=~g)rc4bZymWnqsiYWiY2f3i7cnA^6yX zBu=fs_M8lo(MaJ0$ z*we_wUC3^>MS;!vc3mXN$ye>){JKO34kwp`=*#c^dLGm3DRh$sj@;&ksDq;bZp7iA z=hbY)!E&5$qc;&5sFcD^Rc};nl2c?meF&v1DNckMT#|Jw&by|$w4iaXI!Qg4K40q~ zHmzh43+%r{AKh^p5s8+v!>EO6i7P|qskkN(e4Ia@J?YBGU6XHv1y+>9{GH+5NLTDZ zn)^^Kc9pnyEn)DTxFI@6CImB2S1`~8-N#FwyaSw~U@8=ib7-h2hkPzG(7zUAxubg^ zy_o{UOUnGy%=aWif@&=thTu;%KH07Gay_zl0t+fLcg9gel+0{DO)uhF0NB&;0y7iC_ff$JrnBU5G-_HkE}+8RFn zND3O1NYfcKf!~W|_IUR_VVEFy+dH*gI4_=?jo|eXy4~dUfA+a!a2pi6Ev^p&ZNyx@ z$DK|R^`1E2i=d_-A$U0lxtTT!NpyVT5pv;)X7Vw`6y{t;8(dD^f-kyS`ry+}hM-)Rtj69C`L52q!^tmc1 z0Vf<$r6}-}<2Ub4EAnj1`{TfH*4Ts~m4AgyCH;sU>uhCfTD)?AE&YzI2mCE^S#&D? zz_y7tc%6Y-oG~SS>@23^emP6Ezoz-FpR!N(p5v-Em8c`OU@igr^{7}`v(#;Z;&%RJ z_@!=KDSNRPF>V@lAVSMJBi!Pxt3BjSfAmlY{tZxkrCx{+X-a z*)sXQqZqx*p3-`5lP+a!j@68x2%fZ(xO32ezp5NT*rJ$A(OX)mBP!e>$5_4cUP58g zNb|!5b@&LUg0XkQej{Ish5yW4a~x6P(vJ5%>R}S=plC3%TLmapA3vvK^MpJ3zFM7m zieox>JL=P)oCx<;#*z6K1_O8hG!L;6bh&307ax3zzGaluXQbg^FKikY-)}?Ped^(iZd0&NIc~p7) zXyDwT{tO#rFyQw_BolfamLoBRpkD($hWAmXWcfqqnuKVtScB9r#Zku41OX>a`R~5* z%o7+2Y$GX?K67qCbu$@4k(FW7kz>aZGF8w58p4ZW-@*Hu6jJ!8s}l$B(zdBmllw-e zPNT45UCU!l;iEZMWAX6TKJpk29x;~$M`jgjjg%sAbmY>SPvw>RtSm4x=f(Z@$_ z5);Wx@9`pY&~afS{hYVpd zw9jSnF%R{&%YSX0BbVUr)?e0ua9QeU?t46J-L9$5^XC;73*3nD_ z6=jc5{v^gJL=GcszK$&rb{Rp^!puwjOml?T)t;oQI7ZE%qcR0Z$QbWhAae+{IeS@Q z{X1_G+)ZB3t`w|wd$+wdAnDHOeq-Q&DmgiUz8U<4N7+|zvC9JV$c`f7y7J~cgfS2A z8WZiY@Nc}w$0Ecs^*H?L*6kA#R}9ZD0#(y37TtCHBqYn)um=DUyYm>ya2{W%ikzZuuT%hK}=!jszMFz>Ny^8RT%D6T0g*I0YYDE#qrr z+ER{|^$We{t~vCIBfh|X0DT&kK$W>&{PR3i2@WCRG2rZ>=Zl5IuDQQAiYL-PF!mR( zF}gGNK<{glzn76R0&`F)ui+rCZJg61L*Tx93p2&soZSr0ufgDvvPR(SX3~?)gxXjp za9^Wn>kDHwS9y}>(r5J_Wac8-kF_#*%*N?ejg>JT3&B80o}gL9P12|FgvE|Wn?jSy z!U(Fo{_3S<->`jFiZQDWGql)i(|Wpm?V>Hc7pu`{R&_O zOH5O;VWratmYNhBaLOoQy3(o?b!f=SE<~wfZl*oQd3Kul$ZI$nD{=n2RfO~CAYLps z@DY2{l*B{nRWD-hHjW?BT3&Sq`GYH8adJevpTcwi5Iw2J14% zQT$O3(ka7(tZ-mU@|m;-Hk?3gi9^FgqPGdHW-j1sYtS{!Gw7x65_W9P zWXt*Sm-p!C96lurRr0GtNgYq*k-N!lRQq!)zZ4f0y0}$pdH<~iC1)Zc-j3Oja!^e{ z&7f=_cSQK%({2wB@z~rF6n}yn8|G}p5Qh1|z&Io5tb6CDjJdfTI9g^qq5IvJc-5m~ zU&q1s1C+9Ir?~=UJ@Tik@y0+eClM;{A)+aN$g-{m(UcjPoW_%`tnt3MjQU$G}h6~>Yp;bh$DPIi@b7nZtkMBglo!9 zog>hYiI$W3=w;;kxtvus9w-?HzCmj1Wg2}6t`kZZ<~n1^S7kjB`KDxtdG3_ubiR;2 zt7mm}ajFkpJneK#^$WIo<3uo9G0u(#u3Wf)QnpPS_~+D+x_Nn7G97E|OKaIj#;S;j zb#H^~y$$O1%cEHQwc6JzcSMJ8OT+T@e7L`^q{~XQJp&_U=7z1+4mX%{j|rsvVq3|* ziB0U@au>Xj(N$n=Le>R3?qamB2}a@$9p-aOv1iTP0x+G7@V%|{V%*##o}2d8YcbZ6 zqO+@b?Lb%NlinyzyB%XiYnPBL@RRiP&z-7~kZkRpSnW*?t>GDHjDx$atH(@#=cMG5 z%_3fCUvPS>y&giWgasyx=C2}3-==9sK`codDf0B&WrA0>v{A=e+%;hR@H(oTm788& zEWXb7I#_Mp`O(;#@o&s=aW&$qNcncW59_tMeR=^36`T@Um?$ z)!Ea=AYaKvu-h+c{l!1t1Z3zIU~0ub;Sw{+dov7z^V~grqkqeW|4v^te2_amZ^5-@ zHdqW%23%TNFz1INE0+G5w{&EIh{~w0Wb>Zt1z~90_mPNh=5Yb*o4d35BSpch*^aXH zB}o2P_YSA@{Kb}E8V}v>-gjC$OK%(Gg#Ayj)5dz0)rt6-%+PX#k!XG)s{>2# z;sG}*XWc*+yTc`p1T^;@e?$KAE-3f*NmJFsR!U1c?ftc1hc`>!?^T?WZ&l$dgyzq= z=}g|qPM~_EyB}d+ANC346JiHFk{T?N8{hwx6QXW?(I-Fv05Qn_nmvt8ObuMDot^#( z3h(%S+f4=p;hXmeAa4sG4OefNxj?Mevbg~;npQ!CUtmJj#%M^CaY@LnW4&gS+_IkK zbii)o>8*{(+MC~}&W-TuQ|L~I*`GEtCDy403{-(GbNxaq&9==jhs=jIkuqNU^@MWQ z0xBue36MQjA|pz{{mO?3&p}F>ZGO9IZniFs-io$+CC`)_r^7 zq)p)5$fptt_<68t)n+J#5V2gBVwsH)jSijs11mI-Y9KU=n!}%{#ux?%t?>E18PkV_ zvxg?P={V`>_H4RQkZB6iO=c^|3Cf5f8HH@q*cKsIpqidOdKUOuJ{Se;a&)l5x&;)V zd`BhyKf6*f>qtP#u##TRo^lk zh=SA=X+zoU*Gg8VIjTmTp)jeznKJhO6nIK^i->?*+U&+dV)DybnsEmi;csK^bog!I zDA6=SNhoG7x$)%SW)*CYA6r`QI%HUMdB?`S5-;uHR9-)?fIEN^ZjQ+EhoKOu@}yqt zTD{DoAMI!iuh)NFr+UA}emmU*|8HLG@@dj0fc|sy{zLx%Ei+9V9qkt*4_WfPq{O6y7jCR)6LI5cI1AHIBsulQ^RSFhCPntWlsZ zftoDG0ttp52;FAHcoabp5X!uz!CVC5=903Wp+K>8b8lVR6F$mxbM=Cr# z1ZbO6pbjHQ16@n|`-EHM`#T6&gZ}=@7|grK-6@0ntNyRO10dcoZw8>BCOs6!4^%*_ z{&3kuM|QxtUg_5q9veO!1u^PcVBoi@ud8ZQO%T5(K=|4BxhCMINJ+MwA27Kr89G;#vebReQg#d^)qq zc|h}`F1^9d))y&~LvI!>y7&;vF08GYogjF(3-g}W+q#+_u^Tlz zhp9p|2qXmr3q?jq>dzmDiYIyQP4DjwtlR5^}&#( zDnyia*ct|pf@&%wMT>-ju{N?OY|~$80r{HkR0kLr@hKXv5MXSM6s?xPP?7hXOU)2V zW@~fOmzk7TFHlh)jxa`4#(2A7p?0CjZN*e6Q@~>0gFbF$!eW`u`f@1Z14(D2WGTa0 z!kK~LB09%qikhhGjqffB)*Z`Cor2gCJlPh&59TeEEVZr$Q%Dc#`rr>zLT^+aO;y~& zAvF|e;p4Mr+Nc z4Kye%f*K^&Q{jg3E8t~TKZ)6iE7YreobhX!~o|5m%w1u@f?NrpZR6z@!9J%x+Hey?R)PSzYsL6`#x@BL;s z%JhN)G6ZSG(U^n@uuWu?b~{;^RHjeYw~4cGKCFIR(7LL4Qz?%1e0Th|{b^A&=M*B; zKPDPh4-HP3U$(em33R$1%2%#Q@lcITyFl0)x*W*>tWa5p>hDf<64wa^pq=}vepjO215@* z)-%a>Xr$W_kpQs zRQSc;5i+6==f)@w+LGZ;8=LeN1>jeR^G;ts2G6@)XN zPjOw8Wb6tV5V{|zMIV8Y;wrjDCM?&RROVH~Q5v+Kg0wNhRO&P~i^jj-aj_R}ri0g* zA9ga?517>_zRTPjne82X9N;WQza`_78X^ghO@I}F*s;`g9#WVCx%``K42+~$tmO+g zjFR2Wb}+Z@kb*~`z!dU<8->I@RTs=6gm1Bvj}`kw9O3+BZcF3v$!-b2f1j-(2C*=h zlR(t?ry<_T8`}9n6WH)dAd(I+vvZ3v`^T|Cy@&>$3Qb)|DEXabHQ_XP^;HK=zs|!g~H7TWW+Rb&7$?Cijr&*WPw6TZr!6#+1nSp#V zgi=0kiKiQR0zRxgJHMNQKb#vS)HW}wfX zjk!OpKl%7>W;kDyqKhh&540EXm|a4a{}8PpZd;azEcPUkrxGGI$C`MMJxPG^zPBXh zp38O}Rx%!-5REcq=vqdEsNjnNpUvz`5G(O$PbxT;ncK(P9HvaDwq41e4jc~WvY6YN z*Hac$fLJk52+BF}c9Xn( z8od2QTsD^qn$AFuuFT?qQs~T1 ziw;5f4Mt6A&-Z-#k!j$ScksO}Tkx>@)#zcDLOvftsnnLa$1HVvFCjM|LJI$J;H5M$F?Z!rClYVYv%5Mw0xRjHC%r_dYhJYkC0k&0+9){J1vLQCa~>fN zRS#N>tBeNuTa#cG!adjUOn{2OmQ+E3avu|_tvRPJ7_mq9yq<9zGF7h4@}U5 z@tfO~HYX1kk{cV+)ktj6i8M@X7onJdM;(D(+e(t~gmTb^Wb-<6wElWRO3I4a0vE9w z>PJa7G-$s&(4q+7W+d{=5TI8w34gbY)Dv~`aA(b#O+%0~Xu-psNmqIobv*-V%JTt* zU)tCRL~xZc5cyIpXvNDP1Cb3(gTTG1d-0B89bT?InPl&n&z8JKVEssoHM}Lo65QlP z=R8LhsExJ&d2;h2)*k6V4-r5uw!YU=SEH=CSzoGL*}SD3!iYJlEV*1SsGc;i1rQmr zXavn=B_k@rb66djKeJ{t?(A${u3+IR{%V$MxhiHR8cr6s`(srIZNHh&%|d^W02$t; z*4q?pGCMD6C3Zp}xp6u3xE*MwW5pth-PwqJf^9=_Ivj-G$S^VScA;zMz?tJKJwx`d zB`flXC|`*?w?~d{bP}E+z>nDRkPx0(JCe$cZQo3=c2(UcjV|Tx)5;OCS8k@{;gBcC z7mMD*X1XX`y{QE;_*5E8`pxHYb+;dGpNJ1vuSq~{@sKHpKTv)=%#Hca*YoR@L#I0$ zJ^9W4tC1sTtnXWfyrk^2lt=jd%ck4F{^^(=9_u7!V#=KcjJJaMloJbos8HHmzU{?Q zFIvkCj{*U{Z6Xecyf_!ZUzDO?rqaN}B|?@zcHxv2-gGQK^OEZ6Iqy$jco%G=T2o@3 ztBdO%Jo-t$+Z62Q&{G^6`sM}%_2NHnZ=WFbK2)9p@!xv#E{O<(*f0b{iw$&c@Aq#B z0t4X(1LgYj(3!v^MrW46wn23Y&cM5x5ZKM(ysHB0Rxjo;16>B%iXi_elK;u&p-x4J zkh#nZLSZ7|MjrSp$FK+$>Z4W^!ba?U*@ZRY+* zi<7WRyZ9J-{KvEFX6xtX74I#IK&gcZj7n2TpHrwH_CS+i2BP+{cyrSIH2QFdjp=F7 zlnsir(Nf2@O3ID&7`o)7#bVao=2cpCqF4?D&-h_vdoT0L7h%3(^cU{YjVM-&_lKI{ z&j<0lBdnb2t@xjle_Z&sEuTS0*X~p-Xw>Q8a;oXKh|+ntZ=`v8(JoK{)5OY+9_R(HtUl zG+EL!Vzz@RAfxn@+X5eI-%^ryZIQ;5&2iaLI+D`nTc1S?XA_ zRzx~*=Cym#hW~ys zM;aP-o8n0SymaPs@KOdS6N$rcXuwVC!;kfDpQTuuai#!3u}h~|Kw z42c2H(s>;Cc_EP6uP=g(3kymP`+=(cB4Hlr5vHMsn1#@v1d>E!yzh(5Ml(tWu&K|$ z3zPJf)eHE^P!aXvBWs%1y{@B*@MX%L1wxq@zb08u`Po-M&I6~L9v7ok`U?{W{cZ%A zZDC#TtD>IM?*C4!{CzJI-XQ3dfoNAxG?^@>OQSlu9JEEV+N<|fPsSd3aWQ=K=gm^2 zre=J2`DpU!kB1AST&@4~{yoUs&HepR5W^hS)MU?L0%h@CzDOUm@vyqol`sZ-S(XUT zU&LDpix%3ml=$j4GbW)+&P30eIr_a*8Zye=hXAzqcGu#@yRnBZHx>{y$zC~+fdZjl z=Mh2jM3d6YI!DSo@w0}=fG-FHD2=#TN#kozsunE*=U7Q$x2m$72^l((U+?Yg!9|Fh zbEmdR;`QXigkqGg4)M5ThorF)kRD=A+Ub6@-!z!Q<73uwuVqlEY2phL>)Yr=lj=dW zx)PK{`D&iQSii6dq3j9lO%`n2=iOIJVl;kFHkGMwp3G5!^!;X#zzC=L>o-puNGw80 zY>K|YgBei4LAOvHhXC|}SM_c;cCDcrq_2y5d_;C+sYbl2>k<6RZuq%d>+0w}Sj2>| z$ly^R@-!^E7$AE-Qsssrh>(nWo9vUZ_7lnDrbkcU+Bm@54Z!KJ`ssE z8ST@c(9eW*x4*YSLQNtZLM4p1@M~-!t@x1vE4mHfCt+k zf&=&RieMGHub2eJV<1HP>YE?l?VhmjycMSjo2#_ za=hZJd;U>Kd*pDv_wl~U5DJS1h_#gR2Wq@}l?|DrnPnu<%s9XaIlBsIo;Q{dG#?j7 z%yS?RR)wcy4*L_OfH}J}`RM4P(qU2@h|b^XWciwQP+eryoehv9=;{Y^6fL%_YVTAn z5gR_3^}9R`*^cVoutTo&_-QFVeH8GJxVm2`6*Z=D{Nj*>Aos`UH{ovUu-dtEEStUn zA<)LWoaMHSx93rUQl`!z6T%ml)rZ)97qmi2y=$m3OO3R?w@IX`U=J_YA^c;d6n~Ks zD7{}teW^(8#6il|9pe;Ubm)S1?g>Z^>A=mA^TckqnBmw3E8`qr|I^@9E&`xC^9O!F zU;HMK^sbX|zOyp%wfV*T}WccYxb~7$ykVR<@H>{KXA%0}2`J&C&qWxgId-UVVhqoGs4dd)v zR6jLG#H&8-<+lw`w1In1VmTxdOXH;>UmVQfBaC{kKR(xG$1?uc6p^%5vmcZeBF4g) zDwM}?JXy62v4VNYnhLd{=^WMa7#Qcq@SX5`4`xz4{lcPhLbbw$ii+(5cT%2NBx5@E zZ{&kU|MVO1Uj{Z2#Q&$SuK=oZOSZ-xg1bv_mxH^zdvJFM?he6&ySux)ySuw3xNCmy z%$t`xc{4w!c2TFOwW^ES^?kj&SFdeYgAao;s-#laGBcY)({Be-5}0DA*6T@(&r~}w zLq$1vJI&t}qqT7BzLeW97B{Zvx_Y4iU*|7GcMNH#WtIkkGkJ+$8VEizP;kvDapxHS z;2fLCnr~R*ww5Ejr8P|Qo=Q{SvNoM=q?Lrdfr_(*XJ1j=3#xopwBvY#ao-cR{A3Aa zdr^J}SS352h4&&CLTJK(X9@K^zRB)VVJ=uIdFJHz&1H0&Tr;uGDo;Ic{)WX~Tr_D` zM8r{{vAf}D2*20pFdyMrHtO$w%lVUGiC<&xv+_>VW?l8n*M4ug3Hz+P!3D3FBzW#Z zlXL98h_4kRkBb@&3;KEn^=og~Zjb^6ayHp|zyMwXv^49IcthQ>nlNCih;)32F6+ZLvfSNE>kI zX!p!JGx}C7+!VAiS8qgkb}yc|fIvKd5q8FfQMduGZ4qQ1GJty_*+-g|1$6^W`Iz~w zHu=}{)Xlc1|3j#D|BVmW5gucQfUfQpc&m-e=~y5?a`^e7r3MqlI#*)h^Gl7n$82Kd zU@%6$tnq2CqWm6+!A(|WpdGd&Ius{|RKVY0oCP+S%|P||y~?l;Ke|DO zj5~n;f~#U^fv5Yhh8*fyoDVkhiS7%x?I9!tWi#!0`Dwh<4{ev`^L+<1j~nRD(Nt30 z5WRlX`epqZXP!^>d<<5v(r&z8?D-I)gPO>x5l!lIka_H&=XdlhmMJ46(}JBEBgR>6 z#$>n5b2Z=G=LGHo=gyE`e#i)!eNpzdT#VPPDSTx~u9&vJ3edBYf+v>%r}g|~|)Ux z9KZ916WKT|AsD787@0ZK(812Ip);$a$N`4FsJLz^?*dChx1&8ZCDYe4+m+eS$B*A< zkB!9xNVYtqia;c5o;B%OGM!ffUT=G9LGEOS1`u&uIeE=ir+5^-AdxjM3PG zg6ig8gRenoZHj`^a=@$y9>C#)qu50g^wWO(>*d85lw1k+?-^MBa((mA*na#3&)Z_}` zfPm2dR(7y4HMeoq{a3YNLUqk%O%&1VNDVF-GXdzE!<%xm9q?dMYB32~Y%B`}jJ^o` zK*h)x0~bmy*4K*+4aKHW8|2#pCcSUz8O)~VFA#K24Qvi6T~F zXQAqWPiR7E;(|~StikLRx|3CCB4%3yW#!vQ=SOwjBbSktw|zV(~qN z4GUE5&@%)>J_S}#{1m;^SPE1OW2HFDZWKqH1vQ`VA@{bq#!9)G^GIyz$FI1ImkuYLxjTp>F z-nT`}iG>KT#uf2tA=OF~)0(2rQ`#jk5>3*(Ssek_xsqZbO&ll$?%!e=vPrvt*_B-ad*Q zR!DDU``P19MNX~J@iU8Nd;k7*-)kARbsU(EZBj(a(d8CJhTLdDaXs&3O)~kV8V8K` zbKQ@fp_L)m^!|(UO4IoLwEd%%3A0z;Ok{cWFqqB5RZiEo>4F%G!HDe>bH9{gSV*}k zK3={MU@`}7>MWkhAGzv)iRlY!$4{&4paHj@yT!N}Y?hZ57RR`E7q~t3&&qK{-+z(F zlXzOUq>s*nGE3XBx)YH1t!x}Kb++NjaL+ZmXsS#dOBOwJo>TfDR{+ihR(s7-{i#f{ zsR8n+wL|Z!2IYB+Gs;+$Ng}Fsa{b@!-bV1%$s^L9LS!){JF>%UDj2E{*my*3PGe0# zR_;J7#OJWGrP96z&}l4t-2Ng_JpZx={ifMG;w3vruF^Ks~F7F-rm1muOS9 zCBRik+eu-ycVtmhdU;+oZZ(`%@>2Y-g@Mb^J`C#%?JXtmf!+5NLc%^sW4-Em1XGU+ z2U&-?pm#Zjd6=m;?rcUDeX`6B8EA143P>CyO10wbo&bnXm{U^>5Ifiw<&&BZrsJh^ zcZz(Vy2u2~`4Zb9%3RF(qmD&8LzFhJ@Hq1u@e#!+oIBZ#CVEGTtonZ5&khH({k0tE z0|WE(R#QV~I24(-ML#c4C-|cJQi}N)ohIV990w&)o$4S`gX{=z(`xKrl_vdcLZS(x z)}UF3v}1X#ebOzxKJCBmwPG1qOTJ!8nsd&8q#05@#`2G4+CRk$SlF0|iW`uX$T%&o zW^2Kruj#}|707ULw9!9{^ik%V0Ew&}g>?h-Gcl}?s;ke#sumNsI;?6m!YM_hIcaB1j-0;ty*pvcElP|KVn(YwT=na{HxhNEUGHht!?MT z|0?q`9+h&6HOrcJr4t|I^DBi?Kudx^*$=O-{xAwMd~sf)1-{y{HF?V%q~)BUZ|LBv zn`-A_PD61`G4Y^e(}Kr1!Z>1Bskt!U1wWY#(m-~Nkwnx44kt!}Eo`~gLmsLoMFN+q zY;NDnG0jjXw<7_`q`()<4Al1DbVxR$>GnAYgC%HICYIUm<$?@hTRw>8x!hJt%}c~v z6NP`1*oBWlrO^Jwxb%xe^RGWw&JlLkT5`E<#8 zfCSn}PIHj2QVAnz8azm`j*<$Ggbt5+&7Lah9A2??r3l=C3k>s9v-%vwrxisy44|-6>nwb+-7<4HOhVE8boYLT6 zw%ObB5}3T{JYCtQBr2Yd;hQd)pKl-eSLgzh17@Q7T}+FJ(mU}pIvc)N@?+|0yY;Wx zc?CpkbFzIqUsj`=LVbE1e|&@kl-AL7MF#a6RYN8=xC1mguc``{;vN7$5~82J@^~C{ zrIqXE>2UFv1qb=Zh|xqhTpy=dC*Ty_jD)9^@Vzl)RK2AQP$M(F@O&lAy>jifF~pB* z7tR%(q#6OuWKdkJb7o>szGdrzTQustj_9 z*g=w@5K9*&X(5FeBcR^t?t5l@-&c)GfU8X=vIkQSYQmkP5eIyGj7o583qj+2rsY_z zi#T*D=%;304v!B>tppoQiRi=-4=NV3msur9-tFbtJqWQ=CNqmt07ESJmbL4udp%@7 z2ivpSpJ*R!D-Y|1kPr;_7Gk0RoUByau}S<=d|74+H-@Q5t}%c%lg6Eo7pEFSb@Folw-#tg7XF4UxS7%a;{*%g57I?Cf>>MEx+h+x#NQ@;xM zf+yZwbhEj!wugv%s&Nwb@G`<6vSolmkwPAnBgUg*q5@zZLtTaPBt!P;YNs6YdpA)a za5$|tPTI-wU|Gw_rMAG0EJ(5=S0}n8KW#Qb)MJ*aUmDcp3qQ7BB8J1pZym&@5<^Co zi08bo(wH6vy@k$&lUW(^@R**_A_HfQ6fKdc4g^q8 z>JKsz%RBLwa#U}-tF9FjBMCM344pcur^YyqTfJrhb)`Dw{u;#~42y@?}Dgpx%1 zf?zKKP0%(;)Z^!mOKXC>N_%HBr`SD;F=yg{Tyj0?;+)hbl4UdIgJQX_Nu{-uLmZ;kR&#GhWc<=#zng9}gh0=Y7 z>;34gczSjPw*hWR?1(ssFDDkVHjA)ra$z`QQG4o$iSxd5KDdAyj>9%cKgOKm&*pRW zIBMSsgFr0NQ26u2_g*bNEqacl3|ZlI6Bx2z7`Cb@PYjuvgR!|Unmu~HA7CD#h2Mb; z6>hqW)|)6YD+E!}V}}vt?M1+3bt&^Ec!o4l@qbB?1{}HvL(KBPH#GiWAK+^0W*o|o zCXH3}{;^EU;QFij43sQDmKA^6?GsXZcQ{Y{64V0& zg$gWPTMVq=5(@uQRT?`e}rzKEo~vj{US)3rIrzX z2mSYVtX7*$+u?_F@bF_I;q(7}$GRFDI(?`O{`s19(RVcb=SwzHRpGB*>PMlQor0bz zd(_;w19O-G;G|#}gtxrupL?t=%Bi7E7E4%?e_s81k0+tgm=2aqU=Z%|u;JoDAlKrH zQ*xhrK?SRpq?W{By(~vrtIb9^E|Tdt-(3N$n%I6go;#waxm4{y1fgms&!_iY1Y-u= zbC&}q0K7hzAHGpAnJRHk%~WDXZa!EFSZ#%jbdu;?`idf8s+KH1_)I_?wnD&v8-juv z==ibARdc#OrH2 zaqpDfvFUtjel-MjnOm@ET^={JRIl7N`Dl!9jVHmnKl3xp4VXT9b-HGOC@#$FyymcA z?>)6dO{3~XvQxCh>%)nDbd57nWGDJ7jky9dJo#yHU4mM71}B0bXkm)o`e^IkpV&u* zqVw3J01->os?t8>*vUSDEHW(c2SM)iEHT(NSaRK3!6LsusXJ;`|h{-aKL;i)b1*YoC&WpZh4w<6I4ym9RLgKoAYctI<+aI1bKX}O}9J?K$J!Y71Lcg}|SOWT2G z1Yh8+43Bm^EonJbB=sz5+-`VXP; zVTvH=)kikY|B;RVT~PYfQQy(&AA(YKt*>ire>q98_i+b~PuK6xTdQ)NDm%>zugy=Y zuTIqCN5g!N31pS12Ex>u*zdaF0CJ5xI1_r(i|g)jImbB0(jK;`>UTEXw(?{P*64YU zH;x@84t7iVOo#@R=V#4Ej4^JP(M>b8lNx2#e3)7`#VsW|B|5boAnsP;2H})q4{kD~ zuUdop>nQ{Cy)Oa1Hn7eJjtB|Nn>rNvQDreyv6?4-mcN2S5DI=c1=L5}&6q#lw^oHh zGR2`8!^CvO6)_&Zsf=eB9^{S~00HfVJ_QV(g)j&hz^*<+; zJhSgbIRVFEMFu2_{UaJJ6)TB-s@{jCq$|{wFgYXi$Hz`$N%>6Ox1G{KR=mH;j91!^9NI)UL~+Pqhm-ZqC+iQf zSMyTDW!9}5;@3m{0Ye83hW@`4r1l1ECUg8OReEo>$CkF;{9BY63^ho>knWyHy2w{NFaF)-&;@Z1^Y5V0m8x-OPJFmbh9FK>4xaZO{+d{rIR0Rc|SZ8^el<8Lmp;-)&Xvt z2ay@3fShsE4;L-l;j(#_&Dl*byqcG>yYnAaMKOJ$1yHYkVa+kEz-dE?W7D^dd8=@e zZDXpwu|BFscm?9hcPR@6=iDtioWU%%ex_@tt<+~B%(+ENhq68o7R64Kn=({4Y#f^~ zY0xkEZow&QH*uw#BcUkEma>^1J;azEJ#{=q{d_ZVhNw#(R4#=A(K(i*<$?B@X1Q-P zoZ;?Q9ubf#q$I%Sb!>=$@Cm=oM82$qqJB_tef&7$WhtNVG6sAO6F zRCp8qY*}QX6#C_4`g|uG~OW(A~?GE_$qmBTX50M z#1uohnP8xi$@^)QrhsV=mXG(CD84q_Ng~B{#H2C7--kH9N(L8S24&e$DwskK? zqFPlqa=X}j`2PKur@{M@BII>dr6K$w84n;UAyV}<{0Llp8&Me(gj0(yT^4Yt$a#wV z(^<3^664X+>2P&hq*SL~K9Pg_J#^Nv)zg;NZvFepiZocIs)~}d6>@W3NjCz~&d0Tu zc)sPZ)lOiv;^dwjOh~V(x~&(cWu=Q|%#Zc10LWWj z*}KZGnh&|l*E(%Lc?{j+U{M$#G0vy=C=K#*Y^p;$L1#t@$l!fMG8e@xF_esi-BG6G ztvY)jjS#j;hr~ynQm%b@1I0bu%H|9lbGq|O+;8VQIeage@O^l7m2A=6jgpj()Ae-0 zy~h$_2x)Fw&T;Rr=m9}#t}Olyu+iM5IEWjO5F0imac_L$`FIjQPSN9|DRD?46V-c34&UYj$3xbwz6{r)4qB=Im={-pF;V(Kb6v2mZ#D7v2Tx)YnB=9%GD32qyX-pW(XGfX@`O?B_bN*rT&ngJEWZE>^9`z9K$&5V}aFg0aOFqq@%*LZKc6u zXABJ113%|2uwRhmW*rrhB7@o|z-XJd@F4sKY9ovgg7@xxfBnj+BM6zeY{F`#vBTkC z-gd_gwWa4w&7wf|pb^%R#+mEKH^k=Vedqi4lSI?%56IjPz495@zvl-o`sR-RsLUD1 zf3aL+Kn=clqeeK7oZv3FWt#S1avrxLjd7(mju5ViMq$)oid|>HdAZ<<&0xvt6tA8f zdP%=o?ezE9z>8Qu$6eRn(iYmh=F_Z^Y{Eh8A`pn0O8QH8VKmWqPfgv7YC=XNp1_IzSdH3J9y#753SjLu;4>Nd~YRw$8LtLeqZHSk|9Uw zY>LQ&$hz#74vdRA24oA?sYjKAB3E(4s6pm5+UCQJqSRL_>%#tsSiaRs`+Nkm>j^!; z6fE~tt8Q|s9It!0CFMGb5$>sq9xatjzwOQIK{0B;o7Xlq|2d1NZ^__326%S9UdQtc zP7~+xRdg6!omeMg2oj$8UiUZjyuoAi^_-iJvjpoeORwAFkM=$;S!FYt3(a{sX)KT7%y2+Boh8-iIE7} zH<1-1BDKd5f0#n@`mBf=I5y2OU5#X+2OF5k`E_G&6~U!FYZ!tHy+SkuX;bZLeAp$u znZ73xP9lA;$`*)#NKsp?2UJ(8#M?Y;Oq3 zHKl#mO?0kL-eg1EE&Qr(PDn!N7pCGu4E-7H;`NMu`nJb0I{~@Pf{>B6^6K)~))}dp zw(SUl^|h7TQW%~0Hwl7oh_f9-1i@Dzd0=_;?x**(2 zdY+ihj5$>GLw+fpEK#Fxf1)?eV9R)Pes4tV8nKDLr|9&0xozR0JtMIic%jZSpS6ymMb`E?j0r4#9qsZS^DYCzV2K<*rID zn`(qCT5suQss+uJ{f@{^TY_`I!WvQ*?1^xYR;i>_v_YOE`ZfG;lgawoz^C5%OMyO* zLR_r{_#$^Kk3x0x=e&isH^T88B~@R^YWYuML__+vdj>&R9COf zNNpmdfLM^@*h}S3#ba`D6Pi~A(nv^7gNy@RxHCfNT*6o!xLPtC>&YlVnX5cb$IOCf zLS#jK0Uwi`Yb?BpeGT8u4&fQwfj`>qsL2=OTixXECoK~Vfh3q{kH^9r_(ttDAvGbe z0IO<@<%jnjuRm3Yqh<{2Vd0ev1cAlD z;q`;MIOI-iXDD|V708ddBeOEWmV{9+bZHIvGU??VH3VFAD)Ef5JMX#ajybXELb|C2 zU#3}N=v}I#8HQR9iiF{*(YMDFkpqP z?nN12Yk&zdG-2aa{5`L-?qt^FUAOCN zrv;ty-4qiY^raW5v1)PF7~e%8OpN283M+~z1oR~*Vcx+>EnPkIeNnX$d9$LP0wbcA zw%q|m)Eero566-WAS9=NaGdOTLRxB#?=UTSILbd9ph8l)sA!1D7Q}x+v=LAf)Fliz zIOJhqMM&oo*0HU^v)gcRmR{EzO1Ykx?~@C*E2qvYG4$S+6XEYrR4yHui9(L7Nxop^ zOnr7nrATscUqZSUqR5RNuEroi>al19@sRp{bn5Ax*-QS)iO*%UjX^1A!{0x2Z=_TM zY1|023r&$nObS_WFX8jEYI3ef4O2&tOOB$rpQA%bqaA zVlNV+={=O`6sd|l@=W!8FD=;{5_A*|-nA=14st7f$T!xn(_-JUUwpPRsMQOffl=Tz zqln~5<)`h19BJKoEf0CJe-K*H6QI?!XCvOt6jpv1)*{KnchmpEKpa^1!$Yy}Mlqg( zfi~$KnFjlrS6%k=1!7HR&TnpHH1vy3L+y9$pA#1*A)wT;G3%{J0Ip%3qx^;Vj0W&q zXuQbTocn`sJ_nUK@N5~^Xh_?iFeQxc>VG;4up_!Ocdq(BOJ5g`KG7l^hspmqG|h$@l^b@Sw8L! z8GSWXB|>uUaR(yxEO@VKPamS^Oe-P$OMh6-c3X>jnI{uVQH7S9^ssZ7pl<-DT^clg zNB8wGnK+Jq9TI=kE)*90>sEglw{==eAYXuyh(*vaA!WU0!SYyxc{cg(Gk*1tA$ilP zeby|;K=HMl&SLxroNZNjG2!Xa65F2$S4TgyV^90c$*)inW~z_a*5V)m-y{tQg5J13 zIne|o)_-d4W8XQSFo!|k-0Pn96#;Ui$#;trVKoFfTNYJ zr2$cyB-7$)Yu6)rCsBy#=Zg@N0S;n)hyBSdqVvR#7ZRQs8I~*ux?}4rM)0GP&Efc# zFn)7Flo0PRk>X7loN;N?6s=(So)H_tSa3Pj>xMyBoS4yPvf^{vThN`AkVm_2R zCHBo?ss$B89J3#!1|ZdTzcAL_!px1f8Tx^kwWU9&stQc?b9YA-T@3}hop0{uep6Hv zs80yKlLnH7&`qJ159Jf9WQy8nsa=E~>*(H|GrQL@v znjBCjc58NG`*5{R-%yn)wKLjam-F(H}v+?8`!zqDzhXzG`g<)JxxNeVbz zxZbH|(3+S|7ZWI5SQ9{5w=p=4NqP;h!9`tEoE0F*VOOf?i4t+_s&d&oZbKJOum5>Bx%%u# zf&8OP_|jZ4ygHVv7&PQ^-e}l6tvCD%Vy^OD)FR2VCn=94UH_MLm zbXJ08M=~K&LJ49fT^S;sQ!q}dFgJKOQ-}uh#{0uVwGpVSN{3$UVrguFzNu@g@*MW+ z49B*x>pIZVDpD<%(&_SN z=HeF*)zra5jEbWVPn1o*!%L22R1B|ahccX2Ze4=|G=85}zgs=5%iGk&x57QapQ%0^GqC>8ESi*{CCYsZmP!~W#&S;v!H%pXPu26lJ58LrbX(Hocu z%NdLd0)VHVm&~Zfa}UTxfy2-g<84v6fS zA>z|=koG#FxP@b<3Ug%UXI-7ZSlf|^o$K2JUm;!@Fv5{7K{Sa7>CfB6gE*za(HGJ)p52iH=g5#$~*7K?R}3}jevy;?|M zht_DMWw$Y`4a((&Hnj^Z3mlHA(c0mUV{GZ3_-5DiJTBl?qwK0OVMqK-ftEeWv#ocO zamGRBXzD`GA*%F=;7`?yza=Y-vr$0KEs{_`hw%O!!4p@BmBqNju{FZxR4-FtVW#E^ z&aijdd#t^h7()^B!)zdaGOyvJaa=A6I4*~J$Rr`8c;IQ>)#KKP1n)Q$U7Rh_=siZn zP!uPR?ndnw_`e?wlQKJt6+dbn%^x~Y{NG*EKaPenBFX~70?Gmj$~_Kge+_4&c}RHx zLH?YN;yj0&Gb&7Bmo1b|y8&S;2L2}5i}3bb8F4mP^XV4E?4e_$DLM$!hEXj1U9K`x zK2`uVa1+^bvU|Qz;B6OAF)3bhwN-S3Q9V?74s;or*6y460Q|A4BoVAQE>dRwXfq?- zrBne#d|>k+pQBXu_NT|*h6kC2b=%|N?D|GVD;BCi+BKG8WuD$htTUsC_U49ySu5SW zl%g2B4)pmxt!Wi(Dr7)%0fvnL(T3>BI)khF&ylOIAXgiFc(x5T10Hf{s|mBL z0R&?&2YWZOFPcQas{V1oIGu+H78Z20r0CX`zKoawpX?XnT?0Gf635by5Sv=Inf4G` zsreW2^^=q(4Z&gKMWl9dZ&lF~7!HtZ_y`IJ;|8^LC1{ z6gK3Z-Y9RAW~mT+*q>0&@_ThQNw7v!5vGUnslEh~^7XI4wDLT%OQi`|7IPo{i%g;*Jl8<-qZ&rPQRVT0Edv zLdeWJ(yd<%8p*e1?wDF^bV+H3?nJ&WY2Tjp>fyo@&uX8W)^5P9ruV=pssqA0kF4vC z=iEx0exbN+>i+v~e3kNLQvcY6x*zMqRPbN-J^ry9W#yDVN)>oxGBCb`h!E2qMUM!G z#2fsY4HGDEtSS2hd0C<- zIe_WT5X|DQbJdInPm+DTkq5gnWq_ zf>v^3M7mayf|5$+07gQ(Ns*$MWpQF`N@_u7ylj6LrEYN{UvoTE$oLSb%Jo2>EJ+BSlrD!0oEI*Um6ZD$apfDyBYQUI=XOD+=>XbMqf@ zdBoWHIDW*`__0Vn*8g1B`A1xiwsyK!ADTz2zk>P_*@Mu>03~$sN=l3+vr>x)Y~I$T zyLdMrPWGa~mdgSyEMB^!KhK4;PZiTZtGL|*8;OlzRd`q(7P1PNVQtu_dxIVsb9y-s zRw2$scVT;BM9+@qwui3Xz<&R|^pR7S-q8;K>xG<4m}hM^CIXj)^ur%dKm>x>&p$tO zBYqzP`2T&+DT)ZmDGEEyk0s`cBSLI@@xdw!Ip~gq5D9=Oqrw=)L81b;2oVp@MVp$e zHoD7Nt9!p9VIc{z)+^|~3R6^ljhGgDAq=Jg!Tp6nT|8}WyX)>0A-<2DAm@NXhLy1| zue`o!1Mn_pUa_C&_J3x4g&dFxfLs@|PP?^LhqRW4c_%NADx~m??IoEgwLT3iEl~|t zqz14B_nDl7i35f0_P}x{J*Cx=O*sI-90hfwC-6^{&=HGe%KQX0zFM0b{~ zMbz+H2w;r;>Lp=g1R+UVmHCNd0<{toa7Xbmsl>V={!mT{HaY&%D9r(estSz%4&@G+@sn@byI*jA7dg-KYQrymrC!Ox8*M|1tQdw(E z&oy|U-6fNYeqQ-Fu#dv#61hw>W5nLxUpWA(adOSA5Gt$d+VZ@65!1-YqH6sHT(WiVpe0>cvdI|t zuC|!Qq=_e~pBUKp5*=U6)zem}GljZHEQIFO@f?-53FlsX_TZ< zX)1g7B3mhbk3OZ)y##9sb|e9hpMta00p8qs25vRF14N}bdyYsjp&%U!`?AXPbIGUY zy=z<|rmCt5xiR_ednlh0$=Ifc4IzZ1gmZXJ6!Q(`aa^1;%f7(oSYjJ6Lxw(c*Gi@d ze_M5&)>^8z>mT#Ut>^_i!IB}&R)ZYol6FxxOJ5GD;ayef>C%hcwbx{)^p93-m(M1> z4!!VT^V`Z?iBXVJK07-{^)Rb^tCCQwz*HK9Cce3=VZa50{`L}lyM(CO)gNDRCeR!s zK>59n7<^*m2c%O4Wb(W=%xrIoC24l=k2hiQt%djW`6K5>86CbUq|7V&bce$quF$>} z+lkr%ZVr{sxTBBn-#C8=iuN9L%S}51cIqI?kWB|$$z>6i_b0)hMsB|ez-fLH{I9LspYT6zgnq*Z8Gpn7 zqp8r}ss5AU%5SP8mj6oiuYst)ll`Yj`rl+)T))ZwIamL8?0*`;`i<2Q`HlVefvi6{ z{v0m&%`q(bo8$jGYVarJpIXY_lr)O}hw{HDEC1yDQ+4&5Q&shEod42b{R#iGDf=7l ztn(ZGzb)Fo!~atY>^EFY|9|0sBr5;Y5c`wr&m+@sDg)d9M)j`))Sp~`CcMA7F75vt z*FV$WKbihaRDU!1JN!4Mf26H{68)K6{wBis`rnBDOf~=H{4+89%~|zdIsYw9{FCs{ or0rjX^}t3S3Fv>$JO7!&$xDHMBzizVXdj=Yk3(;m>tEmgA9?o8Hvj+t literal 0 HcmV?d00001 diff --git a/spark/processing/3.0/py3/yum/emr-apps.repo b/spark/processing/3.0/py3/yum/emr-apps.repo new file mode 100644 index 0000000..6466eba --- /dev/null +++ b/spark/processing/3.0/py3/yum/emr-apps.repo @@ -0,0 +1,7 @@ +[emr-apps] +name = EMR Application Repository +gpgkey = https://s3-REGION.amazonaws.com/repo.REGION.emr.amazonaws.com/apps-repository/emr-6.1.0/72a9ec2e-9bf6-4d7d-9244-86a0ab1e50d6/repoPublicKey.txt +enabled = 1 +baseurl = https://s3-REGION.amazonaws.com/repo.REGION.emr.amazonaws.com/apps-repository/emr-6.1.0/72a9ec2e-9bf6-4d7d-9244-86a0ab1e50d6 +priority = 5 +gpgcheck = 0 diff --git a/src/smspark/bootstrapper.py b/src/smspark/bootstrapper.py index ff1ff65..0369662 100644 --- a/src/smspark/bootstrapper.py +++ b/src/smspark/bootstrapper.py @@ -19,12 +19,13 @@ import shutil import socket import subprocess -from typing import Any, Dict, List, Optional, Sequence, Tuple, Union +from typing import Any, Dict, List, Sequence, Tuple, Union import psutil import requests from smspark.config import Configuration from smspark.defaults import default_resource_config +from smspark.errors import AlgorithmError from smspark.waiter import Waiter @@ -36,11 +37,18 @@ class Bootstrapper: HADOOP_CONFIG_PATH = "/opt/hadoop-config/" HADOOP_PATH = "/usr/lib/hadoop" SPARK_PATH = "/usr/lib/spark" + HIVE_PATH = "/usr/lib/hive" PROCESSING_CONF_INPUT_PATH = "/opt/ml/processing/input/conf/configuration.json" PROCESSING_JOB_CONFIG_PATH = "/opt/ml/config/processingjobconfig.json" INSTANCE_TYPE_INFO_PATH = "/opt/aws-config/ec2-instance-type-info.json" EMR_CONFIGURE_APPS_URL = "https://docs.aws.amazon.com/emr/latest/ReleaseGuide/emr-configure-apps.html" + JAR_DEST = SPARK_PATH + "/jars" + # jets3t-0.9.0.jar is used by hadoop 2.8.5(https://mvnrepository.com/artifact/org.apache.hadoop/hadoop-common) + # and 2.10.0(https://mvnrepository.com/artifact/org.apache.hadoop/hadoop-common/2.10.0). However, it's not + # needed in 3.2.1 (https://mvnrepository.com/artifact/org.apache.hadoop/hadoop-common/3.2.1) + # TODO: use a map with spark version as the key to maintain the optional jars + OPTIONAL_JARS = {"jets3t-0.9.0.jar": HADOOP_PATH + "/lib"} def __init__(self, resource_config: Dict[str, Any] = default_resource_config): logging.basicConfig(level=logging.INFO) @@ -63,28 +71,43 @@ def bootstrap_history_server(self) -> None: def copy_aws_jars(self) -> None: self.logger.info("copying aws jars") - jar_dest = Bootstrapper.SPARK_PATH + "/jars" for f in glob.glob("/usr/share/aws/aws-java-sdk/*.jar"): - shutil.copyfile(f, os.path.join(jar_dest, os.path.basename(f))) - hadoop_aws_jar = "hadoop-aws-2.8.5-amzn-6.jar" - jets3t_jar = "jets3t-0.9.0.jar" - shutil.copyfile( - os.path.join(Bootstrapper.HADOOP_PATH, hadoop_aws_jar), os.path.join(jar_dest, hadoop_aws_jar), - ) - # this jar required for using s3a client + shutil.copyfile(f, os.path.join(self.JAR_DEST, os.path.basename(f))) + hadoop_aws_jar = self._get_hadoop_jar() shutil.copyfile( - os.path.join(Bootstrapper.HADOOP_PATH + "/lib", jets3t_jar), os.path.join(jar_dest, jets3t_jar), + os.path.join(Bootstrapper.HADOOP_PATH, hadoop_aws_jar), os.path.join(self.JAR_DEST, hadoop_aws_jar) ) + + self._copy_optional_jars() # copy hmclient (glue data catalog hive metastore client) jars to classpath: # https://github.com/awslabs/aws-glue-data-catalog-client-for-apache-hive-metastore for f in glob.glob("/usr/share/aws/hmclient/lib/*.jar"): - shutil.copyfile(f, os.path.join(jar_dest, os.path.basename(f))) + shutil.copyfile(f, os.path.join(self.JAR_DEST, os.path.basename(f))) + + # TODO: use glob.glob + def _get_hadoop_jar(self) -> str: + for file_name in os.listdir(Bootstrapper.HADOOP_PATH): + if file_name.startswith("hadoop-aws") and file_name.endswith(".jar"): + self.logger.info(f"Found hadoop jar {file_name}") + return file_name + + raise AlgorithmError("Error finding hadoop jar", caused_by=FileNotFoundError()) + + def _copy_optional_jars(self) -> None: + for jar, jar_path in self.OPTIONAL_JARS.items(): + if os.path.isfile(os.path.join(jar_path, jar)): + self.logger.info(f"Copying optional jar {jar} from {jar_path} to {self.JAR_DEST}") + shutil.copyfile( + os.path.join(jar_path, jar), os.path.join(self.JAR_DEST, jar), + ) + else: + self.logger.info(f"Optional jar {jar} in {jar_path} does not exist") def copy_cluster_config(self) -> None: self.logger.info("copying cluster config") def copy_config(src: str, dst: str) -> None: - self.logger.info("copying {} to {}".format(src, dst)) + self.logger.info(f"copying {src} to {dst}") shutil.copyfile(src, dst) copy_config( @@ -395,7 +418,7 @@ def get_yarn_spark_resource_config( { "spark.driver.memory": f"{driver_mem_mb}m", "spark.driver.memoryOverhead": f"{driver_mem_ovr_mb}m", - "spark.driver.defaultJavaOptions": f"{driver_java_opts}m", + "spark.driver.defaultJavaOptions": f"{driver_java_opts}", "spark.executor.memory": f"{executor_mem_mb}m", "spark.executor.memoryOverhead": f"{executor_mem_ovr_mb}m", "spark.executor.cores": f"{executor_cores}", diff --git a/test/integration/local/test_multinode_container.py b/test/integration/local/test_multinode_container.py index 4c63857..d70143a 100644 --- a/test/integration/local/test_multinode_container.py +++ b/test/integration/local/test_multinode_container.py @@ -75,12 +75,12 @@ def test_pyspark_multinode(input_data: str, output_data: str, verbose_opt: str) def test_scala_spark_multinode(input_data: str, output_data: str, verbose_opt: str) -> None: input = "--input {}".format(input_data) output = "--output {}".format(output_data) - host_jars_dir = "./test/resources/code/scala/hello-scala-spark/lib_managed/jars/org.json4s/json4s-native_2.11" + host_jars_dir = "./test/resources/code/scala/hello-scala-spark/lib_managed/jars/org.json4s/json4s-native_2.12" container_jars_dir = "/opt/ml/processing/input/jars" jars_mount = f"{host_jars_dir}:{container_jars_dir}" jars_arg = f"--jars {container_jars_dir}" class_arg = "--class com.amazonaws.sagemaker.spark.test.HelloScalaSparkApp" - app_jar = "/opt/ml/processing/input/code/scala/hello-scala-spark/target/scala-2.11/hello-scala-spark_2.11-1.0.jar" + app_jar = "/opt/ml/processing/input/code/scala/hello-scala-spark/target/scala-2.12/hello-scala-spark_2.12-1.0.jar" docker_compose_cmd = ( f"JARS_MOUNT={jars_mount} " f"CMD='{jars_arg} {class_arg} {verbose_opt} {app_jar} {input} {output}' " diff --git a/test/integration/sagemaker/test_spark.py b/test/integration/sagemaker/test_spark.py index 5f1c772..be25bd9 100644 --- a/test/integration/sagemaker/test_spark.py +++ b/test/integration/sagemaker/test_spark.py @@ -210,6 +210,69 @@ def test_sagemaker_pyspark_sse_s3(role, image_uri, sagemaker_session, region, sa assert len(output_contents) != 0 +def test_sagemaker_pyspark_sse_kms_s3(role, image_uri, sagemaker_session, region, sagemaker_client, account_id): + spark = PySparkProcessor( + base_job_name="sm-spark-py", + image_uri=image_uri, + role=role, + instance_count=2, + instance_type="ml.c5.xlarge", + max_runtime_in_seconds=1200, + sagemaker_session=sagemaker_session, + ) + + # This test expected AWS managed s3 kms key to be present. The key will be in + # KMS > AWS managed keys > aws/s3 + kms_key_id = None + kms_client = sagemaker_session.boto_session.client("kms", region_name=region) + for alias in kms_client.list_aliases()["Aliases"]: + if "s3" in alias["AliasName"]: + kms_key_id = alias["TargetKeyId"] + + if not kms_key_id: + raise ValueError("AWS managed s3 kms key(alias: aws/s3) does not exist") + + bucket = sagemaker_session.default_bucket() + timestamp = datetime.now().isoformat() + input_data_key = f"spark/input/sales/{timestamp}/data.jsonl" + input_data_uri = f"s3://{bucket}/{input_data_key}" + output_data_uri_prefix = f"spark/output/sales/{timestamp}" + output_data_uri = f"s3://{bucket}/{output_data_uri_prefix}" + s3_client = sagemaker_session.boto_session.client("s3", region_name=region) + with open("test/resources/data/files/data.jsonl") as data: + body = data.read() + s3_client.put_object( + Body=body, Bucket=bucket, Key=input_data_key, ServerSideEncryption="aws:kms", SSEKMSKeyId=kms_key_id + ) + + spark.run( + submit_app="test/resources/code/python/hello_py_spark/hello_py_spark_app.py", + submit_py_files=["test/resources/code/python/hello_py_spark/hello_py_spark_udfs.py"], + arguments=["--input", input_data_uri, "--output", output_data_uri], + configuration={ + "Classification": "core-site", + "Properties": { + "fs.s3a.server-side-encryption-algorithm": "SSE-KMS", + "fs.s3a.server-side-encryption.key": f"arn:aws:kms:{region}:{account_id}:key/{kms_key_id}", + }, + }, + ) + processing_job = spark.latest_job + waiter = sagemaker_client.get_waiter("processing_job_completed_or_stopped") + waiter.wait( + ProcessingJobName=processing_job.job_name, + # poll every 15 seconds. timeout after 15 minutes. + WaiterConfig={"Delay": 15, "MaxAttempts": 60}, + ) + + s3_objects = s3_client.list_objects(Bucket=bucket, Prefix=output_data_uri_prefix)["Contents"] + assert len(s3_objects) != 0 + for s3_object in s3_objects: + object_metadata = s3_client.get_object(Bucket=bucket, Key=s3_object["Key"]) + assert object_metadata["ServerSideEncryption"] == "aws:kms" + assert object_metadata["SSEKMSKeyId"] == f"arn:aws:kms:{region}:{account_id}:key/{kms_key_id}" + + def test_sagemaker_scala_jar_multinode(role, image_uri, configuration, sagemaker_session, sagemaker_client): """Test SparkJarProcessor using Scala application jar with external runtime dependency jars staged by SDK""" spark = SparkJarProcessor( @@ -233,10 +296,10 @@ def test_sagemaker_scala_jar_multinode(role, image_uri, configuration, sagemaker scala_project_dir = "test/resources/code/scala/hello-scala-spark" spark.run( - submit_app="{}/target/scala-2.11/hello-scala-spark_2.11-1.0.jar".format(scala_project_dir), + submit_app="{}/target/scala-2.12/hello-scala-spark_2.12-1.0.jar".format(scala_project_dir), submit_class="com.amazonaws.sagemaker.spark.test.HelloScalaSparkApp", submit_jars=[ - "{}/lib_managed/jars/org.json4s/json4s-native_2.11/json4s-native_2.11-3.6.9.jar".format(scala_project_dir) + "{}/lib_managed/jars/org.json4s/json4s-native_2.12/json4s-native_2.12-3.6.9.jar".format(scala_project_dir) ], arguments=["--input", input_data_uri, "--output", output_data_uri], configuration=configuration, diff --git a/test/resources/code/scala/hello-scala-spark/hello-scala-spark.sbt b/test/resources/code/scala/hello-scala-spark/hello-scala-spark.sbt index 7b44959..1ba7d17 100644 --- a/test/resources/code/scala/hello-scala-spark/hello-scala-spark.sbt +++ b/test/resources/code/scala/hello-scala-spark/hello-scala-spark.sbt @@ -1,9 +1,9 @@ name := "hello-scala-spark" version := "1.0" -scalaVersion := "2.11.12" +scalaVersion := "2.12.12" useCoursier := false retrieveManaged := true -libraryDependencies += "org.apache.spark" %% "spark-sql" % "2.4.4" +libraryDependencies += "org.apache.spark" %% "spark-sql" % "3.0.0" libraryDependencies += "org.json4s" %% "json4s-native" % "3.6.9" mainClass in (Compile, packageBin) := Some("HelloScalaSparkApp") mainClass in (Compile, run) := Some("HelloScalaSparkApp") diff --git a/test/unit/test_bootstrapper.py b/test/unit/test_bootstrapper.py index c20430c..759fd57 100644 --- a/test/unit/test_bootstrapper.py +++ b/test/unit/test_bootstrapper.py @@ -97,9 +97,11 @@ def test_env_classification(default_bootstrapper): assert output == expected +@patch("os.listdir", return_value=["hadoop-aws-2.8.5-amzn-5.jar"]) +@patch("os.path.isfile", return_value=True) @patch("glob.glob", side_effect=[["/aws-sdk.jar"], ["/hmclient/lib/client.jar"]]) @patch("shutil.copyfile", side_effect=None) -def test_copy_aws_jars(patched_copyfile, patched_glob, default_bootstrapper) -> None: +def test_copy_aws_jars(patched_copyfile, patched_glob, patched_isfile, patched_listdir, default_bootstrapper) -> None: default_bootstrapper.copy_aws_jars() expected = [ @@ -422,7 +424,7 @@ def test_get_yarn_spark_resource_config(default_bootstrapper: Bootstrapper) -> N exp_spark_config_props = { "spark.driver.memory": f"{exp_driver_mem_mb}m", "spark.driver.memoryOverhead": f"{exp_driver_mem_ovr_mb}m", - "spark.driver.defaultJavaOptions": f"{exp_driver_java_opts}m", + "spark.driver.defaultJavaOptions": f"{exp_driver_java_opts}", "spark.executor.memory": f"{exp_executor_mem_mb}m", "spark.executor.memoryOverhead": f"{exp_executor_mem_ovr_mb}m", "spark.executor.cores": f"{exp_executor_cores}", From faf06eebf6b855a2aeff73418febf90dd4899bd2 Mon Sep 17 00:00:00 2001 From: guoqiao <64932716+guoqiaoli1992@users.noreply.github.com> Date: Sat, 21 Nov 2020 23:31:52 -0800 Subject: [PATCH 2/3] fix: use al2 base image from ecr (#38) * fix: use al2 from ecr * extend timeout in job --- scripts/build.sh | 4 ++++ spark/processing/3.0/py3/docker/Dockerfile.cpu | 2 +- src/smspark/job.py | 11 +++++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/scripts/build.sh b/scripts/build.sh index a88c3dd..9e915e1 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -22,6 +22,8 @@ source scripts/shared.sh parse_std_args "$@" +aws ecr get-login-password --region us-west-2 | docker login --username AWS --password-stdin 137112412989.dkr.ecr.us-west-2.amazonaws.com + echo "building image ${version} ... " docker build \ -f ${build_context}/docker/Dockerfile.${processor} \ @@ -29,3 +31,5 @@ docker build \ --build-arg REGION=${REGION} \ -t sagemaker-spark:latest \ ${build_context} + +docker logout https://137112412989.dkr.ecr.us-west-2.amazonaws.com diff --git a/spark/processing/3.0/py3/docker/Dockerfile.cpu b/spark/processing/3.0/py3/docker/Dockerfile.cpu index a61b4f4..516a2a3 100644 --- a/spark/processing/3.0/py3/docker/Dockerfile.cpu +++ b/spark/processing/3.0/py3/docker/Dockerfile.cpu @@ -1,4 +1,4 @@ -FROM amazonlinux:2 +FROM 137112412989.dkr.ecr.us-west-2.amazonaws.com/amazonlinux:2 ARG REGION ENV AWS_REGION ${REGION} RUN yum clean all diff --git a/src/smspark/job.py b/src/smspark/job.py index 3096b95..a0aef46 100644 --- a/src/smspark/job.py +++ b/src/smspark/job.py @@ -32,6 +32,9 @@ class ProcessingJobManager(object): """Manages the lifecycle of a Spark job.""" + _bootstrapping_timeout = 600.0 # all hosts should report as ready within this timeout. + _wait_for_primary_timeout = 600.0 # then, all workers ask the primary if it's up within this timeout. + def __init__( self, resource_config: Dict[str, Any] = None, # type: ignore @@ -136,7 +139,11 @@ def all_hosts_have_bootstrapped() -> bool: has_bootstrapped = [message.status == Status.WAITING for message in host_statuses.values()] return all(has_bootstrapped) - self.waiter.wait_for(predicate_fn=all_hosts_have_bootstrapped, timeout=180.0, period=5.0) + self.waiter.wait_for( + predicate_fn=all_hosts_have_bootstrapped, + timeout=ProcessingJobManager._bootstrapping_timeout, + period=5.0, + ) try: subprocess.run(spark_submit_cmd, check=True, shell=True) @@ -172,7 +179,7 @@ def primary_is_down() -> bool: return not primary_is_up() self.logger.info("waiting for the primary to come up") - self.waiter.wait_for(primary_is_up, timeout=60.0, period=1.0) + self.waiter.wait_for(primary_is_up, timeout=ProcessingJobManager._wait_for_primary_timeout, period=1.0) self.logger.info("waiting for the primary to go down") self.waiter.wait_for(primary_is_down, timeout=float("inf"), period=5.0) self.logger.info("primary is down, worker now exiting") From 3d07cc09cf5c5cb18649c32fb6b9c138d4f2acc2 Mon Sep 17 00:00:00 2001 From: guoqiao <64932716+guoqiaoli1992@users.noreply.github.com> Date: Mon, 30 Nov 2020 17:28:29 -0800 Subject: [PATCH 3/3] fix: update partition for us-gov-west-1 (#43) * use different partition for PDT * update partition in one more place --- test/integration/sagemaker/test_spark.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/integration/sagemaker/test_spark.py b/test/integration/sagemaker/test_spark.py index be25bd9..e0d5296 100644 --- a/test/integration/sagemaker/test_spark.py +++ b/test/integration/sagemaker/test_spark.py @@ -232,6 +232,12 @@ def test_sagemaker_pyspark_sse_kms_s3(role, image_uri, sagemaker_session, region if not kms_key_id: raise ValueError("AWS managed s3 kms key(alias: aws/s3) does not exist") + # TODO: PDT is the only case requires different partition at this time, + # in the future we need to change it to fixture + aws_partition = "aws" + if region == "us-gov-west-1": + aws_partition = "aws-us-gov" + bucket = sagemaker_session.default_bucket() timestamp = datetime.now().isoformat() input_data_key = f"spark/input/sales/{timestamp}/data.jsonl" @@ -253,7 +259,7 @@ def test_sagemaker_pyspark_sse_kms_s3(role, image_uri, sagemaker_session, region "Classification": "core-site", "Properties": { "fs.s3a.server-side-encryption-algorithm": "SSE-KMS", - "fs.s3a.server-side-encryption.key": f"arn:aws:kms:{region}:{account_id}:key/{kms_key_id}", + "fs.s3a.server-side-encryption.key": f"arn:{aws_partition}:kms:{region}:{account_id}:key/{kms_key_id}", }, }, ) @@ -270,7 +276,7 @@ def test_sagemaker_pyspark_sse_kms_s3(role, image_uri, sagemaker_session, region for s3_object in s3_objects: object_metadata = s3_client.get_object(Bucket=bucket, Key=s3_object["Key"]) assert object_metadata["ServerSideEncryption"] == "aws:kms" - assert object_metadata["SSEKMSKeyId"] == f"arn:aws:kms:{region}:{account_id}:key/{kms_key_id}" + assert object_metadata["SSEKMSKeyId"] == f"arn:{aws_partition}:kms:{region}:{account_id}:key/{kms_key_id}" def test_sagemaker_scala_jar_multinode(role, image_uri, configuration, sagemaker_session, sagemaker_client):