├── .babelrc
├── .dockerignore
├── .flowconfig
├── .gitignore
├── .gitlab-ci.yml
├── .vscode
└── settings.json
├── Dockerfile
├── LICENSE
├── README.md
├── __mocks__
├── node-docker-api.ts
└── winston.ts
├── doc
├── assets
│ ├── kontext.png
│ ├── kontext.xml
│ ├── logo.png
│ └── logo_small.png
└── diagram.wsd
├── dockerize
├── buildDocker.sh
└── publish.sh
├── example
├── SWAPIGraphQLBackend
│ ├── docker-compose.yml
│ ├── k8sService.yml
│ └── swapiManifest.yml
├── apiProxy
│ └── docker-compose.yml
├── developAPIProxy
│ └── docker-compose.yml
├── kubernetes
│ ├── graphqlProxyManifest.yml
│ └── swapiManifest.yml
└── quickstart
│ ├── api
│ └── docker-compose.yml
│ └── swapi
│ └── docker-compose.yml
├── healthcheck.js
├── index.js
├── jest.config.js
├── package-lock.json
├── package.json
├── setup-jasmine-env.js
├── sonar-project.properties
├── sonarexec.sh
├── src
├── __tests__
│ ├── __snapshots__
│ │ ├── properties.test.ts.snap
│ │ ├── runtimeIni.test.ts.snap
│ │ └── schemaBuilder.test.ts.snap
│ ├── properties.test.ts
│ ├── runtimeIni.test.ts
│ └── schemaBuilder.test.ts
├── admin
│ ├── __tests__
│ │ ├── __snapshots__
│ │ │ ├── generalSchema.test.ts.snap
│ │ │ ├── index.test.ts.snap
│ │ │ └── k8sSchema.test.ts.snap
│ │ ├── generalSchema.test.ts
│ │ ├── index.test.ts
│ │ └── k8sSchema.test.ts
│ ├── generalSchema.ts
│ ├── index.ts
│ └── k8sSchema.ts
├── global-modifying-module.d.ts
├── idx.ts
├── interpreter
│ ├── Interpreter.ts
│ ├── __tests__
│ │ ├── __snapshots__
│ │ │ ├── clientLabels.test.ts.snap
│ │ │ └── endpointsAvailable.test.ts.snap
│ │ ├── clientLabels.test.ts
│ │ └── endpointsAvailable.test.ts
│ ├── clientLabels.ts
│ ├── endpoints.d.ts
│ ├── endpointsAvailable.ts
│ ├── finder
│ │ ├── __tests__
│ │ │ └── findEndpointsInterface.test.ts
│ │ ├── dockerFinder
│ │ │ ├── __tests__
│ │ │ │ ├── __snapshots__
│ │ │ │ │ └── dockerFinder.test.ts.snap
│ │ │ │ └── dockerFinder.test.ts
│ │ │ └── dockerFinder.ts
│ │ ├── findEndpointsInterface.ts
│ │ └── k8sFinder
│ │ │ ├── __tests__
│ │ │ ├── __snapshots__
│ │ │ │ └── getInClusterByUser.test.ts.snap
│ │ │ ├── blacklist.test.ts
│ │ │ ├── getInClusterByUser.test.ts
│ │ │ └── k8sFinder.test.ts
│ │ │ ├── blacklist.ts
│ │ │ ├── getInClusterByUser.ts
│ │ │ └── k8sFinder.ts
│ ├── loadBalancer.ts
│ └── watcher
│ │ ├── WatcherInterface.ts
│ │ ├── __tests__
│ │ ├── WatcherInterface.test.ts
│ │ └── __snapshots__
│ │ │ └── WatcherInterface.test.ts.snap
│ │ ├── docker
│ │ ├── DockerWatcher.ts
│ │ └── __tests__
│ │ │ ├── DockerWatcher.test.ts
│ │ │ └── __snapshots__
│ │ │ └── DockerWatcher.test.ts.snap
│ │ └── k8s
│ │ ├── K8sWatcher.ts
│ │ ├── __tests__
│ │ ├── K8sWatcher.test.ts
│ │ ├── __snapshots__
│ │ │ └── getInClusterByUser.test.ts.snap
│ │ └── getInClusterByUser.test.ts
│ │ └── getInClusterByUser.ts
├── jestlogger.ts
├── logger.ts
├── main.ts
├── properties.ts
├── runtimeIni.ts
└── schemaBuilder.ts
├── tsconfig.jest.json
├── tsconfig.json
├── tslint.json
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["es2015"],
3 | "plugins": [
4 | "syntax-trailing-function-commas"
5 | ],
6 | "retainLines": true,
7 | "sourceMaps": true
8 | }
9 |
10 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | pkg
--------------------------------------------------------------------------------
/.flowconfig:
--------------------------------------------------------------------------------
1 | [ignore]
2 |
3 | [include]
4 |
5 | [libs]
6 | ./src/idx.js
7 | ./src/logger.js
8 | [lints]
9 |
10 | [options]
11 |
12 | [strict]
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | # next.js build output
61 | .next
62 | out/*
63 | dist/*
64 | testfolder/*
65 | yarn-error.log
66 | out.txt
67 | pkg
68 | reports
--------------------------------------------------------------------------------
/.gitlab-ci.yml:
--------------------------------------------------------------------------------
1 | test:
2 | stage: test
3 | tags:
4 | - dockerfasibio
5 | image: node:9
6 | script:
7 | - npm install
8 | - npm test -- --coverage=true
9 | coverage: /All files[^|]*\|[^|]*\s+([\d\.]+)/
10 | artifacts:
11 | paths:
12 | - coverage/
13 | - reports/
14 | only:
15 | - /^([0-9]{0,3})\.([0-9]{0,3})\.([0-9]{0,3})$/
16 | - /^rc_([0-9]{0,3})\.([0-9]{0,3})\.([0-9]{0,3}).*/
17 | - master
18 |
19 | sonarqubeAnalyse:
20 | stage: sonar
21 | tags:
22 | - dockerfasibio
23 | image: ciricihq/gitlab-sonar-scanner
24 | artifacts:
25 | paths:
26 | - coverage/
27 | script:
28 | - ls -al
29 | - sh -x ./sonarexec.sh
30 | only:
31 | - /^([0-9]{0,3})\.([0-9]{0,3})\.([0-9]{0,3})$/
32 | - /^rc_([0-9]{0,3})\.([0-9]{0,3})\.([0-9]{0,3})$/
33 | - master
34 |
35 | pages:
36 | stage: testPages
37 | tags:
38 | - dockerfasibio
39 | dependencies:
40 | - test
41 | script:
42 | - mv coverage/lcov-report public/
43 | artifacts:
44 | paths:
45 | - public
46 | expire_in: 30 days
47 | only:
48 | - master
49 | build:
50 | stage: build
51 | tags:
52 | - dockerfasibio
53 | image: docker
54 | script:
55 | - sh -x ./dockerize/buildDocker.sh latest
56 | only:
57 | - master
58 |
59 | # integrationTest:
60 | # stage: integrationtest
61 | # tags:
62 | # - dockerfasibio
63 | # image: node:9
64 | # services:
65 | # - fasibio/graphqldockerproxy:$CI_COMMIT_REF_NAME
66 | # only:
67 | # - /^([0-9]{0,3})\.([0-9]{0,3})\.([0-9]{0,3})$/
68 | # - /^rc_([0-9]{0,3})\.([0-9]{0,3})\.([0-9]{0,3})$/
69 | # script:
70 | # - env
71 | # - ls /var/run/
72 |
73 |
74 | publish:
75 | stage: publish
76 | tags:
77 | - dockerfasibio
78 | image: docker
79 | script:
80 | - sh -x ./dockerize/publish.sh latest
81 | only:
82 | - master
83 | buildTag:
84 | stage: build
85 | tags:
86 | - dockerfasibio
87 | image: docker
88 | script:
89 | - sh -x ./dockerize/buildDocker.sh $CI_COMMIT_REF_NAME
90 | only:
91 | - /^([0-9]{0,3})\.([0-9]{0,3})\.([0-9]{0,3})$/
92 | - /^rc_([0-9]{0,3})\.([0-9]{0,3})\.([0-9]{0,3}).*/
93 |
94 |
95 | publishTag:
96 | stage: publish
97 | tags:
98 | - dockerfasibio
99 | image: docker
100 | script:
101 | - sh -x ./dockerize/publish.sh $CI_COMMIT_REF_NAME
102 | only:
103 | - /^([0-9]{0,3})\.([0-9]{0,3})\.([0-9]{0,3})$/
104 | - /^rc_([0-9]{0,3})\.([0-9]{0,3})\.([0-9]{0,3}).*/
105 |
106 | cleanup:
107 | stage: cleanup
108 | tags:
109 | - dockerfasibio
110 | image: docker
111 | script:
112 | - docker rmi fasibio/graphqldockerproxy:$CI_COMMIT_REF_NAME
113 | only:
114 | - /^([0-9]{0,3})\.([0-9]{0,3})\.([0-9]{0,3})$/
115 | - /^rc_([0-9]{0,3})\.([0-9]{0,3})\.([0-9]{0,3}).*/
116 |
117 | cleanupLatest:
118 | stage: cleanup
119 | tags:
120 | - dockerfasibio
121 | image: docker
122 | script:
123 | - docker rmi fasibio/graphqldockerproxy:latest
124 | only:
125 | - master
126 | stages:
127 | - test
128 | - testPages
129 | - sonar
130 | - build
131 | # - integrationtest
132 | - publish
133 | - cleanup
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:6 as build_job
2 | ADD . /src
3 | WORKDIR /src
4 | RUN npm install && mkdir /src/pkg
5 | RUN npm run pkg-docker && npm run pkg-docker-healthcheck
6 |
7 | FROM alpine:3.5
8 | ARG version
9 | ARG buildNumber
10 | # RUN apk update && apk add ca-certificates && rm -rf /var/cache/apk/*
11 | ENV VERSION=${version}
12 | ENV BUILD_NUMBER=${buildNumber}
13 |
14 | RUN apk update && apk add --no-cache libstdc++ libgcc
15 | COPY --from=build_job /src/pkg/app /src/app
16 | COPY --from=build_job /src/pkg/healthcheck /src/healthcheck
17 | WORKDIR /src
18 | EXPOSE 3000
19 | CMD ["/src/app"]
20 | HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=3 CMD "/src/healthcheck"
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 | # GraphqlDockerProxy
3 |
4 | It's a generic Graphql Proxy Api Gateway.
5 |
6 | To build Graphql microservices and combine this automatically, in one API, without extra Code.
7 |
8 |
9 | [](https://hub.docker.com/r/fasibio/graphqldockerproxy/)
10 | [](https://gitlab.com/fasibio/GraphqlDockerProxy/commits/master)
11 | [](https://fasibio.gitlab.io/GraphqlDockerProxy)
12 |
13 | 
14 |
15 | # Features
16 | - Continuously integrates the Backend GraphQL Nodes (**No restart!**)
17 | - !!!It works with **Docker** and **Kubernetes**!!!
18 | - Supports load balancing (with docker)
19 |
20 |
21 | # Run with Docker (5 Minutes Quickstart)
22 |
23 | ### Note
24 | You can find examples docker-compose files in the example directory of this git project ([./example/quickstart](/example/quickstart)).
25 |
26 | ## How Does it Work:
27 | - It works without dependencies.
28 | - You can start it in your docker cloud.
29 | - Use it to manage your GraphQL-Microservices. With docker labels you can registry your microservices in the proxy.
30 | - The proxy automatically will find your services and add them to the gateway.
31 |
32 | ## How to Start the Proxy
33 | In this example we will use docker-compose to start the proxy.
34 | Here is an example docker-compose file:
35 | ```
36 | version: '3'
37 | services:
38 | api:
39 | restart: always
40 | image: fasibio/graphqldockerproxy
41 | expose:
42 | - 3000
43 | ports:
44 | - 3000:3000
45 | networks:
46 | - web
47 | environment:
48 | - qglProxyRuntime=dockerWatch
49 | - dockerNetwork=web
50 | - gqlProxyToken=1234
51 | volumes:
52 | - /var/run/docker.sock:/var/run/docker.sock
53 | networks:
54 | web:
55 | external: true
56 | ```
57 | This will start the proxy on port 3000.
58 | It's important to include the ```docker.sock``` as volume.
59 |
60 | **That's all!!**
61 |
62 | Now you can open the proxy playground under http://127.0.0.1:3000/graphql .
63 | The API is reachable under http://127.0.0.1:3000/graphql too.
64 |
65 | At the moment it is an empty gateway.
66 |
67 | ## Let's Start a GraphQL Microservice
68 |
69 |
70 | For this example we will use the Docker image ```bfillmer/graphql-swapi```
71 |
72 | Create a docker-compose file:
73 | ```
74 | version: '3'
75 | services:
76 | swapi:
77 | image: bfillmer/graphql-swapi
78 | expose:
79 | - 9000
80 | networks:
81 | - web
82 | labels:
83 | - gqlProxy.token=1234
84 | - gqlProxy.url=:9000/graphql
85 | - gqlProxy.namespace=swapi
86 | networks:
87 | web:
88 | external: true
89 | ```
90 |
91 | Start the docker-compose file.
92 | The proxy will automatically find the microservice and include it.
93 | Under http://127.0.0.1:3000/graphql you can now see that swapi has wrapped your graphql microservice.
94 |
95 | Inside this namespace you can make graphql requests.
96 | For example:
97 | ```
98 | {
99 | swapi{
100 | allFilms{
101 | films{
102 | title
103 | }
104 | }
105 | }
106 | }
107 | ```
108 |
109 | Or you can use the admin Page to see what has been included http://127.0.0.1:3000/admin/graphql (See the second tab at playground)
110 |
111 |
112 | It is important to put your microservice in the same network as the proxy (In this example the network is called 'web').
113 | We have to set the following labels, so that the Api can find the service:
114 | - ```gqlProxy.token```: The same token you set in the proxy. (In this example 1234)
115 | - ```gqlProxy.url```: This is the relative path to the proxy running inside the container. (For example: :9000/graphql)
116 | - ```gqlProxy.namespace```: The namespace that wraps your microservice.
117 |
118 | ## Now Let's Scale the GraphQL Microservice !
119 | The proxy knows how to reference the same images with a round robin loadbalancer.
120 |
121 | Go in the folder where the SWAPI docker-compose file is.
122 |
123 | Enter the command:
124 | ```sudo docker-compose scale swapi=3```
125 |
126 | The proxy will automatically start a loadbalancer
127 |
128 |
129 | **And thats all!**
130 | Now you can add you Graphql microservices by adding the labels to your compose file and set the same Network (for example 'web').
131 |
132 |
133 | # Run with Kubernetes (18min example)
134 |
135 | It will use the Kubernetes API to find available GraphQL Endpoints.
136 |
137 | General use is the same like docker.
138 | See [how it works with Docker](#runWithDocker).
139 | You have to set labels in the Deployment-Manifest.
140 |
141 | - ```kubernetesConfigurationKind```: How the proxy find the Kubernetes API.
142 | - ```fromKubeconfig```: A Config file which is mount in the Container
143 | - ```getInCluster```: The POD as it self.
144 | - ```getInClusterByUser```: The POD as it self but with a spezial self set user
145 |
146 | ([example Configurations](#possibleK8sCombinations))
147 |
148 | ([also see this full configuration description ](#availableEndpoints))
149 |
150 |
151 | ([see ./example/kubernetes](/example/kubernetes)).
152 |
153 | ## The Yaml for the GraphQL Proxy:
154 |
155 | Deployment.yaml
156 | ```
157 | apiVersion: extensions/v1beta1
158 | kind: Deployment
159 | metadata:
160 | annotations:
161 | kompose.cmd: kompose convert
162 | kompose.version: 1.13.0 (84fa826)
163 | creationTimestamp: null
164 | labels:
165 | io.kompose.service: api
166 | name: api
167 | namespace: gqlproxy
168 | spec:
169 | replicas: 1
170 | strategy: {}
171 | template:
172 | metadata:
173 | creationTimestamp: null
174 | labels:
175 | io.kompose.service: api
176 | spec:
177 | containers:
178 | - env:
179 | - name: gqlProxyToken
180 | value: "1234"
181 | - name: kubernetesConfigurationKind
182 | value: getInCluster
183 | - name: qglProxyRuntime
184 | value: kubernetesWatch
185 | image: fasibio/graphqldockerproxy
186 | name: api
187 | ports:
188 | - containerPort: 3000
189 | resources: {}
190 | restartPolicy: Always
191 | status: {}
192 |
193 | ---
194 | kind: Service
195 | apiVersion: v1
196 | metadata:
197 | labels:
198 | name: api
199 | name: api
200 | namespace: graphqlproxy
201 | spec:
202 | ports:
203 | - port: 3000
204 | targetPort: 3000
205 | name: http
206 | selector:
207 | app: api
208 |
209 | ```
210 |
211 |
212 | ## The Yaml for the GraphQL (SWAPI)
213 |
214 | Here it is importend that the ```service``` have the ```annotations```
215 | - ```gqlProxy.token```: The same token you set in the proxy. (In this example 1234)
216 | - ```gqlProxy.url```: This is the relative path to the proxy running inside the container. (For example: :9000/graphql)
217 | - ```gqlProxy.namespace```: The namespace that wraps your microservice queries.
218 |
219 |
220 | ```
221 | ---
222 | kind: Deployment
223 | apiVersion: extensions/v1beta1
224 | metadata:
225 |
226 | labels:
227 | app: swapi
228 | name: swapi
229 | namespace: starwars
230 | spec:
231 | minReadySeconds: 20
232 | replicas: 2
233 | revisionHistoryLimit: 32
234 | template:
235 | metadata:
236 | name: swapi
237 | labels:
238 | app: swapi
239 | spec:
240 | terminationGracePeriodSeconds: 1
241 | containers:
242 | - image: bfillmer/graphql-swapi
243 | imagePullPolicy: Always
244 | name: swapi
245 | ports:
246 | - containerPort: 9000
247 | name: http-port
248 | ---
249 | kind: Service
250 | apiVersion: v1
251 | metadata:
252 | annotations:
253 | gqlProxy.token: '1234'
254 | gqlProxy.url: ':9001/graphql'
255 | gqlProxy.namespace: 'swapi'
256 | labels:
257 | name: swapi
258 | name: swapi
259 | namespace: starwars
260 | spec:
261 | ports:
262 | - port: 9001
263 | targetPort: 9000
264 | name: http
265 | selector:
266 | app: swapi
267 |
268 | ```
269 |
270 | Thats it ! Now you have the API running under Kubernetes.
271 |
272 | ## All About Namespaces
273 | Namespaces are set by the GraphQl backend microservice, with the label ```gqlProxy.namespace```.
274 | If you need more than one GraphQL backend server in the same namespace, then give the same name in the label ```gqlProxy.namespace```. The proxy will merge the services.
275 |
276 |
277 | ### WARNING!!!!
278 | At the moment it's not possible to have same queries, mutations or types for different entities. The proxy will use the first one it finds.
279 |
280 |
281 | # Admin page / Metadata Page
282 |
283 | To see what the proxy has included and there is another graphql service under ```/admin/graphql``` as well.
284 | Here you can see all of the namespaces and endpoint metadata for the included proxy nodes.
285 | If an endpoint being served by a loadbalancer, then you can also find the "real" endpoints.
286 |
287 | Set the environment variables, ```gqlProxyAdminUser``` and ```gqlProxyAdminPassword```, to configure a Basic Auth for the admin page.
288 |
289 | ## Available Environments for the GraphQL Proxy
290 |
291 | Key | Available Values | Default | Description | Need for | Required
292 | --- | --- | --- | --- | --- | ---
293 | | ```qglProxyRuntime``` | ```dockerWatch``` or ```kubernetesWatch``` | ```dockerWatch``` | tells the proxy run to in a docker image or in a kubernetes "world" | docker and kubernetes | true
294 | |```dockerNetwork``` | string | none | the network where the backend GraphQL-Server is shared with the proxy| ```dockerWatch```| for docker
295 | | ```gqlProxyToken``` | string | empty string | a token which verifies that the microservice belongs to the proxy | ```dockerWatch``` or ```kubernetesWatch``` | false but better you set it
296 | |```kubernetesConfigurationKind``` | ```fromKubeconfig``` or ```getInCluster``` or ```getInClusterByUser``` | ```fromKubeconfig``` | How the proxy finds the Kubernetes API config. | ```kubernetesWatch``` | false
297 | |```gqlProxyPollingMs```| int | 5000 | The polling time to check for changes (send introspection Query) | all | false
298 | |```gqlProxyK8sUser```| string | no Default | The K8s user. This is only needed for configuration type ```getInClusterByUser```. | ```kubernetesWatch``` | false
299 | |```gqlProxyK8sUserPassword```| string | no Default | The password for the K8s user. This is only needed for configuration type ```getInClusterByUser```. | ```kubernetesWatch``` | false
300 | |```gqlProxyAdminUser```| string | empty string | The Basic Auth user for the admin page | all | false
301 | |```gqlProxyAdminPassword```| string | empty string | The Basic Auth password for the admin page | all | false
302 | |```gqlShowPlayground```| bool | true | toggle graphql playground ui on and off | all | true
303 | |```gqlBodyParserLimit```| string| 1mb | Set the body size limit for big Data | all | false
304 | |```winstonLogLevel```| string| ```info``` | Set standart loglevel for winston e.g: ```debug```, ```info```, ```warn``` ```error``` | all | false
305 | |```winstonLogStyle```| string| ```simple``` | Set the style to logging for winston ```simple``` or ```json``` | all | false
306 | |```enableClustering```| bool | ```false``` | Staring a cluster set a proxy for each cpu kernel. (sometimes can bring more boost) | all | false
307 | |```sendIntrospection```|bool | ```true```| if it true: client can see the structure. if it false: no introspection will be send. **For more __security__ Set to false in Produktion mode** | all| false
308 | |```gqlApolloEngineApiKey```|string| empty string | The apollo Engine Key (after login by apollo you get this key)|all| false
309 | ### Possible Environment Variable Combinations for Docker
310 | - ```qglProxyRuntime```=dockerWatch
311 | - ```dockerNetwork```=web
312 |
313 |
314 |
315 | ## Available Labels/Annotations for all GraphQL Endpoints
316 |
317 | Key | Available Values | Description | Required
318 | --- | --- | --- | ---
319 | | ```gqlProxy.token``` |string | The same token you set in the proxy. (In this example 1234) | true
320 | |```gqlProxy.url``` | string | This is the relative path to the proxy running inside the container. (For example: :9000/graphql)| true
321 | | ```gqlProxy.namespace``` | string | The namespace that wraps your microservice. See ["All About Namespaces"](#allAboutNamespaces) for more information| true
322 |
323 |
324 | ### Possible Environment Variable Combinations for Kubernetes
325 |
326 | ### Watching:
327 | #### For a User in the Pod
328 | - ```qglProxyRuntime```=kubernetesWatch
329 | - ```kubernetesConfigurationKind```=getInCluster
330 |
331 | #### For an Explicit User
332 | - ```qglProxyRuntime```=kubernetesWatch
333 | - ```kubernetesConfigurationKind```=getInClusterByUser
334 | - ```gqlProxyK8sUser```=myK8sUser
335 | - ```gqlProxyK8sUserPassword```=thePassword
336 |
337 | ### For All of the Above
338 | - ```gqlProxyPollingMs```=10000
339 | - ```gqlProxyAdminUser```=myAdminPageUser
340 | - ```gqlProxyAdminPassword```=adminPassword
341 |
342 | # and finally
343 |
344 | If you find a bug, have questions please open an issue.
345 |
346 | Have fun.
347 |
--------------------------------------------------------------------------------
/__mocks__/node-docker-api.ts:
--------------------------------------------------------------------------------
1 | export class Docker{
2 | constructor() {
3 | }
4 |
5 | containers = [
6 | {
7 | data: {
8 | Labels: {
9 | 'gqlProxy.token': 1234,
10 | 'gqlProxy.url': ':9000/graphql',
11 | 'gqlProxy.namespace': 'one',
12 | },
13 | Created: '20180101',
14 | Image: 'one',
15 | NetworkSettings: {
16 | Networks: {
17 | web: {
18 | IPAddress: '123.122.123.123',
19 | },
20 | },
21 | },
22 | },
23 | },
24 | {
25 | data: {
26 | Labels: {},
27 | NetworkSettings: {
28 | Networks: {
29 | web: {
30 | IPAddress: '123.122.123.123',
31 | },
32 | },
33 | },
34 | },
35 | },
36 | {
37 | data: {
38 | Labels: {
39 | 'gqlProxy.token': 1234,
40 | 'gqlProxy.url': ':9000/graphql',
41 | 'gqlProxy.namespace': 'two',
42 | },
43 | Created: '20180101',
44 | Image: 'two',
45 | NetworkSettings: {
46 | Networks: {
47 | web: {
48 | IPAddress: '123.122.123.123',
49 | },
50 | },
51 | },
52 | },
53 | },
54 | {
55 | data: {
56 | Labels: {
57 | 'gqlProxy.token': 1234,
58 | 'gqlProxy.url': ':9000/graphql',
59 | 'gqlProxy.namespace': 'tree',
60 | },
61 | Created: '20180101',
62 | Image: 'twoTWO',
63 | NetworkSettings: {
64 | Networks: {
65 | web: {
66 | IPAddress: '123.122.123.123',
67 | },
68 | },
69 | },
70 | },
71 | },
72 | ];
73 |
74 | container = {
75 | list: () => Promise.resolve(this.containers),
76 | };
77 |
78 | }
79 |
--------------------------------------------------------------------------------
/__mocks__/winston.ts:
--------------------------------------------------------------------------------
1 |
2 | module.exports = {
3 |
4 | get format () {
5 | return {
6 | combine: (...args) => {
7 | return args
8 | },
9 | simple: () => {
10 | return 'simple'
11 | },
12 | json: () => {
13 | return 'json'
14 | },
15 | }
16 | },
17 |
18 | set format(d) {},
19 | createLogger: (config) => {
20 | return config
21 | },
22 | combine: () => {
23 |
24 | },
25 | debug: (...args) => {
26 | console.log(args)
27 | },
28 | info: (...args) => {
29 | console.log(args)
30 | },
31 | warn: (...args) => {
32 | console.log(args)
33 | },
34 | error: (...args) => {
35 | console.log(args)
36 | },
37 | }
38 |
--------------------------------------------------------------------------------
/doc/assets/kontext.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fasibio/GraphqlDockerProxy/1619d41da6a10bacc99e011d1f0d1f33f0a36866/doc/assets/kontext.png
--------------------------------------------------------------------------------
/doc/assets/kontext.xml:
--------------------------------------------------------------------------------
1 | 7L3XsuTIkQX4NTTbfeAYtHiE1kBCi5c1aJnQMr9+gRLNrmJzhsNpjuxrlvcmIhOBQLj78XM8AlV/gpn3KczxWGlDlnd/goDs/BPM/gmCIILE7z9Py/W1hSSgrw3lXGdfm8C/NNj1J//WCHxr3eosX3744joM3VqPPzamQ9/n6fpDWzzPw/Hj14qh+/GqY1zmf9Vgp3H3161+na3V11YCwv/SLuZ1WX2/MoiRXz9J4rQt52Hrv13vTxBcfPn5+vE7/t7Xtxtdqjgbjl81wdyfYGYehvXru/fJ5N0zt9+n7et5/N/49Jdxz3m//j0nwF9P2ONuy7+PGOvuU+nkflM+b743FMPd5T3i9fo2S9i0Dd8/+PPyxYbU/YX78udfPnxmP05/PIGa67j79Ve+XuiLL5nqfcZrHs7r+3XvwX+99I/DuZt/NUToh5FBa34+7dX67u4G8H475/cI4+TLF4D7eBzqfv3iJSj9J5S9W+JtHb7exZcT4q4u+/t9lxdPV3s+r/XtINS35nUY79ZljNO6L53ngP0z8m0y+Phdd4/nM8O7Tu+R2XG/3H80+5ex/tpC34z2XCE/f9X0zWJCPrzzdb7nA/geXwjwL+jXk77FF0x+Oz7+4q0g9s3Hql956ne/i78FSPlL539xkvvNNz/5bZ9B/6bPZPX+9P1tiv5i4fS+13z+a4t/P++exv4fca3fdNC/1930G7X+pXns8ovj/VsO93Wcf9X85bZ/bP0fNRP33/j9uHOfLM8fO5/3L4P8Habj/1RgYjDwQ1giwG+EJf4bYYn8DmEJQv874lKO9/iHoPzBN//PBukfQfkPBiUBkz8EJfY9L/46KNF/VlAi/zuCUhi6uC//CMs/wvL3CksSQX7MlRj+L/+Z2fK3WOxPBsizWxR+O8y7ZDi4vzTQXxruD6phrj/3fN3OcTf2GfXIz7s97eJluSfuBwMu6zy0OTN0w/zlEjDPw/fPL598F5vwvzbHy7DN35zzl6l4hvqvzvqv5vS3sO5725x38VrvP8rg35rnb1d4PR74F6Oi5I8ECAV+MtXXsX8769e69KeOYOAnJvWzzdd4LvP1rzq6Zz++fvW1bxHyNwf883VQ5Ae9fL/52uNffOqXOf373Az7m/j/7wQatV7WvP9SCLl/US/pb2LOfxsYmYf1dqah/3aZfxNVni98qwWBxO+DMjD2s1RGAeKvUQb6J0llEP+b5v/H6yvoP1hfMea0ym+cidcbfSDg/2GHtH1SF/DlUNmSfO7zNV/+3387m/1frbxg3wub35wJJMG/cibkt/D1d3Em8r9FygIAhgGA30xZ/+4Iz896DR6/+Jd7Yr8ehl8O0W9Hr3yu74nK52/O8zdt+FMCvGHyS4L4FvP/pUkRJn9kOvB3VPj3JsVflMv3jjD4n5IUUfyn68C/b1L8Hgz/8aQo9Wm3Zfl//1T4s+P/Z4AVAhE/mBEi/jrz/Ra9/j2w6pfR/m/EKhwHfwArgvhbcHUbb76+ngTA349/hXDP4T8Kcf9lcIZCP6HQPwhnBPojLmLfNdnvDGcY+JOWwIB/dVzITyIGAX5n+PutRbf/LbFBgMgPsQGCf0dsoD+GBkD+j40NDPwRcv/R2AABEP4pysh/SnD89YWg39nbf6sC+pO3d3Wf/5UjIl9KgXOefpOQ99Q97b/26fFX7vGsvCdDn//KZX4jm999PBdjftk38A8J05+EbRYvVZ59G9PvkLhB6MeKNQj/tcoAf8uJkZ985B/K3L9bxeI113u83uQM0PP1GOb2D5r2myWKn9IgCGB/bW3kn8XT/rpAIT1V9j5ffylUzP/TDPXVH38X4/wZBf5N45D/JNvAfwdP+GX/0A84BPxrqb8fvoBtFyd597pN8Q3Ivq2u/MUE6k9fSIZ1Hd5/00bfDflLP/fQxmeU77N81oz+5b2kcf4vaTds2b90Q1mn/188PicWddf9QEtwksX+i8qFfwaJn6Tvd4H0d/KH/5C9//bGmn8n8lLj2N0W+mK4PzD3t+wM/VT3h74X9n5Isf+swP6tFPufLwCeH57/3cXx98OvXB6H/g5pDP1C+L+ehMHgv1sB/HpV7Dty/jdRBX/+SYD+wwVA8KfyMwwjP3b0e4mCH5dMIAL/yb3/Y5IA/q1Vkf/D/g9CyI/+DyPY/1QF/OdfGNJ358F/EiV/r7P/snj2M0T/7kvAP8bU71ztgYk/WNx/Oov7aQEDQuD/PBb3W6t0f7C4fw6L+ylRIdB/Iov7Lsn/Fxe2fgcboT8Jql+q3/8ZtSzk71iF+h9soj+Tv5ORSPjHEtSfQeg3Nsn8hpVw4PewEvS/2kq/k43A7yz8vyaS/o5a1P9gG/2OkfTjAhAC/sae1n9WIKF/R0b6zufq95dHIP9hnriMX2e+qM+HrNLf+mOzeI3/BFNfDyF+fDaNM7VHG9YBKEI5UPePbrsV55b3uzy9f0kSQ4X3X9olrzi/35QU13GmZyHBdmVMUGVW0QvwMw/bJ1MyfAFtZrRBNYPsslwGtSdd/u2N5siTCuZFpsjRtHQx0iDXLW51SrgvjkXTHkfzLsfy8huqVMscYlQH7m6AC05e5O3fvGGHu747hRykqOy88Zz4HH+CaEks3u9nSYbWPsXeO+0HvorSPKTWpL6+EGZhkHrdMeqQW4r6/ho+48HJHMV8eRnUh6qsvj8ACbY5mqupL6+6efUU9Ta/vnqkoVge8lPW7DOppKyvL0PVDPdbP/frVetUWiRRqVPZ9364iULSAzO/91VqhOTKJ6nXNFAd3/qhzGdM7B9j+mNMf4zpjzH9MaY/xvTHmP4Y0z9jTEUIUpQphbV/cClNLSJFqe+LR3h0P/hpsLD3gkA8YROMRtezVliIezOsNqQCWrppU3kplky9QpmiYo9CikN3AWos6dPZFZhqlJ1B+As35fJtmsLBKRp2pr40cltN0gwu3mxupo/mYMXaZk3PTV2GE8+tHjbz8nJ8GojXldJczxSUFkRFHAM2pT5cz2MUs+R8xqHMmF+68+5oGABlpkivFgNYwQ0wmlQuZfCKkTBin5zRAMxSagEG4OqOK+mSUDnuql+JT00gY/h3r58Mv5xlYQa55aiUZhojTenatdRBN21F4wEtqCJ6LG+pQk+YiwhWgLTHBu2sfoqShHBvvUTui7Ald9zHJX9LFbrm44XeJThZwvYepTYfCdWN0H3PPORA/sqZj2CoaZOujeSe59PQh7U6rfB5fISvUvbiHPK6sGjI7t7uJgdVxDNiBYwJleGm7XS/gp5jjgRrxanl+kgNKauwNCan5ZxCIfnBeHL7Lq2jdZFDsmAGsOHP/p77Cyrp1Wy2DVfWfO2Ju3PepJzDO7lUDhl58EEvy7DuhbTvgl7hofYFoz4ZagWN239SPG0iieoQG6CVhTc5gDw3K+ThlT7EDQLgMFi6x1gHVRzxEeA3S+ct3q6Y6aKRpnKHmq9EsLZo9qhNUYQY5qO3LfPi2FKkIHPQy9783B3AkOhyKmKuYSkX6VpB8G5LqWqKhBWydJoV2YhIDA4N82sgeF9+JICYhR2VpLIrfzK5HXFZZxyaG4+h4gj+ljw0OHlVttYfzbzW+7B5J8vZJws2crM3a6fZqKeK8B+M2ccTlAjKp3xlZdyV5WKJEgKGZ9LAlGXtckrmULpN60MPJYi8so9BbzqL1pyEyg875F5DVg33reP750W4GMMiwkIBgo9zyXbSnATOFqoNOFzJrtAK1huEgJK77dJ6GzSZK9fWAqW5qBBMi308NyeBjGKZMXJR2YYngJMkz7M+q5e3PcBkLYnoVH+WSj7QICcc4jCui0rxZn5yCh43/H2jlAaEZRW7hs/zj26F+Cxtdpg/xmULLBEHFYQhvclYnA+Nh0Zknmn6II1TmrbNuIwcl0e5yTlU++CBXsNwT84crAFSjgSinxbdhZShp1LUz9YLWzyYuFHPMPgkG9oyKB+rNHKaXLJB99Tih4n52QjVUJmi8ddygm/L5G5SEZZ/7a8zOQzUwqV7lIdMyp33mKuk614r+oNdimGwNpqNopr1pTnE96t/gjm3sYGgZ39AxsvrqsSSj+qJOQsyVk5i9porR69sDrGGRU8GqT5x8fKtxh5ZCXPTX2OYPeGqR6A6Z1FfxCmmlJ1t4Aa5tv7lmFz4+BHvB/UiSxTjmpUrKXiT08xKtogfDwssSfo9ErYVTJ81xSdUjR0BOI0KBelgexhJo1AtmeHiQnbxHn1NY0yuPGCGFklAosUrgK3TTVZTaeA8EvLLNhmKLwqntTGTnR8FW9FCWOOcfdLKeWYCa53B3YEX6rdrk/v5rnKN3tg2ROiYWuKw3FGBYlBX+sT3jNJlYzksd7/xjpM3FClnQvft3xjLs6+mQMi+mFmgX3u9wWlDbUOmp9krwWQUc3LtVtVKxl0VPZnVVn/EPNMPgPcrnznzUsj8DebBDUf4cu6NKG6+JA6rZC5JpA2+DNUj4oPWfB67RtcgVHwfIWokTCiGCSrqRpiF8uxYpriCo6nx8LTa0Hk1AJ1zp8KTw7OqRosVV91V9squLLt8VXwlwk3mKROwjP6GbFN9sg5+3tiMclup00ZSh1Rfen6fiC1OLD6RZ/cw7VC4QVPm34+bFTKJmg3rqQS/NAZbCMDOuQus+x/A5MXhMSS3bHbJp3ffaN7GXnb3jVK18uCdslAB0Lu06TFD32hxy43ATpGcd1QmP4rkHi3pA2tM1H3qw0mewaITWMAmRwRjv9eOHT0O3JLvPRSv+y1n2ZZ60s3cSpT65CW1ZVrGUg8+5G2YsRURCHVwFXm7r164cQV8UvJ80TTjc/Jyf4Vg6FBJeUYsEv9zT5Y/PThyeQZV9A03ngUgGFr2eCua1EbnlFaTrUAVyjJnUMh4aK8veEpYChffXxLYYz80+gBB+wwqX21SsUMiMqp7gs4pSfhg0p0h5fcbH8KB5F6vs3hyYQRkuqrxQQ2nlUSe3IlTzppaawQKDz6mlOHjobcE6lbhT/KD5iVbtDyJGEo4bu7zRs2RAmyCMmiqNFsGIoF7ci012BKIkM5zY3oulxfEs1xjah3jmVklVKkIPmMWRqo7fxX7cMF8fbDvgVL2DHIh/E7nfI/0pYwMXetACA6R7ZMSaMk/+2NVBTTwQ47jap5KXYwTts0sX4TEcNMBUOXWJHK9znmCAxx6B4jMVw0debz/6XSr9LIGX6Ol4ah3XzllTHGMmHmOFWlTPVM0i1HxxxGTBvNKzr3ZnzJwnx0eN5r00ItNXK+m9IAPotK1Cbmkzqy7iYP1ujMwEzLo26bZhzwkapbcqEGHL0Bqlg1i/SkMG4IuvcFEKpgK0c1YNxFYj8hlYL3ymDZT+6AwYcDm209i735qpCNjdIcTvkgEh+MxHCoXeb/bGrYWw++V18PAXlEMWJREmhjFZb29kodjrHEHwVDLZ9Ne27xTitmCKin3kfYq5sUHWqiXkYHS53xhfihLo2ixzDgQc5xV7CodYMVvSbMVZYLFzu0HdHrPlnzU9EzYphcvocuvPWieVOSFePwkUp0LGbVivgBEa0fSQKUypPGCWBW0kvHEIrGzGyIJ2SNvOah4msv4WtVL1ogqk1BJRRSZpEM7ms/A1L47jJfKvsmivKgaY1T6pno7h2NSap6LfnmLLgq9yJRCFGO1yRge18ZLPMLg3Bk66b2gWqP5ozRFHqJsWW+5m8FEtUoAgEkCVqLGcy3iAxOQMIzftNEoC4G9eRv/8TBQYDX/4XVsIxdlrRC2a70YmvKmXDxD1os1L1U19mMnYXQnNd6te8FkcmYiFmZkoOFoUqrImwUwbZSnyPOeEzF9RfgdMBvivCWYlTMdhT8DUW8V98qPVs7KEqntBHplFna+gBMGh0wL5M0qpRLw6lQdqCNEF3ztrifh+sxG+XAeYPpl4trS2dRkCTi9sC9XQucgb/Hmvpj9RGeN0SN95yU+ZBZUEFj23OmHrrxiPqsqTElrYE7FPY5aS13Y23FpAo2u4U5i94XAgxS6nLK5yJSNh2Bbs0bN0GB9Xjc9ocluowkJL2FkSLWF1+HQOXw5EVYYg9gPkWBUKSgpSIRREZ6kWboIVgiIOg2ip7u+5SJPrlM+14d16Rdv3Qdi/KHe5GyYOEjr8hJx7VssvcOhc9pGB3ejnZmQmtnDcCibDgpzJCEGzEN/q+CslhGfNAp3hIBdSvLDsuWD0T23/sKyw+gId+bNNFLlDYIS6G8uxRfnzdCkqmmu1LUuChQgrqbZDffxyPB5SAn4PJUQSIhFVnn6weIR7Dlx9bmDtrs5qyAE4M6Jam895KaL6MyrJbo7OrPnAcHutRa5b00JeL+4Pzea1dcj49ZkOfueWMwddJH2l/wpzpPIlKg5GTRBbHaSzhGK/PGVT3PieL5yPs2AlHWPZ46T+KO55rV4AkWKMQR1CB9I+sNOGHXU37fQ/MHrKZhLKP7TILfN+RySRjpakVkyO7sZ05cXlBUR1xZVbCTWTRqdwZkiQnm1a72wHnfuBjqkJGGRugLl/jzCAkPGPsZqd3RdvrRpO85pPW70PAbhuVrw5MGeuRUdv0idQYcSgLVz2OyzPedADoWg8ZaxU03F2idGcNYNOoYznI3JYdL73FU2HuBoOPErSprBVEnZPmW5XdhpxtBDFqAWiATlq5ZDOlHcyJf8LKwo56uiGLkuudW3VybhEBok1ArgVF0PMaiAacFUB+Waszkmg11jCElBQaYnMsVJdjut6sktbyGnGp03BvgFx7Ui4By9w9HKe5BSpZFxVSI0GdI6p3QFp7dkiGRKoIRbPqccU9SZeecRQYLwJuVRN7U4vqg6lcn10bT3+k1fG+nXfYZaWuaAb704NQbmaVMl7IGp+hvFeJscGTpH2qmuaUnj/bvTiv2QpCgwbV2/s6JjZfTOwfQjOWSOeTgApfNhVfpI50qTysaPI3BgQl+f4XUSTt2gJiLjnySIOnvgfDicyj6Psib1x11w1iATif7JVl7VexjqrvWM3emyNDOzWiPkPZSDAAH9keDLnNXlgQq6NuKpMwy7cmV8adz0hUV9uRRejOU0JssblwYOIBbGd9Lh+LhWT3O5mczCNNSaKE4yZeUJqs4YqhIKeFMoeUhDzxG4pp8dm4r6VntF8GhyFVepy0ey0sV9Jo5yrvQMhYzU/cGo2jaVe+bTe7C+RHDbIFCdIe9Uf3R+p/kpIyrSpedZbvudoJCU8krqRMvEeFnnPlJ12OzXOnmH08Dd/k/WG9aWM5QFGOHft9/1A6ynsmXL93g463iU3PDuchp5VI6oMln3tmhZau84M7iamVuE7m8de/Z29XC7nl+vGZnHoOQwmDOmSlDWABiPNK0F+nh/xqc/o7zUE8AW3Pa8pIkFiYaD2mAV56keqFQMTjAJGuf5SesRPFeo7w/zhtqTXyGq6FT6yl0JnzcZLGlLueGOPtHpKCmHsBfObWoj5QTa2W4Clay06ywpg7KV85Ewb2B1IvcIT/QHwZqXwmvW9+yUe+zHmMEjGk5n9Rf2L02+IigXyOUFGs5K4DLDTfNWPHUzjGMWEh6ZCTJN8SuXJ3x55HrmZVKirVkWnUhuliwvg1F8gzAlTtZ22QxOyhL0VcGnPXQeTxbGtKHzsg/Rq31SCvQkIGwMR8sPeTlf8Rol1CFqGANxDyfFP4HcPUn4WhmOWS85cATcDy0mvEdMD5Z2nl/qMbQFMVKOp81ARO8sbCknfmoDhqVLg7pIkPxugYbLy+3F7+8noAzaZoqODjnlDE5zNV9UUer3RW9mFEIcd+HtzSOHDh+9jcPsmIJ2ZvOHoXdD3tnKe4Y34CY3iJRUtuzIAtqAEFjKhVUO28k9PFqAxhvw+ZADrhfiq6XXFY+vzGIUNqWTSi19ZsPS4hFNRYelaBznkndkAB5ELV3xcm8fpAN+v7MyfTXba9L9Ck9XK6Rt9YDApH40TkWtT+1OD19BtwVKs7HgZmN7oHfRPCTNEdWbJXt1V3I5kxclKp+baSJ++N720LXEOx9Tp9N21ShkBOvT4XoM9NPjrsoRcLs+j8yAYvpN2yB5S8hzIzDC9eKg9MkNdhYkwT499SqV06w3SsyeetiZRd8ZCzgen58AnAiBbQEvcIyMhScESVEX9QW4fLEdM92bt5Z4cucjeWq9p8VaN+nBeBHqQuLAnJyBYIBXprbRecc7A1s9vfnqOt0UM/po6UwUc/OQOBUKE16+0317kwCpOPxjhp/yIKSUvrrQX8bYOfudxQdLTEU/AIMHOGdNQ2GZHEmNqoDDWRj+ViaQLl/BFZUVeUv32R2coYN4e8h1YSgyaq4uxTTk0StrS2xXaA9e3fmG4W07lAi5+3zd2SPQDuq1V0bFEx7CAE3/SR558zokeTzlHOdOUFSD0KqAlYxefibMMkYroGA8NYNtwmHzPX6dFyzX3+gzKw0lN7C2bqNG2WpQ5B6eZ5iEf1pLXjhNGBba62owRNo0tZfq3TPk7XSEPw5z3Q2fT5CfEywh2wryiXa2KiFjI5tk+zMqZLvyHmWvj0hDAG4aZU88ddF3YtC0frxdyam4hRKp8jw+gZMoTX7u2tppqsT7h2tRbg3FQd9fdpQSOIGzPGQAZlX1ban5Mb4oYDG7Fmah+Vg38uPWMfXYLDHitsXN4ym8vh72TYie6DahtRGdKYrb7uvi5+GkykA3hV6Gry2HcqraAFF41WuzJHGkyGoujnIXWAMdSHW25fC1uMglnRvXlzSfPyxorWrmOh0GygSL4ubTAoUBRLuJ1JTm5T07rumuiB8loxSCoUrBq5ZK2UWQS7s/QvBI+eTd9hriZkNkcLQOyJe4F+c7PQzhuaSsuMVUn2UdvyLRIqa0tQwlSuIIF0EkQT4qQ90HouFY8NXlm8VNp4Yogej4IB1mpZThoGU9G0As7nY3tu7JF3OldzYIGcToajbHjcNImoc9++GYuBJXnILX6PuojvXLwHKYE4JKvz5cp3YbOSU0qQiEttc508MR1LhwknbzszubXM9jyqK5nY9eFqWTTpWbk8GpTRlngA5ZhyaJccO9jgBKML3m877JRvdJ1YQ5pmGYGwlf8OAVz/IDTd5uZTLWR/MYpmlK8QATQ4VWPlHfZX68MLaHCFZKKzYkooxW3qCOebG0295wKC8Jg7InLqEt5IfJGeFDCMfliUqxiumhEpjmzrINf0Y15yRA3KEAXD2VHuzJIlSItY7E2T2zccXHf6ILGcw1OkYtdUl/2jkIDCo2Eut4Jzs+Kd8ojmJ1wd7w+tQ0RIZdnpKXZ1tZMFcOHH6Arbs+hOyNTKNhGGqbnZvnGynd+o553NNs6SKvUrruX7tQkxgGp8yYNCElZiim3KoUZeS4cUSYC09igOniMm4CJcDDeQWnf/XkmQJvaW/kkE0sh9lclPd2Zabs+vYF5A57zAup3rucmJQGFnuqik4AU0YrMTPNThKQn75wzAtwyw+7c5YMNdQ0DE9n/QxZ82QcNafiztDeK/yZ9SBWNPoQasA63Pdt8VFdny89SOvvnO4ZrBcJ0Fmsd7DL3gc+1w+5VipV+2FUWuru34IoJQc6QazuicI6EFKtqMb8PFTDYtu8SVcfq0w/kLCPxd4won9AcrP2wfQJhnA5T6HGLGNOmQTpQ9bkp+a7HDehYHx67T4auQmz6eM4Cl6dtt8Galrx836kqkO1ebWx2kKN05pLqi8xQdjEt6571Hd5c6kzIzlNC56SKZ+8hjvxOoBH4LIOP3oEnoXbj9EbN6runOvidGu69+atFvHFJ2eonSnOvkxibuKy2yjqoxliEfg3wMBwala1ViOelbyBPqUhMuDr4ZNVjpSeuweuTRrZ1UN7PU9od/hgEVFthZzxdTkaJ+umgq5akJHS09Ue2D2tV5wGJXlOTbfCeGEId8En1O4R0QZIWd4YleXwZsDmBIcbnk7S/ce10rchPxH28H3CBIeydEtm9Kq2lPbUnAylgV2YHXacfQM0sAAheqLb9hV+MKXB7Xpp0tPHUejaV954qedGoSp8wtQIhKyW7YsGPAt4uB3lpTl8phYet8sSj4qgCa2tZJeJmGBu5pfCxpxL4NyRdEtwO0ZUanA1ARwPr971ABKfBPUYKJoIWjvlzPg46LkkwVLvBeu7hzOzfjjzXPDtQ6pPbqBq0S4fBcK9aWR4I6hyoaW4zCccWkvHsAGjxwIIeuUWRY3fSpCL23ZnUC+paaKnXopGqlMaT9bgXvOzXugk78F6UBPs60uHkcaVDvbM6X1oyqMNCrN7S9ZtEPlYIYyX8pBVD09KMQXF3VrrI+DYoNbk3PFISI7g3/v2vjU9RqU1fl0Ovo1WeHfOz82VIa3t0tatqO+rh/wHKymOJd+bDXaczdL39WLgpvs56bEbMFkMP9wqROG0L6sOXEW5KFh2GLcXT1b6yAjzgVp0r0b4DS9zJzDK7ZYvaUFfMeUZTlZtTBe77n4GJd3lWQpdKYVMREwH5LuFV7N6GML80t8JTfXh0B1wkd/0dTOngx1dSW5Nmj4/TLkxct1MQ1IlrdqW22O0Ngg40DPjmwOUBd8jQ911uNM+aqf4uhR15211ZSH8zmBDJUov9PksS/EcWAPp8xJOHe3IzoalVAlkxvx49upJg8iKDOSR0OfWK6w9f5YugGPHBhjeKFzhkXmQXHIMWfQefKiA3lIGJsre9MxnFpKA1GXrFZfr5kwUemc/QRrKRZESNm1RTQeEQyzjZ0FIo2TECVeEFdzhYqp1mA/VelEcyD+poLyzIDtUb6zzYyexW4n7WAX9oQLllhq4V+6iRueAB2f2x4Z5Y3f4roGkdhHG2iu+pnG+oOb6VhduyQoEVmJG+OSN+N0SeTrOz2Rg2ghNYuSsIeqMtMqc2lF8pIzIsrXbGLc2Qv4SjXp+D7bBAhma6uQkMbKLacSy3sJp3REcUjghdFIXTxPikZo5xW4f2csr+b3JbfzEf6iTyYSapuuFPJgHCiyMV+Dx6a3jAp8s1jRrjRQiP5YwQrqu9ZRW8Rr7IsMyI0fDT265tMho1dFxZHsE1A5fBRPfrGOq8HlY0lCLOIcd1LbSXyRko691oS/hfGI00Z0VuQHN9KjK6fdGtXEhxane/fArUWoseUnW5O6ZSOIKumIAgkYcOBJLq6NvRWCceRSzhWTen0C8Zd8rK/MPSCuKhcEspvBvXI9T8/CRx99OyJi7yPR4gzLpgGGU8EZOXHk0oyHwgXnWTxAV9bCRY2vgUEFpPkoiRJWfVF+GsDMd6KbPsk3T3elt0LFJ1pHtiV0Oub2uN3W870R84876XmgSo4h9qhp3kGIut3klgci22l72fAWr+jBCHWQoSkpqTnBqo+SU3T0swYT4FCCz1FymVA4KN3vyz03FSuO1zi+j0vWTGT7YNpWRCJWjt2zInEFwtIusd3qZYEQcVAk88SmntDHfCNND1eNKZxMFidvJ3p0QGr5C7c/MHR+q8Jtl6rGHpPeHwbYUxlc734g1NN+Ml9I4icoUB38odedLkiLK/iJmyZ4/deAe3D+vFWhIUsgfMfnMsXJL0ZCKz4SiqPv2bNeiPUOZcps0Wa/rUQYhB2LQD15RnFCFlmeRKetkgRFvNFyJxLW6NcUzQLpwu63fYIzk5i2boP6+p9uFiMrgJODs9y7rJQUR3wDSLGGTFSeXxfFFE2BaCFep56VBh4+7M/ZW16dG1RiRXDrHbZVHG/OHRzP6IxFk6s1gHV2x2adMfjpNTiRl1z9x+KxuliR3yh4TliD3FvOlwVtkBt8lz+4CZ7pEtGYPfReHVqXVMi0XvYYT1LfChdkRjxx6IINbClRlp3yVotE6NXOGFgyBElHxwM0w4BDodAkS8F1eGC99BEp+c99CRkTs2f88grYo8yAvONM+n8M4BxXqVx9pdua0qc+LGx64e2ocq7jBmpvH72FjHYveraMmkNZfD/qe+ZV70TXOSS9HPO2bB0oyG86AMYbji1fL10Hf/JPeCnSpIQ3UHj1TJV0XSl1i8rWxb8lkTsR4xtthJoS4egNZavVzUQpnPCZ9NlnkCJAvHMM28HtfVaLOJwN646c/a6+JhXrRlTc6i0bLhJ+FH0p0UwtsoaQ09U98RnDkbmY0MNHDzTOk2Hb9wiXCVCuGtQH3lNcIvqYxnBAVB8JRUW8ZyRc4/NHckMlpjws/MfkhZkCRqoBSHlgpZYJNKasUddEoaVo5ndnzKPW0tFuiPpqLTdZMCIgYy7RnbZfM9wb3thskN3yYoTgcAa58T7cOO7AH75MQj01e1fiaIgG3hg8ykaLruRkPTt0Lf8gC+mWBo5K77r5X1G1b4Kk5oLGtDeebo6Jhp09T0urFfTwTBEj6OX+snO1gkK0O2myGzif3wEdjsKo+9tCzNCNafvjUsB6X6C6Khb7sJZkQc0A7ni/fQSm5KFzLS3DtckJBKzYVV6Qi4VsVoWwreedEMgMaccKK5vxVn141sMYhRRuQe8n1Pj6cHhWhPh1Yzo3nm1aJZ1sAadmwfs3znoRp2sJSmQr7abPvZ+mOd46IiZeMXWGK9Dau2iXWZ7u3h1Rns8OL0UDQlj9LgreLq0oPnbc8VO2WX5653trYzODTGMwuKB+B1tvctvixfmTuVkOwMyLINDOoQjq4DxJsiZmZeji4B01RLs6TZn/EYowaadNq97Ri6im31fAbUfMHy0nX5i+YCekXJ5VKf4o6bSM0BGrU5rXZjnEZFDb2Rz7Upb5lfKgXORkyAKml46IdA103Rm9FRO3KZJE8mvmZ/HAIeivhp+2mDu17gy6c3t3oKEbHI8D7c+DoW1HYgZvheUJ6a/erap+kQ3vIIbqPTR0rbB57KwgXSmyVlqqbOGSVAAvU0CTOxdNnzZScbFKOyFHioXMzOqwMEBhv6KFNsk6xj/Z4mUzmFKFWhoncABgMtBBY05+2NvNJONgZGK/Yf7g9Fs+EZKHLF6/U+d4AV+hOv28pPnhq0aW54FRz6Qdafj0hMTUCkSwxjzWpjHAuJZdu9UlADJo+tbDTQcnORDhdA67KRS0zCqLDaGee5qAjtxjHe7kJhbbhzNsbAXtYXGyXZnrDak30NbHMTNYgahW5eCTMUelcCdVGwbEMAp3C46tTB+7h4KP0Kc+rnpHmasQrE+PWkwg5pWUucJePVI0bmXYovgDp+RakuemNyjpC7RMMIbV+KrjjBFwsjEJ5swC3OCT/ZsqHi3CF+VowqAWEiTezisoSIRmOzk7p7NnvlMG9/R4IaZf1QLdNRyszyGNgejFQ0MkBoIGzQYGQZ7sGNFZFJq88JGBMTEHXNqG2t5ySN+Uh09GzWb8r0rOmM2gKhiNOpzTLcuGbhhHv4Fg5uZ1eHUsXWc1XXs2x5vt8PeuUkVbVO5MNYggLHJRKqWeoW2Vc141DiCWxNU7L0nvnXtQjzPWBwK5doJmZ/Zp4K2hD8zuVV9PkPtstse0Ty7qs3IBVhjgd01Qm+g4YDOdtgYIg/e5c5fNmDeVnA5IPs9Zl8qH5MtnrafV1vqrgErlz1fwJoqE6+2xCju2Iu/cXALcxI/1sOPEKMNi5KOhmLc96GY4tdR+qhKdsxEPytxpZJZE+Q2VrT0gqskBga9v6KC/Ds0McELQAvjyUuaCKwY3gggmuigXuvR1In5v9s/8rEhC/J1SCVVKzjiWcU4EdObrIeOpg6gcH6gdMnwDh8aKQB5whaJia+fWyBHwapOkmvNWLahLOBKeRCzf41nNE0Ds0tVvkm491dhU0cnaPXne66Hqbzx5av9T19EPT89Vv4z4EQiXIR9Ieuf+alukDlOVlr3y3uyYn1JxbLEAWZYyKDfvq2GAhvtJ6NqBzuxntEMRA228dy9JruIaHfmd+wi/4Z0GEtplXKHA+Ba+hItIjDBGdq2ixRa3HTnevRotflEBOzM2Ij/T5z7F4sdnXa47OxdM79t08/7QKDXM8Mjuu1PbcnOh17ZRSKSgLshxxPOOMXZZs0XmWRacM6llBfJb3792OF5TE4E4BETTTYo4/hHMmqWcmpaqOoJpsRqcCu7p9Iy7zrBXcYLqdg3fDfsleR5I8NavnpUzH3tTqWTQ8oyMvbl5QCMaY5KkmBoWXNYuhGY3QQDAseoVAeBP9EU/jzXnxyTC5fIRE9abP17NvLOiN0SktsBZ5HxXhj9It6s3Ob94EJDdkX+M9gDUbT8C25ZA6b5OV06cID8e/SdKzYHETXB6hjXJoxoC2wAWcHym9oS+6Qa9LHpx3NV/TRXqhIAYQyWeZRhrI0cxldsUage2HBdNa0LHNWdCqxZzI9eVptPpZRFuVB0xhj/mQBUoE+mzf90P2wCKDw3DLofvDy2lG+fT84WS+Tg8PYpyKllQwVNdzmFU3I158fVM29MjATj76ulYo9zCxYIAPH2evJOoQB5Me7s9jo70nT4oRjfXwus3eFRaNvK1ci5tojxuXJ3c6bp99tRyD6jmgl0mGsRYytTBUcU5HlRDfzsNaWuXsl15uQxSausbQGj1lPotcCSVI7xeLYWMH2dvc2dJ1w79PUa87e0S17tp6GRy7ubIrGNBqDOLGBKXbs3kDXHu+BBfWdXjsTcaDBfRNEXGeR8opoARshkHPf0XJ10gXMjECcGPv+CH3TlJrgO+LuqESWd2NxgvBJK2L7hXpGA9cu+8BsGxN67ieroMlIxrwop14QthLTGvTC7mHXDlp5K6nLdQ54xLO3H2MZrvJIFXMhNU1iCcfgsbwQv6iMoPIHx+Jlzi2Wtd8Tsa5180KZSdB6me3EU3chnHcj5iJ6ZeiT+s9zj3RXSZMD0Jf7967eWte4JTeP/WFm5u2xHonhKjww3QLCOgRUuuXvbtPubgUl1XFn2UqkjktC0Btwv1YyTHoXls00Xbm/Z6106pGcFuRKoRgYhaC7WAx/uSGB3pzsap7JMZuMsF7c8JpwR+f0HrBoLjg4SbGexC9wJ1yml4FObfEcADRTxISj7diEcbvx1IDkBXzEyqMCYwfQ/p0cWMOsl07EVOwazYhYy/Ms6vBBPQigtLYLNd4t9dKJq0LGD8e6BO3U9wCH8HTkjeRrG/2g0Kst95TOp0vrxBabnj/3MwmM+Z9Qp46oW8Ax+24vK00xqcjIM1mLesJcv+Mo/H9sXJLyKt6NMlhwGo0mrOYRK5dLS7EBEkllRcroB9JvtkDhAWTdpxVn1lAtM2aF0EaLbbjNZT9O2wu5s1sipGpPIv5ZJL1wSawhZtEZm0ladPsVWwptvNYJ6y3RSNk48D9C7yB0azpPkPb3Ab4N5l8Udr5ejMwb7dB9h29Hf12X1txlGYW06nQDb9lM+bZOMi/PR0/F9i6+YricM+dnfwodfyLnbYNxtMY8Sl0AHMveNlhPQKpNGABGK2wb+Jaog7AZOwBCj5exmDiNURiAc7dVvmAdZpJtBXeDPkh+GUJI0pZ2SyFSemcxIpTWZZTSN4giDugD+QjteUiHCE7ponPucyJkkkV7zeV3Fh7kC1GrxhdPT5caY2setMJcfUYlDtX+ww/7KRRHwK6nmKTMkF2JVfRisxmmdqX1ZBUjBFnksk0i+o3ctWDSI0W5+55myfmswsJTbdi1rXRAntDPkTg2Rdk5LUhFZDAPqvIi7UBLR9BX/hw9FGo5UWaC8Cbz72h+fkyttmkQS1/n5UGnIEFV+/E27Z2Nrz+2eLb+gIFiq8+RqKunm8+wrjHw647FWoRJTZwvcYpTbSIBlYt5dNK0zZA9a5HMayXqTL4JNRt2lo+lGJ18utIF3Z9SruBmIzbEUKNOrypUzdhWIjq47EIHfkPnN8q1BRnX/uwNi4CCwhF14eK4qRiS/VAuE78UrUzMOUdNbKHyc/2zAiEKVSYHCqJ04rqLUFj+QX00aR83f2UEAKY59IilPs8aRN4r638BIEjjmdJO+rakkZR+ZRELkALysnyKh1eL/lcc0GMjEqvsrc6TFTextWbzZZj64R47zxIpYJvX0nHbOuV7mYjBH6CRwIlMUHx88lVxsYJvLp0mdtXrgfAz/4kuHv441uDp61+DXUzyV9KPciLsdY4g7daTQxFnxxp6QjCKUkGYYe4fibGXl94NvvGazP1Yn/0inoLBqDE4Jl+WRS5i8M1vz6HekJQUr+F1fzYw51hhNOiFpz6LNawcXBXQunL3z83e0UTJDx5+Q4N5wmuoQ0d6eb3o8kUYMLrXv3SyWeTNFsFH0MOarETUhFFJ7d61rQBakh5XMvuWKXFmqbSHKExpXdRcXVwEthf2SPR3SXQeI4XGhFO1HCeEAFZhdntSZwnA7gnSWX+gHI188FoduSjivht5oG5j8ZBX+EPLd6qxu+vQoDpouoxaQTpjwJ/oeA06NtrQ+rACI4sTFAlGPsEXbOXnuStYyQ+P8JR98HfzSt+YQvIpqzGnX0UwXh7U6RrmNIGyQlujVJEctj4eSzIzcbMKvw+6D9fo/fU9JsAQKfBneESw6/4xAhSe7bqPVzhCSeyA1yLSIbz0emQVRYvzplTWqSjj1TOfFIGW4kE6Jf97wECoBxg+UfOIvsZpRrxKiQRUgbwDNM69KXXUWkvA/qoVNvazxoF41B7d5HJMjUVMMYIw0lE9aokZphnP0GrKQTRs3xqSL316jd3G96f0RusNthng3xm6VW89ASC9JEk9LGBTLFseXy0sCebcZLLQknAy4dTo/Ch9QHqOTovGwrGVZY+R48AK9aPfbRNVZOKuWBDAeQEURadtRprxplKk3PAsgUJX7LZbM2uUcDV82+g8QznDQLuZC+/OFCCTCS1x9duyXQBgv0hJXwjv+CVA2TgbItVPGDKxOnDuKbTqAR/XsPRjBHKJhQnzi/UhoFNWUU/+CAt8oVlpAWLYEXgLfhoz+Ko7R9G2yfu2eD+2ip3KkmeX6gRdBqxuaLoHYgwFSPeA4qaHz4wN0RgkXhDpkjMNCUAYFSKbQR91prL7Z/MjJLp7fQPx4CFmVYsIhdBK/C6rIWrthDDwNnOeGwA7rPBBOOb6iYXysdsh5BgGOmSRgS+2cCtN76oMzr/aPHxdUkvw4YBHcbyJrYQrlA9ZUxpJwHrpLFrG/NMiItNXrV4tafhSzC5CHGStbK9SSrvVJpj7MeSqBoxh1umUqqwvxP2IeOfRMuNo6AkHNwNEgoJdmZ6fXQWeOTSVtneDzeCE3tivMgMBhCauNPdxy91Vd6A5vcJkPv7rYREDo1HvqjZAWEwYvl3LtlnHdaHD/JBosAp0MM75Q9ccYObqzY/PIWzxbMz9JYKvOg8JXC2WWUw41fyI8Zjzpv7wb7RuYOu5iJHma8bJqVqo9L4M3xzF/1a6YXX1pkzpc/FcCvKyPO54J++aiFdhleqQ52B8l+1YzJHZfbiTkuvXj+TibAuwuHAppw8clVuTBJWpe4Fl2eP+eFeO3m+4DvvB4R/OAWjXNOTMTc9M5jan0A9070pePZGeTvntBhR2QjefpLG4XEYW+HILgjiecTumgZEru+kVu/5C/O9a6fg5ak14bT7QCINmkXG29EzyddWpqpMz16gMpYY79nO8Jec0BPFHGqKsWY29eQQrFJcyHNGerdCdqWnjiAPguS579J/diCri5dBA+CNZOE9FNpKKBIHDsWDIWdfJ8zLaZ3FDd0tAtzTj+DKTxenhQIfweCLhOHAKiDB4Rk99Cj4F8CZr8CwN7SwCHyh3otPJH7/EllLX60XTab689WGIZ1hZ7CmYYK4FvXr2cwvgF/kFCscD0VuVKauK29CLGUx34Am45dDMMoSV622bjQ+zI0F7630gt0sBgwfEWYtPO3DF99nzz7P4H5IzcTIc9gELAvEaeHUmpERO8jrxNxxFqcYD0r3Vwobk+y4LkoWuyUxBvCF1gAFiOZX3KapvK927FGvLw+2MOG+39JWO17HdgOLPqejVDwVFBQhYAn9TAxkZo3H4aFp982nMx49VnU7nPDLPS1df+oHXBnvDzORXyYwHijMDwG881k/EXJ+IIQz6frdBEXE5IHdL3bGc2M5FLlnd+JTgn35EbSA6JeH2IZBQfDSEVluB99JNlkjTshWxi0fbMf6Seq4IGGreEbDZoGesiqeTzOCh68H0PWnzrSjMNQgAvsoJgcmyU5lWuTFXm6soSlJ59DsffwiuNNjtr8kSJT9k+wp+myTVLsApAN8gqUWVbMIiBVlDp4yTigcLiHHZ09GGVOPb+jguYvhKuMI5rQm3ytrX3B1uT7PPbxyvy0UuofgqFcc66mJ5EVdkp+4nAU5W70IXVFUY3Qgl/tnA44D4YzPZ6MkgIDfh2lSL2DwuFXG1a3wHsiCvDoUHBnAsz/YdiP4c9bQ+5KMES6uwOvzX2PSBNHlOwt+8AyxDz4cdUzKVs74CKZxCoKvKPgYuQ+Wwopc1BUjYvlHGtHgJBYD9d50rAgZB8feOZRU+PjbQVLjpkRNmpGbXaKLqIthIjrq/GJWCEzcL7ySJK9hjtODxtE7N2TYrXAhvY533/FqDalrO4jxFcXIovNUQLp5LyNNNR7tPkwuYb4oLoQkcIfmfvd6T1s8anrjEx3GogeB+cMi4o5ghFiPzJeyPnTHhr6WExOmFUhFjxKT0jgM48PqfPaJlpvkCMIpMnjcvOCo4dHJatKYO1/HxcpuCVcJTRgWDkARzbtwXJddWGvYp0h6TzIgs6SeFQBkM41jlsdzCIWWfbRjZMA24usB76NyZ7wF6SSwiXWfzXkR/OSoPfHkLkCdFl8/L5lL0syeL2HHqjLMHTTu351FoPDyXh/DcsiWxUKgNF8qZgSqZA+rRY1VRBYyxUJXih7g8FrPJnny+tQ4p0yHJJTCQsETLG4QBxtEcvAQ37D4KLcryi/mUYW3xXvanyn2k5Wv+WakXmc2Fa2miWkG57ZSTy3SXZljNetYufU1/+F2SqXC6RMAcf1MbHOSC/dit6copWsGnmMqhOUJaLzpJfB5htpN4hoUEXXJdO64V+nzoNPCNJfdOthB99R5a3VaSnQW6MBK77eAYS/z3dnjlXVoAYWw4e2FCuHijn/cPgj1d7niC+uS+yQKNWPw++Cj55pJWvTst4QOjl+3FmsMHCohcMsQ58Gz8qA/ON4WYVQ+CnwanOlUnmXgcmeeLVJqNnTK4mD9WDPzBUsZrzK7U4qXJd8wW0UM+l7QC3hA0McUCMH6gG9gPahBJWIqm0GePRjLVqdFvr+9oHgl2yfVYxrfINTatxP53FJBJq1FSnikgyZwn3ybJ/jCyChIJmyN/bIPa6+Iuvh0YQ/moMjF79wQPH9qlNmGJEZdQvvmHVP/sL9Gd01LqvFpR7YVi46oPiGYGW91D58i9Y4rS4Q8MWPMtmhVtvsktvyokAIOg68RgS1afMLAu026DMRWCUaCl4N+Zhjy1HOxPwQnB0hkz1ATgMrLlJ5NHo2nPU922o+xpxFMSBFRr5lJ+7XyenQ13Zc8SsHj3tc03fIKJYW65iE/igwh2YCUA+qsMtYFxh8+YxrL3UERqzbrWFH+Ub48vchJ9brl+f/P1HVkS4oEySuhxRINCYnWO3SitTz9EL965s2q+3VVkxDhwszC3cPj+X5uocc8H+ZNdwGF03zX0Ci+A8hRXIKEWJ/WigXx9JmRrb4RqTrf1Nw1yAWIMq3fVbOmbKh+KFxLLPlmPYOkW9RDPmTqNRDPNckCc/ayq/Ybpn6myyxSIIIkocIIzoZbSql+ywvi9CZseAnctIdj+HQXCEJvUH4D6sFoJITFH1AEQwVbGG9bsrLTAAd9QqyHKJr4/uCiV27o3oWxvo7UW33qT3SFFTIvQSLKD/0ho+d7bRMncVWLNXzEU+O1MtjZQBk7m2t5jqcwKrbHPLlwB5t8ObEguXtGqhT+bz8D1SAudWBtYfpFneUQjcebJSIRS7KtNwdx+RtTzUtT+IDYhy10VFRfQMMxm9eAO8FFtJY/Xhx+eeZHqgG/C/IDgMSf5Nuvdbol6kZzW778xXaDu6xbbk2+IY08n2Z2JMqNDkH2712w7XClCqe4vpnGA+pfmlbO5j9W+fhlVwDCre/DOchxMDNC9DOxCFd+sGAY1c1IFh8ZNqMkmnFQ2Rtirfmj0+ppcLkGMFyd/ayZlNFx3B1xj+MubTg9XEGHZwHmkRRnhXYegKTZ4biOCd5q75YSUT2aEOP2MZYRcFxRNmnYWEzF+uu7/lIqnjq/0dWfpI+6TUB3eKu3PgRhe5b/hM+c4EcQQDDXgn/uTjpVszpJcIjxldKR/xtvodtTXkIV3fbM0j5sFPzfGnbUzhrEtU7ZefFgSEA3ChNXJuE2vmRYlBg8y3Ffok6zv7BEdmQuB1xnTDA55ZCtaOtMzq3rFpdwaXoDTiDaaX7DSzhASnAY7x9hjgcqVWFr9Hvb+pXqtGTIJMm73YPEewHoBPcl8H74hFVm5Hp4Tr8tkRgB+r7WshNcpZjIkEB0ZroVhH1ZDFQO4yk6anNFAD2wON7tI+clgAgAVtlpYfJPx8sx3A/tXGd4ooTnm7mgMhsu5bWL/6qP5RscwRsdqGLfi/Gcvt3z/RJIXN5mNH0RHULNskDX/agWBg3dmcJCBc3ll53acK3KTQfU3BVed1JOO5DY44AODuUcdmC7E+BDVkY41ehwEX1dJOvHA4IAiKiXP3jmBLShUAc6NIzSy/1o1OkqMxT2yn3fHjUTZGVrSrKMvEIstcxqLNcRFbTAGLL0cFDJZTZlSw5//QqFK9twN2npP6+jKQO5PpHdV8mQN/cVfBb2jaI9qiw00dKt1K5CBmKvrxtpJn/qImHyKbd6MiAOdTnqSSztVMit8YCv+Oli8s4cbdWapVsJwcryTWddvw/75le8gckAiCYN2vUJH3LASLLEVSq/uAA7q351o1yMZe+4TN+qJCAPoj2h0MLAiaAPWowuGnlhlrqw+z7gPgMrQpfQAig0EGfhTYNqxmiKtfv7+wwfEn+qKN50biwplrL0/eWbvTE2HYq+yOS/Piuk4WanEMUAt9+gvCOw0heBe69EApN08ltUQWhXT+TYk9ri5RimKELTizJS+pjUiSih9u6RwR9tCHC9RSZBTIq2g9xxLNqPvxNB8N/EUjnLztj9tJIJ5xd7nGy4gaE2VYSgLObXDB2Q8cRCDvUZQcf5cg7o9/hBuUNrXRFJzy9v9tX7EDV7oI1LYdSQyaxrrHou3SYlc5r7on+UHIE09QB64v/RTsjBFYQc1jQ+3f+q416uFAg3luTU0mv/4to0ELGcyuD/iP8rHhu6yg/NIYPAndAi6SPehlhkpGfHlfR1IVZC7/XpCpkc1+KyERLlxkGJoog76FUj2zQqk2Daf51/n9atB6qYSJ7hGLZALzhCij5hXaBChOX0EhQwOVOKeIfqNplLHzHmkgHuZ/SnVgeQoWjU95GGeAQcShvw1+l5GWAZ5iGmrqZnpDuoybqiBO2Top8t1mFXf2NwxtegmQokxgW2kW9nZNx39vSYL5LWzWMUdlQutI/ld5p9Ur1k4ZDwBLaLbdROi/HYX+CnI6qJWnwDCZo9LCcd4D+BUbDWv5ozO79seAJti5qcT1GuHQGsc4MBs+o3Ma+CT2zGTkQHUn315a3oiA+LrTMdZmPfJVbJD42oeIjtN4VQpNzf4232H385JgpoSR268vQyUwTAMOoZBnaZ8XKQr1LziVqqG1K906XBQI1QIZor00r7c319V6YPXyPFkRPXhymBxATpFAYCadsyENOIej4rTRVWSsGFRh52fof1wHhssPsTGwS6kvexRiEJ0lLlEXuIC+NrAZ1TLKbbBIoFe1FPe5HPyBSlHae7tmaFxfNrn6YAJFBdwVU23TbxSI8jSJNe+JlrVJsKl5su6KhOP5mAciyThQmv9AgEzqg50ZD/pGXD5LeTnkM7hJeFE3f08xL0FJ16aRxIEovZXzORGfTveG/e5hoORjQEAKvehiPWxxL+GINZgJNfhiTGvLVx6svi/PImbHGjWwj+mYyU7wKNKgV2EkJ/TwPsfFkNVKEAgDQgXr4VtazT6K6S0fcRBf9gTzmOjLont4Pn0TEhQVm6SDQuzEE0DnMvp0hom/8Q2JwKROve7fKMAxO9rkioHsgpb0jiKVJLU2y59mALBE9kZ9aoQhRkO+2s/SY5V7BAcSfbJEEDdQa11aJnT26t3VWV85HyuQl8fL7363WZk0h5uBDMLzX6m9XVshe95dj0woN03J7SJ/36YW+XszLUZsfkB6g9vvgPqBhEyStKqRnMiplvpbVL38xnd+ewS/vMgn+5Y38I7mFfQHcyD4w1OeCDCKFS9DT5PgUx5ZkrO1o8OTDTHk8tzx9en0EvMC1LzOMX3FLfQu4x6ZeTf2cKL/2nfi2K61YQAqp+aJc7FEp/mWB6GpgaFNINtL8ksd0zHbymsTHRd8ZMdpaZcTwmw3+5mBJihsFqi6WngxlGdGNibXb4q31imptdhbHveiChMe40BpWzdWBr5+eU1BVAG3nycxKPI8yg2tqWCF2Y3yQGYna0/VGBOkz9K6iQGFBZO0QNabGsl5bgtg/MTlvqdq2lnAOzlcYsALOVTGA9JYZrwCxSlQ9BgM1pjR+f7w3RMdeyCZ6sUY64npih4xs6+IFAcm+wZGWEZULUPol1F2bopgvhN6SDSkZnu1obgZ8EVZ5ASFmRI48ekbJj4lBC9M7rZ4S48UUxE1YQQmQmLsb4z1ku1FPSrue8byFpPb14+5OcU89xzgttPS8GU3F0TYZ+VWYBJcTFqDgZbFbxpzOlfkS+/NSxjmPY6cW897fn6pHJYJjSHWDIrpmo4HyV5S4f0CKw+y9Zir+F10xNPWD7Vf2p6QkTTU94HVe6/47v/snaFyEFSwTE5WSTQk+sgzDWrRjDWntyKWIwZ3gUlCsXiRyWEhwqhO9uMJmH60UmFXSAAk0NZIIYy6HtaC8ZIIZ98MWbYwris5L+WBu8SDqF8KC5+pH7pR3Se1biTtLbQttvaG4QN3zMC/EJ/HX5tb6+9Oc0KdDxDJAfoRc4ItXYfI4cnZq9WReILLknOA4SlaeFWR9S+x+tORbDKAz7sYV3SQFo5MUsBA3Ae6/VCPu1YxvYvPNiCIvcksP/Zh4hBhnQcWjZe4MeMHCz+lIEQOg11t0lYIK/vjY7dhdW7icoPzN8VmhVYHlpFgP/qo4AUHPuQkLg/6Vx9SJmJPVhEPN+i+Y11z28vHxzjNbiMtuojG+hN2xAKBOnGCBQlDTEBh6vlS1X2+vmcm9Eh1k3h+EAm8sU20k0L73epO3EaDvrFhdQF3yAd3O+8fv02XVrklk/G5czWZF8sw/MKO/+DnAJG8qdSUvnwCDtrKj7O0l14bQEI4nB4EHVxgN9zvJrGQcRwvtD78PwJoIFGEWdR+IyXbS5sFBz2rXyooqmEpyasmVFxK9l2hLdDzjqryU/RnInkFSRWxT3+WLeQJd/XBj6X8yufgOl+taHZrbU9FKk4rX8f9hHlx6oZtwMqM75KFQpaTWCQhHq3LL0Md/+i5LDF+MSDpl/u23SgOQyztiYUDPg/GObrU1+OadBmrHML9SWm8sJmu+ae+mP55ciBu6BuZJu4to0NOVvWO324vBBB7NMfczP3De22MDv6uwi7ZKTDwgDRuUyW14EVZDvv72R3NajeUtOcU/9QRSlrEDsEeGqzx9t3M8JXfDJfLlcCbLIfJR7x1r5Kn7EkUqN8pmxyVDFQ+ifksAS6gpNr1d8O1AcyAdKtx76KUaCG39EjJrjFYuEoqH++h9lyCxphw2ygFSAeEDgUnfm0xtyv0tmGDKP9T+jlofmDqOMzvdmq/6tbfYNABCQvc3Dv9XolhAZQwOL340hVxT3xDqjXwaYn/NQGmmF3AMq8STgAiP+5YSuUm0pyasZrPmttlvDpUlpiSXMv/jw/vcj5tehRFP6zXVQ4y+GNAqTPWL6wvNrmM6X+LQK0Dkn/QNMhfCmEJsvy6Aaqb0Y3IiW6NAlqihKiKH/oYlRF4CBlxIvY+MN4QxKeW00sO6HZ+kWoL8fZgUyYG/15V/h4KBK1FSjZta4PGWqphABy5hVshbrxjWR+0XxwpTK7+/uNQ3dAzlLPPOjMzYTjQ+OpQurjQ7QQrxHbtZ3G6d2Uy/XX6tZ0o61+ZsRRnaWKunnSRSyvcQDy451VBvkutK7Fezq+h1CQC6H80P8PO5RLMK44b+aaWKKqoIRb+jTpXDyLUFdu+ryLSko5edFCuRq8CQchsZlDr9tlrr5oUbgQOKtuyfd/p0XFXdy1jT5ovL5feQfFa9F1kFUatX7H+mnFz2wQfcRB4YCTJNEv6P1vRcsHXNHs1l3SHpusRZlKtl4fsru+hMl3DhB6+kL8iuZ/SjQyYLTPDoXSFkvegWLVJoJvb/AP0V/snofAmALYCdTl4tkYluaFClEEf4qmpF6YogQkVd6nAJMAwBK+SGXn4T/TS4gZ75knhI1kBdiBKPps8NUjwc2e9KerA2uVR7hKcYBf8D0EeAKjktLiHFpA7ymfZRJX5rPftKLcLTouAd95m6JSdVsHPuA9HG/1EXh5296xknVpLcEM4E8gtamFoA1qXt2YBuzbob6wiqiAFyrDLX4R1y25yKX8WuGpW1ZVCAYLH/KS5SIg5wb+YdsC4V5vaBV6slzA/DCpLwgeuhLTG6/tKBsrDXzAfSPvZHa019K5N57nqFG0AV/2kmg1t9bETmbdiBLYq4KFGF17eNppDbp0EXFvhsOpS5Z1Nd19vm4cKmsXyfRDpjV50fUs/2FE+8XBdbHDyJPeEMrc6WDDKm5cSJv8jV8pT8uDvucrfxTrxemObBHwQEhSnikrsgEgpNrQzV6FG16zM1wiSBWju3I+CeR0LCTBCNMDUqD6yP4k2FLgFkzHToceW5t8iODpVSfb1MmIThUCOUwj/3IWaw5/s53XV+g3vmuP5PzXSH0L7KTT/cjoc0gDiHTGqbz1IaZhypFAp8P45uKs4ivnAvtPxhQaag5wHPwlIhhYgqoRb7xJ6FxV5Xj3w86LKdK/B3cf/9FoR+IXJbrwPvMqxyzFLlnbaj22Uszy7edtGXA+Eb+R9ppZCIqas44plVor7TDF4N3YgmOcoA/13Us9laZxqeieNJ+4GBQ40TwCj9vPR8SoZ4ymtuHF1PNr8d+JjtASL+HYycErMmqv/p+51MIx4kJ1nrXS7M0IpsaEux4F5chAW7aMf3mltT+aiihM0jFtUw1F9ik6aG16E8S5P4HKbqmA037LHHIJr3o3ANtWL8imBH9Jd0GxPagvEuYxMNfoyzkPu5+02DV4DUsIBiMMVa9TmQE9e25QXMoZd9MbZm+3u1WkvWicCQfPbo4Y7DT/nFLw5vWhh+JZ4Q6RZqXtzCKk5u1995GCLIDr2R0fcizqCIanclvDABUIwM7Poyto5FbRDqZtUKfnVYVN5g3q1pJhbf9rnlw9HdPSJUYuj5S1gi355nrwsIbNLVKfxXC8l03gELSFEjBZKwy/d8MRNIhOQMhDmMZTv58/Moo10C1369/EI9pp995lEGM4pmZSNCWTyCc1XilAwv5G0NrcLVI4q1NdyrxbbdA3/9KR5gvdyuQ2iBWm3gSiw2zZUUYxHwkaRUU3zpZXdCFqmP1iq5IjUrGeZKQA0qAOught89G60LdqqNJsSflcW1Z1GfnY5ESm+Pv0Ph3VuNc/c1ETRW9GqV97xkZ1WLIPlLn7KaQTJJqtejBlyiD+Zo5uS0E/b0fCsb+FaQkArLfk/vQFDJJoUKuQvwkMq19f5EYfxe218XctYW18u5Q8GTGrphRoxgpjGqBBHFnjhk62vcJ+6a8pb4/rh7K7tSFUV/au0MimGsMmmRsMkk3GAItq7TVPhWMEDtyJLXHrC9PjIJQpzUodDTkpgqKKA6ZdjCCogtKpOBM5xMZIpJDTCv08y2WDbx6szMz9AvPUgoXw+PFQw18xM8xtMJShRH+TRSO+KiDTggXX0yWCg+zAMZSS6o7pQtXZvsUwaLINwkwnhQdoQBvMgmcv0FWFfvI3+g1Q6y9JtocbA4f4DLJgFu2XydsWn9A/VsPqYP1yKuMfvbYHLQq/euSgGqi7tVsg8LKY6bc6KNEKg+YrFa0mFEH7LIAO78Y3H/M7k3HkWpAC+6AcNHnEg6mvWnuEIizNKhqNfqX2WYwD256Yy9RP9SMKLAdBWWQDNW7XyWtXq7kO3fwXMjlu0cwC5Uyz5C6CZqlfVjJoYVx3PYKYRy78bklrOXlTZluLcPjo8NyVRaBH1glruJ+UH9ZElTbaHDTo2qAPiR2ZjL9Q6KzFky1KQDFNg6X52LzLIanJs0781aOi5qv0J/iyuxWzoC3k61lnAWND7biMgKSvpGk2mYFKc5Bru1kl6YyK/+OPhnDJzJEjLUFHhGg6pe9yGJEXqMi2EsPuZijeAxku+eyjrV8GGK2R1FNkfCWKKcfRbyZNFIvATt238vXLEznY+xtnQq54FrTYMmh6QZkzBfPHNGbGUkQXWzJYEyG8nLmJ8O00XtqTkrq7+xjGNzWcKXwYIMclAUXSrGbrAMuOooH2uj3/lpfEiuCyYlMVkZCgozJFmajhMSvB2+gXFQkFKQWAjvmTzM/vEkHFNLHk8jyIpgQ/DK4BaKAwcl5OaDMDAL7qiM2uJtMnHItDNKsO85qqBwafWpUgZtvpW4HFtYqgqKDC+M66zgLeROKseEu8bgqSEd/nUxy5PN+OgDlFIvHBq+ckgZp1YaTVDa4KPz+oc/IPaWGxKAo4EHlH0xhjkbOt1brT17i2udvcvqvgM8eNlSpxWAwSxRjmGNbZHSbS+Y3o2l/4n/YEOMlcm9C5OZZHcwhF5Easco1NxG06Wjm5uIB9P3FYgTPNJriJo7R1Iabx2bGfOiiOOVzNY17s7h7KvNXTlonHJSapku0OEkSFVI0xlqMdP+A2svZdDc9VdJf9LJYdM6Zfjav3FYofhIpx5z7rtcM6nscxke6T7XqION8ESCZNWBZIF3f7fBIrHpA313ApeGvtMH1u/tG+wePUXKbHGTftkBr+AFU22VF5J0ukVzBl8oWeHBSa29+C23IoVmKwFhuLmT/gAzOd8vnTLgT1A0rNyMirKYaUidVz6KuN0zkIB4d8pyFS/MzK63l5fp2kRZy2KlLRZh9Y1r05OQqK9Bs/ZuJMrB6khxwe2FxTT1VUjXZfjEvt519o29B7MG3nsjbprsS6mW7P0wjL+TNTMSc5WVtcXgr2H1hdwuCjAd1qHsx3Cb67BLAOYmRNi0Z3ex358hx+b0RH2sG/oe2lneQAi+pwTr/QIY77g5n/YCfb8k0hUDPFZzJxMCWVsJk4lH4so76BF0/L6S93ibJzf000bNqbkLLpT8w8n7TN8C0ooSqwcpOkorqvxz1A5hswuR4wwq9O555wzuLHp+Xs1VS5FNntJ5DITbboFmYhGi/HyAA+zbyr5FjNAldhJM2iS1+yeDeZ/Xz8T08NQEOWR4cNeYXeiS+6WYLPTRsYIfe4u8d/uObgHvfPfYEQP4PCSQrGLSEkglF3LL9UX4VLxp2R5Rpva+kpr1gj621Foqsx7g7ILKqOvdR94qyFDv3F/YDm5OYr1C83WEGhvQis5rpC4ieDWUrqm9JudN86y72Q3AQLWGK4Db0SyGIqz1WJ4UaA0E1sNyVbfZmqLXsUtouB+89h1o95K5wcMsBrc71NgYORKe7w8tuaya4a3SzQbTvK5s/O/euAiJ7VM4r5qJdh42FKPKaZGY1gC1T8ONLGbopx9SudZ4OEBV9El2NbT3m7JPoWJ5YEEEz40UNX/96f/MiWyTOuu0l5nC/LNk+WPGXTBmGz9G/IOfD1qedrHAhtjv54lhgiEd5lONk4X1shqizIdL6/XkxCnWD1Wu/02I5Hbv3ofQNK5OTss43ANXLICLztZ+2H0CnqoqRwqEs1BWZnPqh4nYgmwpy9EZY/eixaQRL4r/JHzts1paEngxxHEk3t5AaAmXsCpwxJdfbKF4USvXhYl7fb/ATuJlmhg4O24F/6fSBXtdaPVCO20AY4xF4h/UW7QiDTu1Y6wugxJP1cKu5DUaxRJdsVpmQ/lQ/gi2inPMxh1uvq3yadSRcG/L4DE0y2qr/4/nqFUTq/XlXQTVkpPSXkPrq/hGSYDuS5RPQYlHI0AX0rNNyWldhDqt2QQqha+S/CyxAuFr0E07qjgUFmztC59NloWZD7zRF1g942MnYCJpKm95hETpJ+Rby9vKfyoLmBXplcQwIJgU4D2rERuujc+O6nw+H27cbH8Yk+LZniyHr57GINLYfs0sSrx/xFDsvkefn3Ots7dtfpHAzfCN0quxq/Sz1Mirn9vlC1RokEqy1eA/9ciLVP18c6CIbOdgO1Mdt+8WNmOZd/DEOv8SXgXpD8kMDeJtRREY9D2ryDYLwf95JKn+HGsDOtEdikRnktSRzs4nmneNHVl4buAr60TMUxR16MMLS91OnxOxmeUHQCs6CjxdkmlRNlzi6gzrb9LFw8UCST8n7OUIsRMHZGQXWNKbX4xbZ35dx0uHMrRe9IPhG9s3pD8Ms+aW6sln9cWYwv4vFMCRuafI1WpuN2Ry871lTg1kjC36kBCun6cAGzz4yZFMwDWgRze/TJ60le80jpXFnO3qehDyV+hOiMBMh8bQLq+QnePQ1o9G4z2gnXcmh8BUQ5GCEeVroY/6V6K4oF41zvD/lD6NXFCPd1xDgZkRlGaT3+bmrN2JZclMMke/fd56bigfcjCbN6sNbIJqGExp+8GLQ6Aojp6Zu00LDNDC1ST5m7uxSGw9mfV/Ciax6MsP7Y17D9LrstHZRAJuTuv7YGuKiYTpo38pYx+mq1/pbo/gdMiI2YhuOfpq2e6AgKE72U2cstBG9GTzLPP1ctTuopealqbDJvCKTzBzWZjwB9YTuvQ5PgISz2CExufVN67k83NdKVm9KLP879/oFILCh2jd1B7CFn4f4Zy+iwuSQRhRdntHP+He/IlqhRx2CJL2I2o+dy1xVSu6DJ8lQ40RWGibvO1aJyA8tCTBqt50gS1DxRUiXOLM5sOqHPw0tkgLtF0VSgXYHSU5mVH2j7GZDPSqBufGuY0QiDB0nQ5MzMZ4jvhYEJgCYXKbpsQ/adgEnFH4lFRzTC2YQxdwzbepBKlqIv4nIUbiPUrKWpP3J+LaUXR0mue/Wh8b9O0RMjMbP4aK0pgBy2o8E7RtdAuPxB66dyybNXxL6K3A8eCtwnMLM+ry/WK79+BTgPJQ4KnGF1kPTqROlUdfY4SHvUe2Ok8jHVfRwb481pf0Nt+9fz3pRZkVXD+ByVS+Qp2Lr6MNTeLdy+3JDO0NdjAilBtlydcLmPNefO4PVrN+3RfM3CsUQ6qNfP2BaziLsIpVO8tWNn79J/Mk5GWRuIfg3QIBIs/EIxH724memNB+/0AsMoGR1UOuQgEDhYgKo6LfwWdDN3N3BUw834ruTSDeUGrwswSLYlY/872P/VOM61kegJh4/Pywz3f0u8AVj3kEf+Tfn/573GRXNA89KYg9qQx5cTiDav3LHNlKWRaCxUwzGz2UTyUTK5KH+pfbysUmGFAj+QCK1gZsbZFpxd1tU9TAz7J82YMCGTqfiNamegVNJO4tYracA+xku26hr2FX47P3iMkPFZnmTM+Y+fplR6RdCLlj+cTawLCus6gaZjRmYvLjKxxBz3VIQOoSX34D0vp7nYm24fRZiSXT6U/qPSP9iGZb/5Q/VDEz+/N4yClGYkUqJs+3rwj/fP2YGNOWJTobhxna4UVuoftfjukNowLRryEO0Ayh2f6SsMRlsb3zsYPmT+5KH8dOoqnyhyh6KW9kfeX6dH3gp6T3Ui7Z5GQEwIRhsXwakOHHLHm2kdbnZJPhcz2+GYAZyyU//21WRQgXWDKa/Jnk2rjPqRlW6GvdR1gutny79QHiq6JxTh9QVWL855jslQf71Q/80KCYnga8BSAfpsQMOW19bK9jZYhSpVy2OPqFOCKrQlmOeBgiofqqpoiWdTqOk9p1dDaBcGeyalpwJhIthKqVoORCIrNG+tx/Ien7W4V0/7H7/kvYXMI3jN5TkTI0wh0d+nG9qvcYB+flWpUmWRrNApmzACH5kKBmtw2e2ibS55zhTWJeubDlrJbWi28CmdKf8/IvT+jJ+vPR0wFIcwELhST5xqhBM2onR+Ppyt8jVLfPRHyUM47wVRL6BM79URIYTlM8Run2CWuZVXeENHItIPv6PpErUXdD4X5zkjb9bOwj5IEt3ycqETnIXqcBSAlIGGt5+ZNmYqxXM8HB9vfzcrwpfMRpLVeCW6onvlFsa6ftDLoVFyYQnZ6qN+ShDOzNBKyE3vUzPad2QfhZvsS+ckjTS8v1BuZfrLICgVapoC3ClIdUacyGJyEFP3cqQ/YVEZVz2YWuVrVLNeeTAMH3W2j0gbomx+++kStSCmDtVxlPVlW9DkS30BouYaeWaiy5T8cWaH0Gzfd6h8QAyqg8+8gaolvmSlxt4G+R2R1hRfH4fyvHDR6fvHIbd1es5ShQcZ8EFtVLGS6ImShvhK8zR0xhMfMdeqvYTs+I64AQntu+b3KU4tEuyrwJaKwJVsMfntYzGr5HwYDEHWwjEl9w6fPIalb7AduhpnNqv/zJ3LO1u/4qierdrLLdUbcQpWOzAF6tolJWvA5xg4+4teWMbDavb1wGFa2zPYbBGC2LpZ/jCiUC0U3UnDUaI+V8/IobPNhKjJRy6gJRcL6/id/m7x5icf39jVv0O4dtBEYQlAbUPLB6W9Q40yCtfwm+8pHAoc2Le0HI2XJ2K7jS66RHQMu4lH/YP6YrziHrdjaW3+CPgLog35xdgpHlml/glCD0H7n/o+sk+qWNnNDBKnAAWcHTVB9ZKeoxEbReayiB7fKbUMiMHYtgCfpMuyqJK6K+vYkD/rjgLm3C/DmxGtzNfxK0zw/PzfbqztKCFOVIvW/z2Ck/0TTcVSAROOlkjQBTQ5w82UOTW/usYJyhsfPnxwKd5wZWCxVXIg9W7w8BxNKIwXhIUX4RrjKJs7+T5J9AI50j33yzvZPkbkIrynr8TNRs3UTz5q+h9Q2rpo5D23diGRh1UGueCu+7lHxx/StezZwmll0a8M3K85+MjfETwWb11cRErqKLoMHfaV4f0UA7z5ovdLNpJbpyeCiOEVOkpho/+8ybl8PV7DN9DYKjXKO+Mj+FA3zyUOwiD5s3iOjm6wsHxjgbe7RoeQbaejRLM2oXEzgd49nC40MsbzO197aJQsEDKFE6vrwZpgkgPMhl/kW4+MERQH9Lxn0MOyXi6FOv6BFAtfXLn2VD24auypq8UCfQF8Z4maloVF3bmdruy7R+5PdXrNEwZeciluD83FjDirIyRr9L7SeADvscY96w43DNjPhHK9LuH+csbaYxE/YqyQgrhMj+J3EbZVbirvkqVflF0Wpvt92F5s6diD/xQ8GbXM6prd9+F9ZpKq0wbJQoHvycpMcG8kI40PmBAxphq2QLafeGT+IFgYpOgyyEr+cGCC+YOh/GpYZleEgbrujI3CIO/h7a41VH71d9ooe3D/hrd5MsFqBLJgb/+tWzCcZm9MC6wrAlBvjMlcS060DDcQ4W/yxjxFPqAM2Phs8JzFi19lo3IZPnl5gjYy4Y647/S6WAgqgltrinLXLNPLFXY2nJWyQNc2Q7o4eFpEoFbEYqX2xLOsFPUqw1OJ89BK2iKP4DZgV6U8KvywmtXYVsV80X6zDD+nU579jcDniBQUalZVeUeRI1NPgXrCGlPY0iJFgDRbf13wu9uuml3RZ3sQRoDTCPqc0R1SR8fINkTfFzYLPfGdkM247L4rFu+GOOcdCJIxwMsloVKtB949QhW6OVfXRn6mNLKY25aV4HnJd/XXnY99/MQTtYai8Yy3sbghoVqAF5+DF4s7oWI1PmOKNreUvPLzmTqr/wVj9aeJks4HiJ6Pt0lWmlEvvuYFuXq7yPo8l0N0obmMAy7YexE5aTBvYjLv9WEd+Sv6isB1gGjaUNciCdne794sseN+U9SnoWmzDT/0GW5Lmt/pPK6i8j7CztcyFu8ev36QrS2M9UZpFkDIWl46fBgf2Zr62aRMasgGkxjutOI0hL2IUFFntqPw2VV8ZT7Myhjc28iO9KaEPugK5Iplv/usMsR9Cptx00p3y6RJ3WzwRHrDxkENMwvSP6nnxiReu7vAvdI5hQhT6fAsNuiugHQ45Igz+KZIAGWv31X39ADWdFT/0ALnOEz2OMdzJqJTOoIZpf3Y62jOPdL4m1vkcOveBcA/Hn3mT9sihICUS46ALRzSQ1pkPWsZ1qhfXBTp269DfTSry46b97wCaCw4zVCUCFiYL2cIoVSoLtexcAFf9XHR89IABw3Iz8eBbfMSNffl8YON3rnK4r2SwfhuPHbU+tF3DYUtZODoHyBFd0ItSBuxVr1bfEQ/6wg7H+54N7Chnc7kD1EVqi+ca1/fRPtZgjJSxmqUpJ28i1oNJr7lFTy4pb9+XYh/+2TNb078rt96GaB/0oht3kEVIBozy9AJ2lvoAYxhTrmlQWjFMagPKhb8WOepk1WO/iHwtvvnhLrWj6bK2lu0ZAz3Fz9MiS3Mrr+93jJxrrC0UvDUGRZpt8DOUNZUO2AqIJXDJmSRN3fJBDtBX3HfeI0UChGcPAgy4acMuPo+4Oz56y/RpcqNluTGyHp/IBcsj1WTqwcSfQMN0mIOi4SnO0PoYDidlExyx0fMFrNfXT5uNiFYjlCAumyIEgap694R7mBQLlNm5BnXGAPmAx/RQg6i5LQ4ZGw/174NIOYRyr6msY18i3X+qHDciLWTvT701LmtfIAh7lmxnlQWOhsiJCtmTHxxYZQmrjy4aDdXVNyjloB+rVeH2/Lvw9UhJd6Q8Mi12zCm9bd34kKD54f5p45ALb10e905856LHbc+By11Kj2r8v5hsCmFZAfYAUkYQQoix+KbWchau38buTkcqCeKOzLuOQ7ORniuFdqFuQH0QZK8xB2YRTmPh3LX1jnVzRAPlwUBaX4BeqxaA8IfZhi+GHgcGhHY9qIa51RaoblPGmY9xNgijuFsQkW/hc25d0rL6Kehzw4eLDEwM1aho+c2nwS7rkDdMo4IyxXUCwuhuhwyCsyxWZvvBtx/F3wZS2MimjnnywCcFNy3EKZV1sjkdoR7Rw+3QlM+wNWCyqqJdVm5gf5CAgrKcPWhGEEcME19+pxu94+fHQgibWAm8Vh99y95Y78py+2GmF66OlQPK5i2ZXSAfthbVIcpPq+K4iRREmlp++3aBwUxGJJX5MnNDIQatjcsEw7kAMd1XRF+MZQ8xaXEAZ+nwifsCgatYDVBUkPBejebg8I8YRvuA/zL4Ce0guNfrLLiLVNOwNCLgAnWXJnrbbByZfy21SRoSLWPwd1LrE4ljiiLQQ02Zt97NvDaHz4fkORV0dVCDLET2hsNww2XIWZHCDKRnOI4xAM8QiE5q0CC/6hZwwKXi7FDnCQTW1ET4rxWNSTubOhMnAgnQ8L+RqNv3Gex71RXj3TTVQ7btE05tze5NxW6OTEQ+kIzjL8jmWAA42ernoPl2BC7tNrDT6ZVFjHmg96bHIpZL9Vmav7YXD+UTDjMYRTcV6a3tk72nQ9hXjpNBjPdgE84qp4tRU3ERf7ovkd6ej0CKP0XODSYJtVOv/U4s8diLAQDk/cWoDuI+55NI93h9unCYX5svdkkn/Am9KMVH7wn8XlLR8Coswmv/JI2a0G5UlK4v2bUS8mt+rfn18Je3v5NygLFBaIBhmB88ynCs1xEAE9fYGF1lCpYGaehmJj+xGYFtUllzshmHET/HggNE1H+Wb+BsqDlztidLvVbJFuWs1wu6H7+RGh7Fi5h9I4D2fotW6phb6dnBlC3g+hQ26u50ruPy1puETnzSDQl0IuBIja8r2QdkzfV0RcI5SjpNd9no2NJg2rp/sLVYQcGD8JB8d/e0wQhpR0OTlROMAlEN9VMo3fyJal38dl2scYJqxgu1ypEggG5wN4rPFn37ICTvcmn/ckrMM4FT8vZiVXU7n8tHYgzhXuj43DpLQaPgyCD0JfirI5mHMbUN3r4FoVUYfI0ntjB1hDgXWH44yhxl2lJQedA7MwrlrEkIdzhVubgm3ItZRmCbLURcz5+Nub19J0PxDPP0uQ9IiUoHY3f0wt4+M1dSoQQfVJb/pgYfwkmTDMu59Swbji3a7usjAGhhPKBM0Jn8V4I7v7i0RyU0bucEW5ezRoMlAREMO5PGjC15fgTnRR0zPWwIzIadNOu9wQE/hm/68PqdBe6AyKXLKFLfa6Pz1safObnW7sCI5sn31f9SmTfjfyCBC/B/oNnof0h+jY4QsQz3GPZU9JNX+HHfbwuoxEJdN1vekenC+a0KJ8MkY10MVyRZ/kJbAp0A9CgIoRbUfqSqQhDUkZPN/+am+k/FIrFi8LdxSzf2Sp3Utf6zuq8R0gszNAbSiTPeFDBMMwpdR2guVb28iqY+oeZq3HPGSYzO+xL6Bj4vTST3Foxj+08XzrF7AlufzNFhhY575+OhIZyD37bR6w0tqEOMtreDkbspzGYIvyGWhoGuaDf7KQoVlCjxMCPFyPzjCu928RnfGvrauxxiD70EzMNa2kvOwY5aC/dF6rQAJAkecX/lP22MAJtDTfGZqI4utZqoCGckiYYmoDmEwsDuNTMXsK5duAwr8qp32vwdsIdcoyOM7PZZiza/pQ85faQ9w2cxIxZJrLrKSHTmXc3ZY0ml1lJnBmxZopE5W8BgLlp1bwA+goueNSn4Ci3/jh1rf3qGt1iv3nxtn6Z0WMjJb+o+HP2VfdEOe7N8qD4NHwzMnYUdEi4WZHjt50xa4uls5AyqfTNKVlFe0YQh5zLz7/NFo+isgG1XnPkztU378qvABJ1a/5z9/lb7Gui2iQe4u0fn3Q0xUcVhpBHfOibaW8Sfmz2vBZ7VKzsJQ/7E7W4qVr9c6UsNzPzPHnF0W6+0tvbEReQOZAC0pv18VkdM0IAkGmdEWB55K4tYtlBv0ORDYhPkxhl/WM01SOmS2row++jCQ2afhPZgM65erOC+9+2c+e//RdyMid+fgcvRpUOWQoRkOfv4KvscdQP84gElyxLsrSKsrHae8vU5zHwFYzX0T0h9oygWvOfC6LsnvzZ5UuI3MmmDqtBBK7m7ODS+LvReJjVE/VK8o7j3lz01KNqiiwFb0qqHpfZfllU8uM7H5eaqNA7gW5iRKZimauVkYw2R6/n8jIMNAX4lYvfg3n1LqEmYhaCNz2uKULyTuDrPquvdFdAasBV+yBnxIwesMXmkhbxbkUzRUhRu41U4+soF7oNbBIFI7hLF4XLh6EiD8wypfiohHTktpHS+a4Tf4KrYaLP8j+MZ4XHx5EbupOaO8v8+8p88PwGjqnKRxudbOJAp1tQ6z5BIyIAU+voIyrsiJQ5s0ae/LxbFwE460BEfqAsxHUwwlCvyz7YDDZh7Lb4LVb5TTNyF218DfZrbFnP20Hqr84g68vbST/ZKdygYI/rVkOxSvwXL2arD7UGcLZZxKUmbF7Bn4ljMk0DnU+UF48Yv1MbcyRmHG4sWh45fgraBj2UOvL8nQ63ULecCI/sf9FXWxS717eOZioW1x/BsWRaNqlFpHfD8d0wyLFCXQB+PgJUTdED6rjy0cPiL8saAHPA7Hhdz0/5Ttmxk99n2G6a4OKE7+rusUe28cj/y1GoDDkB4HBUfOb6494iyaD/mYL2ZJ/EKBsJfQkN/9x8kHwYRsu1vQYj9Kuwx21MInswZBzVkOs9EXvZnq+UQ1ICX+A/6KHKni/+JcJ5VUO00EHqDEjrPWJ0YLcOu4qVD3vAmbcO124jkKxUen8FmfZB/v3fO1aq0Rimzo+Opdngn404re4LwV+5JAAN33Fk5P/WjrZvuOXy+Y/CEqmdy7hPqL0YUfcCWCMbnmPZAFHIroup0JjJWPvrz+/KNRAjufkrPyR8n61/T/JE71E7ZzOlCPhHaEfVYxFGq/M9NguJBIzBOFozDne378oHMcaDWOZUSW7b6W7kIhUL51Y5E+3QPXAtO+PtZRoEc+3pia5xT8XQxXdcq7F/jwhcxpagGBQ55Dq9m9Dko1k8r8RMpuW4FGOo1j5VJS4/uIqW4mkYb0R0jZm1VIYQrSWipRYLR4pojY5UtqW21LZy+PzyzqLSIuWt/1mkOZTCh+TG4QkjxgksOKR4MdDAJKEBR3JZV9kvyU5vBxUepiUhd8/x8R9RQCRXOVqy8bEeMsBrfQkywGTCsUHIoEz1TDjI7cpd112GiVNQ5WqRdiuT8XfyPbUvyzY4E3ioLecnmAX9D5CXYOB+UBa/9lJU8zRw2opLlYshrRmnRkrbGXpLZ23TLffWLIejXkLCJ1GAJqFTBXeKs815OE31z6POhS0FxFFRXFy3IAW/r4vBpnDR87dL/Q4kXYlh8A4tx3lo9+F/vXUgvCCU3jTPBksgkhdOCRy+iwUpjNv/54KSWIAvp9hvwPmebbNOgxpap/pSWIaVnWuBOcvJaRRhhXcItMEtp+pmSgFcKrsy8pKFyXrBrmcIS135ZFHNyLjfdpjeTDbrYN+lgfDT1nX3+iCxtjm5EVQ1E7Q8P3JY8Zm+2SwaA7Z/Egd3qZoHcj1xdXmns/Xp0otDk69DTh7FH+RSpbVX31AgI3DrLUHZ6IUqNuu5uHBX8+pxJFmb+K6jukuyTfThcH2RKz3ofNHkYBKsT/mET3IH9YdKnX/Y7GnIFL1eWLkEzoYEX/zhQ6HT/96876lw/7D5YzCL19GlkM1yRfDAy5xAHb3N28o5swn+BGMqEA1/qs3xH6D9+dklQG6Kp5DTLm/tbwTP0UUliw/Yx8XRtzEX1cbk1iOf90McszcGaya4lekq33BR9wGRnq+QfjOfCnkvERMJhwC1r8fQcf5dp8G400Bm9MOxn1+wNxk5liblhqH2DTPkGP+7FKwSw7Hg5+PmZ12lBcVmownOdgbsVOuRQArOoUXMDQdNIs//Gs2wsRileGHJ1vNq3AbSmTrwzDzxMSlPQB2KXl3qDbSRD0J861t1gh+3Y6RC8ayXnthxmLIWx9tSJPmOKmX7l/DvzCP9fDCRw0tUdRIZGb81qdhUer3WWO5UmfGngOCeqIF08+oZiW1qamsRqh2612kJz3lCepiJr8oVoeSsFDCIZ8+qtelEn4tGxWJfaoRiKG/TvtXjWDILk5a65b3LmrL75uWHlJthJ/pzp4oPYhEGcc/Fzl6t5gA/T79xslgfSKuysdNvkR3Y261su8+epNKjS/gyPCgzx3suhBs1FDNNEvZmE2EQu396Xtq4ZrNjmZH/f09J9JNGMHnqQgzfzd8HM9gg9p4NlklfPJTelD/bnU0+RTvdZmwl6kcf6fsv+4KIj6Q97JtigdG4YWeveUfU6rwt/pi8g4Rk1q6ObuJSSXAofjBgMfTKfqkNEMfWUOnS8eL3Ldy5OXetWM9JpS853wt/6r8to6G4EgbN/VTo5zJ2Kf0fUyLTDt/9ewjDJ6kCFxPCNcXhQpx+a3igdZ+t9/XVEQXF4HiER7jCVxkg6fnxIqJlE4EVtQfzXYo7q6JSolpVJFib75+WXq2IG88kc/PKQY/WyYF2200Q8cY3eq09SNFWpjam5nbGJCCOvNzq4SmZfKNC+5iTsIGCb/GlKO+WymaNBit++NshUEsOK1N+d8dtAjKVJhX0K6k/W1IUfoy4PQ6AnLUDdHio4Sf223ZAgVSsQvX5ZISSQ/imT8H5kugQfnpugMV9JYt5f97aGcpipR4sqV/TGQJ1IkA53ygVZPdQWXO/CYC44v75zQ2heYI6QAK8cVHmmJgO8ifJtnJJWo+50+HRKlL2X5GphqFQuizu0bHy1BI78ncYLFP5TSa+ouyID8embMVuSn3psaeeS4sJgWEvfFtriomE+eZzIxFbDux1Wp569DCaKAvIRh9CeWejyU9qk0foR5gTekwpLrQP2ZpPajrH3CHphlQrRH4hla0mJg+9RoWmWIxWh/H55YHzuITcUtSarNTlGGffUl2yAfLnmJTKKcmFGiMoRQB8zEf6DOZTR8pXHbvTYagvEy80T2BR7SatrXIIJqOzyTP1pBca1FstEe1klsa3aGwrGe+LXI3CG/Eir9WLKjKX5hp2BcuRRWYrxvqtDgQ7grOMpC71XWsVJZi3KdikJf4PpuGSPFlI3eCA2cB7FR2LaLLUfOs7b/zU140udN64+P/sPdny7IqSdog+DR5GSnMwyXgzLMzc1PCDO7MMzx9Y75PRGZk5l9/dVeXtLRILDn7rIU5bhhmaqr6qaqplnklWyO1vfX83NfQ86y+vXUlnGwCDsUXy30laLfl8Z6cYto3en14ILbe+RqgvyTGa/DslHtyyyj2CHrbwu/X8bsynneipFB+BxGPg5ZjyTY15ICXqrvhZoQhxsv3FJZ+mfnn9Uvm+keDfteWNZRbGUkfjV6gclOjb+vZLnCRmX4jmuZC4K4N+eTQ6iJQcyYCns3VsBemBLLdyedrmWgRNYpjsBqiKZbSsgqepnuAa7ux6/vBhnpgHUqjUJR3z0/KMuzrTr0byNmVeoNwgWaJN3RAVmveihh7vIoL3cMde2sQL5PZv79j4YKzyCeubGh8DMP6DT+4k7aLxqrL5030w7IKuHgMzQfYf172OmTvd6ik6VqsD4ShNdaTHJTTo5zcwEySbFqu3con4zHVGCG0huGUW/v+efS+oUUUpU59EMJgDGD3WdthcQ7hMz5a6aMjxp3hoJS1lquS4EIBvrVrd3agxu5Opa7NX/NmieKdoNsZcFZEJAh6n9C+BkXwCpd4Igg2YiBczvLfOfd76FLpJurztIbxQiPPJa7R3Axc1GuhqBeLZFHqF6tp9n4kvS+HoMd6z2RFf33De4xW72J3Zg3CVYYQEMiByFV8ecUU9GEaRwJBbBq6XjILByHtO0kcyoLLYbbz+uhIP5N5cQT1PQwYAXyWJpHEWZYveWFWfXDGA98BfSgYpBgWBO9r5d/WCEvHYnRclSYTMPdOcTdT0109NfXaHkaGc7NLeLSO6NF0KnsT09lJ0qiYg5An5oLoLtSFSfQgepocUj+Eqnum1yAY34c2vIaWPmYoOvf82hDAOJz+XsSNJICQoqyPF50lSaANY6EUiaW/Ihpxi8WIJiJb+Dv9FieI+tUiqrCI7YE2wg59zJRoloqxEQHmqjOq9BY/5PfTor4eCKG/35nK88S9JznJlztnzU4Jop6+o2vBAlnS39xwv+BZ70YmFblih4qMk2bKoOE1jY1iP2J/wNaUsp2rrCi8xBti23sjkYIHPl5NbF9csCIYqTk/GE6VKQNAycAwPhFmVfSiTF+zSHyQ9KpYupsCMWxUkMotV12qzcM5KdO2ZW9OsSQ3RtWta6M/2WrDX86f4o0edq/m94ZpbJu/igrSvrIDrM9dzs0F5ecITBd+YGleeYudgEaPjKJZ/tkqc5ZVWmcl+y/AmQTEtqP6GdCGlsqEQWS0UCtX5ptY+WMFMmJqgl4XwDNY3FYw6MxbSJpsvPS+pQIEyDDl/WAYONUf5XwI4UKW1XVGaHxtSiNncqK1+W+3knfuoJOwmbmCkgqA0NSvri1KYrVG5agiWSJj1o/+LdLyrqRhAoztJQ9jSWtt4e8Uw6NDqHh0otDHJelZLIB3YSn19LU21cEzD37GpGcHY88O5pgN3SK9HME2WYt55QA8TcviVyatSyDkq4XNt/RcdyvrD66yrtA57/FRV1njeZkZ+ZSwRer1lJvEcvQhwSDFDJ0bt+FAtiFbfLxLZGzJWBc/rTl0XeEIua/JxK9cYtnqJ4G8o3+S8gfLeA+mbz7vndkJT7EvFvlxFSXl6q24mq6ei+ADG4co40BM1VtQ1JW/VvBcNtlbI6m9lQoYwvCJ3ky4uVC1cCwiZkjknlKyaTCRIFRIdIy2D4iHg8gpQmSfWzs2YQ3K2YYR5gq74b5fkoHYAqrrZ/PNY+2onReCDlZ9fCpgoFwCPz0RimdMLoAO4HRTiXTw2h4bL/qRAi1DfUOO2D+9DZMMN+Sf+L27MW7xEyU13zTP2yNXlQ6yoTjNDj/I34pwK8D5kLIB91lkBFj0Cn3yG5tZMXd6dLJ9paSN6RVnaM0lFPcWeI2VgkI/5AYCDqUvmwsfoHX1x41o1T5EsBVbKZqjvc+siDlM5XTG9Ey2NdVtuPOnCApbWJvO+yk3578wi9sHJlH+61sQsLZtLINKJSE+/LEeUEwo7Wzo4tz52N3BuTqdiWyEX9WfeNQOeo/aJszJTLy5d/Yt0yC/RIn0KVvy/aD6btX2ZQVm7MC6CdsxFMsJLQGNHDvXpRDJdnUODoOzBQONJ/CEMwXExCrFjE6H7qxfJjaI7WPv01lG1kyV5RkEnjGnHQ2V4lN0YqhOl1Iv2URz/BeH45GD/TvHpjeXDVG/clxj37fTPVKuaVCsoSsNcGAGa+S8mskllk9+2/0AvHrNB5xDHoIGbLehyiEu+xzdVzH1U+0OMgLTHZZAtHvwcM8Lxwjst2N8inixOrypAitXyTwPq6FK6iHpL8WwTnT9fAy49Kw2wAJ9AM9GmIdlMo1yfc4KH/AM8/B5KCtYg0wbuxcIBHIIId7OW5fv1wqxGsymCVoueARXJgjH2+f5lwO65/r2kqXNiemlvB7UIgLLGXJBwYOwpDqSa1UG1TB3tCyoV82OSb9vL/XKfmHNoxxfSCRZ+GVqDXu6WMGlpZNS7gypqJpdvluGIPn1c+vcRN52ms0v0IfqN5JPUQjyWor4lWAy8PfRqweugaiCyO1XybJtIWaZyfIEcDZB2wzTcBFK6rtzB3HLAknetFO9vn7EnCCooqXcqqtP4hUs0M3m/kdb6IRPyE/TV5zkpAOpBD+0BsxQD9IKq6RvvhiJhSdFh4i/nzsId0a53V4P09UFT2Vlx37k8B0WbJ8uLpkfv2QwWdSYRAuLGRNVyiFj74opUVHvh3Dxl/r7O2J2RxGwQABWu1IieSZYcfl5KNxUYm+Y5PW9DsxOIqw2Tf6rT8JQvPHCESi6X0R0Ut4FYqTcr6vESX08W8x79OqNdjhrdD9+4nHLAr/x60HQ3a4QINwmlN7NMqTbHkXQZgBzOWtKHjeyl/Gog5gvhNw3fOGrHS4VBLsytwCusWxCEzHfQrJfj7btMyAOnY0SMn+ksEpbh3BRSnshypoj2dT3gE9sWU0h9acXpxcJsgezrnOvMbx2aI7kCh7FEmAWqLHUhOYRTAVMZWwgtMNpY0ul3M9WzkbKk8RLjKyFlc1aVkrxhvX9j+3xNXM5qe6Wd61Ham742eWbV7B5F2J7H8IS6oaev94P4SU9qSN5hG7Le+BZzn2kA9ZYrdNXbsTHeUKVOhxLFnB8cdMgiJM801RYS49MahUSOyEtvmaXvqE8nX4ZfuGXPKOxZfL5g9CnYt7lTVRfSO7WiDd6XAU8J9yVKjlXn2wVj3ZyGPxDESvLF2WC3Ed5MraC0Gu8anY1Ftq0aa4ySXy/NTcpaJoOtASmjJqVYWm/dCBn/jQtwyO3Y49fLXt9+5cXq/CLQQ7FH/IUbaMPZY20MkvLzTNFaHAobN1TBWV06sMHXKxv/sNCJ+Rkg59vcfxu7/41EK79dt8v9j3EVmGtySmqo8C1FafbU2S3EJxjtALlUUSPmZVI1+LKPAVmED865/ZfjHrI8JADl5WqlHrx+SpZQxcejU78H2/SrBgyNIsBa2oS2NaYvAhfIUF4PSROz04+QPixlV4dXRNwurpEqi6oLP9ltcFUXUD+1KsogKNR9v3lg7m0Wq47Tm9v0ucr/hDANlctWRG4YLCZR3XSZXgJE1aZUcUwaSb6NPHJZAPF8DYXsWaVqJAjX8zKou4SEepOl17BGX8iTjf98oJo4Oq3h6k79RbihJ99qyLYICpyBCEaTfpUt0NAuaTBTnIJ4ek17rPtCxIZmUQ58jtnqsN8UzlF2YMac29bZE/iLAwlzL++0aS17+bps4fImqzCAivZ/H5p6/mRDoohxhIcExFSqoTfZ3i5S4j/kqes34ZN5TCSNBMdDlg12bjy2FwTr2p9Y4pnQNg26ivGvCZGVGeZE6femYdaHwrGJJDOXSq+tfyXTWTGg1xSj+hTOAfww6jzX/h6UP0R3rqCsitxqp+sX9hzLGSEqIuN/o3iUKHbiR4S9IBlN4jVk2EKSmXFNZVO5uMf8FUbcLidL37Iq0/VolPEmZV4vrFXxs7N14bQySM3aCLyjhgs4gssikLTgZNdaXsrjQ5x4qNQAJNsP02FjH5TIF3BQK1YWbfmp3Yqx6N2FsSD/yObewES+nqfOt3WOWb9pDEXuaOmXq0IuCNKOPweaMNLs90KfYHkY/Q1WJds1bpR0UblgLAMZzWgBH5gYQloHSZt1I20pfWDAy3qCBnMptbK78NVu/rJ+FAqYICKCreSHxBarQPZkjmu/yB2VBkOgD4iL+qVzTIFtxVx4UAKhMnBDhBxLOPCmq2yPeW0FyNy21uLDEydiHSZX4+gAmbF+QLDQuWjTxu7SEQMT9s/TnRkQdaTXtRV8JM3ibRMjMTh8ryW715XhG/0JIatbxW1jmIDxn5ZpSstl/Few69SASurNWH8ajn/ThwF2/6+pj+6vZAxs5N6uN5r9Jno+dwHRz90fr9LanJIbmMBSPhoCYQGNmQB4WzGqgLXVMC3wIRSbDCBWfNMtVr+ItPAboKjKOl2bO3RzR/GzTf6lANzdUtUym70iP+W+mJqdOKiFWDJKwtMGwU/JT2BI0EeirZABkTMzvqPOwXzRFnmgCfZPe4DCQ/j9gV6cdosC5LfBK2oScNW9i6MverF7d0wIexojCva5KfAJYY37VlivbRDp0R69Or7l386Gr4iQIY51TxwkOtrJtLTdsnaz4lwq61musEe6OZ4nh/sPwuy90qTvaKL4pl7Hz+hjwRyyMpvt1OyUDm28xZJGkHQlcw9P4yby+HKqQ0PJjVfgzMDr5wX4+lH3CNOP3PJvbaL0g5mA9rRM16uKD7Tn105/0yfB3t5DGvHb4CxeU2KBdsTA4LtqDKKP2OI1bY4Lf1FO3Vw7W8dgGo8JKgxE02ERN6YVGvLQ9LGGTI6CCXCmTCjsYenXR0O2YAoWJHVLagHCxERCOrz1GC3v9OKM3nNkVU4jPll5EIQ2YveuK/O2arAq1WySGLyIKJvtRi3mlUdR9gRwZpLNGE1dEEMqTzil0byTe4JPCXRq+Zf4uUytSu78kt5lKlHgdYYiJUBjcFtST0TqSDV87XdAtmSWTQr81TESbUEQ5+HKHxB/qu6mOUAZ2QE3AwYXlAZhnVqpnpjPz2eZ52KYbxHNIgWkFwFcnIigwkqz7BcyzFX3M4VI3sDwwiqyZwg+uADQfJB8L9+GHs4zFA3PJnhfn27R/dofstit7X5j775LiawX39M7VSVngEeRRsNr9fHX30/rOR++U9/ow2eRymPykNakjK49T/6jptse2DH058K2qTaAFwiql9M9ve+//i5mL/G/2IiYAutiZSX49ff+36kRCdOzF/jHzEwv0igu0fP/KPvPxjnX3Pzr7n519z8a27+NTf/mpt/zc3/dm4sgEUYBxdayIRDRDoY8MO3gvt1NrvjuH9D2XLoVyHpmvb6N/TFzE3SPo3/hiD/hkBjMhf9+jQ/l/Cfpr2Y1+L8T00o/28o151iMXTFCnwG0N8/BUmGwVeuv64R9N/xPy1Hk6/1n1YU+6vjumiq+q9noQT2pzFZ/jRU/+j9V/rr90xgpT+5om3/PoTf3wjU5H++g/99xEm7FX+a/jQs69X+1bDUyQj+bLqken6z9do93b3g50/wok2WtEzbVP3Ttg7jf2rVkrRorWFp1mYAn6bDug7dc0MLPmCT7FvNw9bn3NAO8+9ZaPn7eW75PYxZxiIDrws9LcnfL8rmLPK/3/Jc1+s6PlPA/EEiWd4j/95kQ182fV7M/54NwAWQJytQfkH7L4tLl9xD/7fkWP62rEWfNS1ohaFf0taXuvwf2TBe/0c/5IXi/A1GqH8f++r/QSogcOKfqAABder/Cw08o/jvNPCPxv9bNID8v08D/9sl/p8p47+v6v+8iDk8ESWUFO0JjcOzhu2w5eX8zP6/97/TUOM85Fu2/q0dquFXGQ4mUKjIkL/lCI7+DStS5G8pSmR/K1Icg0gkS6mc+Fs1tElf/T+8lDSO/dNSovh/384wAv1PSwn9319K4v/Cdi7yqnD+uizadDj4/2hgfw1gOYa5eXbI+puWos+Zef61Z22yLE32z1xgWefhW/ynXQx+BOEfnwR/vTr6X+edG7qnLwRyErArId356wanucHoYOr/bE3Ae/yfrsh/mnH8f5jwv7fNRZuszV78U+f/0yL89QRraJ6R/GPB/4b+MwfHqf/SxTJsc1b89a3/WMr/1hFM/3NHGPRfCGJN5qpY/1tHz9Ik13+6bQQ3LP/rAYMEn//DeP+Dwv50+B/09o8p/b9Ggtj/ngSHbW2b/qGYvv8PTpAnSw34+u/i/0P+kvx1lT3EUsz/hUr/YmHdWc3JWP/7w/zRf0/6fB7A0Nmyadv/RMI8j5EI87Q/N+fN093fP+uHvvgHWf43vvA/UOr/klVQ5D+zCpyA/huroP4HwqWR/y8wCur/hzz/fynJf1IaZf5cAu9r9W8I1/is+T4gVawGoJcZjlfz3qPdMQH//A8YTzkmYoD+VxJb/PyhTl37smFWsSG98iRlj7t2iW2GceTOb6vc+1LfANhjS3Lf35xz1IKzVblqy+xo5DZrtpV82aw9fGs5+UTfzPMDZpnkqRq21hbgQ5VfqVAFh+0t4KyQPEzc1hxeqm7US+okdC+twvzLaZ7dwLmMGheeIdmVUVjJ6AP9+TYe2yzMlyN1QS7xwlU+0QZqBrNqxjAZUJ9rps5Mv3mdnI3pTLJaH5DyEH2FNmt8tGxVupn+x/keoL2833jJfEOGqzzcdopa2Uyq6r2A/DaZEzGcOTIp3HWJrGiemmiQHb+tz806vpWZC6946NnrTdbczN7KNiMLDC71jA18xi6UxEh8fmmGEF6z6n/Vwpaby6CmDRVtE5xI8rNWR10Iz01KEL3PG4S0eU05prfnxi8P7zk7dn8mQKj6FMzwHT6pBYzH92X+Yi/HJNdGo3zb4+X8sjXp3Tm0twNDiSoa9TFfdvJOD2XXe9PvJ+nujVBuQ/Tl3qZBsMvcjhSmdqYvVRee+lfwllzNwJK4WSxgZG/4mzQkS9KDVfMcoGfEW38hsxgj2deFDDFThi02lsuGLYnpQLiBAO2v+AX1TQ+3BQSOLgiiXzJztFi3jh8gohV5U7ieoqfyUQ9JAqpJ+j6j8L2M11vpimWfmlVg419mUMM9kzCWQw3vkqZF4C6dPkspyBiTSO5LOhlBY9jXwAjzS8Ovq9wMi03UdqiFxm7jW6YhoXzFpCqE/HqPA+Ib9sY0bobRb7BbtrvE14YRIycJIFtnnHMTjvgbjafJAL3WY6AXaZ2Ws0CmvzJuFR5joC5E3HTy19I4hi08Rkq+jLZ7lngw2nq/MYxBC+g+HjJoFEYTKn3jmHhk9DNg4qAqvep1OswcML3KhC3GcQP7fQg8Z/h7YDSMmTDGHdkXWoFjUAvz8IH26Tk72C9vS5PCGXxlQEOkyy9LZjuFnUfGeqCYYKeCbSk2zzn8uDJzwrA+8/aZMWe4qQoJ1v3y4SCutoDaJTEU4pCWtnA2rOoyOTzYBpZ/D8ADEwaQLy8XrJixYskgIbPt7HernL06UOZTMsAnxZTl8x+rFazNcmxby69JZjOF2xXGVkBglu7KYmOXqhdNX2n6skErpY1BX9z+1Y9EGgIxEplN16mo5LG+jj7ykdyc0AmKbiTROzvaW6RNS8wE6oTVU2yre2bN4BOhrNKJYVSax5nXKsn3SxwcVl6/SHDIUQ9Z0WSKzLqwoMWUtHF2nsw8GBPGM0wrFuWq4gOXLxAII+QUJ2CvD+b2WFl+wFbTLeAp2E+QOKKxTklmRYR7ZWLI85nN97J8Ru/k+LQccGDxeBRdg/2yK7MWXs3rbrhNDrBGaMA88kEWM9i7lb9iLGQOK34FQyG8qhv73olp+QN9vkdH4HJ+lZbC7DKRVT2uWt9pA+4iqksUXk98zHvHzqi4RsuNKjgFAJx8+BvxnU/w3VrrMym9d+d2QnzDn6956M14ysb3mX6aAvUnXJ9bcQOBBwRsCVJhG/irnAVq7S8QlSJJODgap+6CXKl1NRZcfQmGPbbclHlF7YtC0KnwMNTN1ql1NMHNmnk17h7OED+DHajRv9oKGsLWjgeORyz8Gz6vltqvld+gIx1NbXj3HmwN79L+0AxJcJbqLOPdqqbXXluoxrRPdnr14nWxwyUR0WXsnD/ggJqqNlOh1kdbxuMzQmhCLy8MgmlepvD2y9UTf+VWAk1sLeN9wX4ar8mXHj95dBFTOVJ0EP7yddn7BCKbkjeY2daSF1IFTu5g8b+WITKGh0HHJ/YS9va3ZDinpOuJJBqgHFkLWLraM2cvGJrfyCTAbrdGHZGuU4Pa6CTQ4b4ID68eD+/8tB9m170cMiSXyhObYFGbyPUY+oT2B5SsFsHewyUoeDZy8dkhZH+LtBb+8pT4IZGXo3UzlTgz+reCETG10/L1SOThQFDxE8Wv+juKM28sAzJ9DCLmDzj/WKauYsfemZyOiPGozItRfKHaQ1dXMYwLOvcuEzq6i7iFhLuwiKMMyu8h7IQioYQz017ysjX3pJXfjo9jpf0ggYsXM3C7ktonRpDk5xXEtzgo/LgPZWIS4W8fMHmEw0Ac+akI6+n8ReL7DafThOykPz9LsaAZdXeUWS4daoEI+jDVyve8oFtHIq89B142tCcjO6dqtBMtG0SeaHc1tsKjF5idnFtvNDRHHNCpeX5J3B1z/R5dJNNw2O2Drai3P/XUQt16nn1AYVhv7HkQodFuS0KSfblmtHFiZO1vLxiGUavf/AJUpxWyg/I+p2neJ7JaKthVJw7t5qfFrTCZ6CCl3PS+93KjDOlySmek3b4BbtUtxZW9B2HVco+jpTPTQUmAIQ4WBbRJmECUcdfm543YV8XPDIEJfKW8GGp5hUcSs8P31Q1KPeivas4kCx97naew+frytcY+GmBGvT6eetsVbb0p1EJBoCZSnn2Ux/VEacmB9LVDKfNx03zBx0sTVlHyPsZPcGnfgU5EwXNpV0pMchi2hu7y5Z46djuJWeQQM8JK4fRCMcwIRi1ZdwlfHAEFeINSJtYMUKgJMJzOGoalpRQrpaKZ/GaeiPUBAQHGjJH0ZX8qvzd4Pl/UombFTUCzkL5Cq0+Ll0GdKPZ+hnsVhMJG7asJcEWELfGLI86Fm2UTHvxoq+jkYiPu9CV/GE4Cq+DUIas9u0tWCFcaccpRq9at4yJIGjjf57cWc6kKQlz91hTD5WarN4sNsxKz329i41+MUPt4dYBC+LWgHVtg4rpm8AVtGKM8JAgs1D4UXs2uEK8ByMorII8CgCtB1OZImKoj7BNwCQMXcxyVAWOsXFy7+ZrsaYAnByjpwybIHH7ppCfBcQ34vOZeVTP63BqDdL/JlmzX8/oHoA5p+1y4JQkFCaX9pwCH16IeA7+M5rzC1CW/aEmhPQ4f28INg7bX8cURfzTT2ILdC9z76U4dzZGnV1LZVYSYM3PP04BMp2HxGlLV2DmSUuRH0Pn1BtF0Fn03Hjmf4cNoyfKdDy2a+yHfus4uAHWLptM8hDbXQXfV0peE7j4Lkj5CECYLdMPpR8WfrjvUOApE4JBsuexoyZAQ+6kDQaA8KTuS63UfuCAFYK660r/dxCdiT2rXa6x9n4YDK2jFoUi6djKQBoYzBYHS/XMci5m/ycsyjDyFWXqIXrYnMb40er/qPyxQYy8a/lRZmXxm04Rx6jA1qVdM+Fs2lrepyjlyHT4lFL7O/ezr3LjWad+OZq95dNjnPO6hMRB3xRVkxPveilK8kaKUEMhfU4L4Su96EGEFoUxNf5d7qID406kwLswMZh+99lC80eQFDkPDRTh1wWrCj5Iew/C+/c7YgSQYYodmiLvTM13M2mufrLNdzBX/ktI9Jtvmo/22IoRG38jeo+1rfxhLjOOWD6PyH/RREDAaIWhh5WDthnKdaQOcwruk4aBDKwO3XUAFXjLtgz0Q0QUZQVlAtN3DIv6gGBIBsfT5H5L5BTEKlnTgBwuTv7DNniaQ1wYO5E03tpXJjWxiKqfmy3qWgcQjStSOsfi0yAPDrBNENiGWBXicFSgWMKgK++vr6LeGvfcT2IRFtDZJH/WB8tzvIpW+3jBNNRJ90B8QmsTTU0+A+VluAi0xcLiS6s8F8UsKfrimstLJXoEAzvJ2CuuDbdhQ4V9YZuw3bLqPgp2+PQYWas8kmKSa35XTGJb4cVriykD+aq4ooFV044pmbndg5PVgOR5YqcluEQ7xgbWTeHpWqZji+SucCMKHu8Z9w10yCfNXaZ1UAGEV1u6JiGwcUhX8WY9fdtKfDmg1qDO7MXtwUDl+HLw2u5IVfRx6gxejrUIa4YsCnSMAxZUM+wu5sKrPCCB1H7ReIL0j1zuICKFXrRO+KaK0Wl+vGfPnx5IqohDhIw18KHJYLA3OLbtHzHKUOhbpJnZHcL3G4bvWLrkqXiOWhiyU3FAT8LAt88aedV5lOBikfZjGqoZK5hgq696d2Sr8u/lHn1csRrTc1VAuMYR20WiOZlt+61uKKr1287jpfnf9Je/6R17B97XQwNP+XRccvGWX/ve+/6P/77vNEONKwHgCepMl5Rt/xjrqaDh/QY38H/f/9e8fY2kLse2edxxy6X2YDbWnvb5FwXuPOm/7zRXiY0kQ7bqDHc97AztF8596ep2/HsyWHQvRXwL+PT4zuGVIu6cf7DZcnfqrB8ITWywOHm1dbNc4wG8wUuufR0bFYT0+I7eTsG5ToeWTAG7T3hjT3wkn/a4ui6P/+YnePz1R/Xtvz31gnom36N8RqoyZ9P6rl9/4X8f+j5V5vvXcjZmAGkS4zUWgs3MzSpPVl2E4lHK8N/sAcpaMIaVkfb8FhQGFD84w1cexZU0VbNlInUPlojfNZq9xwLARGjDkkQWplryKRpXnBFFVoyLkprtU9eOoKfRgy4++V7zKCcwrbfhP3Hq2zHmOXe9a62vHojCpSu3CXPgkVV7J9qsBWWQUjja/CnSAi/QL10Sd8mBNlmFku5KZD6sOeFQHH15+iJ9jKx7i+O3NVwzDy+bziw8j25qiGlzLupYxJ/fsADtTZEY2j6qE/lzbUYE9e/n5s8Ke17TzWqbH3/VQHfTB/Lpnj291VCR33vbv+q3U5J9xcC+WZetfIvmPWD0tDNPcL+jpuGYOnucajXtRL94A1wwrddIARvt6XkDgEV7COjl6rjmGk3TLftaBlzib4bRFlk7dPp5r/nkrO/vXW/3rrf7/660mJVPXYA6mB16rYZfFABA3sJCiZnJHc8syW/V0JnGGrb4ffVhc/jzNYhTGcyMF+1J+XbFnsp7ZR29BhU/BrzYdvLH77H0yed+p7+2VDwOlZJz6MIcZI0JS6dhEIsrvafr4MvmMxta+yjPS9r0lOXcBQ4sFtycy4bH8y/ZM6uf+KXSWvBtrPDmZr1R5kSm3zF+8MK/tmkQ/xyVO2QNL9+LIUTfD4oNN99p1bTHurl0C/XkbkeFeG8ghZvit3udB5STH/SBV6NTAitZed0hvvs5FnKKUKQGKrhTfxIYbc4CdzJ/qb/BU4JCgUs+ciynDGXVPQ3JN+g87JeJZ+ZZlxzcNbEp7XZIar3FVBCw/3Itx6BbMBvmLJQ/utwaPFBFff5JxjnDJVrX86L3WYUdcufQtlAsAUQOjR2BWi8xyWqOD1V8Gh4dOdFYiNDsFl5ZLwTa1rdWOmpHlh65M/jj6d+pDy1++fJmS8jOCyRvrW4IYcDRFdb3k+BfDi6yJ6AV6xMX+Wg1hpqIZSY58CcpUrfXxT1h1SpgGwuMtoFhW4uE8WLEHHy9Johl6xjjYrNMjKysyEFzKEjv5HIE5lwVAvErGVA35RxYoCsOKpmJCMqKOOhvz8iZnCsdzFfvQ/PbsuLzhwf7gvxXTSHyfIJLKM4YvSLpoQq7sYGYn64PadG6meALKd2inKRP1xoKPui50M4M8Bd6fnKF0uOU5PHykvDpcuMysQhw49btlte285r/Pplu561cO+PXBswDBuTtEdKr5elAmvYn60kEn/E21c1T8G+xnnG7Iu1eYJp1Xwssh6N0Z9ky2ZF5eapiPuHHkdE+mtmNBh8xsU71+8gXBYdcQgV76gW5IWfmf/x0a7J+l7dUWS1D0iSqOC1L3xTexTCj8pGgvqTA9jbRYhpeDujFMcDYN7W1vhdbY+4PH088qvnV44hcgkuELJjkkAUglJ42QxK1+L7svjuHF8P0lm2oqHTt+GZZjApGW4BOz8E0Qztbf8YNShCuak/xOxe7KTTMV7W8qvXVdOY/+OOP7o4acxZCehAYgXU4qgBnbR+dXkPtXowVRJxzZTbSCW/JlSGMlwOZN1iYYkvmO9nJdjQesApYj5WUYy5iEb8U0OMkSjI43qa0m8L+qP8AfYqwX0/2KM/AGGUF4AGPrvuzbWVvYJZMfn+6Ih0A/5KJ2CCP+So6PSVFakTH7mUYDkPIailcvjIFWsGIRiuoVPjD1l3WbRQ2AhNYaVz70oKXD+s76hAjwCiQyMZczOSx0+mzL5+2+Po/eq+q1gYQ8lVFJfYRBv2DKlachil3BL01xkYQ2cJMsOxn8qW0uNOn9vsOxUCJOJHNVy3A7m3MTmkd/mr+oz3+gFrvHbU9ZIkzTK48tGVeUkL46giKnIXUeNjG/04R1kiqF8UILb7VHtqlxxTDs09wMAzfwi3bK1u7O59yyfjYR9nLbZEvw4fMAPCIyAP4T3J0MiYckOvh1/uqiaqMs28CKUzz7U0Z4S+FaR+tis0t4H3ifkskFYJIteimRGyR7yBLt++JKXWxI0Ng3Z9jVUF8jk2mT4mU00Q1du18+8T92C3av0MD+SsJ6S/t8c/OW+vb8Sz9KUCqUUdCQz3tRIH7vJWZUSB42ExmRg3MikzZFW7inhZQ9RPk0OA87t/QPcDdovzozFU/7gltxMkRgzcF/omciDnj8UlqevvwLDtKvAkyVbrwOTFzCvkGzLdErN5XERMTfagWR154Xpp8jtZfQSjql2kuEYvN27P0X6o+sYegisUeNxiwiFSipx05h8csJfrR1oek+r8GfUIcxnnaBK/Md0u1y0zr9orJt1Fo1Y9F9U8LXN9PNhykp8Zv5eBt6RX1fH401IK9uFrrvva7J3T3DIHQwZqMVtSTRryGggSFsSr8EPZ/BQCUmlMFt8HoV4gjcZ4s5kqyHW/C7g6t2QjM1yj+oB85OlmaZJ5+WecGwg1Cwi0y9tSPSs8MjLPLvY8m0XRR3FX3nv1eF4gXCpIRwE8BAN5Q0lMXbBq2VdfZeNkV4IVgUaX1WtBSryfMj2C8ftXRgGCBqdzYvWuUmQFqHhQCTQDEXhYNPA0Ku2Szwu8Da1p2rD34uFiKa7UZzoCz5QgWFJy+PXOd4kPZVJhtS1nw6phK6GhQPyuaRADQlfF2tamG/Q1G1C9sPGaM465TkUqF/7TeCTMIw+TIpcUYmMXNxeOKHje5tmlztehOZZ3x0cSykaVio+WzTzYC0o3yxhm3/kMwIbHpwyEThnPmN/rEMTu2yAAr2hTo92pjUsM4NY4Kj2c+pL26kapoAKBTIi5XphC9e99hMgPr38bcInxiGJzj5GZQzPIynuPCmT2JU64y76YSQzFyWNGuK9zuJjgJZfTRdQg5L6wbdPsqWNRB/fAlm8KToG07kQSgCxm+WjKahP0FHSamAX6b1uPnxsvSD0R9suvtCfqoag3cR0DTTBTP2yBFsmqG1gvmZVnXy7eS2i6kEYIPtAkQ0Uyk6z4t8TwR6ytScXDw6V2a4JfP1mJmjdsR51OGsAvrw5zrX+SfZ5eFty46GBS9pOt4Xf05DF3ZKzUBc2JiPBrus0UkeMDwyOBaRKa0LWnZy+ykB4+xAZNNe9HuSKBp0248SNW/fkNG4qOLZZ/QSG+mGpnxtYPd2Pi2QY78DYyPGh/n9uR4tGki0K4Xh23R72iuvnVFzvTU1XdqB3xbz/HzL528hoaqmJNgahM0etz0kbwrP3EdH6s5SgFCA7kuiaTrfjHRLD/nk7RR/8qC7knVNu4ZEiIQtHHQE52zcZUE7JVavzrm0gHRYm7cfzVt+HwUQBzKRGcX3sl7bVW1N6pEaGudVoFdTTb4kuPglV5Qc2juirl2LO1vgHUTRipNlVGVMFHosj3liGgnQ5JznG51KaYJYTmeGenhI44Gr+eYXZHHSoXolqDKBD/9adveEVYkIUKIF/a3VHxMle3E78QanCpmY6bDqkBxeJLUqe1Mcl5cp096Wpt33d+pMyjOyhEGbavIpsSmyUULndqzv6AbH+6D37FsagkozipbaQpzf8gaKV7fSdBmxoBKvQJ4IbXXA7I03cP6rlt3DQVv5sxbNwG5WdpNz3vgwif3iwBJ6qJZH+q/3997mzCFQPXwf1Lt/9ithgZ3tVNXBVxg3KhnsiMg+ObNoSz2hoWT9e/ach3CucjC8ou8h/LnrzeQAGxGYzeO1V6Z7+k7KNCKdqGQk8jW+Cd8iHyWhde+LclI5PmJgTq0+YcO79ISAurpHz8OJrwuDc4z+NcYHMJgHbxGRBZEqR2ODb+NO8VwHGgUGszjOUreaatYa9IWu2nNE5QvJyvMGwlkE7KNmm3wyENPdk5SshXOZ+/vrPNokPdWvFBofUPCosrkH406wGRFr49+EpMdgKSCrv3luDHC4Em/2kTLcsuSLG9t0+uhalmlSyHGuRIRVsz91cEqmb3Hfu+6tgHRfyZim+UwzQkzG75vEV+MnpXE0+sM5ZY5pj+rIrUfMtvdEICKJ9peZd5ju52EEz+BUOuZUGMAk34l2ZS7loA9SFipTzvTBCUkfrIaE27ryJWERr9w38oNx5taJfZcazrzaFRSZPJvHHQTFkI7MvFpK1BXPQflnFIq6AjVSgD/bfDQKiXGMhPJfjkfjJb3RD7IlHVManF5Le5lH9MMyXHcB8mcJtpp0pRhQ+iZ+31sxRC77aIAU/t5gDiV1f226IURL5CyLR3lv6xYZVQRe07uhu4093TfV5QQxIjglJIbKtpjrCRKmJXNxU9dy/KoQVAbjUIDbPdOPAFnRDnOEXV1pOXkyEv64S64059I+ghJMLKqb6vyZ4/RXtedN+tZvK4Rtjw0TrlhZYMo9y5/2OZN9aRIjitAh4EOFWouolMRokFI9iLSBbRgEnLEDkg3x1wqTGST+R5lfqagydm2gEBlmF41jLKB05ktb9oi106xebb0ipQyAOmlIKAK0i70wfAV1CTZbHZuin40HgOn4Juc1Wry7+zyM54XM9jLQb27ITTEkHpD3BumJBAwv6fHafhV2rLy4velrtx9CHVliNdFShk3U77YTJx403Jfhvdfh2IS5hD+b7eIOKeQ9jg/jzCHv8etnebRMLditBHyrzz88vOKjnezS8wl/63K8punj+GAJkS7eknWv0snpLM7MAMpW1DDtiVM558X59hWRjfE6uIlsRdEPPZWRDz1TEf5zPAgGEV4Ob4PjFdIFbJIXmE1dvYBRpOE/9oM4X4kH5ctbcDuQaUPI+bD/PvLn1Yq5/HqEI1PZg/tIvvkjY5psP/JScAqAcYsohYDdxVZfHMu+am6yPnZVg0A6X8EY2cex2vKAnYEbdEYo4Kk6zAbT2UOcHTNb08D0aG/NDTfyDfWeRG8XUWLr0RX+EdGQCBgRTcZG1g8evzK0mEHmmEbc6i9Falk7gEuIQD9W+yD0XCnPnRYsf40umFYthDZgQIBgk8lNoG+H/KbnQcYOfuPCF6eY2/Wlqr3HXmceqD6hly3SOz8nNoBfZI2F4Ss+w1g6iW2BnFftG3rngADA2KmRc6wUiTQKjS56lElD4vb7bkxbDHXxdmSl6oCm0vTaT7jTXo2PKNSjn/QCDqQKPtjKGbqKqUM94AjrFtmBa2vauNbPgyQTQBlTn1CmmtB1hq7CKrDtJythkqyCcVbEaGquuhaiiVTFvQgXSHnQFMmEBZDRBP0K7nTxLbN/TwkOf2aEz3z9MoGf0n993vGEXGwKRpLxgmJF7gpH9K7xXCIIb+Z7SG/hDQJYPx5TafS2zGyc0KrYJW8GMoq5uVPVCy6KyjvP2HAq0v/wseNAHthO1o5CZ/TM9iVp2K+pbFvze72OA6zqgJM9jpfHZWy75a4oIit5V1FZhbEusDSMVP2hMZvnmnUOXy7fL3uubzU+v/do1d9wkM0Kd0LEmRDEN9lfuadr3B83YFaQJkiCLIwvqkwplKAwoAlEmUIxKxFWamayEas/wP0BEwmx7eE06f0hE3g09LmZ7wgxKtTcTwB1ZcAHtlGSdnWrmT3D4yr2vagmtpTY4Z6u6oC9ooj7mxOaEmi8xXrVQ9iHQR363eePmKlaRR6eD1oRzmMRAvY0CBoVcyckwRKK2CvE1yh8cYLyghb9+HEyySFSphEQBycQnbLJFMwg8aduqwIWnEJHKrd+LPlt+3uGXb4hzCo65AZIoZahy3RYvrTDo26+4TIwDcF/F1mau3PY8qFqBrHw8KyglFVGB1Y3d3898rgF2TKuepYIZATOympeV3Y6EGs/dUkybVvTOXeRJVoH9txHU5a2n/2WkU0gpaTIsZLftSrrVs6Mduvh+v7ska1lHh72fNKQisHzVcTLJmswQ3OA2bMehlMa33Gay68q9EgNnWKh28trZaWavwsozgZgWbCIMpt7QGAb/UtJEdblue7Er97uvi32jT5yfD2+B7/HYOYaDZCB9/0FxfQEgsXzs4vAihSOJwjRpzlr/GV1ObCG0ccl6SlWGgQaiASA4EDLpmhDiKdsJ6dYlJaewCpehnjeFIZbGtqow7/+QH+rg3lQSsbwNlZe12dSPaw33Mp7l7yjVs70qZgmlPylzNsbH10lmPl9eFSMMqkEjiZtNMhurZC0N2t4J/M5ct7lPWt/edpf/lii/S7Ih6gipCQUyPK7ORc/YLcdvwQcz79vtXluzTGs8ICbFf04Ube6iTUV/nDYYJbh3C5vG8TMcRqLfrG+n5sXE15COOo86Xa3xnmA3cBii5z8vKA9X7qXVJnA7myuDtYS0syEAYhJaOFHzuHRDBK5LklUI0A6eIMbvaYGqprPgFkvJ8wHtFpp0i27V3dG8wEt3xrLG6dU1eYRHEKGRz0VNUYPbd+0WKHbp/sHAMY74qk9JyHflqszG1Sp/zL2vomcL2ZjBAjc+xVs4d/MiDWHch0jrQrL+eA3g3owCNDVnlU4gd+gb1SAwKbQD2eN9a5pjPbdMx9F5kF2YuuZTAS8HJVd2TZmNyw9yDFxt9WEDuLpmtSXgymaWdh+IA/nSl88X2oyUKmHLcQNMgZeTZDis2DqLx4JUVwQtszxr+qBmSl3ErobFxnymWnLoFH0Y6SUILDqH0aFv4UPWtvLg0ienaDTZ57kPiH0n7TrCzhrt+318dc5IqSw+HbuV2L8w7YjAEJa/b5ZmDktsLI298hwr3pkeMjEMpRXX5E1+3PIRpBpizU2p+wuVnnE74NbTisI+3Nc+2Sso4RaYUZD0UtN7NaKGajfvYkZL5Pq5kfLfS3rnGRJtxOohsIS91NT7HGN9ARSk9yA12cT16pFv5qq/YF9hU4CCkgXN9wxMnx/N4YvBEeVHqS88TXwMhSvDzGNZSbxK4zRDq8GQonHfvUousyjGPVhfd9qQ9V+ACWnM3YnsJfCrba/FvesTGmQ9QeDWSmggyhx1vp7qyWib5N++D3+s77YENBzVKDnqIAl28YNLxIw6UV1jwaOBPAMSFQe2terjI2I6XkY+HcelKnMksGmuZTQHCo+OlQWlErUID9Xhoho+Ha45mFX2OuRA8bnFMyf4cC+7Yd+go9pzcLr4X8meLbkUezr+7DDVdCB7f9f3/3n75ajy0st92DsB9JD8uFUcufy1HsysTFF+YBpPNng62H+VXe22vBBh/EQES/eSS1yupYSot3ZJBpgE03PDxa+cl0kYSiCMKbiJaAXvxZNyjlPViw3pE5j8/U1eWdWekdLnyxNtGUesZc3Fev+uCGJUfLNrZMUjEPjm+iUcGFM9tk7ldmo16MYBQoIRBceevLz0kSIxASF5Nl+dyFQuGm94ERD83TeSeVshIpI9rxap9sHpsqwTJZjYbUqkSQ/A0J3aT4OdmAdw/dhCmE/P8JHT+r3rSIAYLTAUkU6l/YlSQtBKigdKlvI/Vcpksp4T0ihw/tr7LEw6UZpHBmwCX3zpdHRQJuQZ7jx+jJpHS6MYRf73TL9mfyVlTF+xunvhqATxLfEaFlWsaWOSXqDMnGsfMWT9yhk9NxOiL9A5B8phCb7fZUlCIkyYvS4Vl8JgAtPYGK4xh8AnER+fl3p0H2LEiIYNq8tEqjRiwFYZrjRAQwXf3kA3k65uFQgbNlbyRupUhlBPAayyfHyRAZrWysfAyBFC+nS+QI+574NdPH9ey/yXpkfXTaxUk4TQRpJdhiLUlJXdCeQLm9/VbO1/X7jZHC9nNofr2RU37LoLdAf3gtbqwP1g5lo+iN+8e4N72X14DZaIzjqrtp1JZE4rbTF6pBBo6ykBRHAvRL/Spk/AMTeT/GRrlbNK5uYv5dBa/BCzbqeUHeBRD7FCFMDYL9XiHL49LMw3yqfzwJxDJbgG33u1E5yx59roc4qkzcZYmREROPszZbnLMGpuAJ29v4S1GrFjzAwE8orQKpnYczR5aZpgEenqpMy2olMI5gyL3YWrCzyNBPBR2BtGiqG0HCtaGtdR3MFal0CbNSJvaerAQPLsW8HKw7dRYV43PcXfT1eVLB3vrTmsN7p24JAx5jHRuwTwXghqYGsEEUvg0sqpj/h87eidhzw48DKgbJKk1uOaTTQDmppd1aJoHBEoToQnle6AfHzLXwomgr1Dp7LvpEkcAaSXZ3m7C+u0peHxytI4KlZzuX3VYFaQuxLCNNyMOFhd4Wur1GuI2jyctO44JYuDsdcJ2lfRWGVnMChnGU20ARdEQrgvomkVztrRkbcgRjgdgMOmB0m9uDWvgfpQ9L9zttfFOsFRkICejt1Zz7QLw2nAumj3inT5bK7m7cCI4Yc3IbUvfugRUYppSPAFgLvfr2lG3WypBRo0MI8OygnMr393mDdbi5kuf7VQ7u1j9zn+2KUR6Bz6fLlA/yQwZM1umT9Bvdnad+JNB+ZV/qIS8Aa9J3HrfLjmi7TRV7QFz45n9e8Qjs4KoZWn2AJ8r0J6B8Q8sF3krPIH53Tbb7IMhecCBZ+yolKmL/iTbySmTjnb0KpxXgC543TAF+mYcXoDuVqjf8swlLR/VLfgzUTSmteoOCOLWTM6Jijk6UUNzHUdWZjhZPF9E9dN0t2VH3PGQ5ZKk1RSBwIXftEZrIEiWsicKh/tU/47stxGM/UfXeCiE497GT/r/auY9l1JTl+kSIIQ5glvLeE38Ebwnvg64XmnSdpMYuZUEirtzuHQQBEd3VWZlV1NRIseQwJjQ2DOl+zz+gqhh3siOedd0rS8/PQJmZfjc4xgLXM+aM2ieiEJsOlTSSTk+enxdOfk8j40B6QrG+q3mROYyfGKGHzYqpNDGRVH39yEfb6EoPYSJK5Eops9xUkkqFBrH9lHbVMdnRJLVIv1ihqSX364S5alGcdoi8QSTa1HkGrEmkjv2EhkDRKepwvdqjXZQ7x8BYUXQ8+YA7ku489ME0xGb1sv2GkHZwBziNsNZPtDXXjEQOt4pkm4yFt5yYp4fkP1gpNpHxsQwSBiLQtyJj7vFQd/AflY3wTI9HCb9nahvqb3bYWZrvhQLk5daTbZMrD5CfcO2aPdy/V39gD0X5Ndz/imWt49GtIlzXomKuPW/5wp6H2fbiPsLH6s/eJpjCSIu8GT6ugduyUoN1tDFPlsfJnMhz3tJfmMwuvAL3aCKrFukN8j/MCAk3iIYKKmPQ7Mw0Mz4/7oFUmuLGUeEsRLJsu5bePwnLW9SXUupg1fja8kHc9OVKMrUopaWw8KTNX4OxnVAV/u5qtJXEFW33QHx0gC55m+jVn1ZcExag0lYCzboCjUr0wmQcvX4q2S+KFeMVSmCMFsQjCYU3WQdlzeZDz7nJlDAppfZounctarvH+zsN1VbS2gWokFWgfdb9KJ7VKjnFB7G647uo8dhD93JUPa0lWzRy0zdaAHwhADw/0w3fUMmT7mPjUFDKEQtxFmQQ/d9St5456er1LYKFz6L2Tubn1FrGkmqiRPcwML9gdZNU5ECYJU93Gjoo/KdB7k7LAfT+EWr8yCtjoGF0A4ztgOi+CUY23kw+aYyMKCFvkXXIBDI7XtQKwTLVI7fY7v7L46kAChOQtlmnl2s0XHBs+9O2QqEKphCmYdKG3G+2FwtnHDQT9X0pKMIsMXf7sR3CIe+OrnRJQ9jtv0XC91ADO2+71EGTBvX07QhRJpxmZATyQ+R2SIdztM9M6lgmUuGCMcfOKCwch30jKkB8nGewFYb92TxPkMQkWRNUTIOzPqblDEc77Rvgogd80geq/RjIY6/1x1J/vkcK37rTFswq11cXs5vlQQGb/2G4BQfQrU0UXLSbwCzrP3C7AfgBwKMpeicGi94xa05Su2clSMyfykmT+3BmEAt6LZ40PFmJnSZ6D40vMO7ni6Dy+e7z6sGo0WYDa/vrLczp5vjs5gSLgVFWRgItxLooApkCYGqAuke3Ou9+3cA38fn+T3hjy8QUrmIm8p++Skga0DCST/+oRZAcTP0OsdRmET57/rlGjq90T/rClbSlapcKhHhVvxJw+cxdyvWL+lQZ6nW2NpeycBFvTMo/03D0dzbL5DyCOWehv2MjU8RRN1RUYZKTGAs3nQ+3vjqdrR4Vgj2yP4OaNlC0KQWe2aNHSYVs8c88MQcvmpXPy+p5tRZ8PlbmLNW3c37biRC8M/gNyXlamMdt9J9GMOEKnCKwIuBNhKerc7Ll29/OkWTolLXxAD+1blTBSsOVtQ+CJ0/dP26vuBJ+QE7RyjEIlz3b2vMNtfOK/0dQspU16n0o0voBd5TrhU29ZaF+0F4HiN6589RgdGl95+HwSFPwuTt0ZjcRkZ/G6k0wLHVG1Q7i++gQ4gIFP9oJljwqtLZqlL7K9MBB1Eed4SqqyQubv6fFe4xM7VgUmk7ttMD0kLRSW6EreAauzO33gNtaegD5M9FcThQkhmNQQVNyCBgRKm2Tm+ZGHZ+qgxEnrtsawtyt0obZg6X4dcZJ4bXCyFIERz6yEzLV0OOnnwamHg2+mYUZvLGSIL3dz25uEbmj6ZrQB8BMEQMVdy7oKC3NHGOGmWn5n0wb4V1BdtUhJH1bisliR6NctcUemWAjiMcpWNU579ATbLPzQ+M5bwOeQAdacP5TcymDErw806yZd0HtwuBlatDNiLYNo2ehfb0jPYsHXXu/Y3D3TWHQJLsi5JInZS5GIvF2Nz0C+bCdBzCge8KYnyXRmeMTTsbp9OBTXKF0Upjh5e8AVvf5hp/fx0iB0TlScTR5LvAbgW9UaAuzky2oQgfEKjwwpGG91NpNWRYdXL+pq8N3wlhH7+2BLsXvWiaNAGW6AAM3rLuZCh2TImfRiZSySVG22THUHn9t4ed1to8c6283OMpw17XxV+HskxQx3fH7/v+Z9Amz5rKSvJ7n2q4Ds6g6HVF6mEOiRmXzgVKsvwoeVSgMlbYeb55fQhrSX1T2rzBtHSbmnvCHPtJ4RULZcs8Nflawoa5TUn5ou/5I+pWu6HGcIE8hdnzgDOV9ChBUmof6RT6qD+VePWKc1RdXFg+W9lIKadqsC+v0zcR8y7eWeVmZ4h8ZzxxSD41RoAuKLyBNIJfvvqSO+FPpsCtUWkB4YmkgOXJ+sd39oS3FlSVM8+1uK7Uc8toNjMx/MtYI4WOp+0K6Zx2VadohAgoDnNkDs3E3EhCDAwW6giS2AjZYwCmcP8D9/mrEqp5XwNUqutFFn/uMtidK+bmNXqcQg3knSmDqJ+0YXG7mr69l8WcmzaKermHjj09RYxKtKlELP6nZkLjwqtwTxW6pFH7zqqB5oq1deP98IxFmyfEOl+QWccU1bkXkHN4BDPNd2DMNgMvJI6eEwGoK7t/Ggkf2FWiftnKJugFynN6Be70jxx0Hp0xQeu8048Wj+FmLwGefWGufSRJhdbTsoCdHCQW4Vnx97+UHUQOCe9UfRmkCA6U7mGhOsit9dRLCpRdazgO5PKtlSSb1OzqYGOHqVX3dWaP86naln1bHwYf1DWh57Q54h+Qc6mu9uR0wH8UunekEJIk+LC9RA0k8T18385LZKPX5OWzWBumqdxOHh5wXlIDbjVAjAOiZuSKg4qG3G7tbzfYW9CUvHlSzSpntYxAu95edjiC1sIpiPZdvwKgQjd1LSAwwC1Yr1IwQ+tWtpxsL6L/XdnEsBYXdYmng0qF4HcisccOzJe23mFyDUNMYvt83XD31dsTRujbp6r7glauEuZDqEh6aDJ4YDKmV5/2UorRePUAgwD3Ia7JF6gBCPoyeybKok7BsBs6rHPK1bt9pnmW/c7zk5kvs2Vqlay8erMaxBSzCua2amZSc1SAc/O3ulSmjit2tDTSNabcgEjCYViUUZ70qOLvWugOIygb9k400RiaZYoLBlt89DW4f0vuN5C70Famy9/FUCnudeHctu54Y1sRfIs16N8a1ew4fREk8DtURqZdsAzlGswHXMkPGhva1HSaorpDGR0QBLaebaj5acyEHMOUcku43eIYFrtJ5FXgNdUyVZLzOgK+Vq9+1+Rxw0OJsA9+nbTxFugG75FXvYRhvzQxSucYeYxZkwuHvLqpcBI8FVyBD7GXHN7QuGlpYu0g0bhn98fh5zExrOexTbloHuw6ejKw1DYn7sMUxh/QY/H4EDDeMW6HxpmvQ1TcKVXc4GVScaIy8d7M+n44hZDufm7+sozrEmlTnJba2LwbRmoFBqezS2iOEgMKj1wUZutZDGzyiAWq+X+VJM/NOpgEGHD5M7rJp+0EiQ63AANjXD4YrIStR57RZcK9b7/jCsmU+y49I3Ndn6fPISd6owW9hZSIfUDOz9KJo/nazpkg/YCnBmQtZoFS5QlOTiR9kVbSHC6LnXKtV/y9AZYp0SM+Jfy8xnD/cHteKAxj/oW0g3ig4fmpmvt3/J6lo/o2t9LOnT6I+qYUtQXf64gu0SVLOhKoho9cdDWEvvicFhCQz/GOogE1IV85ZhVBs2TwlQ3lvrdeF4+Y9sBL7nnE7sCPD4sfVa5izFk1Na2lSJ2zxMXN3BDAAOParx7MsDRWKFyC+ntUqL4q5vSXEftFHkIesJdgu8DcoNUBlL30H9wrfHY76b+ytR1AA8Ec/w4jWoFD/3HgqDdy1+b6qeQ+9GDP/ZXfxg7Oq7DdaXG8aQkqoLmiwQkEN1F24yQ4OCxf2Vw+z+TJelhxHv+D4EjBcZs9YojgTCkbkqZ4ich4dP4fOq3rLnQ/8HQ4mfgPSflf4J8oO1eBPsMzEjrkvu+q1i/iPUZxFiw11U+HNB8KNadZSxBJFxqAwNqF+WAJyRcz6oM8DtmXoRpN+hZCnUAtbs0jK1E32lFQiyE8RU9nctEH8ejxPXGqc2KETUbIQOPHqk1jBJAU/QBcy9wxcEqofpa28LQJTeLfcohFNJMZ/FGJYK3sFwiYZndx2pOVl3mtA01scKrNpYuZy2kKScdHfu/3tHS/syhnONb+V1+99+oQiaPeXSWWv2k+ot2ClhgqwTwPnv91sCLmGCbK8Z1a+pVyKmaj07AixCCuiFGppjydqDBnt+OMYDFj2d15kdB8crZDd+ZcFaKdzctJFWT6NU0maXcVoz5yJdSief3oWL7yLA8pQcFM7xBv/M8ImhKFuRiMf6WGtyg3EB4fIGz3SdvOFGdw6hXQ0iH21LEg7Flh/zsN8xMmEv/k4XHyZjYnoHSdr7Q+yvkGmcU4ZwBjWjzys1uDQgFRvLXa5ujX5UFPosIc4Qm0PqO5cQQuN3nMDNZq9XcJicuH4ZhmW+ubgon2XfGzvZSPthle9kkrzdXPxXpeRXheu5RLAO5+GqTzmE4+7zFCbCvcctDjf0DkUDqFffT1uiMHlSPIqR0jkChNNZz/W7jWoOAjbjjBDZjMI+xZjMiYm8YlVuUz53joLmTWxzimiP+4AcDou5ImqLspcCML9B0R6IvBEV/4Fi+5GLNFgfUKtu7FLcpQWioTJBvyqTvAMjfLw/HLUgVCkEPBMpN7oTiPsC26qUL0pxMYoaJJltgUiE5wz7HxMA3SfKfCQ8WTPKyufBrQWylHl32l1frlO95m3SiBwHy18tZN2YKRhEtafbWQ6rlOgZjOT8/fLNn9yRRNEi7Gfx2GzCXdGKgYNdZvPb1Cz+sQJ5kwHxvb84X1irjgn0FNEV0o8NyMIHYDip8dLTHlMv427QJcqgKFxDsMbGs13fOMlNepCKdWv4/UW7Vk0doKbDRu3TVk5dDEB+troRrCwc8fxseGrECqxZIc3IHxNUPJktlsc0jMc40PZ7Xq5RHiksYL7Dm+qK1JaYlztQz1SM7wlRLscjhGqtrJQ2QXWLCft3yzc12k1BOtosJzGUf5Tl0eGyLTekoZxet7O+v7kQHms8c0YDBFRMwXWhmlEpzUgfFuBZJRm//jrbMdbmJx/flalve1SYXUx2wvnBX9wIdsnxHKwUiMUxctyJPu1gRBijeV8wk4NpX5Wq0P54rCnDTdThGkOueqwzudNGbpMB14s9UAX3lpef5kCNon2UU7CDTNCtBXwIEk/Zs6IZyhJLWbPRjzhgK7YFOLHrVJLCgJ7kRqc4fPGuGu3BhofoDdWR2gBxYfm3g6cT1sR23LFzI/jrtxGAuG/LsARQwmTHbwV5vfx6QbqUemaD7No3S9gD9Q4Tsm1FgE0PYrsAm9ytl1yKNk9Bkpmwo5uXD7EcdXCSpVEzTZdyfnkgSEZ96cc3fICmvXDr83jXEqVdsNsr0Aa/ADOj/rE/TXmBfb5yLj3Y8vfVf1/999V/X/1/f/VhEmxJOYbhem9N/f46Znxcz7CVNxNK0l+tN/93nUT/47+u+at3LIb/kzbi0D/rJgqR/34bcaCyHk31P3vEgpaq2pDl4Bv/CQ==
--------------------------------------------------------------------------------
/doc/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fasibio/GraphqlDockerProxy/1619d41da6a10bacc99e011d1f0d1f33f0a36866/doc/assets/logo.png
--------------------------------------------------------------------------------
/doc/assets/logo_small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/fasibio/GraphqlDockerProxy/1619d41da6a10bacc99e011d1f0d1f33f0a36866/doc/assets/logo_small.png
--------------------------------------------------------------------------------
/doc/diagram.wsd:
--------------------------------------------------------------------------------
1 | @startuml
2 | actor ClientApplication
3 | collections GraphqlProxy
4 | collections SWAPI
5 | collections HelloWordGraphqClient
6 |
7 | ClientApplication -> GraphqlProxy : send GraphQL Query
8 | GraphqlProxy -> SWAPI : forward to Client
9 | GraphqlProxy -> HelloWordGraphqClient: forward to Client
10 |
11 |
12 | @enduml
--------------------------------------------------------------------------------
/dockerize/buildDocker.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # $1 = version
3 |
4 | docker build --build-arg version=$1 --build-arg buildNumber=$CI_PIPELINE_IID -t fasibio/graphqldockerproxy:$1 .
--------------------------------------------------------------------------------
/dockerize/publish.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # $1 = version
3 | docker login -u $dockerhubuser -p $dockerhubpassword
4 | docker push fasibio/graphqldockerproxy:$1
--------------------------------------------------------------------------------
/example/SWAPIGraphQLBackend/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | swapi:
4 | image: bfillmer/graphql-swapi
5 | expose:
6 | - 9000
7 | networks:
8 | - web
9 | labels:
10 | - gqlProxy.token=1234
11 | - gqlProxy.url=:9000/graphql
12 | - gqlProxy.namespace=swapi
13 |
14 | networks:
15 | web:
16 | external: true
17 |
--------------------------------------------------------------------------------
/example/SWAPIGraphQLBackend/k8sService.yml:
--------------------------------------------------------------------------------
1 | kind: Service
2 | apiVersion: v1
3 | metadata:
4 | name: swapi
5 | annotations:
6 | gqlProxy.token: '1234'
7 | gqlProxy.url: ':9001/graphql'
8 | gqlProxy.namespace: 'swapi'
9 | spec:
10 | selector:
11 | app: swapi
12 | ports:
13 | - protocol: TCP
14 | port: 80
15 | targetPort: 9000
--------------------------------------------------------------------------------
/example/SWAPIGraphQLBackend/swapiManifest.yml:
--------------------------------------------------------------------------------
1 | ---
2 | kind: Deployment
3 | apiVersion: extensions/v1beta1
4 | metadata:
5 |
6 | labels:
7 | app: swapi
8 | name: swapi
9 | namespace: starwars
10 | spec:
11 | minReadySeconds: 20
12 | replicas: 2
13 | revisionHistoryLimit: 32
14 | template:
15 | metadata:
16 | name: swapi
17 | labels:
18 | app: swapi
19 | spec:
20 | terminationGracePeriodSeconds: 1
21 | containers:
22 | - image: alpine #bfillmer/graphql-swapi
23 | imagePullPolicy: Always
24 | name: swapi
25 | ports:
26 | - containerPort: 9000
27 | name: http-port
28 | # readinessProbe:
29 | # httpGet:
30 | # port: http-port
31 | # path: /
32 | ---
33 | kind: Service
34 | apiVersion: v1
35 | metadata:
36 | annotations:
37 | gqlProxy.token: '1234'
38 | gqlProxy.url: ':9002/graphql'
39 | gqlProxy.namespace: 'swapi'
40 | labels:
41 | name: swapi
42 | name: swapi
43 | namespace: starwars
44 | spec:
45 | ports:
46 | - port: 9001
47 | targetPort: 9000
48 | name: http
49 | selector:
50 | app: swapi
51 |
--------------------------------------------------------------------------------
/example/apiProxy/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | api:
4 | restart: always
5 | image: fasibio/graphqldockerproxy:rc_0.0.12_19
6 | expose:
7 | - 3000
8 | ports:
9 | - 3000:3000
10 | networks:
11 | - web
12 | environment:
13 | - dockerNetwork=web
14 | - gqlProxyToken=1234
15 | volumes:
16 | - /var/run/docker.sock:/var/run/docker.sock
17 | networks:
18 | web:
19 | external: true
20 |
--------------------------------------------------------------------------------
/example/developAPIProxy/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | api:
4 | restart: always
5 | build: ../../.
6 | expose:
7 | - 3000
8 | ports:
9 | - 3000:3000
10 | networks:
11 | - web
12 | environment:
13 | - dockerNetwork=web
14 | - gqlProxyToken=1234
15 | - qglProxyRuntime=dockerWatch
16 | - gqlProxyKnownOldSchemas=true
17 | - winstonLogLevel=debug
18 | volumes:
19 | - /var/run/docker.sock:/var/run/docker.sock
20 | networks:
21 | web:
22 | external: true
--------------------------------------------------------------------------------
/example/kubernetes/graphqlProxyManifest.yml:
--------------------------------------------------------------------------------
1 | ---
2 | kind: Deployment
3 | apiVersion: extensions/v1beta1
4 | metadata:
5 | labels:
6 | app: api
7 | name: api
8 | namespace: graphqlproxy
9 | spec:
10 | minReadySeconds: 20
11 | replicas: 1
12 | revisionHistoryLimit: 32
13 | template:
14 | metadata:
15 | name: api
16 | labels:
17 | app: api
18 | spec:
19 | terminationGracePeriodSeconds: 1
20 | containers:
21 | - env:
22 | - name: gqlProxyToken
23 | value: "1234"
24 | - name: qglProxyRuntime
25 | value: kubernetesWatch
26 | - name: gqlProxyAdminUser
27 | value: admin
28 | - name: gqlProxyAdminPassword
29 | value: test1234
30 | - name: kubernetesConfigurationKind
31 | value: getInCluster
32 | - name: gqlBodyParserLimit
33 | value: 5mb
34 | - name: winstonLogLevel
35 | value: debug
36 | image: fasibio/graphqldockerproxy:rc_0.0.12
37 | imagePullPolicy: Always
38 | readinessProbe:
39 | httpGet:
40 | path: /health
41 | port: 3000
42 | initialDelaySeconds: 90
43 | periodSeconds: 60
44 | timeoutSeconds: 1
45 | name: api
46 | ports:
47 | - containerPort: 3000
48 | name: http-port
49 | ---
50 | kind: Service
51 | apiVersion: v1
52 | metadata:
53 | labels:
54 | name: api
55 | name: api
56 | namespace: graphqlproxy
57 | spec:
58 | ports:
59 | - port: 3000
60 | targetPort: 3000
61 | name: http
62 | selector:
63 | app: api
64 |
--------------------------------------------------------------------------------
/example/kubernetes/swapiManifest.yml:
--------------------------------------------------------------------------------
1 | ---
2 | kind: Deployment
3 | apiVersion: extensions/v1beta1
4 | metadata:
5 |
6 | labels:
7 | app: swapi
8 | name: swapi
9 | namespace: starwars
10 | spec:
11 | minReadySeconds: 20
12 | replicas: 1
13 | revisionHistoryLimit: 32
14 | template:
15 | metadata:
16 | name: swapi
17 | labels:
18 | app: swapi
19 | spec:
20 | terminationGracePeriodSeconds: 1
21 | containers:
22 | - image: bfillmer/graphql-swapi
23 | imagePullPolicy: Always
24 | name: swapi
25 | ports:
26 | - containerPort: 9000
27 | name: http-port
28 | # readinessProbe:
29 | # httpGet:
30 | # port: http-port
31 | # path: /
32 | ---
33 | kind: Service
34 | apiVersion: v1
35 | metadata:
36 | annotations:
37 | gqlProxy.token: '1234'
38 | gqlProxy.url: ':9000/graphql'
39 | gqlProxy.namespace: 'swapi'
40 | labels:
41 | name: swapi
42 | name: swapi
43 | namespace: starwars
44 | spec:
45 | ports:
46 | - port: 9000
47 | targetPort: 9000
48 | name: http
49 | selector:
50 | app: swapi
51 |
--------------------------------------------------------------------------------
/example/quickstart/api/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | api:
4 | restart: always
5 | image: fasibio/graphqldockerproxy
6 | expose:
7 | - 3000
8 | ports:
9 | - 3000:3000
10 | networks:
11 | - web
12 | environment:
13 | - qglProxyRuntime=dockerWatch
14 | - dockerNetwork=web
15 | - gqlProxyToken=1234
16 | volumes:
17 | - /var/run/docker.sock:/var/run/docker.sock
18 | networks:
19 | web:
20 | external: true
--------------------------------------------------------------------------------
/example/quickstart/swapi/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3'
2 | services:
3 | swapi:
4 | image: bfillmer/graphql-swapi
5 | expose:
6 | - 9000
7 | networks:
8 | - web
9 | labels:
10 | - gqlProxy.token=1234
11 | - gqlProxy.url=:9000/graphql
12 | - gqlProxy.namespace=swapi
13 | networks:
14 | web:
15 | external: true
--------------------------------------------------------------------------------
/healthcheck.js:
--------------------------------------------------------------------------------
1 | var http = require('http')
2 |
3 | var options = {
4 | host: 'localhost',
5 | port: '3000',
6 | path: '/health',
7 | timeout: 2000,
8 | }
9 |
10 | var request = http.request(options, (res) => {
11 | console.log(`STATUS: ${res.statusCode}`)
12 | if (res.statusCode == 200) {
13 | process.exit(0)
14 | } else {
15 | process.exit(1)
16 | }
17 | })
18 |
19 | request.on('error', function() {
20 | console.log('ERROR')
21 | process.exit(1)
22 | })
23 |
24 | request.end()
25 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | // require('babel-core/register')
2 | // require('babel-polyfill')
3 | require('babel-core/register')
4 |
5 | require('./src/main')
6 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | verbose: true,
3 | testURL: 'http://localhost/',
4 | "collectCoverageFrom": [
5 | "**/*.{ts,tsx}",
6 | "!dist/**/*"
7 | ],
8 | "transform": {
9 | "^.+\\.tsx?$": "ts-jest"
10 | },
11 | "testRegex": "(/__tests__/.*|(\\.|/)(test|spec))\\.(tsx?)$",
12 |
13 | "moduleFileExtensions": [
14 | "ts",
15 | "tsx",
16 | "js",
17 | "jsx",
18 | "json",
19 | "node"
20 | ],
21 | // collectCoverage: true,
22 | // 'coverageReporters': ['json', 'html'],
23 | coverageDirectory: 'coverage',
24 | 'collectCoverageFrom': [
25 | '**/*.{ts}',
26 | '!**/node_modules/**',
27 | '!**/vendor/**',
28 | '!**/*.d.{ts}'
29 | ],
30 | setupFiles: [
31 | './src/idx.ts',
32 | './src/jestlogger.ts',
33 | ],
34 | globals: {
35 | idx: global.idx,
36 | },
37 | }
38 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "grapqlproxy",
3 | "version": "1.0.0",
4 | "description": "A generic Graphql Proxy",
5 | "main": "index.js",
6 | "scripts": {
7 | "precommit": "yarn lint",
8 | "prepush": "npm test",
9 | "prepkg-docker": "yarn build",
10 | "dev": "tsc -w -p . --skipLibCheck",
11 | "build": "tsc -p . --skipLibCheck",
12 | "start": "npm run build &&dockerNetwork=web gqlProxyToken=1234 node dist/main.js",
13 | "startDev": "dockerNetwork=web gqlProxyToken=1234 gqlProxyAdminUser=admin gqlProxyAdminPassword=test1234 nodemon dist/main.js",
14 | "startDevDockerWatcher": "qglProxyRuntime=dockerWatch dockerNetwork=web gqlProxyToken=1234 nodemon dist/main.js",
15 | "startDevK8sWatcher": "gqlProxyToken=1234 qglProxyRuntime=kubernetesWatch nodemon dist/main.js",
16 | "test": "jest .",
17 | "lint": "tslint -p . --fix",
18 | "pkg-docker": "pkg dist/main.js --targets node9-alpine-x64 --output pkg/app",
19 | "pkg-docker-healthcheck": "pkg ./healthcheck.js --targets node9-alpine-x64 --output pkg/healthcheck"
20 | },
21 | "author": "fasibio",
22 | "license": "ISC",
23 | "dependencies": {
24 | "apollo-link-context": "1.0.10",
25 | "apollo-link-http": "1.5.7",
26 | "apollo-server-express": "2.2.5",
27 | "babel-core": "6.26.3",
28 | "babel-plugin-syntax-trailing-function-commas": "6.22.0",
29 | "babel-preset-es2015": "6.24.1",
30 | "cloner": "0.4.0",
31 | "cluster": "0.7.7",
32 | "express": "4.16.4",
33 | "express-basic-auth": "1.1.6",
34 | "graphql": "14.0.2",
35 | "graphql-tools": "4.0.3",
36 | "graphql-weaver": "0.13.0",
37 | "json-stream": "1.0.0",
38 | "kubernetes-client": "6.4.1",
39 | "node-docker-api": "1.1.22",
40 | "node-docker-monitor": "1.0.11",
41 | "node-fetch": "2.3.0",
42 | "sort-object": "3.0.2",
43 | "typescript": "3.2.1",
44 | "winston": "3.1.0"
45 | },
46 | "devDependencies": {
47 | "@types/jest": "23.3.10",
48 | "@types/node-fetch": "2.1.4",
49 | "babel-jest": "23.6.0",
50 | "electron": "3.0.10",
51 | "husky": "1.2.0",
52 | "jasmine-reporters": "2.3.2",
53 | "jest": "23.6.0",
54 | "jsdom": "13.0.0",
55 | "nodemon": "1.18.7",
56 | "pkg": "4.3.1",
57 | "shebang-loader": "0.0.1",
58 | "ts-jest": "23.10.5",
59 | "tslint": "5.11.0",
60 | "tslint-config-airbnb": "5.11.1"
61 | },
62 | "pkg": {
63 | "scripts": "**/*.js"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/setup-jasmine-env.js:
--------------------------------------------------------------------------------
1 | require('babel-core/register')
2 | require('babel-polyfill')
3 |
--------------------------------------------------------------------------------
/sonar-project.properties:
--------------------------------------------------------------------------------
1 | sonar.exclusions=node_modules/**,coverage/**
2 |
--------------------------------------------------------------------------------
/sonarexec.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | sonar-scanner -Dsonar.projectKey=graphqldockerproxy_$CI_COMMIT_REF_NAME -Dsonar.sources=. -Dsonar.host.url=https://sonar.server2.fasibio.de -Dsonar.login=$sonarqubelogin
4 |
--------------------------------------------------------------------------------
/src/__tests__/__snapshots__/properties.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`testing the properties snapshot default result of propertie adminPassword 1`] = `""`;
4 |
5 | exports[`testing the properties snapshot default result of propertie adminUser 1`] = `""`;
6 |
7 | exports[`testing the properties snapshot default result of propertie getApolloEngineApiKey 1`] = `""`;
8 |
9 | exports[`testing the properties snapshot default result of propertie getBodyParserLimit 1`] = `"1mb"`;
10 |
11 | exports[`testing the properties snapshot default result of propertie getBuildNumber 1`] = `null`;
12 |
13 | exports[`testing the properties snapshot default result of propertie getEnableClustering 1`] = `false`;
14 |
15 | exports[`testing the properties snapshot default result of propertie getLogFormat 1`] = `"simple"`;
16 |
17 | exports[`testing the properties snapshot default result of propertie getLogLevel 1`] = `"info"`;
18 |
19 | exports[`testing the properties snapshot default result of propertie getPollingMs 1`] = `5000`;
20 |
21 | exports[`testing the properties snapshot default result of propertie getResetEndpointTime 1`] = `3600000`;
22 |
23 | exports[`testing the properties snapshot default result of propertie getVersion 1`] = `null`;
24 |
25 | exports[`testing the properties snapshot default result of propertie k8sUser 1`] = `""`;
26 |
27 | exports[`testing the properties snapshot default result of propertie k8sUserPassword 1`] = `""`;
28 |
29 | exports[`testing the properties snapshot default result of propertie knownOldSchemas 1`] = `false`;
30 |
31 | exports[`testing the properties snapshot default result of propertie kubernetesConfigurationKind 1`] = `"fromKubeconfig"`;
32 |
33 | exports[`testing the properties snapshot default result of propertie network 1`] = `"web"`;
34 |
35 | exports[`testing the properties snapshot default result of propertie printAllConfigs 1`] = `undefined`;
36 |
37 | exports[`testing the properties snapshot default result of propertie runtime 1`] = `"dockerWatch"`;
38 |
39 | exports[`testing the properties snapshot default result of propertie sendIntrospection 1`] = `true`;
40 |
41 | exports[`testing the properties snapshot default result of propertie showPlayground 1`] = `true`;
42 |
43 | exports[`testing the properties snapshot default result of propertie token 1`] = `""`;
44 |
45 | exports[`testing the properties tests with given envs snapshot adminPassword is filled by env\`s correctly 1`] = `"mock"`;
46 |
47 | exports[`testing the properties tests with given envs snapshot adminUser is filled by env\`s correctly 1`] = `"mock"`;
48 |
49 | exports[`testing the properties tests with given envs snapshot getApolloEngineApiKey is filled by env\`s correctly 1`] = `""`;
50 |
51 | exports[`testing the properties tests with given envs snapshot getBodyParserLimit is filled by env\`s correctly 1`] = `"mock"`;
52 |
53 | exports[`testing the properties tests with given envs snapshot getBuildNumber is filled by env\`s correctly 1`] = `"mock"`;
54 |
55 | exports[`testing the properties tests with given envs snapshot getEnableClustering is filled by env\`s correctly 1`] = `false`;
56 |
57 | exports[`testing the properties tests with given envs snapshot getLogFormat is filled by env\`s correctly 1`] = `"mock"`;
58 |
59 | exports[`testing the properties tests with given envs snapshot getLogLevel is filled by env\`s correctly 1`] = `"mock"`;
60 |
61 | exports[`testing the properties tests with given envs snapshot getPollingMs is filled by env\`s correctly 1`] = `"mock"`;
62 |
63 | exports[`testing the properties tests with given envs snapshot getResetEndpointTime is filled by env\`s correctly 1`] = `"mock"`;
64 |
65 | exports[`testing the properties tests with given envs snapshot getVersion is filled by env\`s correctly 1`] = `"mock"`;
66 |
67 | exports[`testing the properties tests with given envs snapshot k8sUser is filled by env\`s correctly 1`] = `"mock"`;
68 |
69 | exports[`testing the properties tests with given envs snapshot k8sUserPassword is filled by env\`s correctly 1`] = `"mock"`;
70 |
71 | exports[`testing the properties tests with given envs snapshot knownOldSchemas is filled by env\`s correctly 1`] = `false`;
72 |
73 | exports[`testing the properties tests with given envs snapshot kubernetesConfigurationKind is filled by env\`s correctly 1`] = `"mock"`;
74 |
75 | exports[`testing the properties tests with given envs snapshot network is filled by env\`s correctly 1`] = `"mock"`;
76 |
77 | exports[`testing the properties tests with given envs snapshot printAllConfigs is filled by env\`s correctly 1`] = `undefined`;
78 |
79 | exports[`testing the properties tests with given envs snapshot runtime is filled by env\`s correctly 1`] = `"mock"`;
80 |
81 | exports[`testing the properties tests with given envs snapshot sendIntrospection is filled by env\`s correctly 1`] = `true`;
82 |
83 | exports[`testing the properties tests with given envs snapshot showPlayground is filled by env\`s correctly 1`] = `false`;
84 |
85 | exports[`testing the properties tests with given envs snapshot token is filled by env\`s correctly 1`] = `"mock"`;
86 |
--------------------------------------------------------------------------------
/src/__tests__/__snapshots__/runtimeIni.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`tests the runtimeini tests loadRuntime by runtime: kubernetes 1`] = `
4 | Object {
5 | "foundedEndpoints": Object {
6 | "a": Array [
7 | Object {
8 | "__deploymentName": "mock",
9 | "__imageID": "mock",
10 | "namespace": "mock",
11 | "typePrefix": "_mock",
12 | "url": "mock",
13 | },
14 | ],
15 | },
16 | "handleRestart": [Function],
17 | "interpreter": K8sWatcherMock {},
18 | }
19 | `;
20 |
21 | exports[`tests the runtimeini tests loadRuntime by runtime: kubernetes 2`] = `
22 | Object {
23 | "foundedEndpoints": Object {
24 | "a": Array [
25 | Object {
26 | "__deploymentName": "mock",
27 | "__imageID": "mock",
28 | "namespace": "mock",
29 | "typePrefix": "_mock",
30 | "url": "mock",
31 | },
32 | ],
33 | },
34 | "handleRestart": [Function],
35 | "interpreter": K8sWatcherMock {},
36 | }
37 | `;
38 |
39 | exports[`tests the runtimeini tests loadRuntime by runtime: kubernetes 3`] = `
40 | Object {
41 | "foundedEndpoints": Object {
42 | "a": Array [
43 | Object {
44 | "__deploymentName": "mock",
45 | "__imageID": "mock",
46 | "namespace": "mock",
47 | "typePrefix": "_mock",
48 | "url": "mock",
49 | },
50 | ],
51 | },
52 | "handleRestart": [Function],
53 | "interpreter": K8sFinderMock {},
54 | }
55 | `;
56 |
57 | exports[`tests the runtimeini tests loadRuntime by runtime: kubernetes 4`] = `
58 | Object {
59 | "foundedEndpoints": Object {
60 | "a": Array [
61 | Object {
62 | "__deploymentName": "mock",
63 | "__imageID": "mock",
64 | "namespace": "mock",
65 | "typePrefix": "_mock",
66 | "url": "mock",
67 | },
68 | ],
69 | },
70 | "handleRestart": [Function],
71 | "interpreter": K8sFinderMock {},
72 | }
73 | `;
74 |
75 | exports[`tests the runtimeini tests loadRuntime by runtime: kubernetes 5`] = `
76 | Object {
77 | "foundedEndpoints": Object {
78 | "a": Array [
79 | Object {
80 | "__deploymentName": "mock",
81 | "__imageID": "mock",
82 | "namespace": "mock",
83 | "typePrefix": "_mock",
84 | "url": "mock",
85 | },
86 | ],
87 | },
88 | "handleRestart": [Function],
89 | "interpreter": DockerFinderMock {},
90 | }
91 | `;
92 |
--------------------------------------------------------------------------------
/src/__tests__/__snapshots__/schemaBuilder.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`test schemabuilder tests createRemoteSchema 1`] = `
4 | Object {
5 | "link": class_1 {
6 | "uri": "mock",
7 | },
8 | "schema": class_1 {
9 | "uri": "mock",
10 | },
11 | }
12 | `;
13 |
14 | exports[`test schemabuilder tests getMergedInformation 1`] = `
15 | Object {
16 | "schemas": Array [
17 | Object {
18 | "link": class_1 {
19 | "uri": "mock",
20 | },
21 | "schema": class_1 {
22 | "uri": "mock",
23 | },
24 | },
25 | ],
26 | }
27 | `;
28 |
--------------------------------------------------------------------------------
/src/__tests__/properties.test.ts:
--------------------------------------------------------------------------------
1 | import * as props from '../properties'
2 |
3 | describe('testing the properties', () => {
4 |
5 | for (const one in props) {
6 | it('snapshot default result of propertie ' + one, () => {
7 | expect(props[one]()).toMatchSnapshot()
8 | })
9 |
10 | }
11 |
12 | describe('tests with given envs', () => {
13 | beforeAll(() => {
14 | process.env.dockerNetwork = 'mock'
15 | process.env.gqlProxyToken = 'mock'
16 | process.env.VERSION = 'mock'
17 | process.env.BUILD_NUMBER = 'mock'
18 | process.env.winstonLogLevel = 'mock'
19 | process.env.winstonLogStyle = 'mock'
20 | process.env.qglProxyRuntime = 'mock'
21 | process.env.enableClustering = 'mock'
22 | process.env.gqlProxyKnownOldSchemas = 'mock'
23 | process.env.kubernetesConfigurationKind = 'mock'
24 | process.env.gqlProxyK8sUser = 'mock'
25 | process.env.gqlProxyK8sUserPassword = 'mock'
26 | process.env.gqlProxyPollingMs = 'mock'
27 | process.env.gqlProxyPollingMs = 'mock'
28 | process.env.gqlProxyAdminUser = 'mock'
29 | process.env.gqlProxyAdminPassword = 'mock'
30 | process.env.gqlShowPlayground = 'mock'
31 | process.env.gqlBodyParserLimit = 'mock'
32 | })
33 | for (const one in props) {
34 | it('snapshot ' + one + ' is filled by env`s correctly', () => {
35 | expect(props[one]()).toMatchSnapshot()
36 | })
37 | }
38 | })
39 | })
40 |
--------------------------------------------------------------------------------
/src/__tests__/runtimeIni.test.ts:
--------------------------------------------------------------------------------
1 | import { loadRuntimeInfo } from '../runtimeIni'
2 | import { Endpoints } from '../interpreter/endpoints'
3 | jest.useFakeTimers()
4 |
5 | jest.mock('../interpreter/watcher/docker/DockerWatcher', () => {
6 | return {
7 | DockerWatcher: class K8sWatcherMock{
8 | setDataUpdatedListener(callBack) {
9 |
10 | const result: Endpoints = {
11 | a: [
12 | {
13 | url: 'mock',
14 | typePrefix: '_mock',
15 | namespace: 'mock',
16 | __deploymentName: 'mock',
17 | __imageID: 'mock',
18 | },
19 | ],
20 | }
21 | callBack(result)
22 | }
23 | handleRestart() {
24 | return {}
25 | }
26 | watchEndpoint() {
27 |
28 | }
29 | },
30 | }
31 | })
32 | jest.mock('../interpreter/watcher/k8s/K8sWatcher', () => {
33 | return {
34 | K8sWatcher: class K8sWatcherMock{
35 | setDataUpdatedListener(callBack) {
36 |
37 | const result: Endpoints = {
38 | a: [
39 | {
40 | url: 'mock',
41 | typePrefix: '_mock',
42 | namespace: 'mock',
43 | __deploymentName: 'mock',
44 | __imageID: 'mock',
45 | },
46 | ],
47 | }
48 | callBack(result)
49 | }
50 | handleRestart() {
51 | return {}
52 | }
53 | watchEndpoint() {
54 |
55 | }
56 | },
57 | }
58 | })
59 | jest.mock('../interpreter/finder/dockerFinder/dockerFinder', () => {
60 | return {
61 | DockerFinder: class DockerFinderMock{
62 | getEndpoints() {
63 | const result: Endpoints = {
64 | a: [
65 | {
66 | url: 'mock',
67 | typePrefix: '_mock',
68 | namespace: 'mock',
69 | __deploymentName: 'mock',
70 | __imageID: 'mock',
71 | },
72 | ],
73 | }
74 | return Promise.resolve(result)
75 | }
76 | handleRestart() {
77 | return {}
78 | }
79 | },
80 | }
81 | })
82 | jest.mock('../interpreter/finder/k8sFinder/k8sFinder', () => {
83 | return {
84 | K8sFinder: class K8sFinderMock{
85 | getEndpoints() {
86 | const result: Endpoints = {
87 | a: [
88 | {
89 | url: 'mock',
90 | typePrefix: '_mock',
91 | namespace: 'mock',
92 | __deploymentName: 'mock',
93 | __imageID: 'mock',
94 | },
95 | ],
96 | }
97 | return Promise.resolve(result)
98 | }
99 | handleRestart() {
100 | return {}
101 | }
102 | },
103 | }
104 | })
105 |
106 | describe('tests the runtimeini', () => {
107 |
108 | const runtime = [
109 | 'kubernetes',
110 | 'docker',
111 | // 'kubernetesWatch',
112 | // 'dockerWatch',
113 | ]
114 |
115 | it('tests loadRuntime by runtime: kubernetes', () => {
116 | expect.assertions(5)
117 | const callBack = (obj) => {
118 | expect(obj).toMatchSnapshot()
119 | }
120 | process.env.qglProxyRuntime = 'kubernetes'
121 | loadRuntimeInfo(callBack)
122 | jest.runOnlyPendingTimers()
123 | process.env.qglProxyRuntime = 'docker'
124 | loadRuntimeInfo(callBack)
125 | jest.runOnlyPendingTimers()
126 | process.env.qglProxyRuntime = 'kubernetesWatch'
127 | loadRuntimeInfo(callBack)
128 | process.env.qglProxyRuntime = 'dockerWatch'
129 | loadRuntimeInfo(callBack)
130 |
131 | })
132 |
133 | })
134 |
--------------------------------------------------------------------------------
/src/__tests__/schemaBuilder.test.ts:
--------------------------------------------------------------------------------
1 | import { createRemoteSchema, getMergedInformation } from '../schemaBuilder'
2 | import { Endpoints } from '../interpreter/endpoints'
3 | jest.mock('graphql-tools', () => {
4 | return {
5 | introspectSchema: (link) => {
6 | return Promise.resolve(link)
7 | },
8 |
9 | makeRemoteExecutableSchema: (obj) => {
10 | return obj
11 | },
12 | mergeSchemas: (obj) => {
13 | return obj
14 | },
15 | }
16 | })
17 |
18 | jest.mock('apollo-link-context', () => {
19 | return {
20 | setContext: () => {
21 | return {
22 | concat: (link) => {
23 | return link
24 | },
25 | }
26 | },
27 | }
28 | })
29 | jest.mock('apollo-link-http', () => {
30 | return {
31 | HttpLink: class {
32 | uri: string
33 | constructor({ uri }) {
34 | this.uri = uri
35 | }
36 | },
37 | }
38 | })
39 |
40 | describe('test schemabuilder', () => {
41 | it('tests createRemoteSchema', async() => {
42 | expect(await createRemoteSchema('mock')).toMatchSnapshot()
43 | })
44 |
45 | it('tests getMergedInformation', async() => {
46 | const endpoints: Endpoints = {
47 | a: [
48 | {
49 | url: 'mock',
50 | namespace: 'mock',
51 | __deploymentName: '---',
52 | __imageID: '---',
53 | typePrefix: '_mock',
54 | },
55 | ],
56 | }
57 |
58 | expect(await getMergedInformation(endpoints.a)).toMatchSnapshot()
59 | })
60 | })
61 |
--------------------------------------------------------------------------------
/src/admin/__tests__/__snapshots__/generalSchema.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`tests the adminSchema snapshot the resolver function 1`] = `
4 | Object {
5 | "Mutation": Object {
6 | "resetEndpointFinderWatcher": [Function],
7 | "updateLogger": [Function],
8 | },
9 | "Query": Object {
10 | "configuration": [Function],
11 | "namespaces": [Function],
12 | },
13 | }
14 | `;
15 |
16 | exports[`tests the adminSchema snapshot the typeDefs 1`] = `
17 | "
18 | type namespace{
19 | name: String
20 | endpoints:[endpoint]
21 | }
22 |
23 | type endpoint{
24 | url: String
25 | created: String
26 | imageID: String
27 | loadBalance: loadBalance
28 | }
29 |
30 | type loadBalance{
31 | count: Int
32 | endpoints: [endpoint]
33 | }
34 |
35 |
36 |
37 | type Configuration{
38 | runtime: String
39 | version: String
40 | buildNumber: String
41 | pollingTimeMS: Int
42 | bodyParserLimit: String
43 | dockerNetwork: String
44 | logging: Logging
45 | sendIntrospection: Boolean
46 | }
47 |
48 | type Logging {
49 | format: String
50 | level: String
51 |
52 | }
53 |
54 | type Query{
55 | namespaces: [namespace]
56 | configuration: Configuration
57 | }
58 |
59 | enum logFormat {
60 | simple
61 | json
62 | }
63 |
64 | enum logLevel {
65 | debug
66 | info
67 | error
68 | warn
69 | }
70 | type Mutation{
71 | updateLogger(logFormat: logFormat, logLevel: logLevel ): Boolean
72 | resetEndpointFinderWatcher: Boolean
73 | }
74 |
75 |
76 | "
77 | `;
78 |
79 | exports[`tests the adminSchema tests resolver namespaces tests struct 1`] = `
80 | Array [
81 | Object {
82 | "endpoints": Array [
83 | Object {
84 | "created": "mock created",
85 | "imageID": "mock image id",
86 | "url": "mockUrl",
87 | },
88 | Object {
89 | "created": "mock created2",
90 | "imageID": "mock image id2",
91 | "loadBalance": Object {
92 | "count": 2,
93 | "endpoints": Array [
94 | Object {
95 | "created": "mock create3",
96 | "imageID": "mock image id3",
97 | "url": "mockUrl3",
98 | },
99 | Object {
100 | "created": "mock create4",
101 | "imageID": "mock image id4",
102 | "url": "mockUrl4",
103 | },
104 | ],
105 | },
106 | "url": "mockUrl2",
107 | },
108 | ],
109 | "name": "testNamspace",
110 | },
111 | ]
112 | `;
113 |
--------------------------------------------------------------------------------
/src/admin/__tests__/__snapshots__/index.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`tests getAdminSchema tests by runtime: docker 1`] = `
4 | Object {
5 | "schemas": Array [
6 | Object {
7 | "resolvers": Object {
8 | "mock": true,
9 | },
10 | "typeDefs": "generalSchema Admin",
11 | },
12 | ],
13 | }
14 | `;
15 |
16 | exports[`tests getAdminSchema tests by runtime: dockerWatch 1`] = `
17 | Object {
18 | "schemas": Array [
19 | Object {
20 | "resolvers": Object {
21 | "mock": true,
22 | },
23 | "typeDefs": "generalSchema Admin",
24 | },
25 | ],
26 | }
27 | `;
28 |
29 | exports[`tests getAdminSchema tests by runtime: kubernetes 1`] = `
30 | Object {
31 | "schemas": Array [
32 | Object {
33 | "resolvers": Object {
34 | "mock": true,
35 | },
36 | "typeDefs": "k8sSchema Admin",
37 | },
38 | Object {
39 | "resolvers": Object {
40 | "mock": true,
41 | },
42 | "typeDefs": "generalSchema Admin",
43 | },
44 | ],
45 | }
46 | `;
47 |
48 | exports[`tests getAdminSchema tests by runtime: kubernetesWatch 1`] = `
49 | Object {
50 | "schemas": Array [
51 | Object {
52 | "resolvers": Object {
53 | "mock": true,
54 | },
55 | "typeDefs": "k8sSchema Admin",
56 | },
57 | Object {
58 | "resolvers": Object {
59 | "mock": true,
60 | },
61 | "typeDefs": "generalSchema Admin",
62 | },
63 | ],
64 | }
65 | `;
66 |
--------------------------------------------------------------------------------
/src/admin/__tests__/__snapshots__/k8sSchema.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`test k8sSchema snapshot the resolver function 1`] = `
4 | Object {
5 | "Query": Object {
6 | "kubernetes": [Function],
7 | },
8 | }
9 | `;
10 |
11 | exports[`test k8sSchema snapshot the typeDefs 1`] = `
12 | "
13 | type Kubernetes{
14 | blacklist: [String]
15 | clearBlackList: Boolean
16 | }
17 | type Query{
18 | kubernetes: Kubernetes
19 |
20 | }
21 | "
22 | `;
23 |
24 | exports[`test k8sSchema tests resolver kubernetes=>blacklist 1`] = `
25 | Array [
26 | "1",
27 | "2",
28 | "3",
29 | ]
30 | `;
31 |
--------------------------------------------------------------------------------
/src/admin/__tests__/generalSchema.test.ts:
--------------------------------------------------------------------------------
1 | import schema, { typeDefs, resolvers } from '../generalSchema'
2 | import { Interpreter } from '../../interpreter/Interpreter'
3 |
4 | jest.mock('../../properties', () => {
5 | return {
6 | getBuildNumber: () => 'mockBnNumber',
7 | getVersion: () => 'mockVersion',
8 | getPollingMs: () => 5000,
9 | }
10 | })
11 | jest.mock('graphql-tools', () => {
12 | return {
13 | makeExecutableSchema: (obj) => {
14 | return obj
15 | },
16 | }
17 | })
18 |
19 | describe('tests the adminSchema', () => {
20 | it('snapshot the typeDefs', () => {
21 |
22 | expect(typeDefs).toMatchSnapshot()// tslint:disable-line
23 | })
24 |
25 | it('snapshot the resolver function', () => {
26 | expect(resolvers).toMatchSnapshot()// tslint:disable-line
27 | })
28 |
29 | it('tests resolver mutation => resetEndpointFinderWatcher', () => {
30 | const testInterpreter = new class MockInterpreter implements Interpreter{
31 | testCallFunc = jest.fn()
32 |
33 | resetConnection = () => {
34 | this.testCallFunc()
35 | }
36 | }
37 | resolvers.Mutation.resetEndpointFinderWatcher(null, null, {
38 | interpreter: testInterpreter,
39 | })
40 |
41 | expect(testInterpreter.testCallFunc).toBeCalled()
42 | })
43 |
44 | it('tests resolver configuration', () => {
45 | expect(resolvers.Query.configuration()).toMatchSnapshot// tslint:disable-line
46 | })
47 |
48 | describe('tests resolver namespaces', () => {
49 | it('tests struct', () => {
50 | const inputData = [
51 | {
52 | url: 'mockUrl',
53 | __created: 'mock created',
54 | __imageID: 'mock image id',
55 | },
56 | {
57 | url: 'mockUrl2',
58 | __created: 'mock created2',
59 | __imageID: 'mock image id2',
60 | __loadbalance: {
61 | count: 2,
62 | endpoints: [
63 | {
64 | url: 'mockUrl3',
65 | __created: 'mock create3',
66 | __imageID: 'mock image id3',
67 | },
68 | {
69 | url: 'mockUrl4',
70 | __created: 'mock create4',
71 | __imageID: 'mock image id4',
72 | },
73 | ],
74 | },
75 | },
76 | ]
77 | const endpoints = {
78 | testNamspace: inputData,
79 | }
80 | expect(resolvers.Query.namespaces({}, {}, { endpoints })).toMatchSnapshot()
81 | })
82 | })
83 |
84 | })
85 |
--------------------------------------------------------------------------------
/src/admin/__tests__/index.test.ts:
--------------------------------------------------------------------------------
1 | import { getAdminSchema } from '../index'
2 |
3 | jest.mock('graphql-tools', () => {
4 | return {
5 | makeExecutableSchema: (obj) => {
6 | return obj
7 | },
8 | mergeSchemas: (obj) => {
9 | return obj
10 | },
11 | }
12 | })
13 |
14 | jest.mock('../k8sSchema', () => {
15 | return {
16 | typeDefs: `k8sSchema Admin`,
17 | resolvers: {
18 | mock: true,
19 | },
20 | }
21 | })
22 |
23 | jest.mock('../generalSchema', () => {
24 | return {
25 | typeDefs: `generalSchema Admin`,
26 | resolvers: {
27 | mock: true,
28 | },
29 | }
30 | })
31 |
32 | describe('tests getAdminSchema', () => {
33 | const runtime = ['docker', 'kubernetesWatch', 'dockerWatch', 'kubernetes']
34 |
35 | runtime.map((one) => {
36 | it('tests by runtime: ' + one, () => {
37 | process.env.qglProxyRuntime = one
38 | expect(getAdminSchema()).toMatchSnapshot()
39 | })
40 | })
41 |
42 | })
43 |
--------------------------------------------------------------------------------
/src/admin/__tests__/k8sSchema.test.ts:
--------------------------------------------------------------------------------
1 | import { resolvers, typeDefs } from '../k8sSchema'
2 | import { clearAll } from '../../interpreter/finder/k8sFinder/blacklist'
3 | jest.mock('../../interpreter/finder/k8sFinder/blacklist', () => {
4 | return {
5 | getBlacklist: () => {
6 | return [
7 | '1',
8 | '2',
9 | '3',
10 | ]
11 | },
12 | clearAll: jest.fn(),
13 | }
14 | })
15 | describe('test k8sSchema', () => {
16 | it('snapshot the typeDefs', () => {
17 |
18 | expect(typeDefs).toMatchSnapshot()// tslint:disable-line
19 | })
20 |
21 | it('snapshot the resolver function', () => {
22 | expect(resolvers).toMatchSnapshot()// tslint:disable-line
23 |
24 | })
25 |
26 | it('tests resolver kubernetes=>blacklist ', () => {
27 |
28 | expect(resolvers.Query.kubernetes().blacklist()).toMatchSnapshot()// tslint:disable-line
29 | })
30 | it('tests resolver kubernetes=>clearBlackList ', () => {
31 | expect(resolvers.Query.kubernetes().clearBlackList()).toBeTruthy()// tslint:disable-line
32 | expect(clearAll).toBeCalled()
33 |
34 | })
35 | })
36 |
--------------------------------------------------------------------------------
/src/admin/generalSchema.ts:
--------------------------------------------------------------------------------
1 | import { makeExecutableSchema } from 'graphql-tools'
2 | import {
3 | getBuildNumber,
4 | getVersion,
5 | getPollingMs,
6 | runtime,
7 | getBodyParserLimit,
8 | network,
9 | getLogFormat,
10 | getLogLevel,
11 | sendIntrospection } from '../properties'
12 | import { loadLogger } from '../logger'
13 | import { Interpreter } from '../interpreter/Interpreter'
14 | export const typeDefs = `
15 | type namespace{
16 | name: String
17 | endpoints:[endpoint]
18 | }
19 |
20 | type endpoint{
21 | url: String
22 | created: String
23 | imageID: String
24 | loadBalance: loadBalance
25 | }
26 |
27 | type loadBalance{
28 | count: Int
29 | endpoints: [endpoint]
30 | }
31 |
32 |
33 |
34 | type Configuration{
35 | runtime: String
36 | version: String
37 | buildNumber: String
38 | pollingTimeMS: Int
39 | bodyParserLimit: String
40 | dockerNetwork: String
41 | logging: Logging
42 | sendIntrospection: Boolean
43 | }
44 |
45 | type Logging {
46 | format: String
47 | level: String
48 |
49 | }
50 |
51 | type Query{
52 | namespaces: [namespace]
53 | configuration: Configuration
54 | }
55 |
56 | enum logFormat {
57 | simple
58 | json
59 | }
60 |
61 | enum logLevel {
62 | debug
63 | info
64 | error
65 | warn
66 | }
67 | type Mutation{
68 | updateLogger(logFormat: logFormat, logLevel: logLevel ): Boolean
69 | resetEndpointFinderWatcher: Boolean
70 | }
71 |
72 |
73 | `
74 | const mappingEndpoint = (endpoint) => {
75 | return endpoint.map((one) => {
76 | let data = {
77 | url: one.url,
78 | created: one.__created,
79 | imageID: one.__imageID,
80 | }
81 | if (one.__loadbalance) {
82 | data = Object.assign({}, data, {
83 | loadBalance: {
84 | count: one.__loadbalance.count,
85 | endpoints: mappingEndpoint(one.__loadbalance.endpoints),
86 | },
87 |
88 | })
89 | }
90 | return data
91 | })
92 | }
93 | export const resolvers = {
94 | Mutation: {
95 | resetEndpointFinderWatcher: (root, args, context) => {
96 | const interpreter : Interpreter = context.interpreter
97 | winston.info('Reset Watching from K8S endpoints manual')
98 | interpreter.resetConnection()
99 | return true
100 | },
101 |
102 | updateLogger: (root, args) => {
103 | loadLogger({
104 | logFormat: args.logFormat,
105 | loglevel: args.logLevel,
106 | })
107 | winston.info('update loggerconfig temporary', args)
108 | return true
109 | },
110 | },
111 | Query: {
112 | configuration: () => {
113 | return {
114 | runtime,
115 | sendIntrospection,
116 | version: getVersion,
117 | buildNumber: getBuildNumber,
118 | pollingTimeMS: getPollingMs,
119 | bodyParserLimit: getBodyParserLimit,
120 | dockerNetwork: network,
121 | logging: {
122 | format: getLogFormat,
123 | level: getLogLevel,
124 | },
125 | }
126 | },
127 |
128 | namespaces: (one, two, context) => {
129 | const result = []
130 | const endpoints = context.endpoints
131 |
132 | for (const one in endpoints) {
133 | const oneEndpoint = endpoints[one]
134 | const oneValue = {
135 | name: one,
136 | endpoints: mappingEndpoint(oneEndpoint),
137 | }
138 | result.push(oneValue)
139 | }
140 | return result
141 | },
142 | },
143 | }
144 |
145 | const adminschema = makeExecutableSchema({
146 | typeDefs,
147 | resolvers,
148 | })
149 |
150 | export default adminschema
151 |
--------------------------------------------------------------------------------
/src/admin/index.ts:
--------------------------------------------------------------------------------
1 | import * as generalSchema from './generalSchema'
2 | import * as k8sSchema from './k8sSchema'
3 | import { runtime } from '../properties'
4 | import { makeExecutableSchema, mergeSchemas } from 'graphql-tools'
5 | // https://blog.apollographql.com/modularizing-your-graphql-schema-code-d7f71d5ed5f2
6 | export const getAdminSchema = () => {
7 | const runt = runtime()
8 |
9 | const schemas = []
10 | if (runt === 'kubernetes' || runt === 'kubernetesWatch') {
11 | schemas.push(makeExecutableSchema({
12 | typeDefs: k8sSchema.typeDefs,
13 | resolvers: k8sSchema.resolvers,
14 | }))
15 | }
16 | schemas.push(makeExecutableSchema({
17 | typeDefs: generalSchema.typeDefs,
18 | resolvers: generalSchema.resolvers,
19 | }))
20 |
21 | return mergeSchemas({ schemas })
22 |
23 | }
24 |
--------------------------------------------------------------------------------
/src/admin/k8sSchema.ts:
--------------------------------------------------------------------------------
1 | import { getBlacklist, clearAll } from '../interpreter/finder/k8sFinder/blacklist'
2 |
3 | export const typeDefs = `
4 | type Kubernetes{
5 | blacklist: [String]
6 | clearBlackList: Boolean
7 | }
8 | type Query{
9 | kubernetes: Kubernetes
10 |
11 | }
12 | `
13 |
14 | export const resolvers = {
15 | Query: {
16 | kubernetes: () => {
17 | return {
18 | blacklist: () => {
19 | return getBlacklist()
20 | },
21 | clearBlackList: () => {
22 | clearAll()
23 | return true
24 | },
25 | }
26 | },
27 | },
28 |
29 | }
30 |
--------------------------------------------------------------------------------
/src/global-modifying-module.d.ts:
--------------------------------------------------------------------------------
1 | export {}
2 | import * as winston from 'winston'
3 | declare global {
4 | function idx(obj: any, callBack: (obj: any) => any) : any
5 | const winston : winston.Logger
6 | }
--------------------------------------------------------------------------------
/src/idx.ts:
--------------------------------------------------------------------------------
1 |
2 | declare module NodeJS {
3 | interface Global {
4 | idx: (obj: any, callBack: (obj: any) => any) => any
5 | winston: any
6 | }
7 | }
8 | global.idx = (obj, callBack) => {
9 | try {
10 | const res = callBack(obj)
11 | if (res === undefined) {
12 | return null
13 | }
14 | return res
15 | } catch (e) {
16 | return null
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/src/interpreter/Interpreter.ts:
--------------------------------------------------------------------------------
1 | import { runtime } from '../properties'
2 | export class Interpreter {
3 |
4 | resetConnection = () => {
5 | winston.info('Reset Connection is not implemented for ' + runtime)
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/interpreter/__tests__/__snapshots__/clientLabels.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`the labels to known thats to include Snapshot the labels 1`] = `
4 | Object {
5 | "NAMESPACE": "gqlProxy.namespace",
6 | "TOKEN": "gqlProxy.token",
7 | "URL": "gqlProxy.url",
8 | }
9 | `;
10 |
--------------------------------------------------------------------------------
/src/interpreter/__tests__/__snapshots__/endpointsAvailable.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`test endpointsAvailable tests that not available endpoints are filtert 1`] = `
4 | Object {
5 | "a": Array [
6 | Object {
7 | "__deploymentName": "___",
8 | "__imageID": "___",
9 | "__introspection": Object {
10 | "found": true,
11 | },
12 | "namespace": "mock1",
13 | "typePrefix": "_mock1",
14 | "url": "found",
15 | },
16 | Object {
17 | "__deploymentName": "___",
18 | "__imageID": "___",
19 | "__introspection": Object {
20 | "found": true,
21 | },
22 | "namespace": "mock1",
23 | "typePrefix": "_mock1",
24 | "url": "found",
25 | },
26 | ],
27 | "b": Array [
28 | Object {
29 | "__deploymentName": "___",
30 | "__imageID": "___",
31 | "__introspection": Object {
32 | "found": true,
33 | },
34 | "namespace": "mock1",
35 | "typePrefix": "_mock1",
36 | "url": "found",
37 | },
38 | ],
39 | }
40 | `;
41 |
--------------------------------------------------------------------------------
/src/interpreter/__tests__/clientLabels.test.ts:
--------------------------------------------------------------------------------
1 | import * as clientLabels from '../clientLabels'
2 | describe('the labels to known thats to include', () => {
3 | it('Snapshot the labels', () => {
4 | expect(clientLabels).toMatchSnapshot()
5 | })
6 | })
7 |
--------------------------------------------------------------------------------
/src/interpreter/__tests__/endpointsAvailable.test.ts:
--------------------------------------------------------------------------------
1 | import { sortEndpointAndFindAvailableEndpoints } from '../endpointsAvailable'
2 |
3 | import { Endpoints } from '../endpoints'
4 | jest.mock('node-fetch', () => {
5 | return {
6 | default: (url) => {
7 | if (url.startsWith('found')) {
8 | // found
9 | return Promise.resolve({
10 | ok: true,
11 | status: 200,
12 | json: () => {
13 | return Promise.resolve({
14 | found: true,
15 | })
16 | },
17 | })
18 | }
19 | return Promise.resolve({
20 | ok: false,
21 | status: 200,
22 | json: () => {
23 | return Promise.resolve({
24 | found: false,
25 | })
26 | },
27 | })
28 |
29 | },
30 | }
31 | })
32 | describe('test endpointsAvailable', () => {
33 | it('tests that not available endpoints are filtert', async() => {
34 | const testEndpoints : Endpoints = {
35 | b: [
36 | {
37 | url: 'found',
38 | namespace: 'mock1',
39 | typePrefix: '_mock1',
40 | __imageID: '___',
41 | __deploymentName: '___',
42 |
43 | },
44 | {
45 | url: 'notfound',
46 | namespace: 'mock1',
47 | typePrefix: '_mock1',
48 | __imageID: '___',
49 | __deploymentName: '___',
50 |
51 | },
52 | ],
53 | a: [
54 | {
55 | url: 'found',
56 | namespace: 'mock1',
57 | typePrefix: '_mock1',
58 | __imageID: '___',
59 | __deploymentName: '___',
60 |
61 | },
62 | {
63 | url: 'found',
64 | namespace: 'mock1',
65 | typePrefix: '_mock1',
66 | __imageID: '___',
67 | __deploymentName: '___',
68 |
69 | },
70 | ],
71 | }
72 |
73 | const result = await sortEndpointAndFindAvailableEndpoints(testEndpoints)
74 | expect(result.a.length).toBe(2)
75 | expect(result.b.length).toBe(1)
76 | expect(result).toMatchSnapshot()
77 | })
78 | })
79 |
--------------------------------------------------------------------------------
/src/interpreter/clientLabels.ts:
--------------------------------------------------------------------------------
1 | export const TOKEN:string = 'gqlProxy.token'
2 | export const URL:string = 'gqlProxy.url'
3 | export const NAMESPACE:string = 'gqlProxy.namespace'
4 |
--------------------------------------------------------------------------------
/src/interpreter/endpoints.d.ts:
--------------------------------------------------------------------------------
1 | export interface Endpoint {
2 | url: string;
3 | namespace: string;
4 | typePrefix: string;
5 | __introspection?: object;
6 | __imageID: string;
7 | __burnd?: boolean;
8 | __deploymentName : string;
9 | __loadbalance?: __loadbalance ;
10 | }
11 |
12 | interface __loadbalance {
13 | count: number;
14 | endpoints: Endpoints;
15 | }
16 |
17 | export type Endpoints = {
18 | [key : string]: Endpoint[],
19 | };
--------------------------------------------------------------------------------
/src/interpreter/endpointsAvailable.ts:
--------------------------------------------------------------------------------
1 | import fetch from 'node-fetch'
2 | import { Endpoints } from './endpoints'
3 | import * as sortObj from 'sort-object'
4 | // tslint:disable-next-line
5 | const queryStr = '?query=%0A%20%20%20%20query%20IntrospectionQuery%20%7B%0A%20%20%20%20%20%20__schema%20%7B%0A%20%20%20%20%20%20%20%20queryType%20%7B%20name%20%7D%0A%20%20%20%20%20%20%20%20mutationType%20%7B%20name%20%7D%0A%20%20%20%20%20%20%20%20subscriptionType%20%7B%20name%20%7D%0A%20%20%20%20%20%20%20%20types%20%7B%0A%20%20%20%20%20%20%20%20%20%20...FullType%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20directives%20%7B%0A%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20description%0A%20%20%20%20%20%20%20%20%20%20locations%0A%20%20%20%20%20%20%20%20%20%20args%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20...InputValue%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%0A%20%20%20%20fragment%20FullType%20on%20__Type%20%7B%0A%20%20%20%20%20%20kind%0A%20%20%20%20%20%20name%0A%20%20%20%20%20%20description%0A%20%20%20%20%20%20fields(includeDeprecated%3A%20true)%20%7B%0A%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20description%0A%20%20%20%20%20%20%20%20args%20%7B%0A%20%20%20%20%20%20%20%20%20%20...InputValue%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20type%20%7B%0A%20%20%20%20%20%20%20%20%20%20...TypeRef%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20isDeprecated%0A%20%20%20%20%20%20%20%20deprecationReason%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20inputFields%20%7B%0A%20%20%20%20%20%20%20%20...InputValue%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20interfaces%20%7B%0A%20%20%20%20%20%20%20%20...TypeRef%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20enumValues(includeDeprecated%3A%20true)%20%7B%0A%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20description%0A%20%20%20%20%20%20%20%20isDeprecated%0A%20%20%20%20%20%20%20%20deprecationReason%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20possibleTypes%20%7B%0A%20%20%20%20%20%20%20%20...TypeRef%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%0A%20%20%20%20fragment%20InputValue%20on%20__InputValue%20%7B%0A%20%20%20%20%20%20name%0A%20%20%20%20%20%20description%0A%20%20%20%20%20%20type%20%7B%20...TypeRef%20%7D%0A%20%20%20%20%20%20defaultValue%0A%20%20%20%20%7D%0A%0A%20%20%20%20fragment%20TypeRef%20on%20__Type%20%7B%0A%20%20%20%20%20%20kind%0A%20%20%20%20%20%20name%0A%20%20%20%20%20%20ofType%20%7B%0A%20%20%20%20%20%20%20%20kind%0A%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20ofType%20%7B%0A%20%20%20%20%20%20%20%20%20%20kind%0A%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20ofType%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20kind%0A%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20ofType%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20kind%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20ofType%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20kind%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20ofType%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20kind%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20ofType%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20kind%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20name%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%7D%0A%20%20%20%20%7D%0A%20%20'
6 | export const sortEndpointAndFindAvailableEndpoints =
7 | async(endpoints :Endpoints): Promise => {
8 | for (const namespace in endpoints) {
9 | const oneNamespace = endpoints[namespace]
10 | for (let i = 0; i < oneNamespace.length; i = i + 1) {
11 | const one = oneNamespace[i]
12 | const result = await fetch(one.url + queryStr, {
13 | timeout: 2000,
14 | }).then(async(res) => {
15 | return {
16 | json: await res.json(),
17 | status: res.status,
18 | ok: res.ok,
19 | }
20 | })
21 | .catch((err) => {
22 | winston.debug('Error by fetching', { err, url: one.url })
23 | return {
24 | json: {},
25 | status: 404,
26 | ok: false,
27 | }
28 | })
29 | if (!result.ok) {
30 | winston.warn('Endpoint not Available: ', { url: one.url , namespace: one.namespace })
31 | oneNamespace.splice(i, 1)
32 | } else {
33 | oneNamespace[i].__introspection = result.json
34 | }
35 |
36 | }
37 | if (oneNamespace.length === 0) {
38 | delete endpoints[namespace]
39 | }
40 |
41 | }
42 | return sortObj(endpoints)
43 | }
44 |
--------------------------------------------------------------------------------
/src/interpreter/finder/__tests__/findEndpointsInterface.test.ts:
--------------------------------------------------------------------------------
1 | import { FindEndpoints } from '../findEndpointsInterface';
2 |
3 | describe('tests the parentFinderClass', () => {
4 | let findendpoints = null;
5 | beforeEach(() => {
6 | findendpoints = new FindEndpoints();
7 | });
8 |
9 | it('tests that handleRestart retrun the input', async() => {
10 | expect(await findendpoints.handleRestart('input')).toBe('input');
11 | });
12 |
13 | it('tests getEndpoints return a empty obj', async() => {
14 | expect(await findendpoints.getEndpoints()).toEqual({});
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/src/interpreter/finder/dockerFinder/__tests__/__snapshots__/dockerFinder.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Tests the docḱerfinder Class tests handleRestart tests there need loadbalance endpoints change 1`] = `
4 | Object {
5 | "__created": "20180101",
6 | "__imageID": "one",
7 | "__loadbalance": Object {
8 | "count": 2,
9 | "endpoints": Array [
10 | Object {
11 | "__burnd": true,
12 | "__imageID": "one",
13 | "created": "20180101",
14 | "namespace": "one",
15 | "typePrefix": "one_",
16 | "url": "http://124.122.123.123:8000/graphql",
17 | },
18 | Object {
19 | "__created": "20180101",
20 | "__imageID": "one",
21 | "namespace": "one",
22 | "typePrefix": "one_",
23 | "url": "http://123.122.123.123:9000/graphql",
24 | },
25 | ],
26 | },
27 | "namespace": "one",
28 | "typePrefix": "one_",
29 | "url": "http://127.0.0.1:8001",
30 | }
31 | `;
32 |
33 | exports[`Tests the docḱerfinder Class tests method getEndpoints gives a list of object and filter the right endpoints 1`] = `Object {}`;
34 |
--------------------------------------------------------------------------------
/src/interpreter/finder/dockerFinder/__tests__/dockerFinder.test.ts:
--------------------------------------------------------------------------------
1 | import { DockerFinder } from '../dockerFinder'
2 | import { Endpoints } from '../../../endpoints'
3 | jest.mock('../../../endpointsAvailable', () => {
4 | return {
5 | sortEndpointAndFindAvailableEndpoints: (data) => {
6 | return Promise.resolve(data)
7 | },
8 | }
9 | })
10 | jest.mock('../../../loadBalancer', () => {
11 | return {
12 | foundEquals: (endpoints: Endpoints): Promise => {
13 | return Promise.resolve(endpoints)
14 | },
15 | closeAllServer: () => {},
16 | loadANewLoadBalaceMiddleware: (listOfBackends) => {
17 | return {
18 | url: 'http://127.0.0.1:8001',
19 | clients: listOfBackends.map((one) => {
20 | return Object.assign({}, one)
21 | }),
22 | }
23 |
24 | },
25 | }
26 | })
27 | jest.mock('../../../../properties', () => {
28 | return {
29 | token: () => {
30 | return '1234'
31 | },
32 | network: () => {
33 | return 'web'
34 | },
35 | }
36 | })
37 | jest.mock('node-docker-api')
38 | describe('Tests the docḱerfinder Class', () => {
39 | let dockerFinder = null
40 | beforeEach(() => {
41 | dockerFinder = new DockerFinder()
42 | })
43 |
44 | describe('tests method getEndpoints', () => {
45 | xit('gives a list of object and filter the right endpoints', async() => {
46 | const endpoints = await dockerFinder.getEndpoints()
47 | expect(endpoints).toMatchSnapshot()
48 | })
49 | })
50 |
51 | describe('tests handleRestart', () => {
52 | xit('tests there does not need loadbalance no change at endpoints ', async() => {
53 | const endpoints = await dockerFinder.getEndpoints()
54 | const newEndpoints = await dockerFinder.handleRestart(endpoints)
55 | expect(endpoints).toEqual(newEndpoints)
56 | })
57 |
58 | xit('tests there need loadbalance endpoints change ', async() => {
59 | const endpoints = await dockerFinder.getEndpoints()
60 | endpoints['one'].push({
61 | url: 'http://124.122.123.123:8000/graphql',
62 | namespace: 'one',
63 | typePrefix: 'one_',
64 | created: '20180101',
65 | __imageID: 'one',
66 | })
67 | const newEndpoints = await dockerFinder.handleRestart(endpoints)
68 | expect(newEndpoints['one'][0]).toMatchSnapshot()
69 |
70 | })
71 |
72 | })
73 | describe('tests updateUrl ', () => {
74 | it('by absolut url', () => {
75 | const url = 'https://test.de/graphql'
76 | expect(dockerFinder.updateUrl(url, {})).toBe(url)
77 | })
78 | it('by relativ url', () => {
79 | const sockData = {
80 | NetworkSettings: {
81 | Networks: {
82 | web: {
83 | IPAddress: '127.0.0.1',
84 | },
85 | },
86 | },
87 | }
88 | expect(dockerFinder.updateUrl(':3000/graphql', sockData))
89 | .toBe('http://127.0.0.1:3000/graphql')
90 | })
91 | })
92 |
93 | })
94 |
--------------------------------------------------------------------------------
/src/interpreter/finder/dockerFinder/dockerFinder.ts:
--------------------------------------------------------------------------------
1 | import { Docker } from 'node-docker-api'
2 | import { token, network } from '../../../properties'
3 | import { foundEquals } from '../../loadBalancer'
4 | import { FindEndpoints } from '../findEndpointsInterface'
5 | import { Endpoints } from '../../endpoints'
6 | import * as clientLabels from '../../clientLabels'
7 | export class DockerFinder extends FindEndpoints{
8 | constructor() {
9 | super()
10 | }
11 |
12 | handleRestart = (endpoints : Endpoints): Promise => {
13 | return this.foundEquals(endpoints)
14 | }
15 |
16 | foundEquals = async(datas: Endpoints) :Promise => {
17 | return await foundEquals(datas)
18 | }
19 |
20 | /**
21 | * Schaut ob es sich um eine absolute url handelt.(Startet mit http(s)://)
22 | * Wenn nicht sucht sie die ip des netzwerkes raus.
23 | * (Relative URL z.B.: :{port}{suburl}(:3001/graphql))
24 | *
25 | *
26 | */
27 | updateUrl = (url:string, sockData:any) :string => {
28 | if (url.startsWith('http')) {
29 | return url
30 | }
31 | return 'http://' + sockData.NetworkSettings.Networks[network()].IPAddress + url
32 |
33 | }
34 |
35 | getEndpoints = async(): Promise => {
36 | const docker = new Docker({ socketPath: '/var/run/docker.sock' })
37 |
38 | return await docker.container.list().then((containers) => {
39 | const result = {}
40 | containers.forEach((one) => {
41 | if (idx(one, _ => _.data.Labels[clientLabels.TOKEN]) + '' === token()) {
42 | const url = idx(one, _ => _.data.Labels[clientLabels.URL])
43 | const namespace = idx(one, _ => _.data.Labels[clientLabels.NAMESPACE])
44 | if (result[namespace] === undefined) {
45 | result[namespace] = []
46 | }
47 |
48 | result[namespace].push({
49 | namespace,
50 | url: this.updateUrl(url, one.data),
51 | typePrefix: namespace + '_',
52 | __created: idx(one, _ => _.data.Created),
53 | __imageID: idx(one, _ => _.data.Image),
54 | })
55 | } else {
56 | // console.log('no gqlProxy labels set')
57 | }
58 | })
59 |
60 | return result
61 | })
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/interpreter/finder/findEndpointsInterface.ts:
--------------------------------------------------------------------------------
1 | import { Endpoints } from '../endpoints'
2 | import { Interpreter } from '../Interpreter'
3 | class FindEndpoints extends Interpreter{
4 |
5 | constructor() {
6 | super()
7 | }
8 | getEndpoints = (): Promise => {
9 | winston.error('you have to override getEndpoints')
10 | return Promise.resolve({})
11 | }
12 |
13 | /**
14 | * If getEndpoints have different to the past
15 | * here you can do extra handling like loadbalacing etc...
16 | */
17 | handleRestart = (endpoints: Endpoints) : Promise => {
18 | return Promise.resolve(endpoints)
19 | }
20 |
21 | }
22 |
23 | export { FindEndpoints }
24 |
--------------------------------------------------------------------------------
/src/interpreter/finder/k8sFinder/__tests__/__snapshots__/getInClusterByUser.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`tests k8s login func getInClusterByUser tests returns valid json 1`] = `
4 | Object {
5 | "auth": Object {
6 | "pass": "mockpw",
7 | "user": "mockuser",
8 | },
9 | "insecureSkipTlsVerify": true,
10 | "url": "https://mockHost:mockport",
11 | }
12 | `;
13 |
--------------------------------------------------------------------------------
/src/interpreter/finder/k8sFinder/__tests__/blacklist.test.ts:
--------------------------------------------------------------------------------
1 | import { addNamespaceToBlacklist,
2 | clearAll,
3 | getBlacklist,
4 | isNamespaceAtBlacklist } from '../blacklist';
5 | describe('Tests the blacklist do the job', () => {
6 | beforeEach(() => {
7 | clearAll();
8 | });
9 |
10 | it('tests addNamespaceToBlacklist, clearAll, getBlacklist, isNamespaceAtBlacklist ', () => {
11 | const data = [
12 | 'testNamespace',
13 | ];
14 | addNamespaceToBlacklist(data[0]);
15 | expect(getBlacklist()).toEqual(data);
16 | expect(isNamespaceAtBlacklist(data[0])).toBe(true);
17 | expect(isNamespaceAtBlacklist('not on list')).toBe(false);
18 | clearAll();
19 | expect(getBlacklist()).toEqual([]);
20 | });
21 |
22 | });
23 |
--------------------------------------------------------------------------------
/src/interpreter/finder/k8sFinder/__tests__/getInClusterByUser.test.ts:
--------------------------------------------------------------------------------
1 | import { getInClusterByUser } from '../getInClusterByUser';
2 | jest.mock('../../../../properties', () => {
3 | return {
4 | k8sUser: () => 'mockuser',
5 | k8sUserPassword: () => 'mockpw',
6 | };
7 | });
8 | describe('tests k8s login func getInClusterByUser', () => {
9 | it('tests returns valid json', () => {
10 | process.env.KUBERNETES_SERVICE_HOST = 'mockHost';
11 | process.env.KUBERNETES_SERVICE_PORT = 'mockport';
12 | expect(getInClusterByUser()).toMatchSnapshot();
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/src/interpreter/finder/k8sFinder/__tests__/k8sFinder.test.ts:
--------------------------------------------------------------------------------
1 | import { K8sFinder } from '../k8sFinder'
2 |
3 | describe('tests K8sFinder', () => {
4 | let k8sFinder = null
5 | beforeEach(() => {
6 | k8sFinder = new K8sFinder()
7 | })
8 | describe('tests updateUrl ', () => {
9 | it('by absolut url', () => {
10 | const url = 'https://test.de/graphql'
11 | expect(k8sFinder.updateUrl(url, {})).toBe(url)
12 | })
13 | it('by relativ url', () => {
14 | const sockData = {
15 | metadata: {
16 | name: 'test',
17 | namespace: 'testNamespace',
18 | },
19 | }
20 | expect(k8sFinder.updateUrl(':3000/graphql', sockData))
21 | .toBe('http://test.testNamespace:3000/graphql')
22 | })
23 | })
24 | })
25 |
--------------------------------------------------------------------------------
/src/interpreter/finder/k8sFinder/blacklist.ts:
--------------------------------------------------------------------------------
1 | let data = []
2 |
3 | export const addNamespaceToBlacklist = (name) => {
4 | data.push(name)
5 | }
6 |
7 | export const isNamespaceAtBlacklist = (name) => {
8 | for (const one in data) {
9 | if (data[one] === name) {
10 | return true
11 | }
12 | }
13 | return false
14 | }
15 |
16 | export const clearAll = () => {
17 | data = []
18 | }
19 |
20 | export const getBlacklist = () => {
21 | return data
22 | }
23 |
--------------------------------------------------------------------------------
/src/interpreter/finder/k8sFinder/getInClusterByUser.ts:
--------------------------------------------------------------------------------
1 | import { k8sUser, k8sUserPassword } from '../../../properties'
2 |
3 | export const getInClusterByUser = () => {
4 |
5 | const host = process.env.KUBERNETES_SERVICE_HOST
6 | const port = process.env.KUBERNETES_SERVICE_PORT
7 |
8 | return {
9 | url: 'https://' + host + ':' + port,
10 | auth: {
11 | user: k8sUser(),
12 | pass: k8sUserPassword(),
13 | },
14 | insecureSkipTlsVerify: true,
15 | }
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/src/interpreter/finder/k8sFinder/k8sFinder.ts:
--------------------------------------------------------------------------------
1 |
2 | const Client = require('kubernetes-client').Client
3 | const config = require('kubernetes-client').config
4 | import * as clientLabels from '../../clientLabels'
5 | import { FindEndpoints } from '../findEndpointsInterface'
6 | import { Endpoints } from '../../endpoints'
7 | import { token, kubernetesConfigurationKind } from '../../../properties'
8 | import { getInClusterByUser } from './getInClusterByUser'
9 | import { addNamespaceToBlacklist, isNamespaceAtBlacklist } from './blacklist'
10 | declare function idx(obj: any, callBack: any):any
11 |
12 | export class K8sFinder extends FindEndpoints{
13 | constructor() {
14 | super()
15 | }
16 |
17 | updateUrl = (url:string, sockData:any) :string => {
18 | if (url.startsWith('http')) {
19 | return url
20 | }
21 | return 'http://' + sockData.metadata.name + '.' + sockData.metadata.namespace + url
22 |
23 | }
24 |
25 | getEndpoints = async(): Promise => {
26 | const result = {}
27 | let client : any = {}
28 | winston.info('Load K8s')
29 | switch (kubernetesConfigurationKind()){
30 | case 'fromKubeconfig': {
31 | winston.info('Load fromKubeconfig')
32 | client = new Client({ config: config.fromKubeconfig() })
33 | break
34 | }
35 | case 'getInCluster': {
36 | winston.info('Load getInCluster')
37 | client = new Client({ config: config.getInCluster() })
38 | break
39 | }
40 | case 'getInClusterByUser': {
41 | winston.info('Load getIntClusterByUser')
42 | client = new Client({ config: getInClusterByUser() })
43 | }
44 | }
45 | await client.loadSpec()
46 | // const namespaces = await client.api.v1.namespaces.get()
47 | // console.log())
48 | const allNamespaces = await client.api.v1.namespaces.get()
49 | const items = allNamespaces.body.items
50 | for (const one in items) {
51 | const namespaceObj = items[one]
52 | const k8sNamespace = namespaceObj.metadata.name
53 | if (isNamespaceAtBlacklist(k8sNamespace)) {
54 | continue
55 | }
56 | // const services = client.api.v1.namespaces(namespace).services.get()
57 | try {
58 | const services = await client.api.v1.namespaces(k8sNamespace).services.get()
59 | const servicesItems = services.body.items
60 | for (const oneService in servicesItems) {
61 | const oneServiceItem = servicesItems[oneService]
62 | if (idx(oneServiceItem, _ => _.metadata.annotations[clientLabels.TOKEN]) === token()) {
63 | const url = this.updateUrl(oneServiceItem.metadata.annotations[clientLabels.URL],
64 | oneServiceItem)
65 | const namespace = oneServiceItem.metadata.annotations[clientLabels.NAMESPACE]
66 | if (result[namespace] === undefined) {
67 | result[namespace] = []
68 | }
69 | const deploymentName = oneServiceItem.spec.selector.app
70 | const deployments = await client.apis.apps.v1beta2.namespaces(k8sNamespace)
71 | .deployments.get()
72 | const compareDeployments = deployments.body.items.filter((one) => {
73 | return one.spec.template.metadata.labels.app === deploymentName
74 | })
75 | let created = ''
76 | compareDeployments.forEach((one) => {
77 | created += one.metadata.creationTimestamp
78 | })
79 | result[namespace].push({
80 | url,
81 | namespace,
82 | typePrefix: namespace + '_',
83 | __created: created,
84 | __imageID: '',
85 | })
86 | }
87 | }
88 | } catch (e) {
89 | addNamespaceToBlacklist(k8sNamespace)
90 | // no loging because user have no permission
91 | // console.log('error by reading namespace:' + k8sNamespace + ' ', e)
92 | }
93 |
94 | }
95 | return result
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/src/interpreter/loadBalancer.ts:
--------------------------------------------------------------------------------
1 | import * as express from 'express'
2 | import * as request from 'request'
3 | import { sortEndpointAndFindAvailableEndpoints } from './endpointsAvailable'
4 | import { Endpoints } from './endpoints'
5 | let lBserver = []
6 |
7 | export const foundEquals = async(datas: Endpoints) :Promise => {
8 | const data = await sortEndpointAndFindAvailableEndpoints(datas)
9 | closeAllServer()
10 | for (const one in data) {
11 | const namespace = data[one]
12 | for (let i = 0, l = namespace.length; i < l; i = i + 1) {
13 | const lbData = {}
14 | const searchingElement = namespace[i]
15 | if (searchingElement.__burnd === undefined) {
16 | for (let j = i + 1; j < l; j = j + 1) {
17 | const testingElement = namespace[j]
18 |
19 | if (searchingElement.__imageID === testingElement.__imageID) {
20 | if (lbData[searchingElement.__imageID] === undefined) {
21 | lbData[searchingElement.__imageID] = []
22 | }
23 | lbData[searchingElement.__imageID].push(testingElement)
24 | namespace[j].__burnd = true
25 | // namespace.splice(j)
26 | }
27 |
28 | }
29 | if (lbData[searchingElement.__imageID] !== undefined) {
30 | lbData[searchingElement.__imageID].push(searchingElement)
31 | const lbResult = loadANewLoadBalaceMiddleware(lbData[searchingElement.__imageID])
32 | namespace[i].url = lbResult.url
33 | namespace[i].__loadbalance = {
34 | count: namespace.length,
35 | endpoints: lbResult.clients,
36 | }
37 |
38 | }
39 | }
40 | const newNamespaces = []
41 | for (const oneBurable in namespace) {
42 | if (!namespace[oneBurable].__burnd) {
43 | newNamespaces.push(namespace[oneBurable])
44 | }
45 | }
46 | data[one] = newNamespaces
47 |
48 | }
49 |
50 | }
51 |
52 | return data
53 | }
54 |
55 | export const loadANewLoadBalaceMiddleware = (listOfBackends) => {
56 |
57 | const servers = listOfBackends.map((one) => {
58 | return one.url
59 | })
60 | let cur = 0
61 |
62 | const handler = (req, res) => {
63 |
64 | // console.log('hier:', req.headers, servers.length)
65 | if (req.url === '/') {
66 | req.url = ''
67 | }
68 | req.pipe(request({ url: servers[cur] + req.url })).pipe(res)
69 | cur = (cur + 1) % servers.length
70 | }
71 | const server = express()
72 | server.get('*', handler).post('*', handler)
73 | server.post('*', handler).post('*', handler)
74 | const port = getNextPortNumber()
75 | lBserver.push(server.listen(port))
76 | return {
77 | url: 'http://127.0.0.1:' + port + '',
78 | clients: listOfBackends.map((one) => {
79 | return Object.assign({}, one)
80 | }),
81 | }
82 | }
83 |
84 | let nextPortNumber = 0
85 | const getNextPortNumber = () => {
86 | nextPortNumber = nextPortNumber + 1
87 | return 8000 + nextPortNumber
88 | }
89 |
90 | export const closeAllServer = () => {
91 | lBserver.forEach((one) => {
92 | winston.info('close load balancer', one._connectionKey)
93 | one.close()
94 | })
95 | nextPortNumber = 0
96 | lBserver = []
97 | }
98 |
--------------------------------------------------------------------------------
/src/interpreter/watcher/WatcherInterface.ts:
--------------------------------------------------------------------------------
1 |
2 | import { Endpoints } from '../endpoints'
3 | import * as cloner from 'cloner'
4 | import { Interpreter } from '../Interpreter'
5 | type dataUpdatedListener = (data: Endpoints) => void
6 |
7 | class WatcherInterface extends Interpreter{
8 | endpoints: Endpoints = {}
9 |
10 | constructor() {
11 | super()
12 | }
13 |
14 | dataUpdatedListener: dataUpdatedListener
15 |
16 | handleRestart = (datas: Endpoints) :Promise => {
17 | return Promise.resolve(datas)
18 | }
19 | watchEndpoint = () => {
20 | winston.error('you have to override watchEndpoint')
21 | }
22 |
23 | abortAllStreams = () => {
24 | winston.warn('you have to override abortAllStreams for good stream handling...')
25 | }
26 |
27 | resetConnection = () => {
28 | this.abortAllStreams()
29 | this.watchEndpoint()
30 | }
31 |
32 | setDataUpdatedListener = (listener:dataUpdatedListener) => {
33 | this.dataUpdatedListener = listener
34 | }
35 |
36 | callDataUpdateListener = async() => {
37 | const realEndpoint = cloner.deep.copy(this.endpoints)
38 | for (const one in realEndpoint) {
39 | if (realEndpoint[one].length === 0) {
40 | delete realEndpoint[one]
41 | }
42 |
43 | }
44 | this.dataUpdatedListener(realEndpoint)
45 | }
46 |
47 | deleteEndpoint = (namespace: string, uniqueIdentifier: string) => {
48 | if (this.endpoints[namespace] === undefined) {
49 | return
50 | }
51 | for (let i = 0 ; i < this.endpoints[namespace].length; i = i + 1) {
52 | if (this.endpoints[namespace][i].__deploymentName === uniqueIdentifier) {
53 | this.endpoints[namespace].splice(i, 1)
54 | }
55 | }
56 | if (this.endpoints[namespace].length === 0) {
57 | delete this.endpoints[namespace]
58 | }
59 | }
60 | }
61 |
62 | export { WatcherInterface }
63 |
--------------------------------------------------------------------------------
/src/interpreter/watcher/__tests__/WatcherInterface.test.ts:
--------------------------------------------------------------------------------
1 | import { WatcherInterface } from '../WatcherInterface'
2 |
3 | describe('Test the WatcherInterface', () => {
4 | let watcher : WatcherInterface = null
5 | beforeEach(() => {
6 | watcher = new WatcherInterface()
7 | })
8 | it('snapshot all methodes and const', () => {
9 | for (const one in watcher) {
10 | expect(one).toMatchSnapshot()
11 | }
12 | })
13 |
14 | it('test call watch and abort Stream', () => {
15 | watcher.watchEndpoint = jest.fn()
16 | watcher.abortAllStreams = jest.fn()
17 | watcher.resetConnection()
18 |
19 | expect(watcher.watchEndpoint).toBeCalled()
20 | expect(watcher.abortAllStreams).toBeCalled()
21 | })
22 |
23 | it('test deleteEndpoint do the right job', () => {
24 | const wa = {
25 | a: [
26 | {
27 | url: 'mock',
28 | namespace:'mock',
29 | typePrefix: '_mock',
30 | __deploymentName: 'mock',
31 | __imageID: 'mock',
32 |
33 | },
34 | {
35 | url: 'mock',
36 | namespace:'mock',
37 | typePrefix: '_mock',
38 | __deploymentName: 'delete',
39 | __imageID: 'mock',
40 | },
41 | ],
42 | }
43 | watcher.endpoints = wa
44 | watcher.deleteEndpoint('a', 'delete')
45 | expect(watcher.endpoints.a.length).toBe(1)
46 | })
47 |
48 | it('test callDataUpdateListener', async() => {
49 | const wa = {
50 | a: [
51 | {
52 | url: 'mock',
53 | namespace:'mock',
54 | typePrefix: '_mock',
55 | __deploymentName: 'mock',
56 | __imageID: 'mock',
57 |
58 | },
59 | ],
60 | }
61 | watcher.endpoints = wa
62 | const listener = jest.fn()
63 | watcher.setDataUpdatedListener(listener)
64 | await watcher.callDataUpdateListener()
65 | expect(listener).toBeCalledWith(wa)
66 | })
67 | })
68 |
--------------------------------------------------------------------------------
/src/interpreter/watcher/__tests__/__snapshots__/WatcherInterface.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`Test the WatcherInterface snapshot all methodes and const 1`] = `"resetConnection"`;
4 |
5 | exports[`Test the WatcherInterface snapshot all methodes and const 2`] = `"endpoints"`;
6 |
7 | exports[`Test the WatcherInterface snapshot all methodes and const 3`] = `"handleRestart"`;
8 |
9 | exports[`Test the WatcherInterface snapshot all methodes and const 4`] = `"watchEndpoint"`;
10 |
11 | exports[`Test the WatcherInterface snapshot all methodes and const 5`] = `"abortAllStreams"`;
12 |
13 | exports[`Test the WatcherInterface snapshot all methodes and const 6`] = `"setDataUpdatedListener"`;
14 |
15 | exports[`Test the WatcherInterface snapshot all methodes and const 7`] = `"callDataUpdateListener"`;
16 |
17 | exports[`Test the WatcherInterface snapshot all methodes and const 8`] = `"deleteEndpoint"`;
18 |
19 | exports[`Test the WatcherInterface snapshot all methodes and const 9`] = `"constructor"`;
20 |
--------------------------------------------------------------------------------
/src/interpreter/watcher/docker/DockerWatcher.ts:
--------------------------------------------------------------------------------
1 |
2 | import * as clientLabels from '../../clientLabels'
3 | import { token, network } from '../../../properties'
4 | import { WatcherInterface } from '../WatcherInterface'
5 | import * as monitor from 'node-docker-monitor'
6 | import { Endpoints } from '../../endpoints'
7 | import { foundEquals } from '../../loadBalancer'
8 | export class DockerWatcher extends WatcherInterface{
9 |
10 | constructor() {
11 | super()
12 | }
13 |
14 | /**
15 | * Schaut ob es sich um eine absolute url handelt.(Startet mit http(s)://)
16 | * Wenn nicht sucht sie die ip des netzwerkes raus.
17 | * (Relative URL z.B.: :{port}{suburl}(:3001/graphql))
18 | */
19 | updateUrl = (url:string, sockData:any) :string => {
20 | if (url.startsWith('http')) {
21 | return url
22 | }
23 | return 'http://' + sockData.NetworkSettings.Networks[network()].IPAddress + url
24 |
25 | }
26 |
27 | onContainerUp = (container: any) => {
28 | if (container.Labels[clientLabels.TOKEN] === token()) {
29 | console.log('onContainerUp')
30 | const namespace :string = container.Labels[clientLabels.NAMESPACE]
31 |
32 | const deploymentName = container.Id
33 | const url = this.updateUrl(container.Labels[clientLabels.URL], container)
34 | this.deleteEndpoint(namespace, deploymentName)
35 |
36 | if (this.endpoints[namespace] === undefined) {
37 | this.endpoints[namespace] = []
38 | }
39 | this.endpoints[namespace].push({
40 | namespace,
41 | url,
42 | typePrefix: namespace + '_',
43 | __imageID: container.Image,
44 | __deploymentName: deploymentName,
45 | })
46 | this.callDataUpdateListener()
47 | }
48 | }
49 |
50 | handleRestart = async(datas:Endpoints) : Promise => {
51 | return await foundEquals(datas)
52 | }
53 |
54 | onContainerDown = (container) => {
55 | if (container.Labels[clientLabels.TOKEN] === token()) {
56 | const deploymentName = container.Id
57 | for (const one in this.endpoints) {
58 | const oneNamespace = this.endpoints[one]
59 | for (let i = 0 ; i < oneNamespace.length; i = i + 1) {
60 | const oneEndpoint = oneNamespace[i]
61 | if (oneEndpoint.__deploymentName === deploymentName) {
62 | this.deleteEndpoint(container.Labels[clientLabels.NAMESPACE], deploymentName)
63 | }
64 | }
65 |
66 | }
67 | this.callDataUpdateListener()
68 | }
69 | }
70 | watchEndpoint = () => {
71 |
72 | monitor({
73 | onMonitorStarted: () => { },
74 | onMonitorStopped: () => {},
75 | onContainerUp: this.onContainerUp,
76 | onContainerDown: this.onContainerDown,
77 | })
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/interpreter/watcher/docker/__tests__/DockerWatcher.test.ts:
--------------------------------------------------------------------------------
1 | import { DockerWatcher } from '../DockerWatcher'
2 |
3 | jest.mock('../../../../properties', () => {
4 | return {
5 | token: () => '123',
6 | network: () => 'web',
7 | }
8 | })
9 | jest.mock('node-docker-monitor', () => {
10 | return (obj) => {
11 | for (const one in obj) {
12 | obj[one]()
13 | }
14 | }
15 | })
16 | describe('test the DockerWatcher', () => {
17 |
18 | let watcher : DockerWatcher = null
19 | beforeEach(() => {
20 | watcher = new DockerWatcher()
21 | })
22 |
23 | describe('tests onContainerDown', () => {
24 |
25 | it('tests by container with gql label token', () => {
26 | const exampleContainer = { NetworkSettings: {
27 | Networks: {
28 | web: {
29 | IPAddress: '127.0.0.1',
30 | },
31 | },
32 | },
33 |
34 | Labels: {
35 | 'gqlProxy.token': '123',
36 | 'gqlProxy.url': ':mock:9000',
37 | 'gqlProxy.namespace': 'a',
38 | },
39 | }
40 | watcher.endpoints = {
41 | b: [
42 | {
43 | url: 'mock',
44 | namespace:'mock',
45 | typePrefix: '_mock',
46 | __deploymentName: 'delete',
47 | __imageID: 'mock',
48 | },
49 | ],
50 | }
51 |
52 | watcher.callDataUpdateListener = jest.fn()
53 | watcher.onContainerDown(exampleContainer)
54 | expect(watcher.callDataUpdateListener).toBeCalled()
55 | expect(watcher.endpoints).toMatchSnapshot()
56 |
57 | })
58 |
59 | it('tests by container with no gql label token', () => {
60 | const exampleContainer = {
61 | Labels: {
62 |
63 | },
64 | }
65 | watcher.callDataUpdateListener = jest.fn()
66 | watcher.onContainerDown(exampleContainer)
67 | expect(watcher.callDataUpdateListener).not.toBeCalled()
68 |
69 | })
70 | })
71 |
72 | describe('tests onContainerUp', () => {
73 |
74 | it('tests by container with gql label token', () => {
75 | const exampleContainer = { NetworkSettings: {
76 | Networks: {
77 | web: {
78 | IPAddress: '127.0.0.1',
79 | },
80 | },
81 | },
82 |
83 | Labels: {
84 | 'gqlProxy.token': '123',
85 | 'gqlProxy.url': ':mock:9000',
86 | 'gqlProxy.namespace': 'a',
87 | },
88 | }
89 | watcher.callDataUpdateListener = jest.fn()
90 | watcher.onContainerUp(exampleContainer)
91 | expect(watcher.callDataUpdateListener).toBeCalled()
92 | expect(watcher.endpoints).toMatchSnapshot()
93 |
94 | })
95 |
96 | it('tests by container with no gql label token', () => {
97 | const exampleContainer = {
98 | Labels: {
99 |
100 | },
101 | }
102 | watcher.callDataUpdateListener = jest.fn()
103 | watcher.onContainerUp(exampleContainer)
104 | expect(watcher.callDataUpdateListener).not.toBeCalled()
105 |
106 | })
107 | })
108 |
109 | describe('tests updateUrl', () => {
110 | it('do the job by absolute url', () => {
111 | const url = 'http://test.de/graphql'
112 | expect(watcher.updateUrl(url, {})).toEqual(url)
113 |
114 | })
115 |
116 | it('do the job by relative path', () => {
117 | const url = ':9000/graphql'
118 | const result = watcher.updateUrl(url, {
119 | NetworkSettings: {
120 | Networks: {
121 | web: {
122 | IPAddress: '127.0.0.1',
123 | },
124 | },
125 | },
126 | })
127 |
128 | expect(result).toEqual('http://127.0.0.1:9000/graphql')
129 | })
130 | })
131 |
132 | it('tests watchendpoint', () => {
133 | watcher.onContainerDown = jest.fn()
134 | watcher.onContainerUp = jest.fn()
135 | watcher.watchEndpoint()
136 | expect(watcher.onContainerDown).toBeCalled()
137 | expect(watcher.onContainerUp).toBeCalled()
138 | })
139 |
140 | })
141 |
--------------------------------------------------------------------------------
/src/interpreter/watcher/docker/__tests__/__snapshots__/DockerWatcher.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`test the DockerWatcher tests onContainerDown tests by container with gql label token 1`] = `
4 | Object {
5 | "b": Array [
6 | Object {
7 | "__deploymentName": "delete",
8 | "__imageID": "mock",
9 | "namespace": "mock",
10 | "typePrefix": "_mock",
11 | "url": "mock",
12 | },
13 | ],
14 | }
15 | `;
16 |
17 | exports[`test the DockerWatcher tests onContainerUp tests by container with gql label token 1`] = `
18 | Object {
19 | "a": Array [
20 | Object {
21 | "__deploymentName": undefined,
22 | "__imageID": undefined,
23 | "namespace": "a",
24 | "typePrefix": "a_",
25 | "url": "http://127.0.0.1:mock:9000",
26 | },
27 | ],
28 | }
29 | `;
30 |
--------------------------------------------------------------------------------
/src/interpreter/watcher/k8s/K8sWatcher.ts:
--------------------------------------------------------------------------------
1 | const Client = require('kubernetes-client').Client
2 | const config = require('kubernetes-client').config
3 |
4 | import * as JSONStream from 'json-stream'
5 | import { WatcherInterface } from '../WatcherInterface'
6 | import { getInClusterByUser } from './getInClusterByUser'
7 | import * as clientLabels from '../../clientLabels'
8 | import { token, kubernetesConfigurationKind } from '../../../properties'
9 | type stream = {
10 | service: any,
11 | }
12 |
13 | type streams = {
14 | [index:string]: stream,
15 | }
16 | export class K8sWatcher extends WatcherInterface{
17 | namespaceStream: any = {}
18 | streams: streams = {}
19 | client: any = {}
20 | constructor() {
21 | super()
22 |
23 | }
24 | abortAllStreams = () => {
25 | this.namespaceStream.abort()
26 | for (const one in this.streams) {
27 | const oneStream = this.streams[one]
28 | idx(oneStream, _ => _.service.abort())
29 | }
30 |
31 | }
32 |
33 | abortServicesForNamespace = (namespaceName : string) => {
34 | this.streams[namespaceName].service.abort()
35 | }
36 |
37 | updateUrl = (url:string, sockData:any) :string => {
38 | if (url.startsWith('http')) {
39 | return url
40 | }
41 | return 'http://' + sockData.metadata.name + '.' + sockData.metadata.namespace + url
42 |
43 | }
44 |
45 | watchServicesForNamespace = (namespaceName: string) => {
46 |
47 | // Sertvices of Namespace
48 | const servicesStream = this.client.api.v1.watch.namespaces(namespaceName).services.getStream()
49 | const servicesJsonStream = new JSONStream()
50 | servicesStream.pipe(servicesJsonStream)
51 | servicesJsonStream.on('error', (err) => {
52 | winston.warn('error by service Stream', err)
53 | })
54 |
55 | servicesJsonStream.on('data', async (service) => {
56 |
57 | const item = service.object
58 | switch (service.type){
59 | case 'MODIFIED':
60 | case 'ADDED': {
61 | if (idx(item, _ => _.metadata.annotations[clientLabels.TOKEN]) + '' === token()) {
62 | const url = this.updateUrl(item.metadata.annotations[clientLabels.URL], service.object)
63 | const namespace = item.metadata.annotations[clientLabels.NAMESPACE]
64 | const deploymentName = item.spec.selector.app
65 | winston.debug('stream send new namespaces for:' + deploymentName, { service })
66 |
67 | // const deployments = await this.client.apis.apps.v1beta2
68 | // .namespaces(namespaceName).deployments.get();
69 | // deployments.body.items.filter((one) => {
70 | // return one.spec.template.metadata.labels.app === deploymentName;
71 | // });
72 |
73 | this.deleteEndpoint(namespace, deploymentName)
74 | if (this.endpoints[namespace] === undefined) {
75 | this.endpoints[namespace] = []
76 | }
77 | this.endpoints[namespace].push({
78 | url,
79 | namespace,
80 | typePrefix: namespace + '_',
81 | __imageID: '',
82 | __deploymentName: deploymentName,
83 | })
84 | this.callDataUpdateListener()
85 |
86 | }
87 | break
88 | }
89 | case 'DELETED': {
90 | if (idx(item, _ => _.metadata.annotations[clientLabels.TOKEN]) === token()) {
91 | const namespace = item.metadata.annotations[clientLabels.NAMESPACE]
92 | const deploymentName = item.spec.selector.app
93 | winston.debug('delete service', namespace, deploymentName)
94 | this.deleteEndpoint(namespace, deploymentName)
95 | winston.debug('delete service no data', this.endpoints)
96 | this.callDataUpdateListener()
97 |
98 | }
99 | break
100 | }
101 | default: {
102 | winston.debug('un used event', service.type)
103 | break
104 | }
105 | }
106 | })
107 |
108 | this.streams[namespaceName] = {
109 | service: servicesStream,
110 | }
111 |
112 | }
113 |
114 | watchEndpoint = async() => {
115 | winston.info('Load K8s')
116 | switch (kubernetesConfigurationKind()){
117 | case 'fromKubeconfig': {
118 | winston.info('Load fromKubeconfig')
119 | this.client = new Client({ config: config.fromKubeconfig() })
120 | break
121 | }
122 | case 'getInCluster': {
123 | winston.info('Load getInCluster')
124 | this.client = new Client({ config: config.getInCluster() })
125 | break
126 | }
127 | case 'getInClusterByUser': {
128 | winston.info('Load getIntClusterByUser')
129 | this.client = new Client({ config: getInClusterByUser() })
130 | }
131 | }
132 | await this.client.loadSpec()
133 | try {
134 | const namespaceStream = this.client.api.v1.watch.namespaces.getStream()
135 | const namespaceJsonStream = new JSONStream()
136 | namespaceStream.pipe(namespaceJsonStream)
137 | namespaceJsonStream.on('error', (err) => {
138 | winston.warn('error by namespaceStream', err)
139 | })
140 | namespaceJsonStream.on('data', (object) => {
141 | const name = object.object.metadata.name
142 | switch (object.type){
143 | case 'ADDED': {
144 | this.watchServicesForNamespace(name)
145 | break
146 | }
147 | case 'DELETED': {
148 | this.abortServicesForNamespace(name)
149 | break
150 |
151 | }
152 | default: {
153 | winston.debug('un used event', object.type)
154 | break
155 | }
156 | }
157 | })
158 | this.namespaceStream = namespaceStream
159 | } catch (err) {
160 | winston.error('Error by watchEndpoints', err)
161 | }
162 |
163 | }
164 |
165 | }
166 |
--------------------------------------------------------------------------------
/src/interpreter/watcher/k8s/__tests__/K8sWatcher.test.ts:
--------------------------------------------------------------------------------
1 | import { K8sWatcher } from '../K8sWatcher'
2 | import { Readable } from 'stream'
3 | import { Endpoints } from '../../../endpoints'
4 | jest.mock('../../../endpointsAvailable', () => {
5 | return {
6 | allEndpointsAvailable: () => true,
7 | sortEndpointAndFindAvailableEndpoints: (endpoints) => {
8 | return endpoints
9 | },
10 | }
11 | })
12 |
13 | jest.mock('../../../../properties', () => {
14 | return {
15 | token: () => {
16 | return '123'
17 | },
18 | kubernetesConfigurationKind: () => {
19 | return 'mock'
20 | },
21 | }
22 | })
23 |
24 | describe('tests the K8sWatcher', () => {
25 |
26 | let k8sWatcher : K8sWatcher = null
27 | let endpoints: Endpoints = {}
28 | beforeEach(() => {
29 | k8sWatcher = new K8sWatcher()
30 | endpoints = {
31 | swapi:
32 | [
33 | { url: 'http://swapi.starwars:9002/graphql',
34 | namespace: 'swapi',
35 | typePrefix: 'swapi_',
36 | __imageID: '',
37 | __deploymentName: 'swapi',
38 | },
39 |
40 | ],
41 | }
42 | })
43 | describe('tests watchEndpoint', () => {
44 | let mockStream: Readable = null
45 | beforeEach(() => {
46 | mockStream = new Readable()
47 |
48 | k8sWatcher.client = {
49 | api: {
50 | v1: {
51 | watch:{
52 | namespaces:{
53 | getStream: () => {
54 | mockStream._read = function () { /* do nothing */ }
55 | return mockStream
56 | },
57 | },
58 | },
59 | },
60 | },
61 | loadSpec: () => {},
62 | }
63 | })
64 | it('tests delete watchEndpoint', async() => {
65 | k8sWatcher.abortServicesForNamespace = jest.fn()
66 | k8sWatcher.watchServicesForNamespace = jest.fn()
67 |
68 | await k8sWatcher.watchEndpoint()
69 | const mockStreamObj = {
70 | type: 'DELETED',
71 | object: {
72 | metadata: {
73 | name: 'test',
74 | },
75 | },
76 | }
77 | await mockStream.emit('data', JSON.stringify(mockStreamObj))
78 | expect(k8sWatcher.abortServicesForNamespace).toBeCalledWith('test')
79 | expect(k8sWatcher.watchServicesForNamespace).not.toBeCalled()
80 | })
81 | it('tests added watchEndpoint', async() => {
82 | k8sWatcher.watchServicesForNamespace = jest.fn()
83 | k8sWatcher.abortServicesForNamespace = jest.fn()
84 |
85 | await k8sWatcher.watchEndpoint()
86 | const mockStreamObj = {
87 | type: 'ADDED',
88 | object: {
89 | metadata: {
90 | name: 'test',
91 | },
92 | },
93 | }
94 | await mockStream.emit('data', JSON.stringify(mockStreamObj))
95 | expect(k8sWatcher.watchServicesForNamespace).toBeCalledWith('test')
96 | expect(k8sWatcher.abortServicesForNamespace).not.toBeCalled()
97 | })
98 | })
99 | describe('tests the updatelistener is called by service stream', () => {
100 | let mockStream = null
101 | beforeEach(() => {
102 | mockStream = new Readable()
103 | k8sWatcher.client = {
104 | api: {
105 | v1: {
106 | watch: {
107 | namespaces: () => {
108 | return {
109 | services: {
110 | getStream: () => {
111 | mockStream._read = function () { /* do nothing */ }
112 | return mockStream
113 | },
114 | },
115 | }
116 | },
117 | },
118 | },
119 | },
120 | }
121 | })
122 |
123 | it('tests addService', async() => {
124 | const callMockFunc = jest.fn()
125 | k8sWatcher.setDataUpdatedListener(callMockFunc)
126 | k8sWatcher.watchServicesForNamespace('mock')
127 | const mockStreamObj = {
128 | type: 'ADDED',
129 | object: {
130 | metadata: {
131 | name: 'mockName',
132 | namespace: 'mockNamespace',
133 | annotations:{
134 | 'gqlProxy.token':'123',
135 | 'gqlProxy.url': ':9000/graph',
136 | 'gqlProxy.namespace': 'mockgqlNamespace',
137 | },
138 | creationTimestamp: 'mock',
139 | resourceVersion: 'mock',
140 | },
141 | spec: {
142 | selector: {
143 | app: 'mockapp',
144 | },
145 | },
146 | },
147 | }
148 |
149 | await mockStream.emit('data', JSON.stringify(mockStreamObj))
150 | const haveTo: Endpoints = {
151 | mockgqlNamespace:
152 | [
153 | { url: 'http://mockName.mockNamespace:9000/graph',
154 | namespace: 'mockgqlNamespace',
155 | typePrefix: 'mockgqlNamespace_',
156 | __imageID: '',
157 | __deploymentName: 'mockapp',
158 | },
159 | ],
160 | }
161 | expect(callMockFunc).toBeCalledWith(haveTo)
162 | })
163 | })
164 |
165 | describe('tests updateUrl ', () => {
166 | it('by absolut url', () => {
167 | const url = 'https://test.de/graphql'
168 | expect(k8sWatcher.updateUrl(url, {})).toBe(url)
169 | })
170 | it('by relativ url', () => {
171 | const sockData = {
172 | metadata: {
173 | name: 'test',
174 | namespace: 'testNamespace',
175 | },
176 | }
177 | expect(k8sWatcher.updateUrl(':3000/graphql', sockData))
178 | .toBe('http://test.testNamespace:3000/graphql')
179 | })
180 | })
181 |
182 | describe('tests abort', () => {
183 |
184 | it('tests abortServiceForNamespace', () => {
185 | const serviceAbortMockFunc = jest.fn()
186 | k8sWatcher.streams = {
187 | test: {
188 | service: {
189 | abort: serviceAbortMockFunc,
190 | },
191 | },
192 | }
193 | k8sWatcher.abortServicesForNamespace('test')
194 | expect(serviceAbortMockFunc).toBeCalled()
195 |
196 | })
197 | it('abortAllStreams', () => {
198 | const namespaceAbortMockFunc = jest.fn()
199 | k8sWatcher.namespaceStream = {
200 | abort: namespaceAbortMockFunc,
201 | }
202 |
203 | const serviceAbortMockFunc = jest.fn()
204 | k8sWatcher.streams = {
205 | one: {
206 | service: {
207 | abort: serviceAbortMockFunc,
208 | },
209 | },
210 | }
211 |
212 | k8sWatcher.abortAllStreams()
213 | expect(namespaceAbortMockFunc).toBeCalled()
214 | expect(serviceAbortMockFunc).toBeCalled()
215 | })
216 |
217 | })
218 |
219 | describe('tests __deleteEndpoint', () => {
220 | it(' delete all', () => {
221 | k8sWatcher.endpoints = endpoints
222 | k8sWatcher.deleteEndpoint('swapi', 'swapi')
223 | expect(k8sWatcher.endpoints).toEqual({})
224 |
225 | })
226 |
227 | it('delete only one ', () => {
228 | const noDelete = {
229 | url: 'http://nodelete.default:9002/graphql',
230 | namespace: 'swapi',
231 | typePrefix: 'swapi_',
232 | __imageID: '',
233 | __deploymentName: 'swapi',
234 | }
235 | endpoints.swapi.push(noDelete)
236 | k8sWatcher.endpoints = endpoints
237 |
238 | k8sWatcher.deleteEndpoint('swapi', 'swapi')
239 | expect(k8sWatcher.endpoints).toEqual({
240 | swapi: [noDelete],
241 | })
242 | })
243 | })
244 |
245 | })
246 |
--------------------------------------------------------------------------------
/src/interpreter/watcher/k8s/__tests__/__snapshots__/getInClusterByUser.test.ts.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`tests the getInClusterByUser snapshot result 1`] = `
4 | Object {
5 | "auth": Object {
6 | "pass": "mockPassword",
7 | "user": "mockUser",
8 | },
9 | "insecureSkipTlsVerify": true,
10 | "url": "https://mockHost:mockPort",
11 | }
12 | `;
13 |
--------------------------------------------------------------------------------
/src/interpreter/watcher/k8s/__tests__/getInClusterByUser.test.ts:
--------------------------------------------------------------------------------
1 | import { getInClusterByUser } from '../getInClusterByUser'
2 | jest.mock('../../../../properties', () => {
3 | return {
4 | k8sUser: () => {
5 | return 'mockUser'
6 | },
7 | k8sUserPassword:() => {
8 | return 'mockPassword'
9 | },
10 | }
11 |
12 | })
13 | describe('tests the getInClusterByUser', () => {
14 | it('snapshot result', () => {
15 | process.env.KUBERNETES_SERVICE_HOST = 'mockHost'
16 | process.env.KUBERNETES_SERVICE_PORT = 'mockPort'
17 | expect(getInClusterByUser()).toMatchSnapshot()
18 | })
19 | })
20 |
--------------------------------------------------------------------------------
/src/interpreter/watcher/k8s/getInClusterByUser.ts:
--------------------------------------------------------------------------------
1 | import { k8sUser, k8sUserPassword } from '../../../properties'
2 |
3 | export const getInClusterByUser = () => {
4 |
5 | const host = process.env.KUBERNETES_SERVICE_HOST
6 | const port = process.env.KUBERNETES_SERVICE_PORT
7 |
8 | return {
9 | url: 'https://' + host + ':' + port,
10 | auth: {
11 | user: k8sUser(),
12 | pass: k8sUserPassword(),
13 | },
14 | insecureSkipTlsVerify: true,
15 | }
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/src/jestlogger.ts:
--------------------------------------------------------------------------------
1 | global.winston = {
2 |
3 | get format () {
4 | return {
5 | combine: (...args) => {
6 | return args
7 | },
8 | simple: () => {
9 | return 'simple'
10 | },
11 | json: () => {
12 | return 'json'
13 | },
14 | }
15 | },
16 |
17 | set format(d) {},
18 | createLogger: (config) => {
19 | return config
20 | },
21 | combine: () => {
22 |
23 | },
24 | debug: (...args) => {
25 | console.log(args)
26 | },
27 | info: (...args) => {
28 | console.log(args)
29 | },
30 | warn: (...args) => {
31 | console.log(args)
32 | },
33 | error: (...args) => {
34 | console.log(args)
35 | },
36 | }
37 |
--------------------------------------------------------------------------------
/src/logger.ts:
--------------------------------------------------------------------------------
1 | import * as winston from 'winston'
2 | import { getEnableClustering } from'./properties'
3 | import * as cluster from 'cluster'
4 | import { Endpoints } from './interpreter/endpoints'
5 | import * as cloner from 'cloner'
6 |
7 | type loadLoggerParam = {
8 | logFormat: string,
9 | loglevel: string,
10 | }
11 | export const loadLogger = (param: loadLoggerParam) => {
12 | let logFormat = winston.format.simple()
13 | switch (param.logFormat){
14 | case 'simple': {
15 | logFormat = winston.format.simple()
16 | break
17 | }
18 | case 'json': {
19 | logFormat = winston.format.json()
20 | break
21 | }
22 |
23 | }
24 |
25 | const maskIntrospectionFormat = winston.format((info) => {
26 | const result = cloner.deep.copy(info)
27 | if (result.endpoints) {
28 | const endpoints: Endpoints = result.endpoints
29 | for (const one in endpoints) {
30 | const oneEndpoint = endpoints[one]
31 | for (let i = 0; i < oneEndpoint.length; i = i + 1) {
32 | const oneConnection = oneEndpoint[i]
33 | delete oneConnection.__introspection
34 | delete oneConnection.__loadbalance
35 | }
36 |
37 | }
38 | }
39 | return result
40 | })
41 |
42 | const workingClusterFormat = winston.format((info) => {
43 | if (cluster.isMaster) {
44 | info.serverRole = 'master'
45 | } else {
46 | info.serverRole = 'slave: ' + cluster.worker.id
47 | }
48 | return info
49 | })
50 |
51 | let format = winston.format.combine(
52 | winston.format.timestamp(),
53 | maskIntrospectionFormat(),
54 | logFormat,
55 | )
56 | if (getEnableClustering()) {
57 | format = winston.format.combine(
58 | workingClusterFormat(),
59 | winston.format.timestamp(),
60 | logFormat)
61 |
62 | }
63 |
64 | global.winston = winston.createLogger({
65 | format,
66 | level: param.loglevel,
67 |
68 | // format: winston.format.simple(),
69 |
70 | transports: [
71 | new winston.transports.Console(),
72 | ],
73 | })
74 |
75 | }
76 |
--------------------------------------------------------------------------------
/src/main.ts:
--------------------------------------------------------------------------------
1 | import { ApolloServer } from 'apollo-server-express'
2 | import * as express from 'express'
3 | import * as core from 'express-serve-static-core'
4 | import { weaveSchemas } from 'graphql-weaver'
5 | import { EngineReportingOptions } from 'apollo-engine-reporting'
6 |
7 | import * as http from 'http'
8 | import {
9 | getPollingMs,
10 | printAllConfigs,
11 | adminPassword,
12 | adminUser,
13 | showPlayground,
14 | getBodyParserLimit,
15 | getEnableClustering,
16 | getLogFormat,
17 | getLogLevel,
18 | sendIntrospection,
19 | getApolloEngineApiKey,
20 | } from './properties'
21 | import { Interpreter } from './interpreter/Interpreter'
22 | import { Endpoints } from './interpreter/endpoints'
23 | import { sortEndpointAndFindAvailableEndpoints } from './interpreter/endpointsAvailable'
24 | import { getAdminSchema } from './admin'
25 | import * as cluster from 'cluster'
26 | import * as basicAuth from 'express-basic-auth'
27 | import * as cloner from 'cloner'
28 | import { getMergedInformation } from './schemaBuilder'
29 | require('./idx')
30 | import { loadLogger } from './logger'
31 | import { loadRuntimeInfo } from './runtimeIni'
32 | loadLogger({
33 | logFormat: getLogFormat(),
34 | loglevel: getLogLevel(),
35 | })
36 |
37 | process.on('unhandledRejection', (reason, p) => {
38 | console.error('Unhandled Rejection at: Promise', p, 'reason:', reason)
39 | // application specific logging, throwing an error, or other logic here
40 | })
41 | const weaverIt = async(endpoints) => {
42 | try {
43 | return await weaveSchemas({
44 | endpoints,
45 | })
46 | } catch (e) {
47 | winston.error('WeaverIt goes Wrong', e)
48 | }
49 |
50 | }
51 | let foundedEndpoints: Endpoints = {}
52 | const run = async() => {
53 | winston.info('Start IT')
54 | winston.info('With Configuration: ')
55 |
56 | let interpreter: Interpreter = null
57 |
58 | printAllConfigs()
59 | let handleRestart = (endpoint: Endpoints) => {
60 | return Promise.resolve(endpoint)
61 | }
62 | loadRuntimeInfo((obj) => {
63 | interpreter = obj.interpreter
64 | handleRestart = obj.handleRestart
65 | foundedEndpoints = obj.foundedEndpoints
66 |
67 | })
68 | setInterval(() => {
69 | startWatcher(cloner.deep.copy(foundedEndpoints), handleRestart, interpreter)
70 | }, getPollingMs())
71 |
72 | }
73 |
74 | // start and restart by listener
75 | let lastEndPoints : string = ''
76 | let server: http.Server = null
77 |
78 | const startWatcher = async(end: Endpoints,
79 | handleRestart:(endpoints:Endpoints) => Promise, interpreter: Interpreter) => {
80 | const endpoints = await sortEndpointAndFindAvailableEndpoints(end)
81 | if (JSON.stringify(endpoints) !== lastEndPoints) {
82 | winston.info('Changes Found restart Server')
83 | if (winston.level === 'debug' && lastEndPoints !== '') {
84 |
85 | }
86 | lastEndPoints = JSON.stringify(endpoints)
87 | await start(await handleRestart(endpoints), interpreter)
88 |
89 | } else {
90 | winston.debug('no Change at endpoints does not need a restart')
91 | }
92 | }
93 |
94 | let app :core.Express = null
95 | const start = async(endpoints : Endpoints, interpreter: Interpreter) => {
96 | winston.info('loading endpoints', { endpoints })
97 | const weaverEndpoints = []
98 |
99 | for (const one in endpoints) {
100 | weaverEndpoints.push({
101 | namespace: one,
102 | typePrefix: one + '_',
103 | schema: await getMergedInformation(endpoints[one]),
104 | })
105 | }
106 | const schema = await weaverIt(weaverEndpoints)
107 | let schemaMerged = null
108 | schemaMerged = schema
109 | app = express()
110 | let playground: any = false
111 | if (showPlayground()) {
112 | playground = {
113 | tabs: [{
114 | endpoint: '/graphql',
115 |
116 | },
117 | {
118 | endpoint: '/admin/graphql',
119 | headers: {
120 | Authorization: 'Basic YOURBasicAuth',
121 | },
122 | },
123 | ],
124 |
125 | }
126 | }
127 |
128 | let engine: boolean | EngineReportingOptions = false
129 | if (getApolloEngineApiKey() !== '') {
130 | engine = {
131 | apiKey: getApolloEngineApiKey(),
132 | }
133 | }
134 | const apiServer = new ApolloServer({
135 | playground,
136 | engine,
137 | schema: schemaMerged,
138 | introspection: sendIntrospection(),
139 | context: (obj) => {
140 | return {
141 | headers: obj.res.req.headers,
142 | }
143 | },
144 | })
145 |
146 | apiServer.applyMiddleware({
147 | app,
148 | path: '/graphql',
149 | bodyParserConfig: { limit: getBodyParserLimit() },
150 |
151 | })
152 |
153 | app.get('/health', (req, res) => {
154 | res.status(200)
155 | res.send('OK')
156 |
157 | })
158 |
159 | if (adminUser() !== '') {
160 | const users = {}
161 | users[adminUser()] = adminPassword()
162 | app.use(basicAuth({
163 | users,
164 | challenge: true,
165 | }))
166 | }
167 |
168 | const adminServer = new ApolloServer({
169 | playground,
170 | introspection: true,
171 | context: {
172 | interpreter,
173 | endpoints: await endpoints,
174 | },
175 | schema: getAdminSchema(),
176 | })
177 | adminServer.applyMiddleware({
178 | app,
179 | bodyParserConfig: true,
180 | path: '/admin/graphql',
181 | })
182 |
183 | winston.info('Server running. Open http://localhost:3000/graphql to run queries.')
184 | if (server != null) {
185 | server.close(() => {
186 | server = app.listen(3000)
187 | })
188 | }else {
189 | server = app.listen(3000)
190 | }
191 | }
192 |
193 | if (getEnableClustering()) {
194 | process.env['NODE_CLUSTER_SCHED_POLICY'] = 'rr'
195 | if (cluster.isMaster) {
196 | const cpuCount = require('os').cpus().length
197 | for (let i = 0; i < cpuCount; i += 1) {
198 | cluster.fork()
199 | }
200 | } else {
201 | winston.info('START Slave')
202 | run()
203 | }
204 | } else {
205 | run()
206 | }
207 |
208 | /**
209 | * Shutdownhandler
210 | */
211 |
212 | const signals = {
213 | SIGHUP: 1,
214 | SIGINT: 2,
215 | SIGTERM: 15,
216 | }
217 | const shutdown = (signal, value) => {
218 | winston.info('shutdown!')
219 | if (server === null) {
220 | process.exit(128 + value)
221 | } else {
222 | winston.info(`server stopped by ${signal} with value ${value}`)
223 | server.close(() => {
224 | process.exit(128 + value)
225 | })
226 | }
227 | }
228 | Object.keys(signals).forEach((signal) => {
229 | (process as NodeJS.EventEmitter).on(signal, () => {
230 | winston.debug(`process received a ${signal} signal`)
231 | shutdown(signal, signals[signal])
232 | })
233 | })
234 |
--------------------------------------------------------------------------------
/src/properties.ts:
--------------------------------------------------------------------------------
1 |
2 | export const network = () => {
3 | return process.env.dockerNetwork || 'web'
4 | }
5 |
6 | export const token = () => {
7 | return idx(process, _ => _.env.gqlProxyToken) || ''
8 | }
9 |
10 | export const getVersion = () => {
11 | return idx(process, _ => _.env.VERSION)
12 | }
13 |
14 | export const getBuildNumber = () => {
15 | return idx(process, _ => _.env.BUILD_NUMBER)
16 | }
17 |
18 | /**
19 | * Set set loglevel
20 | * debug, info, warn, error etc
21 | */
22 | export const getLogLevel = () => {
23 | return idx(process, _ => _.env.winstonLogLevel) || 'info'
24 | }
25 |
26 | /**
27 | * How to show the logs .
28 | * Values: simple or json
29 | */
30 | export const getLogFormat = () => {
31 | return idx(process, _ => _.env.winstonLogStyle) || 'simple'
32 | }
33 | /**
34 | * Available values: docker & kubernetes && kubernetesWatch && dockerWatch
35 | */
36 | export const runtime = () => {
37 | return idx(process, _ => _.env.qglProxyRuntime) || 'dockerWatch'
38 | }
39 |
40 | /**
41 | * Starting Slaves for each CPU
42 | */
43 | export const getEnableClustering = () => {
44 | return idx(process, _ => _.env.enableClustering) === 'true' || false
45 | }
46 |
47 | /**
48 | * true or false (false = default)
49 | * If a backend is not reachable anymore the schema will be allready known
50 | * WIP Not produktionable
51 | */
52 | export const knownOldSchemas = () => {
53 | return idx(process, _ => _.env.gqlProxyKnownOldSchemas) === 'true' || false
54 | }
55 |
56 | export const kubernetesConfigurationKind = () => {
57 | // $kubernetesConfigurationKind
58 | /**
59 | * fromKubeconfig, getInCluster, getInClusterByUser
60 | */
61 | return idx(process, _ => _.env.kubernetesConfigurationKind) || 'fromKubeconfig'
62 | }
63 |
64 | export const k8sUser = () => {
65 | return idx(process, _ => _.env.gqlProxyK8sUser) || ''
66 | }
67 |
68 | export const k8sUserPassword = () => {
69 | return idx(process, _ => _.env.gqlProxyK8sUserPassword) || ''
70 | }
71 |
72 | export const getResetEndpointTime = () => {
73 | return idx(process, _ => _.env.gqlProxyPollingMs) || 3600000
74 | }
75 |
76 | export const getPollingMs = () => {
77 | return idx(process, _ => _.env.gqlProxyPollingMs) || 5000
78 | }
79 |
80 | export const adminUser = () => {
81 | return idx(process, _ => _.env.gqlProxyAdminUser) || ''
82 | }
83 |
84 | export const adminPassword = () => {
85 | return idx(process, _ => _.env.gqlProxyAdminPassword) || ''
86 | }
87 |
88 | export const showPlayground = () => {
89 | if (idx(process, _ => _.env.gqlShowPlayground) === null) {
90 | return true
91 | }
92 | return idx(process, _ => _.env.gqlShowPlayground) === 'true'
93 | }
94 |
95 | export const getBodyParserLimit = () => {
96 | return idx(process, _ => _.env.gqlBodyParserLimit) || '1mb'
97 | }
98 |
99 | /**
100 | * The Key to active the ApolloEngine
101 | */
102 | export const getApolloEngineApiKey = (): string => {
103 | return idx(process, _ => _.env.gqlApolloEngineApiKey) || ''
104 | }
105 |
106 | /**
107 | * boolean if true client can see the structure if false no introspection will be send
108 | * Only for /grapghql
109 | * for /admin/graphql intospection will always send
110 | * default true
111 | */
112 | export const sendIntrospection = (): boolean => {
113 | if (idx(process, _ => _.env.sendIntrospection) === null) {
114 | return true
115 | }
116 | return idx(process, _ => _.env.sendIntrospection) === 'true'
117 | }
118 |
119 | export const printAllConfigs = () => {
120 | console.log('===================================')
121 | console.log('LogLevel:', getLogLevel())
122 | console.log('qglProxyRuntime:', runtime())
123 | console.log('gqlProxyPollingMs:', getPollingMs())
124 | console.log('gqlProxyAdminUser:', adminUser())
125 | console.log('gqlProxyKnownOldSchemas', knownOldSchemas())
126 | console.log('gqlShowPlayground', showPlayground())
127 | console.log('sendIntrospection:', sendIntrospection())
128 | console.log('Version: ', getVersion())
129 | console.log('Buildnumber: ', getBuildNumber())
130 | console.log('gqlProxyToken:', token())
131 | if (getApolloEngineApiKey() !== '') {
132 | console.log('gqlApolloEngineApiKey:', getApolloEngineApiKey())
133 | }
134 | if (runtime() === 'docker' || runtime() === 'dockerWatch') {
135 | console.log('dockerNetwork:', network())
136 | } else if (runtime() === 'kubernetes' || runtime() === 'kubernetesWatch') {
137 | console.log('kubernetesConfigurationKind:', kubernetesConfigurationKind())
138 | if (kubernetesConfigurationKind() === 'getInClusterByUser') {
139 | console.log('gqlProxyK8sUser:', k8sUser())
140 | console.log('gqlProxyK8sUserPassword:', '********')
141 | }
142 | }
143 | console.log('===================================')
144 | }
145 |
--------------------------------------------------------------------------------
/src/runtimeIni.ts:
--------------------------------------------------------------------------------
1 | import { runtime, getPollingMs, getResetEndpointTime } from './properties'
2 | import { Endpoints } from './interpreter/endpoints'
3 | import { Interpreter } from './interpreter/Interpreter'
4 | import { K8sFinder } from './interpreter/finder/k8sFinder/k8sFinder'
5 | import { DockerFinder } from './interpreter/finder/dockerFinder/dockerFinder'
6 | import { K8sWatcher } from './interpreter/watcher/k8s/K8sWatcher'
7 | import { DockerWatcher } from './interpreter/watcher/docker/DockerWatcher'
8 | type callBackPara = {
9 | handleRestart :(endpoints:Endpoints) => Promise,
10 | interpreter: Interpreter,
11 | foundedEndpoints: Endpoints,
12 | }
13 |
14 | type callBack = (callBackPara: callBackPara) => void
15 |
16 | export const loadRuntimeInfo = (callBack: callBack) => {
17 | switch (runtime()){
18 | case 'kubernetes': {
19 | const k8sFinder = new K8sFinder()
20 | setInterval(async() => {
21 | callBack({
22 | foundedEndpoints: await k8sFinder.getEndpoints(),
23 | handleRestart: k8sFinder.handleRestart,
24 | interpreter: k8sFinder,
25 | })
26 | }, getPollingMs())
27 | break
28 | }
29 | case 'docker': {
30 | const dockerFinder = new DockerFinder()
31 | setInterval(async() => {
32 | callBack({
33 | foundedEndpoints: await dockerFinder.getEndpoints(),
34 | handleRestart: dockerFinder.handleRestart,
35 | interpreter: dockerFinder,
36 | })
37 | }, getPollingMs())
38 | break
39 | }
40 |
41 | case 'kubernetesWatch': {
42 | const watcher = new K8sWatcher()
43 | watcher.setDataUpdatedListener((endpoints) => {
44 | winston.info('Watcher called new endpoints ', { endpoints })
45 | callBack({
46 | foundedEndpoints: endpoints,
47 | handleRestart: watcher.handleRestart,
48 | interpreter: watcher,
49 | })
50 | })
51 | watcher.watchEndpoint()
52 | setInterval(() => {
53 | winston.info('Reset Watching from K8S endpoints (work a around)')
54 | watcher.abortAllStreams()
55 | watcher.watchEndpoint()
56 | }, getResetEndpointTime())
57 | break
58 | }
59 | case 'dockerWatch': {
60 | const dockerWatcher = new DockerWatcher()
61 | dockerWatcher.watchEndpoint()
62 | dockerWatcher.setDataUpdatedListener((endpoints) => {
63 | winston.info('Watcher called new endpoints ')
64 | callBack({
65 | foundedEndpoints: endpoints,
66 | handleRestart: dockerWatcher.handleRestart,
67 | interpreter: dockerWatcher,
68 | })
69 | })
70 | setInterval(() => {
71 | winston.info('Reset Watching from K8S endpoints (work a around)')
72 | dockerWatcher.abortAllStreams()
73 | dockerWatcher.watchEndpoint()
74 | }, getResetEndpointTime())
75 | }
76 |
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/src/schemaBuilder.ts:
--------------------------------------------------------------------------------
1 | import { HttpLink } from 'apollo-link-http'
2 | import { setContext } from 'apollo-link-context'
3 | import { makeRemoteExecutableSchema, mergeSchemas, introspectSchema } from 'graphql-tools'
4 | import fetch from 'node-fetch'
5 | import { Endpoint } from './interpreter/endpoints'
6 |
7 | export const createRemoteSchema = async(url : string) => {
8 | const http = new HttpLink({ fetch, uri: url })
9 | const link = setContext((request, previousContext) => {
10 | return previousContext.graphqlContext
11 | }).concat(http)
12 |
13 | const schema = await introspectSchema(link)
14 | const executableSchema = makeRemoteExecutableSchema({
15 | schema,
16 | link,
17 | })
18 | return executableSchema
19 | }
20 |
21 | export const getMergedInformation = async(namespace: Endpoint[]) => {
22 | const schema = []
23 |
24 | for (let i = 0; i < namespace.length; i = i + 1) {
25 | schema.push(await createRemoteSchema(namespace[i].url))
26 | }
27 |
28 | const merged = mergeSchemas({
29 | schemas: schema,
30 | })
31 | return merged
32 | }
33 |
--------------------------------------------------------------------------------
/tsconfig.jest.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig",
3 | "compilerOptions": {
4 | "module": "commonjs"
5 | }
6 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "exclude": [
3 | "__mocks__",
4 | "**/*.test.ts",
5 | "**/jestlogger.ts"
6 | ],
7 | "compilerOptions": {
8 | "target": "es5",
9 | "lib": [
10 | "esnext"
11 | ],
12 | "rootDir": "./src",
13 | "outDir": "dist"
14 |
15 | }
16 | }
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "tslint-config-airbnb",
3 | "rules": {
4 | "import-name": false,
5 | "semicolon": [true, "never"],
6 | "max-line-length": [true, 300],
7 | "prefer-template": false
8 | }
9 | }
--------------------------------------------------------------------------------