├── .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 | ![Logo](/doc/assets/logo.png) 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 | [![Docker Build Status](https://img.shields.io/docker/build/fasibio/graphqldockerproxy.svg)](https://hub.docker.com/r/fasibio/graphqldockerproxy/) 10 | [![pipeline status](https://gitlab.com/fasibio/GraphqlDockerProxy/badges/master/pipeline.svg)](https://gitlab.com/fasibio/GraphqlDockerProxy/commits/master) 11 | [![coverage report](https://gitlab.com/fasibio/GraphqlDockerProxy/badges/master/coverage.svg)](https://fasibio.gitlab.io/GraphqlDockerProxy) 12 | 13 | ![oh man get image not visible](/doc/assets/kontext.png?raw=true) 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 | 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 -------------------------------------------------------------------------------- /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 | } --------------------------------------------------------------------------------