├── .editorconfig ├── .gitignore ├── .vscode └── settings.json ├── BigIpBlueGreen.postman_collection.json ├── README.md ├── api ├── .eslintrc.json ├── manifest.json ├── nodejs │ ├── apiClient.js │ ├── bigIpConfigRestWorker.js │ ├── blueGreenConfigRestWorker.js │ ├── declarationRestWorker.js │ ├── irule-template.tcl │ ├── schema.json │ └── util.js ├── package-lock.json ├── package.json └── scripts │ └── refreshBigIp.sh ├── build ├── Dockerfile ├── Dockerfile.asg ├── bigip-blue-green.spec ├── build.sh ├── buildAll.sh ├── buildApi.sh ├── buildRpm.sh └── buildUi.sh ├── diagram.vsdx ├── dist ├── bigip-blue-green-0.9.0-0001.noarch.rpm └── bigip-blue-green-0.9.0-0001.noarch.rpm.sha256 ├── image-sources ├── 512px-Blue_green_cyan_nevit_116.png └── 512px-Blue_green_cyan_nevit_116.svg ├── images ├── api-screenshot.png ├── diagram.png ├── install-1.png ├── install-2.png ├── ui-screenshot-1.png └── ui-screenshot-2.png ├── load ├── locustfile.py └── run-load-test.sh ├── package-lock.json └── ui ├── README.md ├── angular.json ├── browserslist ├── e2e ├── protractor.conf.js ├── src │ ├── app.e2e-spec.ts │ └── app.po.ts └── tsconfig.e2e.json ├── package-lock.json ├── package.json ├── src ├── app │ ├── app-routing.module.ts │ ├── app.component.css │ ├── app.component.html │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.module.ts │ ├── bigip │ │ ├── bigip.module.ts │ │ ├── models │ │ │ ├── config-data.ts │ │ │ ├── declaration.ts │ │ │ ├── object-reference.ts │ │ │ └── virtual-server-reference.ts │ │ └── services │ │ │ ├── auth.service.ts │ │ │ ├── bigip.service.ts │ │ │ └── index.ts │ ├── declaration-detail │ │ ├── declaration-detail.component.css │ │ ├── declaration-detail.component.html │ │ ├── declaration-detail.component.spec.ts │ │ └── declaration-detail.component.ts │ ├── declarations │ │ ├── declarations.component.css │ │ ├── declarations.component.html │ │ ├── declarations.component.spec.ts │ │ └── declarations.component.ts │ ├── dialogs │ │ ├── auth-dialog.html │ │ ├── auth-dialog.ts │ │ ├── confirm-dialog.html │ │ └── confirm-dialog.ts │ ├── enums │ │ └── form-mode.ts │ ├── http-interceptors │ │ ├── auth-interceptor.ts │ │ └── index.ts │ ├── message.service.spec.ts │ ├── message.service.ts │ ├── messages │ │ ├── messages.component.css │ │ ├── messages.component.html │ │ ├── messages.component.spec.ts │ │ └── messages.component.ts │ └── unique-declaration-name.directive.ts ├── assets │ ├── .gitkeep │ ├── fonts │ │ ├── KFOlCnqEu92Fr1MmEU9fABc4AMP6lbBP.woff2 │ │ ├── KFOlCnqEu92Fr1MmEU9fBBc4AMP6lQ.woff2 │ │ ├── KFOlCnqEu92Fr1MmEU9fBxc4AMP6lbBP.woff2 │ │ ├── KFOlCnqEu92Fr1MmEU9fCBc4AMP6lbBP.woff2 │ │ ├── KFOlCnqEu92Fr1MmEU9fCRc4AMP6lbBP.woff2 │ │ ├── KFOlCnqEu92Fr1MmEU9fChc4AMP6lbBP.woff2 │ │ ├── KFOlCnqEu92Fr1MmEU9fCxc4AMP6lbBP.woff2 │ │ ├── KFOlCnqEu92Fr1MmSU5fABc4AMP6lbBP.woff2 │ │ ├── KFOlCnqEu92Fr1MmSU5fBBc4AMP6lQ.woff2 │ │ ├── KFOlCnqEu92Fr1MmSU5fBxc4AMP6lbBP.woff2 │ │ ├── KFOlCnqEu92Fr1MmSU5fCBc4AMP6lbBP.woff2 │ │ ├── KFOlCnqEu92Fr1MmSU5fCRc4AMP6lbBP.woff2 │ │ ├── KFOlCnqEu92Fr1MmSU5fChc4AMP6lbBP.woff2 │ │ ├── KFOlCnqEu92Fr1MmSU5fCxc4AMP6lbBP.woff2 │ │ ├── KFOmCnqEu92Fr1Mu4WxKKTU1Kvnz.woff2 │ │ ├── KFOmCnqEu92Fr1Mu4mxKKTU1Kg.woff2 │ │ ├── KFOmCnqEu92Fr1Mu5mxKKTU1Kvnz.woff2 │ │ ├── KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2 │ │ ├── KFOmCnqEu92Fr1Mu7GxKKTU1Kvnz.woff2 │ │ ├── KFOmCnqEu92Fr1Mu7WxKKTU1Kvnz.woff2 │ │ ├── KFOmCnqEu92Fr1Mu7mxKKTU1Kvnz.woff2 │ │ └── flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2 │ ├── images │ │ ├── blue-green-logo.png │ │ └── f5-logo.png │ └── styles │ │ ├── material-font.css │ │ └── roboto-font.css ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── karma.conf.js ├── main.ts ├── polyfills.ts ├── styles.css ├── test.ts ├── tsconfig.app.json ├── tsconfig.spec.json └── tslint.json ├── tsconfig.json └── tslint.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /ui/dist 5 | /ui/tmp 6 | /ui/out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # profiling files 12 | chrome-profiler-events.json 13 | speed-measure-plugin.json 14 | 15 | # IDEs and editors 16 | /.idea 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # IDE - VSCode 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | 31 | # misc 32 | /ui/.sass-cache 33 | /ui/connect.lock 34 | /ui/coverage 35 | /ui/libpeerconnection.log 36 | npm-debug.log 37 | yarn-error.log 38 | testem.log 39 | /ui/typings 40 | __pycache__ 41 | 42 | # System Files 43 | .DS_Store 44 | Thumbs.db 45 | azure-proxy.conf.json 46 | local-proxy.conf.json 47 | .vscode/settings.json 48 | *.pyc 49 | rpmbuild 50 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.pythonPath": "/usr/local/bin/python", 3 | "python.linting.enabled": true, 4 | "editor.detectIndentation": false, 5 | "editor.tabSize": 2, 6 | "javascript.format.insertSpaceBeforeFunctionParenthesis": true, 7 | "javascript.format.insertSpaceAfterConstructor": true, 8 | "javascript.validate.enable": true, 9 | "eslint.workingDirectories": [ 10 | "api" 11 | ], 12 | "saveAndRun": { 13 | "commands": [ 14 | { 15 | "match": ".*", 16 | "cmd": "${workspaceRoot}/api/scripts/refreshBigIp.sh ${fileBasename} ${file}", 17 | "useShortcut": false, 18 | "silent": false 19 | } 20 | ] 21 | } 22 | } -------------------------------------------------------------------------------- /BigIpBlueGreen.postman_collection.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "6c41375e-33e3-4f51-bfcf-0fee8b8645b1", 4 | "name": "BigIpBlueGreen", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "Get Auth token", 10 | "event": [ 11 | { 12 | "listen": "test", 13 | "script": { 14 | "id": "a04fd41c-8712-49a5-ba52-bed4f043371b", 15 | "exec": [ 16 | "var jsonData = JSON.parse(responseBody);", 17 | "postman.setEnvironmentVariable(\"bigip_a_auth_token\", jsonData.token.token);" 18 | ], 19 | "type": "text/javascript" 20 | } 21 | } 22 | ], 23 | "request": { 24 | "method": "POST", 25 | "header": [ 26 | { 27 | "key": "Content-Type", 28 | "value": "application/json" 29 | } 30 | ], 31 | "body": { 32 | "mode": "raw", 33 | "raw": "{\n \"username\": \"{{bigip_admin}}\",\n \"password\": \"{{bigip_admin_password}}\",\n \"loginProviderName\": \"tmos\"\n}" 34 | }, 35 | "url": { 36 | "raw": "https://{{bigip_a_mgmt}}/mgmt/shared/authn/login", 37 | "protocol": "https", 38 | "host": [ 39 | "{{bigip_a_mgmt}}" 40 | ], 41 | "path": [ 42 | "mgmt", 43 | "shared", 44 | "authn", 45 | "login" 46 | ] 47 | } 48 | }, 49 | "response": [] 50 | }, 51 | { 52 | "name": "Get Auth token fom Cookie", 53 | "event": [ 54 | { 55 | "listen": "test", 56 | "script": { 57 | "id": "a04fd41c-8712-49a5-ba52-bed4f043371b", 58 | "exec": [ 59 | "var jsonData = JSON.parse(responseBody);", 60 | "postman.setEnvironmentVariable(\"bigip_a_auth_token\", jsonData.token.token);" 61 | ], 62 | "type": "text/javascript" 63 | } 64 | } 65 | ], 66 | "request": { 67 | "method": "POST", 68 | "header": [ 69 | { 70 | "key": "Content-Type", 71 | "value": "application/json" 72 | }, 73 | { 74 | "key": "Cookie", 75 | "value": "BIGIPAuthCookie=7FFE6232D65012AA7D1BCAAD1C7C8320DAC72430", 76 | "type": "text" 77 | } 78 | ], 79 | "body": { 80 | "mode": "raw", 81 | "raw": "{\n \"loginProviderName\": \"tmos\",\n \"needsToken\": true\n}" 82 | }, 83 | "url": { 84 | "raw": "https://{{bigip_a_mgmt}}/mgmt/shared/authn/login", 85 | "protocol": "https", 86 | "host": [ 87 | "{{bigip_a_mgmt}}" 88 | ], 89 | "path": [ 90 | "mgmt", 91 | "shared", 92 | "authn", 93 | "login" 94 | ] 95 | } 96 | }, 97 | "response": [] 98 | }, 99 | { 100 | "name": "GET declaration example", 101 | "protocolProfileBehavior": { 102 | "disableBodyPruning": true 103 | }, 104 | "request": { 105 | "method": "GET", 106 | "header": [ 107 | { 108 | "key": "X-F5-Auth-Token", 109 | "value": "{{bigip_a_auth_token}}", 110 | "type": "text" 111 | }, 112 | { 113 | "key": "Content-Type", 114 | "value": "application/json", 115 | "type": "text" 116 | } 117 | ], 118 | "body": { 119 | "mode": "raw", 120 | "raw": "" 121 | }, 122 | "url": { 123 | "raw": "https://{{bigip_a_mgmt}}/mgmt/shared/blue-green/declare/example", 124 | "protocol": "https", 125 | "host": [ 126 | "{{bigip_a_mgmt}}" 127 | ], 128 | "path": [ 129 | "mgmt", 130 | "shared", 131 | "blue-green", 132 | "declare", 133 | "example" 134 | ] 135 | } 136 | }, 137 | "response": [] 138 | }, 139 | { 140 | "name": "POST with declaration", 141 | "request": { 142 | "method": "POST", 143 | "header": [ 144 | { 145 | "key": "X-F5-Auth-Token", 146 | "value": "{{bigip_a_auth_token}}", 147 | "type": "text" 148 | }, 149 | { 150 | "key": "Content-Type", 151 | "value": "application/json", 152 | "type": "text" 153 | } 154 | ], 155 | "body": { 156 | "mode": "raw", 157 | "raw": "{\n \"name\": \"Sample1\",\n \"virtualServer\": \"/Common/MyVirtualServer\",\n \"distribution\": 0.8,\n \"bluePool\": \"/Common/blue_pool\",\n \"greenPool\": \"/Common/green_pool\"\n}" 158 | }, 159 | "url": { 160 | "raw": "https://{{bigip_a_mgmt}}/mgmt/shared/blue-green/declare", 161 | "protocol": "https", 162 | "host": [ 163 | "{{bigip_a_mgmt}}" 164 | ], 165 | "path": [ 166 | "mgmt", 167 | "shared", 168 | "blue-green", 169 | "declare" 170 | ] 171 | } 172 | }, 173 | "response": [] 174 | }, 175 | { 176 | "name": "DELETE declaration", 177 | "request": { 178 | "method": "DELETE", 179 | "header": [ 180 | { 181 | "key": "X-F5-Auth-Token", 182 | "type": "text", 183 | "value": "{{bigip_a_auth_token}}" 184 | }, 185 | { 186 | "key": "Content-Type", 187 | "type": "text", 188 | "value": "application/json" 189 | } 190 | ], 191 | "body": { 192 | "mode": "raw", 193 | "raw": "" 194 | }, 195 | "url": { 196 | "raw": "https://{{bigip_a_mgmt}}/mgmt/shared/blue-green/declare/Sample1", 197 | "protocol": "https", 198 | "host": [ 199 | "{{bigip_a_mgmt}}" 200 | ], 201 | "path": [ 202 | "mgmt", 203 | "shared", 204 | "blue-green", 205 | "declare", 206 | "Sample1" 207 | ] 208 | } 209 | }, 210 | "response": [] 211 | }, 212 | { 213 | "name": "GET BIG-IP config data", 214 | "request": { 215 | "auth": { 216 | "type": "noauth" 217 | }, 218 | "method": "GET", 219 | "header": [ 220 | { 221 | "key": "X-F5-Auth-Token", 222 | "value": "{{bigip_a_auth_token}}", 223 | "type": "text" 224 | }, 225 | { 226 | "key": "Content-Type", 227 | "value": "application/json", 228 | "type": "text" 229 | } 230 | ], 231 | "body": { 232 | "mode": "raw", 233 | "raw": "" 234 | }, 235 | "url": { 236 | "raw": "https://{{bigip_a_mgmt}}/mgmt/shared/blue-green/bigip-config", 237 | "protocol": "https", 238 | "host": [ 239 | "{{bigip_a_mgmt}}" 240 | ], 241 | "path": [ 242 | "mgmt", 243 | "shared", 244 | "blue-green", 245 | "bigip-config" 246 | ] 247 | } 248 | }, 249 | "response": [] 250 | }, 251 | { 252 | "name": "GET specific blue-green declaration", 253 | "request": { 254 | "auth": { 255 | "type": "noauth" 256 | }, 257 | "method": "GET", 258 | "header": [ 259 | { 260 | "key": "X-F5-Auth-Token", 261 | "type": "text", 262 | "value": "{{bigip_a_auth_token}}" 263 | }, 264 | { 265 | "key": "Content-Type", 266 | "type": "text", 267 | "value": "application/json" 268 | } 269 | ], 270 | "body": { 271 | "mode": "raw", 272 | "raw": "" 273 | }, 274 | "url": { 275 | "raw": "https://{{bigip_a_mgmt}}/mgmt/shared/blue-green/config/Sample1", 276 | "protocol": "https", 277 | "host": [ 278 | "{{bigip_a_mgmt}}" 279 | ], 280 | "path": [ 281 | "mgmt", 282 | "shared", 283 | "blue-green", 284 | "config", 285 | "Sample1" 286 | ] 287 | } 288 | }, 289 | "response": [] 290 | }, 291 | { 292 | "name": "GET all blue-green declarations", 293 | "request": { 294 | "auth": { 295 | "type": "noauth" 296 | }, 297 | "method": "GET", 298 | "header": [ 299 | { 300 | "key": "X-F5-Auth-Token", 301 | "type": "text", 302 | "value": "{{bigip_a_auth_token}}" 303 | }, 304 | { 305 | "key": "Content-Type", 306 | "type": "text", 307 | "value": "application/json" 308 | } 309 | ], 310 | "body": { 311 | "mode": "raw", 312 | "raw": "" 313 | }, 314 | "url": { 315 | "raw": "https://{{bigip_a_mgmt}}/mgmt/shared/blue-green/config", 316 | "protocol": "https", 317 | "host": [ 318 | "{{bigip_a_mgmt}}" 319 | ], 320 | "path": [ 321 | "mgmt", 322 | "shared", 323 | "blue-green", 324 | "config" 325 | ] 326 | } 327 | }, 328 | "response": [] 329 | } 330 | ], 331 | "event": [ 332 | { 333 | "listen": "prerequest", 334 | "script": { 335 | "id": "c9ee4a31-12c9-486f-adda-73b6d22fa1d4", 336 | "type": "text/javascript", 337 | "exec": [ 338 | "" 339 | ] 340 | } 341 | }, 342 | { 343 | "listen": "test", 344 | "script": { 345 | "id": "b952626f-2406-4289-8985-98be176174ae", 346 | "type": "text/javascript", 347 | "exec": [ 348 | "" 349 | ] 350 | } 351 | } 352 | ] 353 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BIG-IP BlueGreen 2 | An iControl LX application and API to distribute traffic between application server pools based on a percentage. The API is implemented in Javascript/NodeJS and runs on a BIG-IP as an [iControl LX](https://clouddocs.f5.com/products/iapp/iapp-lx/tmos-14_0/) application. The user interface is written in [TypeScript](https://www.typescriptlang.org/) and [Angular](https://angular.io/) with [Material](https://material.angular.io/components/select/overview). A simple load test implemented in [Locust](https://locust.io/) (for now). 3 | 4 | Currently, the solution only supports HTTP(S) applications and requires the use of cookies in order to work most efficiently. As such, a virtual server referenced in a BlueGreen declaration is required to have an HTTP Profile attached to it. Once a client has been assigned to a pool at runtime, a cookie is sent to the client in order to continue sending the client to the same pool on subsequent requests. The expiration of the cookie is fixed at 20 minutes from time of issue. If using a distribution value of 0.0 or 1.0, all requests will be sent to the Blue Pool or Green Pool respectively, and no cookie will be issued. 5 | 6 | 7 | 8 | 9 | 10 | 11 | There are 2 methods to configure BIG-IP BlueGreen: 12 | * A web user interface 13 | * Declarative REST API 14 | 15 | A BlueGreen traffic distribution rule consists of 5 elements: 16 | 1. **name** - The name of the BlueGreen declaration. This is used as a unique key for creation, modification and deletion of BlueGreen declarations. 17 | 18 | 2. **virtualServer** - The full path of the virtual server. There may only be a single BlueGreen traffic distribution rule per virtual server. The full path can consist of a partition followed by virtual server name, or can contain partition name, application name and virtual server name. 19 | Examples: 20 | * /Common/VirtualServer 21 | * /DVWA/Application1/serviceMain (this format is common when using an [Application Services 3](https://clouddocs.f5.com/products/extensions/f5-appsvcs-extension/latest/) declaration) 22 | 23 | 3. **distribution** - A decimal value between 0.0 and 1.0 representing the amount of clients to direct to the _Blue_ pool. Think of this value as a percentage. In fact, it is expressed this way in the BlueGreen UI. The remainder of the distribution percentage is used to represent the percentage of traffic that will be directed to the _Green_ pool. Example: with a distribution value of 0.2, **20%** of clients will be directed to the pool identified as **bluePool**; the remaining **80%** of clients will be directed to the **greenPool**. 24 | 25 | 4. **bluePool** - The full path to a pool that typically represents a collection of servers running an _older_ version of an application. The full path of a pool can consist of a partition followed by a pool name, or can contain partition name, application name and pool name. 26 | Examples: 27 | * /Common/blue_pool 28 | * /DVWA/Application1/web_pool (this format is common when using an [Application Services 3](https://clouddocs.f5.com/products/extensions/f5-appsvcs-extension/latest/) declaration) 29 | 30 | 5. **greenPool** - The full path to a pool that typically represents a collection of servers running an _newer_ version of an application. The full path of a pool can consist of a partition followed by a pool name, or can contain partition name, application name and pool name. 31 | Examples: 32 | * /Common/green_pool 33 | * /DVWA/Application1/web_pool (this format is common when using an [Application Services 3](https://clouddocs.f5.com/products/extensions/f5-appsvcs-extension/latest/) declaration) 34 | 35 | 36 | ## Compatibility 37 | Though not exhaustively tested on all BIG-IP versions, this solution has been reported to work on versions 13.0 - 15.0. 38 | 39 | ## Installation 40 | 41 | ### Installation Via BIG-IP UI 42 | 43 | 1. Download the latest RPM package from the [dist](dist/) directory. 44 | 45 | 2. To view installed iControl LX Extensions in the BIG-IP GUI you must first enable this functionality. To do this, log in via SSH into the system with an `admin` account and execute `touch /var/config/rest/iapps/enable`. No reboot is required. This will enable the **iApps ‣ Package Management LX** menu: 46 | 47 | 48 | 49 | 2. Upload and install the RPM package on the using the BIG-IP GUI: 50 | 51 | * **Main tab > iApps > Package Management LX > Import** 52 | * Select the downloaded file and click **Upload** 53 | 54 | 55 | 56 | 3. Be sure to see the [known issues list](https://github.com/aknot242/bigip-blue-green/issues) to review any known issues and other important information before you attempt to use BIG-IP BlueGreen. 57 | 58 | 59 | ### Installation Via Command Line 60 | 61 | Use [directions](https://clouddocs.f5.com/products/extensions/f5-appsvcs-extension/latest/userguide/installation.html#installcurl-ref) provided by the Application Services 3 project as a reference to install BIG-IP BlueGreen via cURL. 62 | 63 | 64 | ## Usage 65 | 66 | ### Limitations 67 | * Since the current implementation uses a form of cookie persistence, the configured Virtual Server must utilize an HTTP Profile. If an HTTP Profile is not set for a Virtual Server, this Virtual Server will not appear in the user interface, and any declarations using this Virtual Server will not be permitted when using the API. 68 | * Virtual servers in custom partitions can only be configured to utilize pools in their own partition, or in the **Common** partition. 69 | 70 | ### UI 71 | 1. Log into a BIG-IP that has BIG-IP BlueGreen installed 72 | 2. Navigate to https://bigip-hostname/iapps/bigip-blue-green 73 | 74 | 75 | ### API 76 | * Included is a [Postman collection](BigIpBlueGreen.postman_collection.json) for references to post declarations to BIG-IP BlueGreen. You can download Postman [here](https://www.getpostman.com/downloads/). 77 | 78 | * To use the API, you must retrieve a BIG-IP authorization token and include it as a header value in all requests. The token can be retrieved by following the directions as indicated [here](https://devcentral.f5.com/articles/demystifying-icontrol-rest-part-6-token-based-authentication). An example of retrieving the token can also be found in the [Postman collection](BigIpBlueGreen.postman_collection.json) of this project. Once the authorization token has been retrieved, it must be inserted as a value into a header named **X-F5-Auth-Token** for all BlueGreen API requests. 79 | * The API supports **GET**, **POST** and **DELETE** REST methods: 80 | * **GET** to retrieve all or specific BlueGreen declarations. 81 | * **POST** to create and modify BlueGreen declarations 82 | * **DELETE** to permanently remove them. 83 | 84 | #### Example Operations 85 | * `POST` to `https:///mgmt/shared/blue-green/declare` with the following JSON payload will create or modify a BlueGreen declaration: 86 | ``` 87 | { 88 | "name": "Sample1", 89 | "virtualServer": "/Common/MyVirtualServer", 90 | "distribution": 0.8, 91 | "bluePool": "/Common/blue_pool", 92 | "greenPool": "/Common/green_pool" 93 | } 94 | ``` 95 | * `GET` request to `https:///mgmt/shared/blue-green` will return a list of all BlueGreen declarations 96 | 97 | * `GET` request to `https:///mgmt/shared/blue-green/` will return a specific BlueGreen declaration 98 | 99 | * `DELETE` request to `https:///mgmt/shared/blue-green/` will delete a specific BlueGreen declaration 100 | 101 | 102 | ## Screenshots 103 | ### User Interface 104 | 105 | #### Declaration list page 106 | 107 | 108 | #### Declaration details page 109 | 110 | 111 | ### Using the API to POST a declaration using Postman 112 | 113 | 114 | ## RPM Package Build 115 | Building the project requires Docker to be installed on the host system. The build script is a bash script that can be invoked from the project root by executing `build/build.sh` in a terminal. Once the build is successful, the RPM and sha256 file can be found in `build/rpmbuild/RPMS/noarch`. 116 | 117 | ## Load Testing 118 | This project uses the [Locust](https://locust.io/) framework. For ease of setup and execution, [Docker](https://www.docker.com/) is utilized to host the testing runtime. Make sure Docker is installed on your host. Update the content checks in the [locustfile.py](load/locustfile.py) and update the URL in [run-load-test.sh](load/run-load-test.sh). To execute: 119 | ``` 120 | cd load 121 | ./run-load-test.sh 122 | ``` 123 | Once the Docker container is running, open http://localhost:8089/ in your browser to configure the load test parameters. 124 | 125 | ## Credits 126 | - Many thanks to [npearce](https://github.com/npearce) for helping me understand the possibilities in management/control plane extensibility of the BIG-IP platform as well as helping me through some of the less-traveled areas of the iControl LX APIs :). Check out some of his other projects such as [BigStats](https://github.com/f5devcentral/BigStats) and [CaC-Github_Webhook_Server](https://github.com/f5devcentral/CaC-Github_Webhook_Server)! 127 | - Core load balancing logic based on [Kevin Davies](https://devcentral.f5.com/s/profile/0051T000008OtS0QAK)' [rand function solution](https://devcentral.f5.com/s/feed/0D51T00006i7MabSAE) on [F5 DevCentral](https://devcentral.f5.com) 128 | - Icon based on a rotated version of https://commons.wikimedia.org/wiki/File:Blue_green_cyan_nevit_116.svg 129 | -------------------------------------------------------------------------------- /api/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "rules": { 4 | "semi": [ 5 | 2, 6 | "always" 7 | ] 8 | }, 9 | "env": { 10 | "node": true, 11 | "mocha": true 12 | } 13 | } -------------------------------------------------------------------------------- /api/manifest.json: -------------------------------------------------------------------------------- 1 | {"tags":["PLUGIN"]} 2 | -------------------------------------------------------------------------------- /api/nodejs/apiClient.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const handlebars = require('handlebars'); 5 | const Util = require('./util'); 6 | 7 | const DEFAULT_PARTITION = 'Common'; 8 | const DATA_GROUP = '____bigip_blue_green'; 9 | const BASE_DATA_GROUP_URI = '/mgmt/tm/ltm/data-group/internal'; 10 | const DATA_GROUP_URI = `${BASE_DATA_GROUP_URI}/~${DEFAULT_PARTITION}~${DATA_GROUP}`; 11 | const SHIM_IRULE_NAME = '_bigip_blue_green'; 12 | const SHIM_IRULE_FULLPATH = `/${DEFAULT_PARTITION}/${SHIM_IRULE_NAME}`; 13 | const COOKIE_PREFIX = 'bg'; 14 | const IRULE_FILE = './irule-template.tcl'; 15 | 16 | class ApiClient { 17 | constructor () { 18 | this.util = new Util('ApiClient', false); 19 | } 20 | 21 | getAllBigIpConfigData (originalRestOp, workerContext) { 22 | const getVsPromise = this.getVirtualServers(originalRestOp, workerContext); 23 | const getPoolsPromise = this.getPools(originalRestOp, workerContext); 24 | return Promise.all([getVsPromise, getPoolsPromise]) 25 | .then((values) => { 26 | return { virtualServers: values[0], pools: values[1] }; 27 | }) 28 | .catch((err) => { 29 | this.util.logError(`getAllBigIpConfigData(): ${err}`); 30 | throw err; 31 | }); 32 | } 33 | 34 | /** partitions from the big-ip */ 35 | getPartitions (originalRestOp, workerContext) { 36 | const uri = workerContext.restHelper.makeRestjavadUri('/mgmt/tm/auth/partition', '$select=fullPath'); 37 | return workerContext.restRequestSender.sendGet(this.getRestOperationInstance(originalRestOp, workerContext, uri)) 38 | .then((response) => { 39 | const body = response.getBody(); 40 | const items = body.items || []; 41 | return items.map(v => v.fullPath); 42 | }) 43 | .catch((err) => { 44 | this.util.logError(`getPartitions(): ${err}`); 45 | throw err; 46 | }); 47 | } 48 | 49 | /** GET virtual servers from the big-ip */ 50 | getVirtualServers (originalRestOp, workerContext) { 51 | const uri = workerContext.restHelper.makeRestjavadUri('/mgmt/tm/ltm/virtual', 'expandSubcollections=true&$select=name,fullPath,profilesReference/items/nameReference/link'); 52 | return workerContext.restRequestSender.sendGet(this.getRestOperationInstance(originalRestOp, workerContext, uri)) 53 | .then((response) => { 54 | const body = response.getBody(); 55 | const items = body.items || []; 56 | return items.map(v => { return { name: v.name, fullPath: v.fullPath, hasHttpProfile: this.virtualServerHasHttpProfile(v) }; }); 57 | }) 58 | .catch((err) => { 59 | this.util.logError(`getVirtualServers(): ${err}`); 60 | throw err; 61 | }); 62 | } 63 | 64 | /** GET virtual server by full path from the big-ip */ 65 | getVirtualServer (originalRestOp, workerContext, virtualServer) { 66 | const vsNamePath = this.convertPathForQuery(virtualServer); 67 | const uri = workerContext.restHelper.makeRestjavadUri(`/mgmt/tm/ltm/virtual/${vsNamePath}`, 'expandSubcollections=true&$select=name,fullPath,profilesReference/items/nameReference/link'); 68 | return workerContext.restRequestSender.sendGet(this.getRestOperationInstance(originalRestOp, workerContext, uri)) 69 | .then((response) => { 70 | const body = response.getBody(); 71 | return { name: body.name, fullPath: body.fullPath, hasHttpProfile: this.virtualServerHasHttpProfile(body) }; 72 | }) 73 | .catch((err) => { 74 | const errorStatusCode = err.getResponseOperation().getStatusCode(); 75 | // virtual server does not exist 76 | if (errorStatusCode === 404) { 77 | return {}; 78 | } 79 | this.util.logError(`getVirtualServer(): ${err}`); 80 | throw err; 81 | }); 82 | } 83 | 84 | /** GET pools from the big-ip */ 85 | getPools (originalRestOp, workerContext) { 86 | const uri = workerContext.restHelper.makeRestjavadUri('/mgmt/tm/ltm/pool', '$select=name,fullPath'); 87 | return workerContext.restRequestSender.sendGet(this.getRestOperationInstance(originalRestOp, workerContext, uri)) 88 | .then((response) => { 89 | const body = response.getBody(); 90 | const items = body.items || []; 91 | return items.map(v => { return { name: v.name, fullPath: v.fullPath }; }); 92 | }) 93 | .catch((err) => { 94 | this.util.logError(`getPools(): ${err}`); 95 | throw err; 96 | }); 97 | } 98 | 99 | /** GET pool by full path from the big-ip */ 100 | getPool (originalRestOp, workerContext, poolFullPath) { 101 | const poolNamePath = this.convertPathForQuery(poolFullPath); 102 | const uri = workerContext.restHelper.makeRestjavadUri(`/mgmt/tm/ltm/pool/${poolNamePath}`, '$select=name,fullPath'); 103 | return workerContext.restRequestSender.sendGet(this.getRestOperationInstance(originalRestOp, workerContext, uri)) 104 | .then((response) => { 105 | const body = response.getBody(); 106 | return { name: body.name, fullPath: body.fullPath }; 107 | }) 108 | .catch((err) => { 109 | const errorStatusCode = err.getResponseOperation().getStatusCode(); 110 | // pool does not exist 111 | if (errorStatusCode === 404) { 112 | return {}; 113 | } 114 | this.util.logError(`getPool(): ${err}`); 115 | throw err; 116 | }); 117 | } 118 | 119 | // *********************** START BUILDERS *************************************** 120 | 121 | /** build blue-green objects on the big-ip */ 122 | buildBlueGreenObjects (originalRestOp, workerContext, declaration) { 123 | this.util.logDebug(`buildBlueGreenObjects(): Declaration: ${workerContext.restHelper.jsonPrinter(declaration)}`); 124 | return this.getBlueGreenDeclaration(originalRestOp, workerContext, declaration.name) 125 | .then((originalDeclaration) => { 126 | // if the virtual server is changing for this declaration, unshim the iRule from the previous virtual server 127 | if (!this.util.isEmptyObject(originalDeclaration) && originalDeclaration.virtualServer.toLowerCase() !== declaration.virtualServer.toLowerCase()) { 128 | return this.unShimIRule(originalRestOp, workerContext, originalDeclaration.virtualServer); 129 | } 130 | }) 131 | .then(() => this.setBlueGreenDeclaration(originalRestOp, workerContext, declaration)) 132 | .then(() => this.iRuleIsShimmed(originalRestOp, workerContext, declaration.virtualServer)) 133 | .then((shimmed) => { 134 | if (!shimmed) { 135 | // check if the irule is present, and plug it in! 136 | this.util.logDebug(`buildBlueGreenObjects(): Ready to shim`); 137 | return this.shimIRule(originalRestOp, workerContext, declaration.virtualServer); 138 | } 139 | }) 140 | .catch((err) => { 141 | this.util.logError(`buildBlueGreenObjects(): ${err}`); 142 | throw err; 143 | }); 144 | } 145 | 146 | /** GET all bluegreen declarations from datagroups from the big-ip */ 147 | getAllBlueGreenDeclarations (originalRestOp, workerContext) { 148 | const uri = workerContext.restHelper.makeRestjavadUri(DATA_GROUP_URI); 149 | this.util.logDebug(`getAllBlueGreenDeclarations(): uri ${workerContext.restHelper.jsonPrinter(uri)}`); 150 | return workerContext.restRequestSender.sendGet(this.getRestOperationInstance(originalRestOp, workerContext, uri)) 151 | .then((response) => { 152 | this.util.logDebug(`getAllBlueGreenDeclarations(): GET data returned: ${workerContext.restHelper.jsonPrinter(response)}`); 153 | const records = response.body.records || []; 154 | const recordObjects = records.map(record => { 155 | return this.buildDeclarationFromDGRecord(record); 156 | }); 157 | this.util.logDebug(`getAllBlueGreenDeclarations(): result: ${workerContext.restHelper.jsonPrinter(recordObjects)}`); 158 | return recordObjects; 159 | }) 160 | .catch((err) => { 161 | const errorStatusCode = err.getResponseOperation().getStatusCode(); 162 | // datagroup does not yet exist until declarations are set 163 | if (errorStatusCode === 404) { 164 | return []; 165 | } 166 | this.util.logError(`getAllBlueGreenDeclarations(): ${err}`); 167 | throw err; 168 | }); 169 | } 170 | 171 | /** GET specific bluegreen declaration from datagroups from the big-ip */ 172 | getBlueGreenDeclaration (originalRestOp, workerContext, declarationName) { 173 | return this.getAllBlueGreenDeclarations(originalRestOp, workerContext) 174 | .then((declarations) => { 175 | return declarations.find(declaration => declaration.name === declarationName) || {}; 176 | }) 177 | .catch((err) => { 178 | this.util.logError(`getBlueGreenDeclaration(): ${err}`); 179 | throw err; 180 | }); 181 | } 182 | 183 | /** Checks to see if a declaration by another name already exists for a virtual server on the big-ip */ 184 | isDeclarationConflicting (originalRestOp, workerContext, declarationToCheck) { 185 | return this.getAllBlueGreenDeclarations(originalRestOp, workerContext) 186 | .then((declarations) => { 187 | const conflictingDeclaration = declarations.find(declaration => 188 | declaration.name.toLowerCase() !== declarationToCheck.name.toLowerCase() && 189 | declaration.virtualServer.toLowerCase() === declarationToCheck.virtualServer.toLowerCase()); 190 | return { conflict: conflictingDeclaration !== undefined, reference: conflictingDeclaration }; 191 | }) 192 | .catch((err) => { 193 | this.util.logError(`isDeclarationConflicting(): ${err}`); 194 | throw err; 195 | }); 196 | } 197 | 198 | /** check if bluegreen declaration exists on big-ip */ 199 | blueGreenDeclarationExists (originalRestOp, workerContext, declarationName) { 200 | return this.getBlueGreenDeclaration(originalRestOp, workerContext, declarationName) 201 | .then((declaration) => { 202 | this.util.logDebug(`blueGreenDeclarationExists(): GET data returned: ${workerContext.restHelper.jsonPrinter(declaration)}`); 203 | return !this.util.isEmptyObject(declaration); 204 | }) 205 | .catch((err) => { 206 | this.util.logError(`blueGreenDeclarationExists(): ${err}`); 207 | throw err; 208 | }); 209 | } 210 | 211 | /** Save declaration in a datagroup with a PATCH. If the datagroup doesn't exist create it first with a POST */ 212 | setBlueGreenDeclaration (originalRestOp, workerContext, declaration) { 213 | const patchUri = workerContext.restHelper.makeRestjavadUri(`${DATA_GROUP_URI}`); 214 | this.util.logDebug(`setBlueGreenDeclaration(): uri ${workerContext.restHelper.jsonPrinter(patchUri)}`); 215 | return this.ensureDataGroupExists(originalRestOp, workerContext) 216 | .then(() => this.getAllBlueGreenDeclarations(originalRestOp, workerContext)) 217 | .then((records) => { 218 | // if a declaration (by name) already exists, always replace it 219 | const newRecordsArray = records.filter(f => f.name !== declaration.name); 220 | newRecordsArray.push(declaration); 221 | this.util.logDebug(`setBlueGreenDeclaration(): new records array ${workerContext.restHelper.jsonPrinter(newRecordsArray)}`); 222 | 223 | return workerContext.restRequestSender.sendPatch(this.getRestOperationInstance(originalRestOp, workerContext, patchUri, this.buildDataGroupBody(newRecordsArray))) 224 | .then((resp) => { 225 | this.util.logDebug(`setBlueGreenDeclaration(): PATCH response ${workerContext.restHelper.jsonPrinter(resp.body)}`); 226 | }) 227 | .catch((err) => { 228 | this.util.logError(`setBlueGreenDeclaration() PATCH: ${err}`); 229 | throw err; 230 | }); 231 | }) 232 | .catch((err) => { 233 | this.util.logError(`setBlueGreenDeclaration(): ${err}`); 234 | throw err; 235 | }); 236 | } 237 | 238 | /** DELETE bluegreen declaration in on the big-ip */ 239 | deleteBlueGreenDeclaration (originalRestOp, workerContext, declarationName) { 240 | const patchUri = workerContext.restHelper.makeRestjavadUri(`${DATA_GROUP_URI}`); 241 | this.util.logDebug(`deleteBlueGreenDeclaration(): uri ${workerContext.restHelper.jsonPrinter(patchUri)}`); 242 | return this.ensureDataGroupExists(originalRestOp, workerContext) 243 | .then(() => this.getAllBlueGreenDeclarations(originalRestOp, workerContext)) 244 | .then((records) => { 245 | // if a declaration (by name) already exists, always replace it 246 | const newRecordsArray = records.filter(f => f.name !== declarationName); 247 | this.util.logDebug(`deleteBlueGreenDeclaration(): new records array ${workerContext.restHelper.jsonPrinter(newRecordsArray)}`); 248 | 249 | return workerContext.restRequestSender.sendPatch(this.getRestOperationInstance(originalRestOp, workerContext, patchUri, this.buildDataGroupBody(newRecordsArray))) 250 | .then((resp) => { 251 | this.util.logDebug(`deleteBlueGreenDeclaration(): PATCH response ${workerContext.restHelper.jsonPrinter(resp.body)}`); 252 | }) 253 | .catch((err) => { 254 | this.util.logError(`deleteBlueGreenDeclaration() PATCH: ${err}`); 255 | throw err; 256 | }); 257 | }) 258 | .catch((err) => { 259 | this.util.logError(`deleteBlueGreenDeclaration(): ${err}`); 260 | throw err; 261 | }); 262 | } 263 | 264 | /** GET to see if the declaration datagroup exists on the big-ip and create it if not */ 265 | ensureDataGroupExists (originalRestOp, workerContext) { 266 | const uri = workerContext.restHelper.makeRestjavadUri(DATA_GROUP_URI); 267 | return workerContext.restRequestSender.sendGet(this.getRestOperationInstance(originalRestOp, workerContext, uri)) 268 | .then(() => true) 269 | .catch((err) => { 270 | const errorStatusCode = err.getResponseOperation().getStatusCode(); 271 | if (errorStatusCode === 404) { 272 | return this.createDataGroup(originalRestOp, workerContext); 273 | } 274 | this.util.logError(`ensureDataGroupExists(): ${err}`); 275 | throw err; 276 | }); 277 | } 278 | 279 | /** Create the declaration datagroup */ 280 | createDataGroup (originalRestOp, workerContext) { 281 | const uri = workerContext.restHelper.makeRestjavadUri(BASE_DATA_GROUP_URI); 282 | this.util.logDebug(`createDataGroup(): at start`); 283 | return workerContext.restRequestSender.sendPost(this.getRestOperationInstance(originalRestOp, workerContext, uri, this.buildDataGroupBody())) 284 | .then((resp) => { 285 | this.util.logDebug(`createDataGroup(): POST response ${workerContext.restHelper.jsonPrinter(resp.body)}`); 286 | return resp.body; 287 | }) 288 | .catch((err) => { 289 | this.util.logError(`createDataGroup() POST: ${err}`); 290 | throw err; 291 | }); 292 | } 293 | 294 | createShimIRule (originalRestOp, workerContext) { 295 | const uri = workerContext.restHelper.makeRestjavadUri('/mgmt/tm/ltm/rule/'); 296 | this.util.logDebug(`createShimIRule(): at start`); 297 | return this.buildIRuleBody(SHIM_IRULE_FULLPATH) 298 | .then((ruleBody) => workerContext.restRequestSender.sendPost(this.getRestOperationInstance(originalRestOp, workerContext, uri, ruleBody))) 299 | .then((resp) => { 300 | this.util.logDebug(`createShimIRule(): POST response ${workerContext.restHelper.jsonPrinter(resp.body)}`); 301 | return resp.body; 302 | }) 303 | .catch((err) => { 304 | this.util.logError(`createShimIRule() POST: ${err}`); 305 | throw err; 306 | }); 307 | } 308 | 309 | /** GET to see if the shim irule exists on the big-ip */ 310 | shimIRuleExists (originalRestOp, workerContext) { 311 | const uri = workerContext.restHelper.makeRestjavadUri(`/mgmt/tm/ltm/rule/${this.convertPathForQuery(SHIM_IRULE_FULLPATH)}`, '$select=fullPath'); 312 | return workerContext.restRequestSender.sendGet(this.getRestOperationInstance(originalRestOp, workerContext, uri)) 313 | .then(() => true) 314 | .catch((err) => { 315 | const errorStatusCode = err.getResponseOperation().getStatusCode(); 316 | if (errorStatusCode === 404) { 317 | return false; 318 | } 319 | this.util.logError(`shimIRuleExists(): ${err}`); 320 | throw err; 321 | }); 322 | } 323 | 324 | shimIRule (originalRestOp, workerContext, virtualServer) { 325 | // Check to make sure the shim irule exists first 326 | return this.shimIRuleExists(originalRestOp, workerContext) 327 | .then((exists) => { 328 | this.util.logDebug(`shimIRule(): in first then() ${exists}`); 329 | if (!exists) { 330 | return this.createShimIRule(originalRestOp, workerContext); 331 | } 332 | }) 333 | .then(() => { 334 | this.util.logDebug(`shimIRule(): in second then() vsfullpath: ${workerContext.restHelper.jsonPrinter(virtualServer)}`); 335 | return this.getIRulesByVirtualServer(originalRestOp, workerContext, virtualServer); 336 | }) 337 | .then((rules) => { 338 | this.util.logDebug(`shimIRule(): in third then()`); 339 | let existingIRules = rules || []; 340 | this.util.logDebug(`shimIRule(): getIRulesByVirtualServer response ${workerContext.restHelper.jsonPrinter(existingIRules)}`); 341 | if (existingIRules.indexOf(SHIM_IRULE_FULLPATH) === -1) { 342 | existingIRules.push(SHIM_IRULE_FULLPATH); 343 | this.util.logDebug(`shimIRule(): after irule push ${workerContext.restHelper.jsonPrinter(existingIRules)}`); 344 | return this.setIRulesByVirtualServer(originalRestOp, workerContext, virtualServer, existingIRules); 345 | } 346 | return existingIRules; 347 | }) 348 | .catch((err) => { 349 | this.util.logError(`shimIRule(): ${err}`); 350 | throw err; 351 | }); 352 | } 353 | 354 | iRuleIsShimmed (originalRestOp, workerContext, virtualServer) { 355 | // Check to make sure the shim irule exists first 356 | return this.getIRulesByVirtualServer(originalRestOp, workerContext, virtualServer) 357 | .then((rules) => { 358 | let existingIRules = rules || []; 359 | this.util.logDebug(`iRuleIsShimmed(): getIRulesByVirtualServer response ${workerContext.restHelper.jsonPrinter(existingIRules)}`); 360 | return existingIRules.indexOf(SHIM_IRULE_FULLPATH) >= 0; 361 | }) 362 | .catch((err) => { 363 | this.util.logError(`iRuleIsShimmed(): ${err}`); 364 | throw err; 365 | }); 366 | } 367 | 368 | /** GET irules bound to a virtual server from the big-ip */ 369 | getIRulesByVirtualServer (originalRestOp, workerContext, virtualServer) { 370 | const uri = workerContext.restHelper.makeRestjavadUri(`/mgmt/tm/ltm/virtual/${this.convertPathForQuery(virtualServer)}`, '$select=rules'); 371 | this.util.logDebug(`getIRulesByVirtualServer(): uri ${workerContext.restHelper.jsonPrinter(uri)}`); 372 | return workerContext.restRequestSender.sendGet(this.getRestOperationInstance(originalRestOp, workerContext, uri)) 373 | .then((data) => data.body['rules']) 374 | .catch((err) => { 375 | this.util.logError(`getIRulesByVirtualServer(): ${err}`); 376 | throw err; 377 | }); 378 | } 379 | 380 | /** PATCH irules bound to a virtual server from the big-ip */ 381 | setIRulesByVirtualServer (originalRestOp, workerContext, virtualServer, iRules) { 382 | const uri = workerContext.restHelper.makeRestjavadUri(`/mgmt/tm/ltm/virtual/${this.convertPathForQuery(virtualServer)}`); 383 | const rules = { rules: iRules }; 384 | this.util.logDebug(`setIRulesByVirtualServer(): uri ${workerContext.restHelper.jsonPrinter(uri)} rules ${workerContext.restHelper.jsonPrinter(rules)}`); 385 | return workerContext.restRequestSender.sendPatch(this.getRestOperationInstance(originalRestOp, workerContext, uri, rules)) 386 | .then((data) => { 387 | this.util.logDebug(`setIRulesByVirtualServer(): PATCH response ${workerContext.restHelper.jsonPrinter(data.body)}`); 388 | return data.body['rules']; 389 | }) 390 | .catch((err) => { 391 | this.util.logError(`setIRulesByVirtualServer(): ${err}`); 392 | throw err; 393 | }); 394 | } 395 | 396 | unShimIRuleAndDeleteDeclaration (originalRestOp, workerContext, declarationName) { 397 | return this.getBlueGreenDeclaration(originalRestOp, workerContext, declarationName) 398 | .then((declaration) => { 399 | const deleteBlueGreenDeclarationPromise = this.deleteBlueGreenDeclaration(originalRestOp, workerContext, declaration.name); 400 | const unShimIRulePromise = this.unShimIRule(originalRestOp, workerContext, declaration.virtualServer); 401 | return Promise.all([deleteBlueGreenDeclarationPromise, unShimIRulePromise]); 402 | }) 403 | .catch((err) => { 404 | this.util.logError(`unShimIRuleAndDeleteDeclaration(): ${err}`); 405 | throw err; 406 | }); 407 | } 408 | 409 | /** DELETE datagroup by partition from the big-ip */ 410 | deleteDataGroup (originalRestOp, workerContext) { 411 | const uri = workerContext.restHelper.makeRestjavadUri(DATA_GROUP_URI); 412 | return workerContext.restRequestSender.sendDelete(this.getRestOperationInstance(originalRestOp, workerContext, uri)); 413 | } 414 | 415 | unShimIRule (originalRestOp, workerContext, virtualServer) { 416 | this.util.logDebug(`unShimIRule(): virtualServer ${virtualServer}`); 417 | return this.getIRulesByVirtualServer(originalRestOp, workerContext, virtualServer) 418 | .then((data) => { 419 | let iRules = data || []; 420 | if (iRules.indexOf(SHIM_IRULE_FULLPATH) !== -1) { 421 | iRules = iRules.filter(v => v !== SHIM_IRULE_FULLPATH); 422 | return this.setIRulesByVirtualServer(originalRestOp, workerContext, virtualServer, iRules); 423 | } 424 | return iRules; 425 | }) 426 | .catch((err) => { 427 | this.util.logError(`unShimIRule(): ${err}`); 428 | throw err; 429 | }); 430 | } 431 | 432 | buildIRuleBody (shimIRuleFullPath) { 433 | return new Promise((resolve, reject) => { 434 | fs.readFile(path.join(__dirname, IRULE_FILE), (err, data) => { 435 | // if has error reject, otherwise resolve 436 | if (err) reject(err); 437 | const template = handlebars.compile(data.toString()); 438 | const outputString = template({ version: this.util.getApiVersion(), cookiePrefix: COOKIE_PREFIX, dataGroup: DATA_GROUP }); 439 | const iRule = { 440 | 'kind': 'tm:ltm:rule:rulestate', 441 | 'name': SHIM_IRULE_NAME, 442 | 'fullPath': shimIRuleFullPath, 443 | 'apiAnonymous': outputString 444 | }; 445 | return resolve(iRule); 446 | }); 447 | }); 448 | } 449 | 450 | buildDataGroupBody (records) { 451 | const body = { 452 | 'name': DATA_GROUP, 453 | 'partition': DEFAULT_PARTITION 454 | }; 455 | if (records) { 456 | body['records'] = records.map(record => this.buildDGRecordFromDeclaration(record)); 457 | } else { 458 | body['type'] = 'string'; 459 | } 460 | return body; 461 | } 462 | 463 | buildDGRecordFromDeclaration (declaration) { 464 | return { 465 | 'name': declaration.name, 466 | 'data': `${declaration.virtualServer},${declaration.distribution},${declaration.bluePool},${declaration.greenPool}` 467 | }; 468 | } 469 | 470 | buildDeclarationFromDGRecord (record) { 471 | const dArray = record.data.split(','); 472 | return { 473 | name: record.name, 474 | virtualServer: dArray[0], 475 | distribution: Number(dArray[1]), 476 | bluePool: dArray[2], 477 | greenPool: dArray[3] 478 | }; 479 | } 480 | 481 | convertPathForQuery (path) { 482 | return path.replace(/\//g, '~'); 483 | } 484 | 485 | getRestOperationInstance (originalRestOp, workerContext, uri, body) { 486 | const restOp = workerContext.restOperationFactory.createRestOperationInstance() 487 | .setUri(uri) 488 | .setIsSetBasicAuthHeader(true) 489 | .setBasicAuthorization(originalRestOp.getBasicAuthorization()); 490 | if (body !== undefined) { 491 | restOp.setBody(body); 492 | } 493 | return restOp; 494 | } 495 | 496 | virtualServerHasHttpProfile (virtualServer) { 497 | const profilesArray = virtualServer.profilesReference.items || []; 498 | this.util.logDebug(`virtualServerHasHttpProfile(): virtualServer ${JSON.stringify(virtualServer)}`); 499 | // eslint-disable-next-line no-useless-escape 500 | const pattern = new RegExp('\/ltm\/profile\/http\/'); 501 | return profilesArray.filter(p => this.util.safeAccess(() => p.nameReference.link, false) && pattern.test(p.nameReference.link.toLowerCase())).length > 0; 502 | } 503 | } 504 | 505 | module.exports = ApiClient; 506 | -------------------------------------------------------------------------------- /api/nodejs/bigIpConfigRestWorker.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const ApiClient = require('./apiClient'); 3 | const Util = require('./util'); 4 | 5 | /** 6 | * @class BigIpConfigRestWorker 7 | * @mixes RestWorker 8 | * 9 | * @description A simple worker that outlines functions that 10 | * can be defined and when and how they are called 11 | * 12 | * Called when the worker is loaded from disk and first 13 | * instantiated by the @LoaderWorker 14 | * @constructor 15 | */ 16 | class BigIpConfigRestWorker { 17 | constructor () { 18 | this.apiClient = new ApiClient(); 19 | this.util = new Util('BigIpConfigRestWorker', false); 20 | this.WORKER_URI_PATH = 'shared/blue-green/bigip-config'; 21 | this.isPublic = true; 22 | } 23 | 24 | /***************** 25 | * http handlers * 26 | *****************/ 27 | 28 | /** 29 | * optional 30 | * handle onGet HTTP request 31 | * @param {Object} restOperation 32 | */ 33 | onGet (restOperation) { 34 | const workerContext = this; 35 | this.apiClient.getAllBigIpConfigData(restOperation, workerContext) 36 | .then((items) => { 37 | this.completeSuccess(restOperation, items); 38 | }) 39 | .catch((err) => { 40 | this.completeError(restOperation, err); 41 | }); 42 | } 43 | 44 | completeSuccess (restOperation, output) { 45 | restOperation.setStatusCode(200); 46 | restOperation.setBody(output); 47 | restOperation.complete(); 48 | } 49 | 50 | completeError (restOperation, error) { 51 | const code = error.code || 500; 52 | this.util.logError(this.restHelper.jsonPrinter(error)); 53 | restOperation.setStatusCode(code); 54 | restOperation.setBody(error.message); 55 | restOperation.complete(); 56 | } 57 | } 58 | 59 | module.exports = BigIpConfigRestWorker; 60 | -------------------------------------------------------------------------------- /api/nodejs/blueGreenConfigRestWorker.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const ApiClient = require('./apiClient'); 3 | const Util = require('./util'); 4 | 5 | /** 6 | * @class BlueGreenConfigRestWorker 7 | * @mixes RestWorker 8 | * 9 | * @description A simple worker that outlines functions that 10 | * can be defined and when and how they are called 11 | * 12 | * Called when the worker is loaded from disk and first 13 | * instantiated by the @LoaderWorker 14 | * @constructor 15 | */ 16 | class BlueGreenConfigRestWorker { 17 | constructor () { 18 | this.apiClient = new ApiClient(); 19 | this.util = new Util('BlueGreenConfigRestWorker', false); 20 | this.WORKER_URI_PATH = 'shared/blue-green/config'; 21 | this.isPublic = true; 22 | this.isPassThrough = true; 23 | } 24 | 25 | /***************** 26 | * http handlers * 27 | *****************/ 28 | 29 | /** 30 | * optional 31 | * handle onGet HTTP request 32 | * @param {Object} restOperation 33 | */ 34 | onGet (restOperation) { 35 | const workerContext = this; 36 | const declarationName = restOperation.getUri().pathname.split('/')[4]; 37 | if (!declarationName) { 38 | this.apiClient.getAllBlueGreenDeclarations(restOperation, workerContext) 39 | .then((declarations) => { 40 | this.completeSuccess(restOperation, declarations); 41 | }) 42 | .catch((err) => { 43 | this.completeError(restOperation, err); 44 | }); 45 | } else { 46 | this.apiClient.getBlueGreenDeclaration(restOperation, workerContext, declarationName) 47 | .then((declaration) => { 48 | if (!this.util.isEmptyObject(declaration)) { 49 | this.completeSuccess(restOperation, declaration); 50 | } else { 51 | const err = new Error(`declaration '${declarationName}' does not exist`); 52 | err.code = 404; 53 | this.completeError(restOperation, err); 54 | } 55 | }) 56 | .catch((err) => { 57 | this.completeError(restOperation, err); 58 | }); 59 | } 60 | } 61 | 62 | completeSuccess (restOperation, output) { 63 | restOperation.setStatusCode(200); 64 | restOperation.setBody(output); 65 | restOperation.complete(); 66 | } 67 | 68 | completeError (restOperation, error) { 69 | const code = error.code || 500; 70 | this.util.logError(this.restHelper.jsonPrinter(error)); 71 | restOperation.setStatusCode(code); 72 | restOperation.setBody(error.message); 73 | restOperation.complete(); 74 | } 75 | } 76 | 77 | module.exports = BlueGreenConfigRestWorker; 78 | -------------------------------------------------------------------------------- /api/nodejs/declarationRestWorker.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const ApiClient = require('./apiClient'); 3 | const Util = require('./util'); 4 | 5 | /** 6 | * @class DeclarationRestWorker 7 | * @mixes RestWorker 8 | * 9 | * @description A simple worker that outlines functions that 10 | * can be defined and when and how they are called 11 | * 12 | * Called when the worker is loaded from disk and first 13 | * instantiated by the @LoaderWorker 14 | * @constructor 15 | */ 16 | class DeclarationRestWorker { 17 | constructor () { 18 | this.apiClient = new ApiClient(); 19 | this.util = new Util('DeclarationRestWorker', false); 20 | this.WORKER_URI_PATH = 'shared/blue-green/declare'; 21 | this.isPublic = true; 22 | this.isPassThrough = true; 23 | } 24 | 25 | /** 26 | * handle onPost HTTP request 27 | * @param {Object} restOperation 28 | */ 29 | onPost (restOperation) { 30 | const workerContext = this; 31 | this.util.logDebug('blue-green declaration POST request'); 32 | 33 | let declaration = restOperation.getBody(); 34 | 35 | this.util.validateDeclaration(declaration) 36 | .then((validatedDeclaration) => { 37 | this.util.logInfo('Schema valid blue-green declaration'); 38 | declaration = validatedDeclaration; 39 | }) 40 | .then(() => this.apiClient.getVirtualServer(restOperation, workerContext, declaration.virtualServer)) 41 | .then((vs) => { 42 | if (this.util.isEmptyObject(vs)) { 43 | throw new Error('Virtual Server does not exist.'); 44 | } else if (!vs.hasHttpProfile) { 45 | throw new Error('Virtual Server has no Http Profile.'); 46 | } 47 | }) 48 | .then(() => this.apiClient.getPools(restOperation, workerContext)) 49 | .then((pools) => { 50 | if (!pools.find(p => p.fullPath === declaration.bluePool)) throw new Error(`Blue pool '${declaration.bluePool}' does not exist.`); 51 | if (!pools.find(p => p.fullPath === declaration.greenPool)) throw new Error(`Green pool '${declaration.greenPool}' does not exist.`); 52 | }) 53 | .then(() => this.apiClient.isDeclarationConflicting(restOperation, workerContext, declaration)) 54 | .then((result) => { 55 | if (result.conflict) { 56 | throw new Error(`Virtual Server in declaration '${declaration.name}' conflicts with existing declaration '${result.reference.name}'`); 57 | } 58 | }) 59 | .then(() => { 60 | this.util.logInfo(`No existing declaration conflicts with provided blue-green declaration`); 61 | return this.apiClient.buildBlueGreenObjects(restOperation, workerContext, declaration); 62 | }) 63 | .then(() => { 64 | restOperation.setBody(declaration); 65 | this.completeRestOperation(restOperation); 66 | }) 67 | .catch((err) => { 68 | err.code = 422; 69 | this.util.logError(`declarationRestWorker onPost(): ${err}`); 70 | this.completeError(restOperation, err); 71 | }); 72 | } 73 | 74 | /** 75 | * Handle HTTP DELETE method 76 | * @param {object} restOperation 77 | * @returns {void} 78 | */ 79 | onDelete (restOperation) { 80 | const workerContext = this; 81 | const objectId = restOperation.getUri().pathname.split('/')[4]; 82 | 83 | this.util.logDebug(`blue-green declaration DELETE request ${objectId}`); 84 | 85 | this.apiClient.blueGreenDeclarationExists(restOperation, workerContext, objectId) 86 | .then((exists) => { 87 | if (exists) { 88 | return this.apiClient.unShimIRuleAndDeleteDeclaration(restOperation, workerContext, objectId); 89 | } else { 90 | const err = new Error(`declaration '${objectId}' does not exist`); 91 | err.code = 404; 92 | throw err; 93 | } 94 | }) 95 | .then(() => { 96 | this.completeSuccess(restOperation, `{"message": "declaration '${objectId}' successfully deleted"}`); 97 | }) 98 | .catch((err) => { 99 | this.completeError(restOperation, err); 100 | }); 101 | } 102 | 103 | /** 104 | * Create a handler for requests to/example 105 | * @return {Object} example of the object model for this worker 106 | */ 107 | getExampleState () { 108 | const example = { 109 | 'name': 'Sample1', 110 | 'virtualServer': '/Common/MyVirtualServer', 111 | 'distribution': 0.8, 112 | 'bluePool': '/Common/blue_pool', 113 | 'greenPool': '/Common/green_pool' 114 | }; 115 | return example; 116 | } 117 | 118 | completeSuccess (restOperation, output) { 119 | restOperation.setStatusCode(200); 120 | restOperation.setBody(output); 121 | restOperation.complete(); 122 | } 123 | 124 | completeError (restOperation, error) { 125 | const code = error.code || 500; 126 | this.util.logError(this.restHelper.jsonPrinter(error)); 127 | restOperation.setStatusCode(code); 128 | restOperation.setBody(error.message); 129 | restOperation.complete(); 130 | } 131 | } 132 | 133 | module.exports = DeclarationRestWorker; 134 | -------------------------------------------------------------------------------- /api/nodejs/irule-template.tcl: -------------------------------------------------------------------------------- 1 | # BlueGreen v{{version}} 2 | 3 | proc get_datagroup_value {dg_name vs_full_path} { 4 | set dg [class get $dg_name] 5 | foreach x $dg { 6 | if { $x starts_with $vs_full_path} { 7 | return $x 8 | break 9 | } 10 | } 11 | } 12 | 13 | proc get_cookie_name {vs_full_path} { 14 | return "{{cookiePrefix}}[string map "/ _" $vs_full_path]" 15 | } 16 | 17 | proc validate_pool {requested_pool blue_pool green_pool} { 18 | set valid_pools [list $blue_pool $green_pool] 19 | if {[lsearch $valid_pools $requested_pool] != -1} { 20 | return $requested_pool 21 | } else { 22 | # if requested pool isn't either blue or green, return blue. 23 | return [lindex $valid_pools 0] 24 | } 25 | } 26 | 27 | proc debug_log {flag message} { 28 | if { $flag } { log local0. $message } 29 | } 30 | 31 | when CLIENT_ACCEPTED { 32 | set DEBUG 0 33 | set cookie_expiration_seconds 1200 34 | } 35 | 36 | when HTTP_REQUEST { 37 | # Use this to set the cookie name as well as to look up the distribution and pool name settings from the datagroup 38 | set traffic_dist_rule [call get_datagroup_value "{{dataGroup}}" [virtual name]] 39 | set fields [split $traffic_dist_rule ","] 40 | set vs [lindex $fields 0] 41 | set distribution [lindex $fields 1] 42 | set blue_pool [lindex $fields 2] 43 | set green_pool [lindex $fields 3] 44 | set blue_green_cookie [call get_cookie_name $vs] 45 | set cookie_exists [HTTP::cookie exists $blue_green_cookie] 46 | call debug_log $DEBUG "distribution: $distribution" 47 | 48 | switch -- $distribution { 49 | "0" { 50 | pool $green_pool 51 | call debug_log $DEBUG "defaulting to green pool" 52 | set remove_cookie 1 53 | } 54 | 55 | "1" { 56 | pool $blue_pool 57 | call debug_log $DEBUG "defaulting to blue pool" 58 | set remove_cookie 1 59 | } 60 | 61 | default { 62 | # Check if there is a pool selector cookie in the request 63 | if { $cookie_exists } { 64 | # Make sure that the pool in the cookie is one of either blue or green pools. Prevent the client from using an undesired pool. 65 | set pool_name [call validate_pool [HTTP::cookie $blue_green_cookie] $blue_pool $green_pool] 66 | call debug_log $DEBUG "validated pool: $pool_name" 67 | pool $pool_name 68 | set cookie_value_to_set "" 69 | } else { 70 | # No pool selector cookie, so choose a pool based on the datagroup distribution 71 | set rand [expr { rand() }] 72 | if { $rand < $distribution } { 73 | pool $blue_pool 74 | set cookie_value_to_set $blue_pool 75 | } else { 76 | pool $green_pool 77 | set cookie_value_to_set $green_pool 78 | } 79 | } 80 | } 81 | } 82 | } 83 | 84 | when HTTP_RESPONSE { 85 | # Set a pool selector cookie from the pool that was was selected for this request 86 | if {[info exists cookie_value_to_set] && $cookie_value_to_set ne ""}{ 87 | call debug_log $DEBUG "inserting cookie" 88 | HTTP::cookie insert name $blue_green_cookie value $cookie_value_to_set path "/" 89 | HTTP::cookie expires $blue_green_cookie $cookie_expiration_seconds relative 90 | } elseif {$cookie_exists && [info exists remove_cookie] } { 91 | unset -- remove_cookie 92 | # If there is no need to store a selected pool in a cookie, remove any previously stored blue-green cookie for this vs 93 | call debug_log $DEBUG "removing cookie $blue_green_cookie" 94 | HTTP::header insert Set-Cookie "$blue_green_cookie=deleted;expires=Thu, 01 Jan 1970 00:00:00 GMT" 95 | } 96 | } -------------------------------------------------------------------------------- /api/nodejs/schema.json: -------------------------------------------------------------------------------- 1 | { 2 | "definitions": {}, 3 | "$id": "https://github.com/aknot242/bigip-blue-green", 4 | "type": "object", 5 | "title": "The Root Schema", 6 | "required": [ 7 | "name", 8 | "virtualServer", 9 | "distribution", 10 | "bluePool", 11 | "greenPool" 12 | ], 13 | "properties": { 14 | "name": { 15 | "$id": "#/properties/name", 16 | "type": "string", 17 | "title": "The Name Schema", 18 | "default": "", 19 | "examples": [ 20 | "My_BlueGreen_Config" 21 | ], 22 | "pattern": "^[A-Za-z][0-9A-Za-z_-]{0,47}$" 23 | }, 24 | "virtualServer": { 25 | "$id": "#/properties/virtualServer", 26 | "type": "string", 27 | "title": "The Virtual Server Schema", 28 | "default": "", 29 | "examples": [ 30 | "/MyPartition/Application_1/serviceMain" 31 | ], 32 | "pattern": "^\\x2f[^\\x00-\\x19\\x22#'*<>?\\x5b-\\x5d\\x7b-\\x7d\\x7f]+$" 33 | }, 34 | "distribution": { 35 | "$id": "#/properties/distribution", 36 | "type": "number", 37 | "title": "The Distribution Schema", 38 | "minimum": 0.0, 39 | "maximum": 1.0, 40 | "default": 0.0, 41 | "examples": [ 42 | 0.5 43 | ] 44 | }, 45 | "bluePool": { 46 | "$id": "#/properties/bluePool", 47 | "type": "string", 48 | "format": "pool-must-be-accessible-to-virtual-server", 49 | "title": "The Blue Pool Schema", 50 | "default": "", 51 | "examples": [ 52 | "blue_pool" 53 | ], 54 | "pattern": "^\\x2f[^\\x00-\\x19\\x22#'*<>?\\x5b-\\x5d\\x7b-\\x7d\\x7f]+$" 55 | }, 56 | "greenPool": { 57 | "$id": "#/properties/greenPool", 58 | "type": "string", 59 | "format": "pool-must-be-accessible-to-virtual-server", 60 | "title": "The Green Pool Schema", 61 | "default": "", 62 | "examples": [ 63 | "green_pool" 64 | ], 65 | "pattern": "^\\x2f[^\\x00-\\x19\\x22#'*<>?\\x5b-\\x5d\\x7b-\\x7d\\x7f]+$" 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /api/nodejs/util.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Util: 3 | * Collection of helper functions for BlueGreen modules. 4 | * 5 | * D. Edgar, January 2019 6 | * http://github.com/aknot242 7 | * 8 | */ 9 | 10 | 'use strict'; 11 | const BlueGreenSchema = require('./schema.json'); 12 | const Ajv = require('ajv'); 13 | const F5Logger = require('f5-logger'); 14 | const COMMON_PARTITION = 'Common'; 15 | 16 | class Util { 17 | constructor (moduleName, debugEnabled) { 18 | this.moduleName = moduleName; 19 | this.debugEnabled = debugEnabled; 20 | this.loggerInstance = F5Logger.getInstance(); 21 | } 22 | 23 | /** 24 | * Logging helper used to log info messages 25 | * @param {*} message The info message to log 26 | */ 27 | logInfo (message) { 28 | this.loggerInstance.info(this.formatMessage(message)); 29 | } 30 | 31 | /** 32 | * Logging helper used to log debug messages 33 | * @param {*} message The debug message to log 34 | */ 35 | logDebug (message) { 36 | if (this.debugEnabled === true) { 37 | this.loggerInstance.info(this.formatMessage(message, 'DEBUG')); 38 | } 39 | } 40 | 41 | /** 42 | * Logging helper used to log error messages 43 | * @param {*} message The error message to log 44 | */ 45 | logError (message) { 46 | this.loggerInstance.info(this.formatMessage(message, 'ERROR')); 47 | } 48 | 49 | /** 50 | * String formatting helper for log messages 51 | * @param {*} message The message to format 52 | */ 53 | formatMessage (message, classifier) { 54 | classifier = typeof classifier !== 'undefined' ? ` - ${classifier}` : ''; 55 | return `[${this.moduleName}${classifier}] - ${message}`; 56 | } 57 | 58 | /** 59 | * Safely access object and property values without having to stack up safety checks for undefined values 60 | * @param {*} func A function that encloses the value to check 61 | * @param {*} fallbackValue An optional default value that is returned if any of the values in the object heirarchy are undefined. If this parameter isn't supplied, undefined will be returned instead of a default fallback value. 62 | */ 63 | safeAccess (func, fallbackValue) { 64 | try { 65 | var value = func(); 66 | return (value === null || value === undefined) ? fallbackValue : value; 67 | } catch (e) { 68 | return fallbackValue; 69 | } 70 | } 71 | 72 | validateDeclaration (input) { 73 | return new Promise((resolve, reject) => { 74 | let message = 'success'; 75 | const jsonInput = this.isJson(input); 76 | // Validate the input against the schema 77 | let ajv = new Ajv({ jsonPointers: true, allErrors: false, verbose: true, useDefaults: false }); 78 | 79 | this.getSchemaFormats(jsonInput).forEach(format => ajv.addFormat(format.name, format.check)); 80 | const validate = ajv.compile(BlueGreenSchema); 81 | const valid = validate(jsonInput); 82 | if (valid === false) { 83 | const error = this.safeAccess(() => validate.errors[0].message, ''); 84 | if (error !== '') { 85 | message = this.translateAjvError(validate.errors[0]); 86 | } else { 87 | message = 'Unknown validation error.'; 88 | } 89 | reject(new Error(`Schema invalid blue-green declaration: ${message}`)); 90 | } 91 | this.logDebug(`validateDeclaration(): ${message}`); 92 | resolve(jsonInput); 93 | }); 94 | } 95 | 96 | /** 97 | * Format an Ajv schema validator message depending on category 98 | * 99 | * @param {*} errorDetail 100 | */ 101 | translateAjvError (errorDetail) { 102 | switch (errorDetail.keyword) { 103 | case 'enum': 104 | return `${errorDetail.dataPath} ${errorDetail.message}. Specified value: ${errorDetail.data} (allowed value(s) are ${errorDetail.params.allowedValues}`; 105 | case 'required': 106 | return errorDetail.message; 107 | default: 108 | return `${errorDetail.dataPath} ${errorDetail.message}`; 109 | } 110 | } 111 | 112 | /** 113 | * If not an Object, can we parse it? 114 | * 115 | * @param {(object|string)} input 116 | * 117 | * @return {object} 118 | */ 119 | isJson (input) { 120 | if (input && typeof input !== 'object') { 121 | try { 122 | input = JSON.parse(input); 123 | } catch (err) { 124 | this.logInfo(`Unable to parse input: ${err}`); 125 | return; 126 | } 127 | } 128 | return input; 129 | } 130 | 131 | isEmptyObject (obj) { 132 | return !Object.keys(obj).length > 0; 133 | } 134 | 135 | getApiVersion () { 136 | try { 137 | const pjson = require('../package.json'); 138 | return pjson.version; 139 | } catch (err) { 140 | this.logError(`Unable to determine API package version: ${err}`); 141 | return 'UNKNOWN'; 142 | } 143 | } 144 | 145 | getLowerCasePartition (fullPath) { 146 | return fullPath.split('/')[1].toLowerCase(); 147 | } 148 | 149 | getSchemaFormats (declaration) { 150 | return [ 151 | { 152 | name: 'pool-must-be-accessible-to-virtual-server', 153 | check: poolRef => { 154 | if (declaration.virtualServer === undefined) return false; 155 | const poolPartition = this.getLowerCasePartition(poolRef); 156 | return poolPartition === this.getLowerCasePartition(declaration.virtualServer) || poolPartition === COMMON_PARTITION.toLowerCase(); 157 | } 158 | }]; 159 | } 160 | } 161 | 162 | module.exports = Util; 163 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bigip-blue-green-api", 3 | "version": "0.9.2", 4 | "description": "API for bigip-blue-green", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/aknot242/bigip-blue-green.git" 8 | }, 9 | "main": "index.js", 10 | "scripts": { 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "author": "", 14 | "license": "ISC", 15 | "devDependencies": { 16 | "eslint": "^5.16.0", 17 | "eslint-config-standard": "^12.0.0", 18 | "eslint-plugin-import": "^2.16.0", 19 | "eslint-plugin-node": "^8.0.1", 20 | "eslint-plugin-promise": "^4.0.1", 21 | "eslint-plugin-standard": "^4.0.0" 22 | }, 23 | "dependencies": { 24 | "ajv": "^6.12.3", 25 | "handlebars": "^4.7.7" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /api/scripts/refreshBigIp.sh: -------------------------------------------------------------------------------- 1 | USER="" 2 | PASS="" 3 | SERVER="" 4 | # echo $USER $PASS 5 | sshpass -p $PASS scp $2 $USER@$SERVER:/var/config/rest/iapps/bigip-blue-green/nodejs/ 6 | ssh $USER@$SERVER "bigstart restart restnoded" 7 | -------------------------------------------------------------------------------- /build/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10-alpine 2 | 3 | RUN apk add rpm bash 4 | -------------------------------------------------------------------------------- /build/Dockerfile.asg: -------------------------------------------------------------------------------- 1 | FROM f5devcentral/f5-api-services-gateway:1.0.7 as builder 2 | ARG TARGET 3 | COPY $TARGET /tmp 4 | RUN rpm --nodeps -i /tmp/$(basename $TARGET) 5 | RUN rm /tmp/$(basename $TARGET) 6 | 7 | FROM f5devcentral/f5-api-services-gateway:1.0.7 8 | COPY --from=builder /var/config/rest/iapps/ /var/config/rest/iapps 9 | -------------------------------------------------------------------------------- /build/bigip-blue-green.spec: -------------------------------------------------------------------------------- 1 | Summary: BIG-IP BlueGreen Deployment iControlLX extension 2 | Name: bigip-blue-green 3 | Version: %{_version} 4 | Release: %{_release} 5 | BuildArch: noarch 6 | Group: Development/Tools 7 | License: Commercial 8 | Packager: F5 Networks 9 | 10 | %description 11 | BlueGreen deployment controller for BIG-IP 12 | 13 | %global __os_install_post %{nil} 14 | 15 | %define _rpmfilename %%{ARCH}/%%{NAME}-%%{VERSION}-%%{RELEASE}.%%{ARCH}.rpm 16 | %define IAPP_INSTALL_DIR /var/config/rest/iapps/%{name} 17 | 18 | %prep 19 | rm -rf %{_builddir}/* 20 | cp %{main}/api/manifest.json %{_builddir} 21 | cp %{main}/api/package.json %{_builddir} 22 | cp -r %{main}/api/nodejs %{_builddir} 23 | cp -r %{main}/api/node_modules %{_builddir}/nodejs 24 | cp -r %{main}/ui/dist/bigip-blue-green %{_builddir}/presentation 25 | 26 | %install 27 | rm -rf $RPM_BUILD_ROOT 28 | mkdir -p $RPM_BUILD_ROOT%{IAPP_INSTALL_DIR} 29 | cp %{_builddir}/manifest.json $RPM_BUILD_ROOT%{IAPP_INSTALL_DIR} 30 | cp %{_builddir}/package.json $RPM_BUILD_ROOT%{IAPP_INSTALL_DIR} 31 | cp -r %{_builddir}/nodejs $RPM_BUILD_ROOT%{IAPP_INSTALL_DIR} 32 | cp -r %{_builddir}/presentation $RPM_BUILD_ROOT%{IAPP_INSTALL_DIR} 33 | 34 | %clean 35 | rm -rf $RPM_BUILD_ROOT 36 | 37 | %files 38 | %defattr(-,root,root) 39 | %{IAPP_INSTALL_DIR} 40 | -------------------------------------------------------------------------------- /build/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker build -t bigip-blue-green-build -f build/Dockerfile . 3 | docker run -v $(pwd):/home/ --workdir /home --name bg_build --rm bigip-blue-green-build build/buildAll.sh -------------------------------------------------------------------------------- /build/buildAll.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | . build/buildUi.sh 3 | . build/buildApi.sh 4 | echo building $RPM_NAME 5 | . build/buildRpm.sh -------------------------------------------------------------------------------- /build/buildApi.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pushd api 3 | export VERSION=$(npm version | grep bigip-blue-green | cut -d : -f 2 | awk -F \' '{print $2}') 4 | export RELEASE=0001 5 | export RPM_NAME=bigip-blue-green-${VERSION}-${RELEASE}.noarch.rpm 6 | rm -rf node_modules 7 | npm install --production 8 | popd 9 | -------------------------------------------------------------------------------- /build/buildRpm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | rpmbuild -bb --define "main $(pwd)" --define '_topdir %{main}/build/rpmbuild' --define "_version ${VERSION}" --define "_release ${RELEASE}" build/bigip-blue-green.spec 3 | pushd build/rpmbuild/RPMS/noarch 4 | sha256sum ${RPM_NAME} > ${RPM_NAME}.sha256 5 | popd -------------------------------------------------------------------------------- /build/buildUi.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | pushd ui 3 | npm install -g @angular/cli 4 | npm install 5 | npm run build-prod 6 | popd 7 | -------------------------------------------------------------------------------- /diagram.vsdx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/diagram.vsdx -------------------------------------------------------------------------------- /dist/bigip-blue-green-0.9.0-0001.noarch.rpm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/dist/bigip-blue-green-0.9.0-0001.noarch.rpm -------------------------------------------------------------------------------- /dist/bigip-blue-green-0.9.0-0001.noarch.rpm.sha256: -------------------------------------------------------------------------------- 1 | 480d9b9990d5dc1b4e20d863384ca533f98651cc126869a4fda1bdce913680ae bigip-blue-green-0.9.0-0001.noarch.rpm 2 | -------------------------------------------------------------------------------- /image-sources/512px-Blue_green_cyan_nevit_116.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/image-sources/512px-Blue_green_cyan_nevit_116.png -------------------------------------------------------------------------------- /image-sources/512px-Blue_green_cyan_nevit_116.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /images/api-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/images/api-screenshot.png -------------------------------------------------------------------------------- /images/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/images/diagram.png -------------------------------------------------------------------------------- /images/install-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/images/install-1.png -------------------------------------------------------------------------------- /images/install-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/images/install-2.png -------------------------------------------------------------------------------- /images/ui-screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/images/ui-screenshot-1.png -------------------------------------------------------------------------------- /images/ui-screenshot-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/images/ui-screenshot-2.png -------------------------------------------------------------------------------- /load/locustfile.py: -------------------------------------------------------------------------------- 1 | import re 2 | from locust import HttpLocust, TaskSet, task 3 | 4 | class WebsiteTasks(TaskSet): 5 | 6 | @task 7 | def index(self): 8 | HOME_PAGE_TITLE_REGEX = re.compile(r"You are on node 1") # regex for contents of blue pool member response 9 | with self.client.get("/", verify=False, catch_response=True) as response: 10 | if HOME_PAGE_TITLE_REGEX.search(response.text) == None: 11 | response.failure("not node 1") # a failure indicates that the response is likely from a green node 12 | 13 | class WebsiteUser(HttpLocust): 14 | task_set = WebsiteTasks 15 | min_wait = 5000 16 | max_wait = 15000 17 | -------------------------------------------------------------------------------- /load/run-load-test.sh: -------------------------------------------------------------------------------- 1 | docker run --rm -it -e ATTACKED_HOST="https://f5demo.com" -v $(pwd):/locust -p 8089:8089 ignatisd/locustio -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1 3 | } 4 | -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # BigipBlueGreen 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 7.1.4. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). 28 | -------------------------------------------------------------------------------- /ui/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "bigip-blue-green": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "prefix": "app", 11 | "schematics": {}, 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/bigip-blue-green", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": "src/polyfills.ts", 20 | "tsConfig": "src/tsconfig.app.json", 21 | "assets": [ 22 | "src/favicon.ico", 23 | "src/assets" 24 | ], 25 | "styles": [ 26 | "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css", 27 | "src/styles.css" 28 | ], 29 | "scripts": [] 30 | }, 31 | "configurations": { 32 | "production": { 33 | "fileReplacements": [ 34 | { 35 | "replace": "src/environments/environment.ts", 36 | "with": "src/environments/environment.prod.ts" 37 | } 38 | ], 39 | "optimization": true, 40 | "outputHashing": "all", 41 | "sourceMap": false, 42 | "extractCss": true, 43 | "namedChunks": false, 44 | "aot": true, 45 | "extractLicenses": true, 46 | "vendorChunk": false, 47 | "buildOptimizer": true, 48 | "budgets": [ 49 | { 50 | "type": "initial", 51 | "maximumWarning": "2mb", 52 | "maximumError": "5mb" 53 | } 54 | ] 55 | } 56 | } 57 | }, 58 | "serve": { 59 | "builder": "@angular-devkit/build-angular:dev-server", 60 | "options": { 61 | "browserTarget": "bigip-blue-green:build" 62 | }, 63 | "configurations": { 64 | "production": { 65 | "browserTarget": "bigip-blue-green:build:production" 66 | } 67 | } 68 | }, 69 | "extract-i18n": { 70 | "builder": "@angular-devkit/build-angular:extract-i18n", 71 | "options": { 72 | "browserTarget": "bigip-blue-green:build" 73 | } 74 | }, 75 | "test": { 76 | "builder": "@angular-devkit/build-angular:karma", 77 | "options": { 78 | "main": "src/test.ts", 79 | "polyfills": "src/polyfills.ts", 80 | "tsConfig": "src/tsconfig.spec.json", 81 | "karmaConfig": "src/karma.conf.js", 82 | "styles": [ 83 | "./node_modules/@angular/material/prebuilt-themes/indigo-pink.css", 84 | "src/styles.css" 85 | ], 86 | "scripts": [], 87 | "assets": [ 88 | "src/favicon.ico", 89 | "src/assets" 90 | ] 91 | } 92 | }, 93 | "lint": { 94 | "builder": "@angular-devkit/build-angular:tslint", 95 | "options": { 96 | "tsConfig": [ 97 | "src/tsconfig.app.json", 98 | "src/tsconfig.spec.json" 99 | ], 100 | "exclude": [ 101 | "**/node_modules/**" 102 | ] 103 | } 104 | } 105 | } 106 | }, 107 | "bigip-blue-green-e2e": { 108 | "root": "e2e/", 109 | "projectType": "application", 110 | "prefix": "", 111 | "architect": { 112 | "e2e": { 113 | "builder": "@angular-devkit/build-angular:protractor", 114 | "options": { 115 | "protractorConfig": "e2e/protractor.conf.js", 116 | "devServerTarget": "bigip-blue-green:serve" 117 | }, 118 | "configurations": { 119 | "production": { 120 | "devServerTarget": "bigip-blue-green:serve:production" 121 | } 122 | } 123 | }, 124 | "lint": { 125 | "builder": "@angular-devkit/build-angular:tslint", 126 | "options": { 127 | "tsConfig": "e2e/tsconfig.e2e.json", 128 | "exclude": [ 129 | "**/node_modules/**" 130 | ] 131 | } 132 | } 133 | } 134 | } 135 | }, 136 | "defaultProject": "bigip-blue-green" 137 | } -------------------------------------------------------------------------------- /ui/browserslist: -------------------------------------------------------------------------------- 1 | # This file is currently used by autoprefixer to adjust CSS to support the below specified browsers 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | # 5 | # For IE 9-11 support, please remove 'not' from the last line of the file and adjust as needed 6 | 7 | > 0.5% 8 | last 2 versions 9 | Firefox ESR 10 | not dead 11 | not IE 9-11 -------------------------------------------------------------------------------- /ui/e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | const { SpecReporter } = require('jasmine-spec-reporter'); 5 | 6 | exports.config = { 7 | allScriptsTimeout: 11000, 8 | specs: [ 9 | './src/**/*.e2e-spec.ts' 10 | ], 11 | capabilities: { 12 | 'browserName': 'chrome' 13 | }, 14 | directConnect: true, 15 | baseUrl: 'http://localhost:4200/', 16 | framework: 'jasmine', 17 | jasmineNodeOpts: { 18 | showColors: true, 19 | defaultTimeoutInterval: 30000, 20 | print: function() {} 21 | }, 22 | onPrepare() { 23 | require('ts-node').register({ 24 | project: require('path').join(__dirname, './tsconfig.e2e.json') 25 | }); 26 | jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 27 | } 28 | }; -------------------------------------------------------------------------------- /ui/e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | 3 | describe('workspace-project App', () => { 4 | let page: AppPage; 5 | 6 | beforeEach(() => { 7 | page = new AppPage(); 8 | }); 9 | 10 | it('should display welcome message', () => { 11 | page.navigateTo(); 12 | expect(page.getTitleText()).toEqual('Welcome to bigip-blue-green!'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /ui/e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo() { 5 | return browser.get('/'); 6 | } 7 | 8 | getTitleText() { 9 | return element(by.css('app-root h1')).getText(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ui/e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bigip-blue-green-ui", 3 | "version": "1.0.0", 4 | "description": "API for bigip-blue-green", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/aknot242/bigip-blue-green.git" 8 | }, 9 | "scripts": { 10 | "ng": "ng", 11 | "start": "ng serve", 12 | "start-proxy": "ng serve --proxy-config local-proxy.conf.json", 13 | "start-proxy-azure": "ng serve --proxy-config azure-proxy.conf.json", 14 | "load-test": "locust -f load/load-test.py", 15 | "build": "ng build", 16 | "build-prod": "ng build --prod --base-href /iapps/bigip-blue-green/", 17 | "test": "ng test", 18 | "lint": "ng lint", 19 | "e2e": "ng e2e" 20 | }, 21 | "private": true, 22 | "dependencies": { 23 | "@angular/animations": "^8.1.0", 24 | "@angular/cdk": "^8.0.2", 25 | "@angular/common": "~8.1.0", 26 | "@angular/compiler": "~8.1.0", 27 | "@angular/core": "~11.0.5", 28 | "@angular/forms": "~8.1.0", 29 | "@angular/material": "^8.0.2", 30 | "@angular/platform-browser": "~8.1.0", 31 | "@angular/platform-browser-dynamic": "~8.1.0", 32 | "@angular/router": "~8.1.0", 33 | "core-js": "^3.1.4", 34 | "hammerjs": "^2.0.8", 35 | "ng-http-loader": "^6.0.1", 36 | "rxjs": "^6.5.2", 37 | "tslib": "^1.10.0", 38 | "zone.js": "~0.9.1" 39 | }, 40 | "devDependencies": { 41 | "@angular-devkit/build-angular": "~15.2.10", 42 | "@angular/cli": "~15.0.3", 43 | "@angular/compiler-cli": "~15.0.3", 44 | "@angular/language-service": "~8.1.0", 45 | "@types/jasmine": "~3.3.13", 46 | "@types/jasminewd2": "~2.0.3", 47 | "@types/node": "~12.0.12", 48 | "codelyzer": "^5.0.1", 49 | "jasmine-core": "~3.4.0", 50 | "jasmine-spec-reporter": "~4.2.1", 51 | "karma": "~6.4.1", 52 | "karma-chrome-launcher": "~2.2.0", 53 | "karma-coverage-istanbul-reporter": "~2.0.5", 54 | "karma-jasmine": "~2.0.1", 55 | "karma-jasmine-html-reporter": "^1.4.2", 56 | "protractor": "~5.4.0", 57 | "ts-node": "~8.3.0", 58 | "tslint": "~5.18.0", 59 | "typescript": "~3.4.5" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /ui/src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | import { DeclarationsComponent } from './declarations/declarations.component'; 4 | import { DeclarationDetailComponent } from './declaration-detail/declaration-detail.component'; 5 | 6 | const routes: Routes = [ 7 | { path: '', redirectTo: '/declarations', pathMatch: 'full' }, 8 | { path: 'detail', component: DeclarationDetailComponent }, 9 | { path: 'detail/:name', component: DeclarationDetailComponent }, 10 | { path: 'declarations', component: DeclarationsComponent } 11 | ]; 12 | 13 | @NgModule({ 14 | imports: [ RouterModule.forRoot(routes) ], 15 | exports: [ RouterModule ] 16 | }) 17 | export class AppRoutingModule {} 18 | -------------------------------------------------------------------------------- /ui/src/app/app.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/app/app.component.css -------------------------------------------------------------------------------- /ui/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ title }} 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /ui/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, async } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing' 3 | import { NgHttpLoaderModule } from 'ng-http-loader'; 4 | import { AppComponent } from './app.component'; 5 | import { MessagesComponent } from './messages/messages.component'; 6 | 7 | describe('AppComponent', () => { 8 | beforeEach(async(() => { 9 | TestBed.configureTestingModule({ 10 | declarations: [ 11 | AppComponent, 12 | MessagesComponent 13 | ], 14 | imports: [ 15 | RouterTestingModule, 16 | NgHttpLoaderModule 17 | ] 18 | }).compileComponents(); 19 | })); 20 | 21 | it('should create the app', () => { 22 | const fixture = TestBed.createComponent(AppComponent); 23 | const app = fixture.debugElement.componentInstance; 24 | expect(app).toBeTruthy(); 25 | }); 26 | 27 | it(`should have as title 'BIG-IP BlueGreen Deployment'`, () => { 28 | const fixture = TestBed.createComponent(AppComponent); 29 | const app = fixture.debugElement.componentInstance; 30 | expect(app.title).toEqual('BIG-IP BlueGreen Deployment'); 31 | }); 32 | 33 | it('should render title in a h1 tag', () => { 34 | const fixture = TestBed.createComponent(AppComponent); 35 | fixture.detectChanges(); 36 | const compiled = fixture.debugElement.nativeElement; 37 | expect(compiled.querySelector('h1').textContent).toContain('BIG-IP BlueGreen Deployment'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /ui/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-root', 5 | templateUrl: './app.component.html', 6 | styleUrls: ['./app.component.css'] 7 | }) 8 | export class AppComponent { 9 | title = 'BIG-IP BlueGreen Deployment'; 10 | } -------------------------------------------------------------------------------- /ui/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { BrowserModule } from '@angular/platform-browser'; 2 | import { NgModule } from '@angular/core'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { AppComponent } from './app.component'; 5 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 6 | import { MatButtonModule } from '@angular/material/button'; 7 | import { MatDialogModule } from '@angular/material/dialog'; 8 | import { MatFormFieldModule } from '@angular/material/form-field'; 9 | import { MatInputModule } from '@angular/material/input'; 10 | import { MatSelectModule } from '@angular/material/select'; 11 | import { MatSliderModule } from '@angular/material/slider'; 12 | import { MatTableModule } from '@angular/material/table'; 13 | import { BigIpService, AuthService } from './bigip/services'; 14 | import { HttpClientModule, HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http'; 15 | import { NgHttpLoaderModule } from 'ng-http-loader'; 16 | import { UniqueDeclarationNameValidator } from './unique-declaration-name.directive'; 17 | import { AuthInterceptor } from './http-interceptors/auth-interceptor'; 18 | import { AuthDialog } from './dialogs/auth-dialog'; 19 | import { DeclarationDetailComponent } from './declaration-detail/declaration-detail.component'; 20 | import { DeclarationsComponent } from './declarations/declarations.component'; 21 | import { AppRoutingModule } from './app-routing.module'; 22 | import { MessagesComponent } from './messages/messages.component'; 23 | import { ConfirmDialog } from './dialogs/confirm-dialog'; 24 | 25 | @NgModule({ 26 | declarations: [ 27 | AppComponent, 28 | UniqueDeclarationNameValidator, 29 | AuthDialog, 30 | ConfirmDialog, 31 | DeclarationDetailComponent, 32 | DeclarationsComponent, 33 | MessagesComponent 34 | ], 35 | imports: [ 36 | AppRoutingModule, 37 | HttpClientModule, 38 | FormsModule, 39 | BrowserModule, 40 | BrowserAnimationsModule, 41 | MatFormFieldModule, 42 | MatButtonModule, 43 | MatSliderModule, 44 | MatSelectModule, 45 | MatInputModule, 46 | MatDialogModule, 47 | MatTableModule, 48 | NgHttpLoaderModule.forRoot() 49 | ], 50 | providers: [ 51 | { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true }, 52 | BigIpService, 53 | AuthService, 54 | HttpClient 55 | ], 56 | entryComponents: [AuthDialog, ConfirmDialog], 57 | bootstrap: [AppComponent] 58 | }) 59 | export class AppModule { } 60 | -------------------------------------------------------------------------------- /ui/src/app/bigip/bigip.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule, ModuleWithProviders } from '@angular/core'; 2 | import { BigIpService } from './services'; 3 | 4 | @NgModule() 5 | export class BigIpModule { 6 | 7 | static forRoot(): ModuleWithProviders { 8 | return { 9 | ngModule: BigIpModule, 10 | providers: [ 11 | BigIpService 12 | ] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /ui/src/app/bigip/models/config-data.ts: -------------------------------------------------------------------------------- 1 | import { ObjectReference } from './object-reference'; 2 | import { VirtualServerReference } from './virtual-server-reference'; 3 | 4 | export class ConfigData { 5 | virtualServers: VirtualServerReference[]; 6 | pools: ObjectReference[]; 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/app/bigip/models/declaration.ts: -------------------------------------------------------------------------------- 1 | export class Declaration { 2 | name: string; 3 | virtualServer: string; 4 | distribution: number; 5 | bluePool: string; 6 | greenPool: string; 7 | } 8 | -------------------------------------------------------------------------------- /ui/src/app/bigip/models/object-reference.ts: -------------------------------------------------------------------------------- 1 | export class ObjectReference { 2 | name: string; 3 | fullPath: string; 4 | } 5 | -------------------------------------------------------------------------------- /ui/src/app/bigip/models/virtual-server-reference.ts: -------------------------------------------------------------------------------- 1 | import { ObjectReference } from './object-reference'; 2 | 3 | export class VirtualServerReference extends ObjectReference { 4 | hasHttpProfile: boolean; 5 | } 6 | -------------------------------------------------------------------------------- /ui/src/app/bigip/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { Observable } from 'rxjs'; 3 | import { map, tap } from 'rxjs/operators'; 4 | import { Injectable } from '@angular/core'; 5 | 6 | @Injectable() 7 | export class AuthService { 8 | public currentToken = ''; 9 | 10 | constructor(private http: HttpClient) { } 11 | 12 | getAuthToken() { 13 | return this.currentToken; 14 | } 15 | 16 | authenticate(): Observable { 17 | return this.http.post('/mgmt/shared/authn/login', { 18 | "needsToken": true, 19 | "loginProviderName": "tmos" 20 | }).pipe( 21 | map(response => response['token']['token']), 22 | tap(token => { 23 | this.currentToken = token; 24 | })); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /ui/src/app/bigip/services/bigip.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { HttpClient } from '@angular/common/http'; 3 | import { Observable, of } from 'rxjs'; 4 | import { catchError, tap } from 'rxjs/operators'; 5 | import { ConfigData } from '../models/config-data'; 6 | import { Declaration } from '../models/declaration'; 7 | import { MessageService } from 'src/app/message.service'; 8 | 9 | const BASE_URI = '/mgmt/shared/blue-green'; 10 | 11 | @Injectable() 12 | export class BigIpService { 13 | 14 | constructor(private http: HttpClient, private messageService: MessageService) { } 15 | 16 | /** GET configuration information from the big-ip */ 17 | getBigIpConfigData(): Observable { 18 | return this.http.get(`${BASE_URI}/bigip-config`); 19 | } 20 | 21 | declarationExists(declarationName: string): Observable { 22 | return this.http.get(`${BASE_URI}/config/${declarationName}`) 23 | .pipe(tap(response => response !== undefined)); 24 | } 25 | 26 | saveDeclaration(declaration: Declaration): Observable { 27 | return this.http.post(`${BASE_URI}/declare`, declaration) 28 | .pipe( 29 | tap((status: any) => this.log(`declaration '${status.name}' successfully saved`)), 30 | catchError(this.handleError('saveDeclaration')) 31 | ); 32 | } 33 | 34 | deleteDeclaration(declarationName: string): Observable { 35 | return this.http.delete(`${BASE_URI}/declare/${declarationName}`) 36 | .pipe( 37 | tap((status: any) => this.log(status.message)), 38 | catchError(this.handleError('deleteDeclaration')) 39 | ); 40 | } 41 | 42 | getAllDeclarations(): Observable { 43 | return this.http.get(`${BASE_URI}/config`) 44 | .pipe( 45 | catchError(this.handleError('getAllDeclarations')) 46 | ); 47 | } 48 | 49 | getDeclaration(declarationName: string): Observable { 50 | return this.http.get(`${BASE_URI}/config/${declarationName}`) 51 | .pipe( 52 | catchError(this.handleError('getDeclaration')) 53 | ); 54 | } 55 | 56 | /** 57 | * Handle Http operation that failed. 58 | * Let the app continue. 59 | * @param operation - name of the operation that failed 60 | * @param result - optional value to return as the observable result 61 | */ 62 | private handleError(operation = 'operation', result?: T) { 63 | return (error: any): Observable => { 64 | 65 | console.error(error); 66 | 67 | // TODO: Super hacky. This is due to iControlLX errors coming back in a string. Need to clean this up once errors are being emitted in a structured way. 68 | const innerMessage = error.error.message; 69 | const keyWord = 'body:'; 70 | const parsedError = innerMessage.substring(innerMessage.search(keyWord) + keyWord.length); 71 | this.log(parsedError); 72 | 73 | // Let the app keep running by returning an empty result. 74 | return of(result as T); 75 | }; 76 | } 77 | 78 | /** Log a BigIpService message with the MessageService */ 79 | private log(message: string) { 80 | this.messageService.add(`BigIpService: ${message}`); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /ui/src/app/bigip/services/index.ts: -------------------------------------------------------------------------------- 1 | export { BigIpService } from './bigip.service'; 2 | export { AuthService } from './auth.service'; 3 | -------------------------------------------------------------------------------- /ui/src/app/declaration-detail/declaration-detail.component.css: -------------------------------------------------------------------------------- 1 | mat-slider { 2 | width: 100%; 3 | } 4 | 5 | .selector-container { 6 | width: 40%; 7 | } 8 | 9 | .wide-container { 10 | width: 80%; 11 | } 12 | 13 | .flex-container { 14 | padding: 0; 15 | display: -webkit-box; 16 | display: -moz-box; 17 | display: -ms-flexbox; 18 | display: -webkit-flex; 19 | display: flex; 20 | -webkit-flex-flow: row wrap; 21 | flex-flow: row wrap; 22 | justify-content: space-between; 23 | } 24 | 25 | .flex-item { 26 | padding: 15px; 27 | margin-top: 20px; 28 | font-weight: bold; 29 | text-align: center; 30 | } 31 | 32 | .huge-font { 33 | font-size: 3em; 34 | } 35 | 36 | button.i-need-my-space { 37 | margin-right: 20px; 38 | } 39 | -------------------------------------------------------------------------------- /ui/src/app/declaration-detail/declaration-detail.component.html: -------------------------------------------------------------------------------- 1 | 2 | < < Back 3 | 4 | 5 | 6 | 9 | 10 | Name is already in use. 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 20 | 21 | {{ virtualServer }} 22 | 23 | 24 | 25 | Note: Only virtual servers that use an HTTP Profile will appear in the list. 26 | 27 | 28 | 29 | 30 | 31 | 33 | 34 | {{pool}} 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 45 | 46 | {{pool}} 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | Traffic Distribution 55 | 56 | 57 | 58 | {{100-currentTrafficDist}}%{{shortenPath(declaration.bluePool)}} 59 | 60 | 61 | {{currentTrafficDist}}%{{shortenPath(declaration.greenPool)}} 62 | 63 | 64 | 65 | 66 | Cancel 67 | Save 68 | 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /ui/src/app/declaration-detail/declaration-detail.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { FormsModule } from '@angular/forms'; 3 | import { DeclarationDetailComponent } from './declaration-detail.component'; 4 | import { UniqueDeclarationNameValidator } from '../unique-declaration-name.directive'; 5 | import { MatButtonModule } from '@angular/material/button'; 6 | import { MatDialogModule } from '@angular/material/dialog'; 7 | import { MatFormFieldModule } from '@angular/material/form-field'; 8 | import { MatInputModule } from '@angular/material/input'; 9 | import { MatSelectModule } from '@angular/material/select'; 10 | import { MatSliderModule } from '@angular/material/slider'; 11 | import { RouterTestingModule } from '@angular/router/testing'; 12 | import { BigIpService, AuthService } from '../bigip/services'; 13 | import { HttpClient, HttpHandler } from '@angular/common/http'; 14 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 15 | import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; 16 | import { AuthDialog } from '../dialogs/auth-dialog'; 17 | import { ConfirmDialog } from '../dialogs/confirm-dialog'; 18 | 19 | describe('DeclarationDetailComponent', () => { 20 | let component: DeclarationDetailComponent; 21 | let fixture: ComponentFixture; 22 | 23 | beforeEach(async(() => { 24 | TestBed.configureTestingModule({ 25 | declarations: [ 26 | DeclarationDetailComponent, 27 | UniqueDeclarationNameValidator, 28 | AuthDialog, 29 | ConfirmDialog 30 | ], 31 | imports: [ 32 | FormsModule, 33 | NoopAnimationsModule, 34 | RouterTestingModule, 35 | MatButtonModule, 36 | MatFormFieldModule, 37 | MatSliderModule, 38 | MatSelectModule, 39 | MatInputModule, 40 | MatDialogModule 41 | ], 42 | providers: [ 43 | BigIpService, 44 | AuthService, 45 | HttpClient, 46 | HttpHandler 47 | ] 48 | }) 49 | .overrideModule(BrowserDynamicTestingModule, { 50 | set: { 51 | entryComponents: [AuthDialog, ConfirmDialog], 52 | } 53 | }) 54 | .compileComponents(); 55 | })); 56 | 57 | beforeEach(() => { 58 | fixture = TestBed.createComponent(DeclarationDetailComponent); 59 | component = fixture.componentInstance; 60 | fixture.detectChanges(); 61 | }); 62 | 63 | it('should create', () => { 64 | expect(component).toBeTruthy(); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /ui/src/app/declaration-detail/declaration-detail.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewEncapsulation } from '@angular/core'; 2 | import { BigIpService, AuthService } from '../bigip/services'; 3 | import { Declaration } from '../bigip/models/declaration'; 4 | import { ActivatedRoute, Router } from '@angular/router'; 5 | import { FormMode } from '../enums/form-mode' 6 | 7 | const defaultRoute = '/'; 8 | 9 | @Component({ 10 | selector: 'app-declaration-detail', 11 | templateUrl: './declaration-detail.component.html', 12 | styleUrls: ['./declaration-detail.component.css'], 13 | encapsulation: ViewEncapsulation.None 14 | }) 15 | export class DeclarationDetailComponent implements OnInit { 16 | 17 | constructor(public bigIpService: BigIpService, 18 | private authService: AuthService, 19 | private router: Router, 20 | private route: ActivatedRoute) { } 21 | 22 | FormMode = FormMode; 23 | formMode = FormMode.Create; 24 | declaration = new Declaration(); 25 | virtualServerList = new Array(); 26 | fullPoolList = new Array(); 27 | currentPoolList = new Array(); 28 | currentTrafficDist = 55; 29 | 30 | ngOnInit() { 31 | // TODO: Move authentication somewhere central to all modules. HttpInterceptor maybe? 32 | this.authService.authenticate() 33 | .subscribe(() => { 34 | this.bigIpService.getBigIpConfigData() 35 | .subscribe((config) => { 36 | this.virtualServerList = config.virtualServers 37 | .filter(v => v.hasHttpProfile) 38 | .map(v => v.fullPath) || []; 39 | this.fullPoolList = config.pools.map(p => p.fullPath) || []; 40 | this.currentPoolList = this.fullPoolList; 41 | const nameParam = this.route.snapshot.paramMap.get('name') 42 | if (nameParam) { 43 | this.getDeclarationForEdit(nameParam); 44 | this.formMode = FormMode.Edit; 45 | } 46 | }) 47 | }) 48 | } 49 | 50 | getDeclarationForEdit(declarationName: string): void { 51 | this.bigIpService.getDeclaration(declarationName) 52 | .subscribe(declaration => { 53 | this.declaration = declaration; 54 | this.filterPools(declaration.virtualServer); 55 | this.currentTrafficDist = this.distributionFactorToSliderValue(declaration.distribution); 56 | }); 57 | } 58 | 59 | filterPools(selectedVirtualServer: string) { 60 | const partition = selectedVirtualServer.split('/')[1].toLowerCase(); 61 | this.currentPoolList = this.fullPoolList.filter(p => p.toLowerCase().startsWith(`/${partition}`) || p.toLowerCase().startsWith('/common')); 62 | } 63 | 64 | goBack(): void { 65 | this.router.navigate([defaultRoute]); 66 | } 67 | 68 | save(): void { 69 | this.declaration.distribution = this.sliderValueToDistributionFactor(this.currentTrafficDist); 70 | this.bigIpService.saveDeclaration(this.declaration) 71 | .subscribe((result) => { 72 | if (result) { 73 | this.router.navigate([defaultRoute]); 74 | } 75 | }); 76 | } 77 | 78 | shortenPath(path: string | null) { 79 | if (!path) { return '' }; 80 | const array = path.split('/'); 81 | return array[array.length - 1]; 82 | } 83 | 84 | sliderValueToDistributionFactor(val: number) { 85 | return Number((100 - val) / 100); 86 | } 87 | 88 | distributionFactorToSliderValue(val: number) { 89 | return Math.round(Number(100 - (val * 100))); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /ui/src/app/declarations/declarations.component.css: -------------------------------------------------------------------------------- 1 | table { 2 | width: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /ui/src/app/declarations/declarations.component.html: -------------------------------------------------------------------------------- 1 | Declarations 2 | 3 | 4 | Name 5 | {{element.name}} 6 | 7 | 8 | 9 | Virtual Server 10 | {{element.virtualServer}} 11 | 12 | 13 | 14 | Action 15 | 16 | Edit 17 | Delete 18 | 19 | 20 | 21 | 22 | 23 | 24 | Add 25 | -------------------------------------------------------------------------------- /ui/src/app/declarations/declarations.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { DeclarationsComponent } from './declarations.component'; 3 | import { MatButtonModule } from '@angular/material/button'; 4 | import { MatDialogModule } from '@angular/material/dialog'; 5 | import { MatTableModule } from '@angular/material/table'; 6 | import { RouterTestingModule } from '@angular/router/testing'; 7 | import { BigIpService, AuthService } from '../bigip/services'; 8 | import { HttpClient, HttpHandler } from '@angular/common/http'; 9 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 10 | import { AuthDialog } from '../dialogs/auth-dialog'; 11 | import { ConfirmDialog } from '../dialogs/confirm-dialog'; 12 | import { BrowserDynamicTestingModule } from '@angular/platform-browser-dynamic/testing'; 13 | 14 | describe('DeclarationsComponent', () => { 15 | let component: DeclarationsComponent; 16 | let fixture: ComponentFixture; 17 | 18 | beforeEach(async(() => { 19 | TestBed.configureTestingModule({ 20 | declarations: [ 21 | DeclarationsComponent, 22 | AuthDialog, 23 | ConfirmDialog 24 | ], 25 | imports: [ 26 | NoopAnimationsModule, 27 | RouterTestingModule, 28 | MatButtonModule, 29 | MatDialogModule, 30 | MatTableModule 31 | ], 32 | providers: [ 33 | BigIpService, 34 | AuthService, 35 | HttpClient, 36 | HttpHandler 37 | ] 38 | }) 39 | .overrideModule(BrowserDynamicTestingModule, { 40 | set: { 41 | entryComponents: [AuthDialog, ConfirmDialog], 42 | } 43 | }) 44 | .compileComponents(); 45 | })); 46 | 47 | beforeEach(() => { 48 | fixture = TestBed.createComponent(DeclarationsComponent); 49 | component = fixture.componentInstance; 50 | fixture.detectChanges(); 51 | }); 52 | 53 | it('should create', () => { 54 | expect(component).toBeTruthy(); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /ui/src/app/declarations/declarations.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { MatDialog } from '@angular/material/dialog'; 3 | import { switchMap, catchError } from 'rxjs/operators'; 4 | import { BigIpService, AuthService } from '../bigip/services'; 5 | import { Declaration } from '../bigip/models/declaration'; 6 | import { Observable, of } from 'rxjs'; 7 | import { AuthDialog } from '../dialogs/auth-dialog'; 8 | import { ConfirmDialog } from '../dialogs/confirm-dialog'; 9 | 10 | @Component({ 11 | selector: 'app-declarations', 12 | templateUrl: './declarations.component.html', 13 | styleUrls: ['./declarations.component.css'] 14 | }) 15 | export class DeclarationsComponent implements OnInit { 16 | displayedColumns: string[] = ['name', 'virtualServer', 'action']; 17 | declarations: Observable; 18 | 19 | constructor(private bigIpService: BigIpService, 20 | private authService: AuthService, 21 | private dialog: MatDialog) { } 22 | 23 | ngOnInit() { 24 | this.declarations = this.getDeclarations(); 25 | } 26 | 27 | getDeclarations(): Observable { 28 | // TODO: Move authentication somewhere central to all modules. HttpInterceptor maybe? 29 | return this.authService.authenticate() 30 | .pipe( 31 | switchMap(() => this.bigIpService.getAllDeclarations()), 32 | catchError((err) => { 33 | this.openAuthDialog(err.statusText); 34 | return of([]); 35 | }) 36 | ); 37 | } 38 | 39 | openAuthDialog(message: string): void { 40 | // Wrapping in a promise to work around issue: https://github.com/angular/material2/issues/5268 41 | Promise.resolve().then(() => { 42 | const dialogRef = this.dialog.open(AuthDialog, { 43 | data: { message: message }, 44 | }); 45 | dialogRef.afterClosed().subscribe(() => { 46 | window.location.href = '/xui/'; 47 | }); 48 | }); 49 | } 50 | 51 | openConfirmDeleteDialog(objectName: string): void { 52 | const deleteDialogRef = this.dialog.open(ConfirmDialog, { 53 | width: '300px', 54 | data: { title: 'Confirm Delete', message: `Are you sure you want to delete '${objectName}'?` } 55 | }); 56 | 57 | deleteDialogRef.afterClosed().subscribe(result => { 58 | if (result) { 59 | this.bigIpService.deleteDeclaration(objectName).subscribe(() => { 60 | // TODO: Seems like a hacky way to trigger a table refresh 61 | this.declarations = this.getDeclarations(); 62 | }); 63 | } 64 | }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /ui/src/app/dialogs/auth-dialog.html: -------------------------------------------------------------------------------- 1 | {{ message }} 2 | 3 | To use this module, you must first log in to BIG-IP TMUI. 4 | 5 | 6 | OK 7 | -------------------------------------------------------------------------------- /ui/src/app/dialogs/auth-dialog.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject } from '@angular/core'; 2 | import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; 3 | 4 | @Component({ 5 | selector: 'auth-dialog', 6 | templateUrl: 'auth-dialog.html', 7 | }) 8 | export class AuthDialog { 9 | message: string; 10 | 11 | constructor(public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: any) { 12 | this.message = data.message; 13 | } 14 | 15 | onOkClick(): void { 16 | this.dialogRef.close(); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /ui/src/app/dialogs/confirm-dialog.html: -------------------------------------------------------------------------------- 1 | {{data.title}} 2 | 3 | {{data.message}} 4 | 5 | 6 | Cancel 7 | Ok 8 | -------------------------------------------------------------------------------- /ui/src/app/dialogs/confirm-dialog.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject } from '@angular/core'; 2 | import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; 3 | 4 | export interface ConfirmDialogData { 5 | title: string; 6 | message: string; 7 | } 8 | 9 | @Component({ 10 | selector: 'confirm-dialog', 11 | templateUrl: 'confirm-dialog.html', 12 | }) 13 | export class ConfirmDialog { 14 | message: string; 15 | 16 | constructor(public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: ConfirmDialogData) { 17 | this.message = data.message; 18 | } 19 | 20 | onCancelClick(): void { 21 | this.dialogRef.close(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ui/src/app/enums/form-mode.ts: -------------------------------------------------------------------------------- 1 | export enum FormMode { 2 | Create, 3 | Edit 4 | } 5 | -------------------------------------------------------------------------------- /ui/src/app/http-interceptors/auth-interceptor.ts: -------------------------------------------------------------------------------- 1 | 2 | import { throwError as observableThrowError, Observable, BehaviorSubject } from 'rxjs'; 3 | import { take, filter, catchError, switchMap, finalize } from 'rxjs/operators'; 4 | import { Injectable, Injector } from '@angular/core'; 5 | import { HttpInterceptor, HttpRequest, HttpHandler, HttpSentEvent, HttpHeaderResponse, HttpProgressEvent, HttpResponse, HttpUserEvent, HttpErrorResponse } from '@angular/common/http'; 6 | import { AuthService } from '../bigip/services/auth.service'; 7 | 8 | // Borrowed from: https://github.com/IntertechInc/http-interceptor-refresh-token 9 | 10 | @Injectable() 11 | export class AuthInterceptor implements HttpInterceptor { 12 | 13 | isRefreshingToken = false; 14 | tokenSubject = new BehaviorSubject(null); 15 | 16 | constructor(private injector: Injector) { } 17 | 18 | addToken(req: HttpRequest, token: string): HttpRequest { 19 | return req.clone({ setHeaders: { 'X-F5-Auth-Token': token } }) 20 | } 21 | 22 | intercept(req: HttpRequest, next: HttpHandler): Observable | HttpUserEvent> { 23 | const authService = this.injector.get(AuthService); 24 | 25 | if (req.url.toLowerCase() === '/mgmt/shared/authn/login' && req.method === 'POST') { 26 | return next.handle(req); 27 | } 28 | 29 | return next.handle(this.addToken(req, authService.getAuthToken())) 30 | .pipe( 31 | catchError(error => { 32 | if (error instanceof HttpErrorResponse) { 33 | switch ((error).status) { 34 | case 401: 35 | return this.handle401Error(req, next); 36 | default: 37 | return observableThrowError(error); 38 | } 39 | } else { 40 | return observableThrowError(error); 41 | } 42 | })); 43 | } 44 | 45 | handle401Error(req: HttpRequest, next: HttpHandler) { 46 | if (!this.isRefreshingToken) { 47 | this.isRefreshingToken = true; 48 | 49 | // Reset here so that the following requests wait until the token 50 | // comes back from the refreshToken call. 51 | this.tokenSubject.next(null); 52 | 53 | const authService = this.injector.get(AuthService); 54 | 55 | return authService.authenticate().pipe( 56 | switchMap((newToken: string) => { 57 | if (newToken) { 58 | this.tokenSubject.next(newToken); 59 | return next.handle(this.addToken(req, newToken)); 60 | } 61 | 62 | // If we don't get a new token, we are in trouble so logout. 63 | return this.logoutUser(); 64 | }), 65 | catchError(error => { 66 | // If there is an exception calling 'refreshToken', bad news so logout. 67 | return this.logoutUser(); 68 | }), 69 | finalize(() => { 70 | this.isRefreshingToken = false; 71 | })); 72 | } else { 73 | return this.tokenSubject.pipe( 74 | filter(token => token != null), 75 | take(1), 76 | switchMap(token => { 77 | return next.handle(this.addToken(req, token)); 78 | })); 79 | } 80 | } 81 | 82 | logoutUser() { 83 | // Route to the login page (implementation up to you) 84 | 85 | return observableThrowError(''); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /ui/src/app/http-interceptors/index.ts: -------------------------------------------------------------------------------- 1 | /* "Barrel" of Http Interceptors */ 2 | import { HTTP_INTERCEPTORS } from '@angular/common/http'; 3 | 4 | import { AuthInterceptor } from './auth-interceptor'; 5 | 6 | /** Http interceptor providers in outside-in order */ 7 | export const httpInterceptorProviders = [ 8 | { provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true } 9 | ]; 10 | -------------------------------------------------------------------------------- /ui/src/app/message.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed, inject } from '@angular/core/testing'; 2 | 3 | import { MessageService } from './message.service'; 4 | 5 | describe('MessageService', () => { 6 | beforeEach(() => { 7 | TestBed.configureTestingModule({ 8 | providers: [MessageService] 9 | }); 10 | }); 11 | 12 | it('should be created', inject([MessageService], (service: MessageService) => { 13 | expect(service).toBeTruthy(); 14 | })); 15 | }); 16 | -------------------------------------------------------------------------------- /ui/src/app/message.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ providedIn: 'root' }) 4 | export class MessageService { 5 | messages: string[] = []; 6 | 7 | add(message: string) { 8 | this.messages.push(message); 9 | } 10 | 11 | clear() { 12 | this.messages = []; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /ui/src/app/messages/messages.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/app/messages/messages.component.css -------------------------------------------------------------------------------- /ui/src/app/messages/messages.component.html: -------------------------------------------------------------------------------- 1 | 2 | Messages 3 | clear 4 | {{message}} 5 | 6 | -------------------------------------------------------------------------------- /ui/src/app/messages/messages.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { MessagesComponent } from './messages.component'; 4 | 5 | describe('MessagesComponent', () => { 6 | let component: MessagesComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ MessagesComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(MessagesComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should be created', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /ui/src/app/messages/messages.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit, ViewEncapsulation } from '@angular/core'; 2 | import { MessageService } from '../message.service'; 3 | 4 | @Component({ 5 | selector: 'app-messages', 6 | templateUrl: './messages.component.html', 7 | styleUrls: ['./messages.component.css'], 8 | encapsulation: ViewEncapsulation.None 9 | }) 10 | export class MessagesComponent implements OnInit { 11 | 12 | constructor(public messageService: MessageService) {} 13 | 14 | ngOnInit() { 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /ui/src/app/unique-declaration-name.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, forwardRef, Input } from '@angular/core'; 2 | import { 3 | AsyncValidator, 4 | AbstractControl, 5 | NG_ASYNC_VALIDATORS, 6 | ValidationErrors 7 | } from '@angular/forms'; 8 | import { catchError, map } from 'rxjs/operators'; 9 | import { Observable, throwError, of as observableOf } from 'rxjs'; 10 | import { BigIpService } from './bigip/services/bigip.service'; 11 | 12 | @Directive({ 13 | selector: '[uniqueDeclarationName]', 14 | providers: [ 15 | { 16 | provide: NG_ASYNC_VALIDATORS, 17 | useExisting: forwardRef(() => UniqueDeclarationNameValidator), 18 | multi: true 19 | } 20 | ] 21 | }) 22 | export class UniqueDeclarationNameValidator implements AsyncValidator { 23 | @Input() uniqueDeclarationName: boolean; 24 | constructor(private bigIpService: BigIpService) { } 25 | 26 | validate(ctrl: AbstractControl): Promise | Observable { 27 | if (!this.uniqueDeclarationName || ctrl.value === null || String(ctrl.value).trim() === '') { return observableOf(null) } 28 | return this.bigIpService.declarationExists(ctrl.value).pipe( 29 | map(exists => (exists ? { uniqueDeclarationName: true } : null)), 30 | catchError((error) => { 31 | if (error.status === 404) { 32 | // handle error 33 | return observableOf(null); 34 | } 35 | return throwError(error); 36 | }) 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /ui/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/.gitkeep -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOlCnqEu92Fr1MmEU9fABc4AMP6lbBP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOlCnqEu92Fr1MmEU9fABc4AMP6lbBP.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOlCnqEu92Fr1MmEU9fBBc4AMP6lQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOlCnqEu92Fr1MmEU9fBBc4AMP6lQ.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOlCnqEu92Fr1MmEU9fBxc4AMP6lbBP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOlCnqEu92Fr1MmEU9fBxc4AMP6lbBP.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOlCnqEu92Fr1MmEU9fCBc4AMP6lbBP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOlCnqEu92Fr1MmEU9fCBc4AMP6lbBP.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOlCnqEu92Fr1MmEU9fCRc4AMP6lbBP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOlCnqEu92Fr1MmEU9fCRc4AMP6lbBP.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOlCnqEu92Fr1MmEU9fChc4AMP6lbBP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOlCnqEu92Fr1MmEU9fChc4AMP6lbBP.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOlCnqEu92Fr1MmEU9fCxc4AMP6lbBP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOlCnqEu92Fr1MmEU9fCxc4AMP6lbBP.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOlCnqEu92Fr1MmSU5fABc4AMP6lbBP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOlCnqEu92Fr1MmSU5fABc4AMP6lbBP.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOlCnqEu92Fr1MmSU5fBBc4AMP6lQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOlCnqEu92Fr1MmSU5fBBc4AMP6lQ.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOlCnqEu92Fr1MmSU5fBxc4AMP6lbBP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOlCnqEu92Fr1MmSU5fBxc4AMP6lbBP.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOlCnqEu92Fr1MmSU5fCBc4AMP6lbBP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOlCnqEu92Fr1MmSU5fCBc4AMP6lbBP.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOlCnqEu92Fr1MmSU5fCRc4AMP6lbBP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOlCnqEu92Fr1MmSU5fCRc4AMP6lbBP.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOlCnqEu92Fr1MmSU5fChc4AMP6lbBP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOlCnqEu92Fr1MmSU5fChc4AMP6lbBP.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOlCnqEu92Fr1MmSU5fCxc4AMP6lbBP.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOlCnqEu92Fr1MmSU5fCxc4AMP6lbBP.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOmCnqEu92Fr1Mu4WxKKTU1Kvnz.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOmCnqEu92Fr1Mu4WxKKTU1Kvnz.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOmCnqEu92Fr1Mu4mxKKTU1Kg.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOmCnqEu92Fr1Mu4mxKKTU1Kg.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOmCnqEu92Fr1Mu5mxKKTU1Kvnz.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOmCnqEu92Fr1Mu5mxKKTU1Kvnz.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOmCnqEu92Fr1Mu7GxKKTU1Kvnz.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOmCnqEu92Fr1Mu7GxKKTU1Kvnz.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOmCnqEu92Fr1Mu7WxKKTU1Kvnz.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOmCnqEu92Fr1Mu7WxKKTU1Kvnz.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/KFOmCnqEu92Fr1Mu7mxKKTU1Kvnz.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/KFOmCnqEu92Fr1Mu7mxKKTU1Kvnz.woff2 -------------------------------------------------------------------------------- /ui/src/assets/fonts/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/fonts/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2 -------------------------------------------------------------------------------- /ui/src/assets/images/blue-green-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/images/blue-green-logo.png -------------------------------------------------------------------------------- /ui/src/assets/images/f5-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/assets/images/f5-logo.png -------------------------------------------------------------------------------- /ui/src/assets/styles/material-font.css: -------------------------------------------------------------------------------- 1 | /* fallback */ 2 | @font-face { 3 | font-family: 'Material Icons'; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: url(../fonts/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2) format('woff2'); 7 | } 8 | 9 | .material-icons { 10 | font-family: 'Material Icons'; 11 | font-weight: normal; 12 | font-style: normal; 13 | font-size: 24px; 14 | line-height: 1; 15 | letter-spacing: normal; 16 | text-transform: none; 17 | display: inline-block; 18 | white-space: nowrap; 19 | word-wrap: normal; 20 | direction: ltr; 21 | -webkit-font-feature-settings: 'liga'; 22 | -webkit-font-smoothing: antialiased; 23 | } -------------------------------------------------------------------------------- /ui/src/assets/styles/roboto-font.css: -------------------------------------------------------------------------------- 1 | /* cyrillic-ext */ 2 | @font-face { 3 | font-family: 'Roboto'; 4 | font-style: normal; 5 | font-weight: 300; 6 | src: local('Roboto Light'), local('Roboto-Light'), url(../fonts/KFOlCnqEu92Fr1MmSU5fCRc4AMP6lbBP.woff2) format('woff2'); 7 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 8 | } 9 | /* cyrillic */ 10 | @font-face { 11 | font-family: 'Roboto'; 12 | font-style: normal; 13 | font-weight: 300; 14 | src: local('Roboto Light'), local('Roboto-Light'), url(../fonts/KFOlCnqEu92Fr1MmSU5fABc4AMP6lbBP.woff2) format('woff2'); 15 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 16 | } 17 | /* greek-ext */ 18 | @font-face { 19 | font-family: 'Roboto'; 20 | font-style: normal; 21 | font-weight: 300; 22 | src: local('Roboto Light'), local('Roboto-Light'), url(../fonts/KFOlCnqEu92Fr1MmSU5fCBc4AMP6lbBP.woff2) format('woff2'); 23 | unicode-range: U+1F00-1FFF; 24 | } 25 | /* greek */ 26 | @font-face { 27 | font-family: 'Roboto'; 28 | font-style: normal; 29 | font-weight: 300; 30 | src: local('Roboto Light'), local('Roboto-Light'), url(../fonts/KFOlCnqEu92Fr1MmSU5fBxc4AMP6lbBP.woff2) format('woff2'); 31 | unicode-range: U+0370-03FF; 32 | } 33 | /* vietnamese */ 34 | @font-face { 35 | font-family: 'Roboto'; 36 | font-style: normal; 37 | font-weight: 300; 38 | src: local('Roboto Light'), local('Roboto-Light'), url(../fonts/KFOlCnqEu92Fr1MmSU5fCxc4AMP6lbBP.woff2) format('woff2'); 39 | unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB; 40 | } 41 | /* latin-ext */ 42 | @font-face { 43 | font-family: 'Roboto'; 44 | font-style: normal; 45 | font-weight: 300; 46 | src: local('Roboto Light'), local('Roboto-Light'), url(../fonts/KFOlCnqEu92Fr1MmSU5fChc4AMP6lbBP.woff2) format('woff2'); 47 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 48 | } 49 | /* latin */ 50 | @font-face { 51 | font-family: 'Roboto'; 52 | font-style: normal; 53 | font-weight: 300; 54 | src: local('Roboto Light'), local('Roboto-Light'), url(../fonts/KFOlCnqEu92Fr1MmSU5fBBc4AMP6lQ.woff2) format('woff2'); 55 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 56 | } 57 | /* cyrillic-ext */ 58 | @font-face { 59 | font-family: 'Roboto'; 60 | font-style: normal; 61 | font-weight: 400; 62 | src: local('Roboto'), local('Roboto-Regular'), url(../fonts/KFOmCnqEu92Fr1Mu72xKKTU1Kvnz.woff2) format('woff2'); 63 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 64 | } 65 | /* cyrillic */ 66 | @font-face { 67 | font-family: 'Roboto'; 68 | font-style: normal; 69 | font-weight: 400; 70 | src: local('Roboto'), local('Roboto-Regular'), url(../fonts/KFOmCnqEu92Fr1Mu5mxKKTU1Kvnz.woff2) format('woff2'); 71 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 72 | } 73 | /* greek-ext */ 74 | @font-face { 75 | font-family: 'Roboto'; 76 | font-style: normal; 77 | font-weight: 400; 78 | src: local('Roboto'), local('Roboto-Regular'), url(../fonts/KFOmCnqEu92Fr1Mu7mxKKTU1Kvnz.woff2) format('woff2'); 79 | unicode-range: U+1F00-1FFF; 80 | } 81 | /* greek */ 82 | @font-face { 83 | font-family: 'Roboto'; 84 | font-style: normal; 85 | font-weight: 400; 86 | src: local('Roboto'), local('Roboto-Regular'), url(../fonts/KFOmCnqEu92Fr1Mu4WxKKTU1Kvnz.woff2) format('woff2'); 87 | unicode-range: U+0370-03FF; 88 | } 89 | /* vietnamese */ 90 | @font-face { 91 | font-family: 'Roboto'; 92 | font-style: normal; 93 | font-weight: 400; 94 | src: local('Roboto'), local('Roboto-Regular'), url(../fonts/KFOmCnqEu92Fr1Mu7WxKKTU1Kvnz.woff2) format('woff2'); 95 | unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB; 96 | } 97 | /* latin-ext */ 98 | @font-face { 99 | font-family: 'Roboto'; 100 | font-style: normal; 101 | font-weight: 400; 102 | src: local('Roboto'), local('Roboto-Regular'), url(../fonts/KFOmCnqEu92Fr1Mu7GxKKTU1Kvnz.woff2) format('woff2'); 103 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 104 | } 105 | /* latin */ 106 | @font-face { 107 | font-family: 'Roboto'; 108 | font-style: normal; 109 | font-weight: 400; 110 | src: local('Roboto'), local('Roboto-Regular'), url(../fonts/KFOmCnqEu92Fr1Mu4mxKKTU1Kg.woff2) format('woff2'); 111 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 112 | } 113 | /* cyrillic-ext */ 114 | @font-face { 115 | font-family: 'Roboto'; 116 | font-style: normal; 117 | font-weight: 500; 118 | src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/KFOlCnqEu92Fr1MmEU9fCRc4AMP6lbBP.woff2) format('woff2'); 119 | unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; 120 | } 121 | /* cyrillic */ 122 | @font-face { 123 | font-family: 'Roboto'; 124 | font-style: normal; 125 | font-weight: 500; 126 | src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/KFOlCnqEu92Fr1MmEU9fABc4AMP6lbBP.woff2) format('woff2'); 127 | unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; 128 | } 129 | /* greek-ext */ 130 | @font-face { 131 | font-family: 'Roboto'; 132 | font-style: normal; 133 | font-weight: 500; 134 | src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/KFOlCnqEu92Fr1MmEU9fCBc4AMP6lbBP.woff2) format('woff2'); 135 | unicode-range: U+1F00-1FFF; 136 | } 137 | /* greek */ 138 | @font-face { 139 | font-family: 'Roboto'; 140 | font-style: normal; 141 | font-weight: 500; 142 | src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/KFOlCnqEu92Fr1MmEU9fBxc4AMP6lbBP.woff2) format('woff2'); 143 | unicode-range: U+0370-03FF; 144 | } 145 | /* vietnamese */ 146 | @font-face { 147 | font-family: 'Roboto'; 148 | font-style: normal; 149 | font-weight: 500; 150 | src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/KFOlCnqEu92Fr1MmEU9fCxc4AMP6lbBP.woff2) format('woff2'); 151 | unicode-range: U+0102-0103, U+0110-0111, U+1EA0-1EF9, U+20AB; 152 | } 153 | /* latin-ext */ 154 | @font-face { 155 | font-family: 'Roboto'; 156 | font-style: normal; 157 | font-weight: 500; 158 | src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/KFOlCnqEu92Fr1MmEU9fChc4AMP6lbBP.woff2) format('woff2'); 159 | unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; 160 | } 161 | /* latin */ 162 | @font-face { 163 | font-family: 'Roboto'; 164 | font-style: normal; 165 | font-weight: 500; 166 | src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/KFOlCnqEu92Fr1MmEU9fBBc4AMP6lQ.woff2) format('woff2'); 167 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 168 | } -------------------------------------------------------------------------------- /ui/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true 3 | }; 4 | -------------------------------------------------------------------------------- /ui/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/dist/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /ui/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aknot242/bigip-blue-green/c0155c79e10df6ed38032fc44a1a80e1ec86696a/ui/src/favicon.ico -------------------------------------------------------------------------------- /ui/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BIG-IP BlueGreen Deployment 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /ui/src/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, '../coverage'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false 30 | }); 31 | }; -------------------------------------------------------------------------------- /ui/src/main.ts: -------------------------------------------------------------------------------- 1 | import 'hammerjs'; 2 | import { enableProdMode } from '@angular/core'; 3 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 4 | 5 | import { AppModule } from './app/app.module'; 6 | import { environment } from './environments/environment'; 7 | 8 | if (environment.production) { 9 | enableProdMode(); 10 | } 11 | 12 | platformBrowserDynamic().bootstrapModule(AppModule) 13 | .catch(err => console.error(err)); 14 | -------------------------------------------------------------------------------- /ui/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags.ts'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true; 52 | * 53 | */ 54 | 55 | /*************************************************************************************************** 56 | * Zone JS is required by default for Angular itself. 57 | */ 58 | import 'zone.js/dist/zone'; // Included with Angular CLI. 59 | 60 | 61 | /*************************************************************************************************** 62 | * APPLICATION IMPORTS 63 | */ 64 | -------------------------------------------------------------------------------- /ui/src/styles.css: -------------------------------------------------------------------------------- 1 | /* You can add global styles to this file, and also import other style files */ 2 | 3 | html, body { height: 100%; } 4 | body { margin: 30px; font-family: Roboto, "Helvetica Neue", sans-serif; } 5 | 6 | .f5-logo { 7 | width: 46px; 8 | padding-left: 16px; 9 | padding-right: 16px; 10 | padding-top: 8px; 11 | padding-bottom: 16px; 12 | vertical-align: middle; 13 | } 14 | 15 | .blue-green-logo { 16 | width: 70px; 17 | padding-left: 16px; 18 | padding-right: 16px; 19 | padding-top: 14px; 20 | padding-bottom: 16px; 21 | vertical-align: middle; 22 | } 23 | 24 | .v-space { 25 | margin-top: 30px; 26 | } 27 | 28 | .more-v-space { 29 | margin-top: 60px; 30 | } 31 | 32 | .even-more-v-space { 33 | margin-top: 100px; 34 | } 35 | 36 | .blue { 37 | color: blue; 38 | } 39 | 40 | .green { 41 | color: green; 42 | } 43 | 44 | .blue-bkg { 45 | background-color: blue; 46 | color: white; 47 | } 48 | 49 | .green-bkg { 50 | background-color: green; 51 | color: white; 52 | } 53 | -------------------------------------------------------------------------------- /ui/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | 3 | import 'zone.js/dist/zone-testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: any; 11 | 12 | // First, initialize the Angular testing environment. 13 | getTestBed().initTestEnvironment( 14 | BrowserDynamicTestingModule, 15 | platformBrowserDynamicTesting() 16 | ); 17 | // Then we find all the tests. 18 | const context = require.context('./', true, /\.spec\.ts$/); 19 | // And load the modules. 20 | context.keys().map(context); 21 | -------------------------------------------------------------------------------- /ui/src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [] 6 | }, 7 | "exclude": [ 8 | "test.ts", 9 | "**/*.spec.ts" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /ui/src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "test.ts", 12 | "polyfills.ts" 13 | ], 14 | "include": [ 15 | "**/*.spec.ts", 16 | "**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /ui/src/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tslint.json", 3 | "rules": { 4 | "directive-selector": [ 5 | true, 6 | "attribute", 7 | "app", 8 | "camelCase" 9 | ], 10 | "component-selector": [ 11 | true, 12 | "element", 13 | "app", 14 | "kebab-case" 15 | ] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "downlevelIteration": true, 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "declaration": false, 9 | "module": "esnext", 10 | "moduleResolution": "node", 11 | "emitDecoratorMetadata": true, 12 | "experimentalDecorators": true, 13 | "importHelpers": true, 14 | "target": "es2015", 15 | "typeRoots": [ 16 | "node_modules/@types" 17 | ], 18 | "lib": [ 19 | "es2018", 20 | "dom" 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /ui/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-return-shorthand": true, 7 | "callable-types": true, 8 | "class-name": true, 9 | "comment-format": [ 10 | true, 11 | "check-space" 12 | ], 13 | "curly": true, 14 | "deprecation": { 15 | "severity": "warn" 16 | }, 17 | "eofline": true, 18 | "forin": true, 19 | "import-blacklist": [ 20 | true, 21 | "rxjs/Rx" 22 | ], 23 | "import-spacing": true, 24 | "indent": [ 25 | true, 26 | "spaces" 27 | ], 28 | "interface-over-type-literal": true, 29 | "label-position": true, 30 | "max-line-length": [ 31 | true, 32 | 140 33 | ], 34 | "member-access": false, 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-arg": true, 47 | "no-bitwise": true, 48 | "no-console": [ 49 | true, 50 | "debug", 51 | "info", 52 | "time", 53 | "timeEnd", 54 | "trace" 55 | ], 56 | "no-construct": true, 57 | "no-debugger": true, 58 | "no-duplicate-super": true, 59 | "no-empty": false, 60 | "no-empty-interface": true, 61 | "no-eval": true, 62 | "no-inferrable-types": [ 63 | true, 64 | "ignore-params" 65 | ], 66 | "no-misused-new": true, 67 | "no-non-null-assertion": true, 68 | "no-redundant-jsdoc": true, 69 | "no-shadowed-variable": true, 70 | "no-string-literal": false, 71 | "no-string-throw": true, 72 | "no-switch-case-fall-through": true, 73 | "no-trailing-whitespace": true, 74 | "no-unnecessary-initializer": true, 75 | "no-unused-expression": true, 76 | "no-use-before-declare": true, 77 | "no-var-keyword": true, 78 | "object-literal-sort-keys": false, 79 | "one-line": [ 80 | true, 81 | "check-open-brace", 82 | "check-catch", 83 | "check-else", 84 | "check-whitespace" 85 | ], 86 | "prefer-const": true, 87 | "quotemark": [ 88 | true, 89 | "single" 90 | ], 91 | "radix": true, 92 | "semicolon": [ 93 | true, 94 | "always" 95 | ], 96 | "triple-equals": [ 97 | true, 98 | "allow-null-check" 99 | ], 100 | "typedef-whitespace": [ 101 | true, 102 | { 103 | "call-signature": "nospace", 104 | "index-signature": "nospace", 105 | "parameter": "nospace", 106 | "property-declaration": "nospace", 107 | "variable-declaration": "nospace" 108 | } 109 | ], 110 | "unified-signatures": true, 111 | "variable-name": false, 112 | "whitespace": [ 113 | true, 114 | "check-branch", 115 | "check-decl", 116 | "check-operator", 117 | "check-separator", 118 | "check-type" 119 | ], 120 | "no-output-on-prefix": true, 121 | "no-inputs-metadata-property": true, 122 | "no-outputs-metadata-property": true, 123 | "no-host-metadata-property": true, 124 | "no-input-rename": true, 125 | "no-output-rename": true, 126 | "use-lifecycle-interface": true, 127 | "use-pipe-transform-interface": true, 128 | "component-class-suffix": true, 129 | "directive-class-suffix": true 130 | } 131 | } 132 | --------------------------------------------------------------------------------
To use this module, you must first log in to BIG-IP TMUI.
{{data.message}}