├── gcp ├── __init__.py ├── gae.py ├── compute.py ├── gke.py └── sql.py ├── model ├── __init__.py ├── gkenoodespoolsmodel.py ├── policymodel.py └── schedulesmodel.py ├── tasks ├── __init__.py ├── schedule_tasks.py └── policy_tasks.py ├── util ├── __init__.py ├── tz.py ├── utils.py └── gcp.py ├── iam.png ├── .gcloudignore ├── Zorya_policies.png ├── Zorya_schedule.png ├── client ├── public │ ├── favicon.png │ ├── manifest.json │ └── index.html ├── src │ ├── assets │ │ └── zorya.png │ ├── index.js │ ├── modules │ │ ├── utils │ │ │ ├── policy.js │ │ │ └── schedule.js │ │ ├── components │ │ │ ├── AppPageActions.js │ │ │ ├── AppPageContent.js │ │ │ ├── ErrorAlert.js │ │ │ ├── ScheduleTimeZone.js │ │ │ ├── PolicyTags.js │ │ │ ├── AppFrame.js │ │ │ └── ScheduleTimeTable.js │ │ └── api │ │ │ ├── policy.js │ │ │ └── schedule.js │ ├── withProps.js │ ├── pages │ │ ├── NotFound │ │ │ └── NotFound.js │ │ ├── AppIndex.js │ │ ├── Schedule │ │ │ ├── ScheduleEdit.js │ │ │ ├── ScheduleCreate.js │ │ │ └── ScheduleList.js │ │ └── Policy │ │ │ ├── PolicyList.js │ │ │ └── Policy.js │ └── withRoot.js ├── .prettierrc ├── README.md ├── .eslintrc ├── .gitignore └── package.json ├── App_Engine_Flex_Tagging_Example.png ├── cron.yaml ├── appengine_config.py ├── docs ├── policy_example.json ├── api_curl.txt └── schedule_example.json ├── requirements.txt ├── .gitattributes ├── App Engine Flex Tagging.md ├── deploy.sh ├── LICENSE ├── app.yaml ├── .gitignore ├── README.md └── main.py /gcp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /model/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tasks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /iam.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doitintl/zorya/HEAD/iam.png -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | 2 | .gcloudignore 3 | .git 4 | .gitignore 5 | client 6 | 7 | -------------------------------------------------------------------------------- /Zorya_policies.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doitintl/zorya/HEAD/Zorya_policies.png -------------------------------------------------------------------------------- /Zorya_schedule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doitintl/zorya/HEAD/Zorya_schedule.png -------------------------------------------------------------------------------- /client/public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doitintl/zorya/HEAD/client/public/favicon.png -------------------------------------------------------------------------------- /client/src/assets/zorya.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doitintl/zorya/HEAD/client/src/assets/zorya.png -------------------------------------------------------------------------------- /App_Engine_Flex_Tagging_Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doitintl/zorya/HEAD/App_Engine_Flex_Tagging_Example.png -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "printWidth": 80 6 | } 7 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | ## Zorya web client 2 | 3 | ### development 4 | yarn start 5 | 6 | assumes backend is running on http://localhost:8080 -------------------------------------------------------------------------------- /client/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app", 3 | "plugins": ["prettier"], 4 | "rules": { 5 | "prettier/prettier": "error" 6 | } 7 | } -------------------------------------------------------------------------------- /cron.yaml: -------------------------------------------------------------------------------- 1 | cron: 2 | - description: "Run scheduled tasks" 3 | url: /tasks/schedule 4 | schedule: every 60 minutes synchronized 5 | target: default 6 | -------------------------------------------------------------------------------- /appengine_config.py: -------------------------------------------------------------------------------- 1 | from google.appengine.ext import vendor 2 | import sys 3 | # Add any libraries installed in the "lib" folder. 4 | vendor.add('lib') 5 | sys.path = ['lib'] + sys.path -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import Index from './pages/AppIndex'; 4 | 5 | ReactDOM.render(, document.querySelector('#root')); 6 | -------------------------------------------------------------------------------- /client/src/modules/utils/policy.js: -------------------------------------------------------------------------------- 1 | export const getDefaultPolicy = () => ({ 2 | name: '', 3 | 4 | displayname: '', 5 | 6 | // array of strings 7 | projects: [], 8 | 9 | // array of objects 10 | tags: [], 11 | 12 | schedulename: '', 13 | }); 14 | -------------------------------------------------------------------------------- /docs/policy_example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "My policy", 3 | "tags": [ 4 | { 5 | "dev": "sleeper", 6 | "staging": "resting" 7 | } 8 | ], 9 | "projetcs": [ 10 | "project-x", 11 | "y-project" 12 | ], 13 | "schedulename": "my schedule name" 14 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==1.1.2 2 | Werkzeug==1.0.1 3 | google-api-python-client 4 | google-auth 5 | google-auth-httplib2 6 | pytz 7 | tzlocal 8 | backoff==1.10.0 9 | google-cloud-ndb==1.8.0 10 | google-cloud-tasks==1.5.0 11 | google-cloud-logging 12 | googleapis-common-protos==1.53.0 13 | numpy -------------------------------------------------------------------------------- /model/gkenoodespoolsmodel.py: -------------------------------------------------------------------------------- 1 | """DB model GKE node pools.""" 2 | from google.cloud import ndb 3 | 4 | 5 | class GkeNodePoolModel(ndb.Model): 6 | """Class that represents Number of nodes in a nodepool.""" 7 | 8 | Name = ndb.StringProperty(indexed=True, required=True) 9 | NumberOfNodes = ndb.IntegerProperty(required=True) 10 | -------------------------------------------------------------------------------- /client/src/withProps.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const withProps = (Component, componentProps) => { 4 | return class Route extends React.Component { 5 | render() { 6 | let props = Object.assign({}, this.props, componentProps); 7 | return ; 8 | } 9 | }; 10 | }; 11 | 12 | export default withProps; 13 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Zorya", 3 | "name": "Zorya", 4 | "icons": [ 5 | { 6 | "src": "favicon.png", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /model/policymodel.py: -------------------------------------------------------------------------------- 1 | """DB model for policy.""" 2 | from google.cloud import ndb 3 | 4 | 5 | class PolicyModel(ndb.Model): 6 | """Class that represents a tags and their associated schedule.""" 7 | Name = ndb.StringProperty(indexed=True, required=True) 8 | DisplayName = ndb.JsonProperty(indexed=True) 9 | Tags = ndb.JsonProperty(repeated=True) 10 | Projects = ndb.JsonProperty(repeated=True) 11 | Schedule = ndb.StringProperty(indexed=True, required=True, repeated=False) 12 | -------------------------------------------------------------------------------- /model/schedulesmodel.py: -------------------------------------------------------------------------------- 1 | """Database representation of schedule.""" 2 | 3 | from google.cloud import ndb 4 | 5 | from util import tz 6 | 7 | 8 | class SchedulesModel(ndb.Model): 9 | """Stores scheduling data.""" 10 | Name = ndb.StringProperty(indexed=True, required=True) 11 | DisplayName = ndb.StringProperty(indexed=True) 12 | Timezone = ndb.StringProperty( 13 | default='UTC', choices=tz.get_all_timezones(), required=True) 14 | Schedule = ndb.JsonProperty(required=True) 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | Basic .gitattributes for a python repo. 2 | 3 | # Source files 4 | # ============ 5 | *.pxd text 6 | *.py text 7 | *.py3 text 8 | *.pyw text 9 | *.pyx text 10 | 11 | # Binary files 12 | # ============ 13 | *.db binary 14 | *.p binary 15 | *.pkl binary 16 | *.pyc binary 17 | *.pyd binary 18 | *.pyo binary 19 | 20 | # Note: .db, .p, and .pkl files are associated 21 | # with the python modules ``pickle``, ``dbm.*``, 22 | # ``shelve``, ``marshal``, ``anydbm``, & ``bsddb`` 23 | # (among others). 24 | static/* linguist-vendored 25 | templates/* linguist-vendored 26 | -------------------------------------------------------------------------------- /App Engine Flex Tagging.md: -------------------------------------------------------------------------------- 1 | # App Engine Flex Tagging 2 | 3 | This file describe how to add an App Engine Flex resource to Zorya policy. 4 | 5 | In order to add GAE Flex to the policy it needed to take the two following steps: 6 | 7 | 1. Use the reserved word @app_engine_flex on the tag key. 8 | 2. Enter the GAE flex service-id and the version id to the tag value, with a delimiter ":". 9 | 10 | Example of tagging GAE flex: 11 | 12 | ![](App_Engine_Flex_Tagging_Example.png) 13 | 14 | Please note, unlike from the other resources that Zorya support, there is no need to add a label on the GAE service. -------------------------------------------------------------------------------- /client/src/pages/NotFound/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Material UI 4 | import { withStyles } from '@material-ui/core/styles'; 5 | import Typography from '@material-ui/core/Typography'; 6 | 7 | const styles = (theme) => ({ 8 | root: { 9 | padding: theme.spacing(1), 10 | }, 11 | }); 12 | 13 | class NotFound extends React.Component { 14 | render() { 15 | const { classes, location } = this.props; 16 | 17 | return ( 18 |
19 | 20 | Not Found: {location.pathname} 21 | 22 |
23 | ); 24 | } 25 | } 26 | 27 | export default withStyles(styles)(NotFound); 28 | -------------------------------------------------------------------------------- /client/src/modules/utils/schedule.js: -------------------------------------------------------------------------------- 1 | export const getDefaultSchedule = () => ({ 2 | name: '', 3 | displayname: '', 4 | timezone: 'UTC', 5 | Shape: [7, 24], 6 | dtype: 'int64', 7 | Corder: true, 8 | __ndarray__: [ 9 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 10 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 11 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 12 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 13 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 14 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 15 | [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 16 | ], 17 | }); 18 | -------------------------------------------------------------------------------- /client/src/modules/components/AppPageActions.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Material UI 4 | import { withStyles } from '@material-ui/core/styles'; 5 | import Divider from '@material-ui/core/Divider'; 6 | import AppBar from '@material-ui/core/AppBar'; 7 | import Toolbar from '@material-ui/core/Toolbar'; 8 | 9 | const styles = (theme) => ({ 10 | appBar: { 11 | backgroundColor: theme.palette.background.default, 12 | }, 13 | }); 14 | 15 | class AppPageActions extends React.Component { 16 | render() { 17 | const { classes, children } = this.props; 18 | 19 | return ( 20 |
21 | 28 | {children} 29 | 30 | 31 |
32 | ); 33 | } 34 | } 35 | 36 | export default withStyles(styles)(AppPageActions); 37 | -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [[ $# -eq 0 ]] ; then 4 | echo Missing project id argument 5 | exit 6 | fi 7 | 8 | PROJECTID=`gcloud projects list | grep -iw "$1" | awk '{print $1}'` 9 | 10 | if [ -z "$PROJECTID" ]; then 11 | echo Project $1 Not Found! 12 | exit 13 | fi 14 | 15 | echo Project ID $PROJECTID 16 | gcloud config set project $PROJECTID 17 | 18 | rm -rf ./build 19 | cd client && yarn install && yarn build && cd .. 20 | ##Build the task queue 21 | 22 | 23 | if gcloud tasks queues list|grep zorya-tasks >/dev/null 2>&1; then 24 | echo "Task Queue all ready exists" 25 | else 26 | gcloud tasks queues create zorya-tasks 27 | fi 28 | loc=`gcloud tasks queues describe zorya-tasks|grep name` 29 | LOCATION="$(echo $loc | cut -d'/' -f4)" 30 | echo 31 | 32 | file_location='util/location.py' 33 | if [ -f "$file_location" ]; then 34 | rm $file_location 35 | fi 36 | cat > $file_location < 24 | {/* CssBaseline kickstart an elegant, consistent, and simple baseline to build upon. */} 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | } 32 | 33 | return WithRoot; 34 | } 35 | 36 | export default withRoot; 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 DoiT International 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | # This file specifies your Python application's runtime configuration 2 | # including URL routing, versions, static file uploads, etc. See 3 | # https://developers.google.com/appengine/docs/python/config/appconfig 4 | # for details. 5 | 6 | runtime: python37 7 | service: default 8 | 9 | # Handlers define how to route requests to your application. 10 | handlers: 11 | - url: /api/v1/(.*) 12 | script: auto 13 | secure: always 14 | 15 | - url: /tasks/(.*) 16 | script: auto 17 | secure: always 18 | 19 | - url: / 20 | static_files: build/index.html 21 | upload: build/index.html 22 | 23 | - url: /favicon\.png 24 | static_files: build/favicon.png 25 | upload: build/favicon\.png 26 | 27 | # unused for now 28 | # - url: /service-worker\.js 29 | # static_files: build/service-worker.js 30 | # upload: build/service-worker\.js 31 | 32 | - url: /manifest\.json 33 | static_files: build/manifest.json 34 | upload: build/manifest\.json 35 | 36 | - url: /static/(.*) 37 | static_files: build/static/\1 38 | upload: build/static/(.*) 39 | 40 | - url: .* 41 | static_files: build/index.html 42 | upload: build/index.html 43 | 44 | 45 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zorya-web-client", 3 | "version": "1.0.0", 4 | "private": true, 5 | "proxy": "http://localhost:8080/", 6 | "dependencies": { 7 | "@material-ui/core": "^4.11.4", 8 | "@material-ui/icons": "^4.11.2", 9 | "classnames": "^2.3.1", 10 | "downshift": "^6.1.3", 11 | "lodash": "^4.17.19", 12 | "react": "^17.0.2", 13 | "react-dom": "^17.0.2", 14 | "react-lineto": "^3.2.0", 15 | "react-recompose": "^0.31.1", 16 | "react-router-dom": "^5.2.0", 17 | "react-scripts": "^4.0.3" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build && mv ./build ../build", 22 | "test": "react-scripts test --env=jsdom", 23 | "eject": "react-scripts eject" 24 | }, 25 | "devDependencies": { 26 | "eslint-plugin-prettier": "^3.4.0", 27 | "prettier": "^2.3.0" 28 | }, 29 | "browserslist": { 30 | "production": [ 31 | ">0.2%", 32 | "not dead", 33 | "not op_mini all" 34 | ], 35 | "development": [ 36 | "last 1 chrome version", 37 | "last 1 firefox version", 38 | "last 1 safari version" 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /util/tz.py: -------------------------------------------------------------------------------- 1 | """Time zone utilities.""" 2 | from datetime import datetime 3 | 4 | import numpy as np 5 | import pytz 6 | import tzlocal 7 | 8 | 9 | def get_all_timezones(): 10 | """Get a list of all timezones.""" 11 | return pytz.all_timezones 12 | 13 | 14 | def get_local_timezone(): 15 | """ 16 | Get the local timezone. 17 | Returns: local time zone. 18 | 19 | """ 20 | return tzlocal.get_localzone() 21 | 22 | 23 | def get_time_at_timezone(timezone): 24 | """ 25 | Get the current time a a time zone. 26 | Args: 27 | timezone: 28 | 29 | Returns: time at the requestd timezone 30 | 31 | """ 32 | tz = pytz.timezone(timezone) 33 | now = datetime.now(tz=tz) 34 | target_tz = pytz.timezone(timezone) 35 | return target_tz.normalize(now.astimezone(target_tz)) 36 | 37 | 38 | def convert_time_to_index(time): 39 | """ 40 | Convert a time to and index in an 7x24 array. 41 | Args: 42 | time: 43 | 44 | Returns: x,y of the index 45 | 46 | """ 47 | days = np.arange(0, 7) 48 | days = np.roll(days, 1) 49 | for index, item in enumerate(days): 50 | if item == time.weekday(): 51 | day = index 52 | hour = time.hour 53 | return day, hour 54 | -------------------------------------------------------------------------------- /tasks/schedule_tasks.py: -------------------------------------------------------------------------------- 1 | """Change a state for all matching instances in a project.""" 2 | import logging 3 | 4 | from gcp.compute import Compute 5 | from gcp.sql import Sql 6 | from gcp.gke import Gke 7 | from gcp.gae import Gae 8 | 9 | 10 | def change_state(tagkey, tagvalue, action, project): 11 | """ 12 | Change a state for all matching instances in a project. 13 | Args: 14 | tagkey: tag key 15 | tagvalue: tag value 16 | action: stop 0 start 1 17 | project: project id 18 | 19 | Returns: 20 | 21 | """ 22 | if not (check_if_app_engine_job(tagkey, tagvalue)): 23 | compute = Compute(project) 24 | sql = Sql(project) 25 | gke = Gke(project) 26 | logging.info("change_state %s action %s", project, action) 27 | compute.change_status(action, tagkey, tagvalue) 28 | sql.change_status(action, tagkey, tagvalue) 29 | gke.change_status(action, tagkey, tagvalue) 30 | else: 31 | service_id, version_id = get_service_and_version_from_tag_value(tagvalue) 32 | app_engine = Gae(project) 33 | logging.info("change_state %s action %s", project, action) 34 | app_engine.change_status(action, service_id, version_id) 35 | return 'ok', 200 36 | 37 | 38 | def check_if_app_engine_job(tagkey, tagvalue): 39 | """ 40 | Infer by the tag key and value if its an App Engine job 41 | 42 | Args: 43 | tagkey: tag key 44 | tagvalue: tag value 45 | 46 | Returns: 47 | """ 48 | 49 | if (tagkey == '@app_engine_flex') and (':' in tagvalue): 50 | return True 51 | else: 52 | return False 53 | 54 | 55 | def get_service_and_version_from_tag_value(tagvalue): 56 | return tagvalue.split(':') 57 | 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | util/location.py 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | .idea 103 | config.json 104 | 105 | # node 106 | node_modules/ 107 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | Zorya 23 | 24 | 25 | 26 | 29 |
30 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /client/src/modules/components/AppPageContent.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | // Material UI 5 | import { withStyles } from '@material-ui/core/styles'; 6 | import CircularProgress from '@material-ui/core/CircularProgress'; 7 | 8 | // Project 9 | import ErrorAlert from './ErrorAlert'; 10 | 11 | const styles = (theme) => ({ 12 | root: { 13 | overflow: 'auto', 14 | padding: theme.spacing(2), 15 | height: 'calc(100% - 56px)', 16 | [theme.breakpoints.up('sm')]: { 17 | height: 'calc(100% - 64px)', 18 | }, 19 | }, 20 | centered: { 21 | position: 'absolute', 22 | left: '50%', 23 | top: '50%', 24 | }, 25 | }); 26 | 27 | class AppPageContent extends React.Component { 28 | render() { 29 | const { classes, children } = this.props; 30 | 31 | const { 32 | showBackendError, 33 | showLoadingSpinner, 34 | backendErrorTitle = 'An Error Occurred:', 35 | backendErrorMessage = 'Unspecified error, check logs.', 36 | onBackendErrorClose, 37 | } = this.props; 38 | 39 | return ( 40 |
41 | {showLoadingSpinner ? ( 42 | 43 | ) : ( 44 | children 45 | )} 46 | 52 |
53 | ); 54 | } 55 | } 56 | 57 | AppPageContent.propTypes = { 58 | showBackendError: PropTypes.bool.isRequired, 59 | showLoadingSpinner: PropTypes.bool, 60 | backendErrorTitle: PropTypes.string, 61 | backendErrorMessage: PropTypes.string, 62 | onBackendErrorClose: PropTypes.func.isRequired, 63 | }; 64 | 65 | export default withStyles(styles)(AppPageContent); 66 | -------------------------------------------------------------------------------- /client/src/modules/api/policy.js: -------------------------------------------------------------------------------- 1 | class PolicyService { 2 | list = async () => { 3 | const response = await fetch(`/api/v1/list_policies?verbose=true`, { 4 | method: 'GET', 5 | credentials: 'same-origin', 6 | }); 7 | 8 | if (!response.ok) { 9 | console.error(response); 10 | const responseBody = await response.text(); 11 | throw Error(responseBody || response.statusText); 12 | } 13 | 14 | return response.json(); 15 | }; 16 | 17 | get = async (policy) => { 18 | const response = await fetch(`/api/v1/get_policy?policy=${policy}`, { 19 | method: 'GET', 20 | credentials: 'same-origin', 21 | }); 22 | 23 | if (!response.ok) { 24 | console.error(response); 25 | const responseBody = await response.text(); 26 | throw Error(responseBody || response.statusText); 27 | } 28 | 29 | return response.json(); 30 | }; 31 | 32 | delete = async (policy) => { 33 | const response = await fetch(`/api/v1/del_policy?policy=${policy}`, { 34 | method: 'GET', 35 | credentials: 'same-origin', 36 | }); 37 | 38 | if (!response.ok) { 39 | console.error(response); 40 | const responseBody = await response.text(); 41 | throw Error(responseBody || response.statusText); 42 | } 43 | 44 | return response; 45 | }; 46 | 47 | add = async (policy) => { 48 | const response = await fetch(`/api/v1/add_policy`, { 49 | method: 'POST', 50 | credentials: 'same-origin', 51 | headers: { 52 | 'Content-Type': 'application/json', 53 | }, 54 | body: JSON.stringify(policy), 55 | }); 56 | 57 | if (!response.ok) { 58 | console.error(response); 59 | const responseBody = await response.text(); 60 | throw Error(responseBody || response.statusText); 61 | } 62 | 63 | return response; 64 | }; 65 | } 66 | 67 | export default PolicyService; 68 | -------------------------------------------------------------------------------- /client/src/modules/components/ErrorAlert.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | // Material UI 5 | import { withStyles } from '@material-ui/core/styles'; 6 | import Button from '@material-ui/core/Button'; 7 | import Dialog from '@material-ui/core/Dialog'; 8 | import DialogActions from '@material-ui/core/DialogActions'; 9 | import DialogContent from '@material-ui/core/DialogContent'; 10 | import DialogContentText from '@material-ui/core/DialogContentText'; 11 | import DialogTitle from '@material-ui/core/DialogTitle'; 12 | 13 | const styles = (theme) => ({ 14 | root: { 15 | height: '100%', 16 | }, 17 | button: { 18 | marginRight: theme.spacing(2), 19 | }, 20 | }); 21 | 22 | class ErrorAlert extends React.Component { 23 | constructor(props, context) { 24 | super(props, context); 25 | this.onClose = props.onClose; 26 | } 27 | 28 | render() { 29 | const { classes, showError, errorTitle, errorMessage } = this.props; 30 | 31 | return ( 32 |
33 | 39 | {errorTitle} 40 | 41 | 42 | {errorMessage} 43 | 44 | 45 | 46 | 49 | 50 | 51 |
52 | ); 53 | } 54 | } 55 | ErrorAlert.propTypes = { 56 | showError: PropTypes.bool.isRequired, 57 | errorTitle: PropTypes.string, 58 | errorMessage: PropTypes.string, 59 | onClose: PropTypes.func.isRequired, 60 | }; 61 | 62 | export default withStyles(styles)(ErrorAlert); 63 | -------------------------------------------------------------------------------- /util/utils.py: -------------------------------------------------------------------------------- 1 | """Misc utils.""" 2 | import json 3 | import logging 4 | import os 5 | 6 | 7 | def detect_gae(): 8 | """Determine whether or not we're running on GAE. 9 | 10 | This is based on: 11 | https://developers.google.com/appengine/docs/python/#The_Environment 12 | 13 | Returns: 14 | True iff we're running on GAE. 15 | """ 16 | server_software = os.environ.get('GAE_ENV', '') 17 | return server_software.startswith('standard') 18 | 19 | 20 | def _get_project_id(): 21 | logging.info("-------------------Running Localy--------------------") 22 | with open('config.json', 'r') as config_file: 23 | config = json.load(config_file) 24 | return config['project'] 25 | 26 | 27 | def get_project_id(): 28 | """ 29 | Return the real or local project id. 30 | 31 | :return: project_id 32 | """ 33 | if detect_gae(): 34 | project = os.environ.get('GOOGLE_CLOUD_PROJECT', 35 | 'Specified environment variable is not set.') 36 | else: 37 | project = _get_project_id() 38 | return project 39 | 40 | 41 | def get_host_name(): 42 | """ 43 | Return the real or local hostname. 44 | 45 | :return: hostname 46 | """ 47 | if detect_gae(): 48 | hostname = '{}.appspot.com'.format(get_project_id()) 49 | else: 50 | hostname = '{}.appspot.com'.format(_get_project_id()) 51 | return hostname 52 | 53 | 54 | def fatal_code(e): 55 | """ 56 | In case of a 500+ errcode do backoff. 57 | 58 | :param e: execption 59 | :return: 60 | """ 61 | return e.resp.status < 500 62 | 63 | 64 | def get_next_idx(idx, matrix_size): 65 | """ 66 | 67 | Args: Get the next index in the matrix. 68 | idx: current index 69 | matrix_size: matrix size 70 | 71 | Returns: 72 | 73 | """ 74 | if idx + 1 == matrix_size: 75 | return 0 76 | else: 77 | return idx + 1 78 | 79 | 80 | def get_prev_idx(idx, matrix_size): 81 | """ 82 | Get the previous index in the matrix. 83 | Args: 84 | idx: current index 85 | matrix_size: matrix size 86 | 87 | Returns: 88 | 89 | """ 90 | if idx == 0: 91 | return matrix_size - 1 92 | else: 93 | return idx - 1 94 | -------------------------------------------------------------------------------- /client/src/modules/api/schedule.js: -------------------------------------------------------------------------------- 1 | class ScheduleService { 2 | list = async () => { 3 | const response = await fetch(`/api/v1/list_schedules?verbose=true`, { 4 | method: 'GET', 5 | credentials: 'same-origin', 6 | }); 7 | 8 | if (!response.ok) { 9 | console.error(response); 10 | const responseBody = await response.text(); 11 | throw Error(responseBody || response.statusText); 12 | } 13 | 14 | return response.json(); 15 | }; 16 | 17 | get = async (schedule) => { 18 | const response = await fetch(`/api/v1/get_schedule?schedule=${schedule}`, { 19 | method: 'GET', 20 | credentials: 'same-origin', 21 | }); 22 | 23 | if (!response.ok) { 24 | console.error(response); 25 | const responseBody = await response.text(); 26 | throw Error(responseBody || response.statusText); 27 | } 28 | 29 | return response.json(); 30 | }; 31 | 32 | delete = async (schedule) => { 33 | const response = await fetch(`/api/v1/del_schedule?schedule=${schedule}`, { 34 | method: 'GET', 35 | credentials: 'same-origin', 36 | }); 37 | 38 | if (!response.ok) { 39 | const responseBody = await response.text(); 40 | throw Error(responseBody || response.statusText); 41 | } 42 | 43 | return response; 44 | }; 45 | 46 | add = async (schedule) => { 47 | const response = await fetch(`/api/v1/add_schedule`, { 48 | method: 'POST', 49 | credentials: 'same-origin', 50 | headers: { 51 | 'Content-Type': 'application/json', 52 | }, 53 | body: JSON.stringify(schedule), 54 | }); 55 | 56 | if (!response.ok) { 57 | console.error(response); 58 | const responseBody = await response.text(); 59 | throw Error(responseBody || response.statusText); 60 | } 61 | 62 | return response; 63 | }; 64 | 65 | timezones = async () => { 66 | const response = await fetch(`/api/v1/time_zones`, { 67 | method: 'GET', 68 | credentials: 'same-origin', 69 | }); 70 | 71 | if (!response.ok) { 72 | console.error(response); 73 | const responseBody = await response.text(); 74 | throw Error(responseBody || response.statusText); 75 | } 76 | 77 | return response.json(); 78 | }; 79 | } 80 | 81 | export default ScheduleService; 82 | -------------------------------------------------------------------------------- /client/src/pages/AppIndex.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | // Recompose 5 | import { compose } from 'react-recompose'; 6 | 7 | // Router 8 | import { Switch, Route, Redirect } from 'react-router-dom'; 9 | 10 | // Material-UI 11 | import { withStyles } from '@material-ui/core/styles'; 12 | 13 | // Project 14 | import withRoot from '../withRoot'; 15 | import withProps from '../withProps'; 16 | import AppFrame from '../modules/components/AppFrame'; 17 | 18 | // Project Views 19 | import NotFound from './NotFound/NotFound'; 20 | 21 | import ScheduleList from './Schedule/ScheduleList'; 22 | import ScheduleCreate from './Schedule/ScheduleCreate'; 23 | import ScheduleEdit from './Schedule/ScheduleEdit'; 24 | 25 | import Policy from './Policy/Policy'; 26 | import PolicyList from './Policy/PolicyList'; 27 | 28 | const styles = (theme) => ({ 29 | '@global': { 30 | 'html, body, #root': { 31 | height: '100%', 32 | }, 33 | }, 34 | root: {}, 35 | }); 36 | 37 | class Index extends React.Component { 38 | render() { 39 | const { classes } = this.props; 40 | 41 | return ( 42 | 43 | 44 | } 48 | /> 49 | 50 | 51 | 52 | 57 | 58 | 63 | 68 | 69 | 70 | 71 | 72 | 73 | ); 74 | } 75 | } 76 | 77 | Index.propTypes = { 78 | classes: PropTypes.object.isRequired, 79 | }; 80 | 81 | export default compose(withRoot, withStyles(styles))(Index); 82 | -------------------------------------------------------------------------------- /util/gcp.py: -------------------------------------------------------------------------------- 1 | """"GCP utils""" 2 | 3 | import logging 4 | 5 | import backoff 6 | import googleapiclient.discovery 7 | from googleapiclient.errors import HttpError 8 | from util import utils 9 | 10 | 11 | def get_regions(): 12 | """ 13 | Get all available regions. 14 | 15 | :return: all regions 16 | """ 17 | compute = googleapiclient.discovery.build("compute", "v1", cache_discovery=False) 18 | 19 | request = compute.regions().list(project=utils.get_project_id()) 20 | 21 | response = request.execute() 22 | rg = [] 23 | for region in response["items"]: 24 | rg.append(region["description"]) 25 | return rg 26 | 27 | 28 | def get_zones(): 29 | """ 30 | Get all available zones. 31 | 32 | :return: all regions 33 | """ 34 | compute = googleapiclient.discovery.build("compute", "v1", cache_discovery=False) 35 | 36 | request = compute.zones().list(project=utils.get_project_id()) 37 | 38 | response = request.execute() 39 | zones = [] 40 | for region in response["items"]: 41 | zones.append(region["description"]) 42 | return zones 43 | 44 | 45 | def get_instancegroup_no_of_nodes_from_url(url): 46 | """ 47 | Get no of instances in a group. 48 | 49 | :return: number 50 | """ 51 | compute = googleapiclient.discovery.build("compute", "v1", cache_discovery=False) 52 | url = url[47:] 53 | project = url[: url.find("/")] 54 | zone = url[url.find("zones") + 6 : url.find("instanceGroupManagers") - 1] 55 | pool = url[url.rfind("/") + 1 :] 56 | res = ( 57 | compute.instanceGroups() 58 | .get(project=project, zone=zone, instanceGroup=pool) 59 | .execute() 60 | ) 61 | return res["size"] 62 | 63 | 64 | @backoff.on_exception(backoff.expo, HttpError, max_tries=8, giveup=utils.fatal_code) 65 | def resize_node_pool(size, url): 66 | """ 67 | resize a node pool 68 | Args: 69 | size: requested size 70 | url: instance group url 71 | 72 | Returns: 73 | 74 | """ 75 | compute = googleapiclient.discovery.build("compute", "v1", cache_discovery=False) 76 | url = url[47:] 77 | project = url[: url.find("/")] 78 | zone = url[url.find("zones") + 6 : url.find("instanceGroupManagers") - 1] 79 | instance_group_manager = url[url.rfind("/") + 1 :] 80 | try: 81 | res = ( 82 | compute.instanceGroupManagers() 83 | .resize( 84 | project=project, 85 | zone=zone, 86 | instanceGroupManager=instance_group_manager, 87 | size=size, 88 | ) 89 | .execute() 90 | ) 91 | except Exception as e: 92 | logging.error(e) 93 | return res 94 | -------------------------------------------------------------------------------- /tasks/policy_tasks.py: -------------------------------------------------------------------------------- 1 | """Check if there is a need to take an action for a policy.""" 2 | import json 3 | import logging 4 | 5 | import numpy as np 6 | from google.cloud import ndb, tasks_v2 7 | from model.policymodel import PolicyModel 8 | from model.schedulesmodel import SchedulesModel 9 | from util import location, tz, utils 10 | 11 | 12 | def policy_checker(name): 13 | """ 14 | Check if there is a need to take an action for a policy. 15 | Args: 16 | name: policy name 17 | 18 | Returns: 19 | 20 | """ 21 | 22 | policy = PolicyModel.query(PolicyModel.Name == name).get() 23 | if not policy: 24 | logging.error("Policy %s not found!", name) 25 | return "not found", 404 26 | schedule = SchedulesModel.query(SchedulesModel.Name == policy.Schedule).get() 27 | if not schedule: 28 | logging.error("Schedule %s not found!", policy.Schedule) 29 | return "not found", 404 30 | logging.debug( 31 | "Time at Timezone %s is %s", 32 | schedule.Timezone, 33 | tz.get_time_at_timezone(schedule.Timezone), 34 | ) 35 | day, hour = tz.convert_time_to_index(tz.get_time_at_timezone(schedule.Timezone)) 36 | logging.debug("Working on day %s hour %s", day, hour) 37 | arr = np.asarray(schedule.Schedule["__ndarray__"], dtype=np.int).flatten() 38 | matrix_size = schedule.Schedule["Shape"][0] * schedule.Schedule["Shape"][1] 39 | prev = utils.get_prev_idx(day * 24 + hour, matrix_size) 40 | now = arr[day * 24 + hour] 41 | prev = arr[prev] 42 | logging.info("Previous state %s current %s", prev, now) 43 | if now == prev: 44 | # do nothing 45 | logging.info("Conditions are met, Nothing should be done for %s", name) 46 | return "ok", 200 47 | else: 48 | # stop/start 49 | logging.info("State is changing for %s to %s", name, now) 50 | # for each tag lets do it 51 | 52 | task_client = tasks_v2.CloudTasksClient() 53 | task = { 54 | "app_engine_http_request": { # Specify the type of request. 55 | "http_method": "POST", 56 | "relative_uri": "/tasks/change_state", 57 | } 58 | } 59 | parent = task_client.queue_path( 60 | queue="zorya-tasks", 61 | project=utils.get_project_id(), 62 | location=location.get_location(), 63 | ) 64 | for tag in policy.Tags: 65 | for project in policy.Projects: 66 | payload = { 67 | "project": project, 68 | "tagkey": next(iter(tag)), 69 | "tagvalue": tag[next(iter(tag))], 70 | "action": str(now), 71 | } 72 | task["app_engine_http_request"]["body"] = ( 73 | json.dumps(payload) 74 | ).encode() 75 | response = task_client.create_task(parent, task) 76 | logging.debug("Task %s enqueued", response.name) 77 | return "ok", 200 78 | -------------------------------------------------------------------------------- /gcp/gae.py: -------------------------------------------------------------------------------- 1 | """Interactions with compute engine.""" 2 | import logging 3 | 4 | import backoff 5 | from googleapiclient import discovery 6 | from googleapiclient.errors import HttpError 7 | from util import utils 8 | 9 | CREDENTIALS = None 10 | 11 | 12 | class Gae(object): 13 | """App Engine actions.""" 14 | 15 | def __init__(self, project): 16 | self.app = discovery.build( 17 | 'appengine', 'v1', credentials=CREDENTIALS, cache_discovery=False 18 | ) 19 | self.project = project 20 | 21 | def change_status(self, to_status, service_id, version_id): 22 | """ 23 | Stop/start version based on tags 24 | Args: 25 | to_status: 0 stop 1 start 26 | service_id: The App Engine service id 27 | version_id: The App Engine version id 28 | 29 | Returns: 30 | 31 | """ 32 | 33 | try: 34 | if int(to_status) == 1: 35 | logging.info( 36 | "Starting App Engine service: %s version: %s on project: %s", 37 | service_id, 38 | version_id, 39 | self.project 40 | ) 41 | self.start_version(service_id, version_id) 42 | 43 | else: 44 | logging.info( 45 | "Stopping App Engine service: %s version: %s on project: %s", 46 | service_id, 47 | version_id, 48 | self.project 49 | ) 50 | self.stop_version(service_id, version_id) 51 | 52 | except HttpError as http_error: 53 | logging.error(http_error) 54 | return "Error", 500 55 | return "ok", 200 56 | 57 | @backoff.on_exception(backoff.expo, HttpError, max_tries=8, giveup=utils.fatal_code) 58 | def stop_version(self, service_id, version_id): 59 | """ 60 | Stop an instance. 61 | Args: 62 | service_id: The App Engine service id 63 | version_id: The App Engine version id 64 | 65 | Returns: 66 | 67 | """ 68 | # TODO add requestId 69 | return ( 70 | self.app.apps().services().versions().patch(servicesId=service_id, appsId=self.project, 71 | versionsId=version_id, updateMask='servingStatus', 72 | body={"servingStatus": "STOPPED"}).execute() 73 | ) 74 | 75 | @backoff.on_exception(backoff.expo, HttpError, max_tries=8, giveup=utils.fatal_code) 76 | def start_version(self, service_id, version_id): 77 | """ 78 | Start an instance. 79 | Args: 80 | service_id: The App Engine service id 81 | version_id: The App Engine version id 82 | 83 | Returns: 84 | 85 | """ 86 | # TODO add requestId 87 | return ( 88 | self.app.apps().services().versions().patch(servicesId=service_id, appsId=self.project, 89 | versionsId=version_id, updateMask='servingStatus', 90 | body={"servingStatus": "SERVING"}).execute() 91 | ) 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zorya 2 | 3 | Schedule GCE Instances, Cloud SQL and GKE node pools 4 | 5 | [Blog Post](http://bit.ly/zorya_blog) 6 | 7 | [![License](https://img.shields.io/github/license/doitintl/zorya.svg)](LICENSE) [![GitHub stars](https://img.shields.io/github/stars/doitintl/zorya.svg?style=social&label=Stars&style=for-the-badge)](https://github.com/doitintl/zorya) 8 | 9 | In Slavic mythology, [Zoryas](https://www.wikiwand.com/en/Zorya) are two guardian goddesses. The Zoryas represent the morning star and the evening star, — if you have read or watched Neil Gaiman’s American Gods, you will probably remember these sisters). 10 | 11 | ## Installation 12 | 13 | ```shell 14 | pip install -r requirements.txt -t lib 15 | ``` 16 | 17 | Next step: Download and install [Yarn](https://yarnpkg.com/). 18 | 19 | ### Known Issues for Installation 20 | 21 | * Deployment from Google Cloud Shell fails with an error [#25](https://github.com/doitintl/zorya/issues/25). 22 | 23 | * Building on macOS running on Apple Silicon (arm M1) may fail due to issues building grpcio. Use the following workaround: 24 | 25 | >```shell 26 | >GRPC_PYTHON_BUILD_SYSTEM_OPENSSL=1 \ 27 | > GRPC_PYTHON_BUILD_SYSTEM_ZLIB=1 \ 28 | > pip install -r requirements.txt -t lib 29 | >``` 30 | 31 | ## Enable required GCP APIs: 32 | 33 | * Cloud Tasks 34 | * App Engine 35 | * Cloud Storage 36 | * Datastore 37 | * IAP 38 | * Cloud Build 39 | * Cloud Scheduler 40 | * Compute Engine 41 | * Cloud SQL Admin API 42 | 43 | ## Deploy Backend and GUI: 44 | ```shell 45 | ./deploy.sh project-id 46 | ``` 47 | 48 | 49 | #### Access the app 50 | ```shell 51 | gcloud app browse 52 | ``` 53 | 54 | **WARNING**: By default this application is public; ensure you turn on IAP, as follows: 55 | 56 | To sign into the app, we are using [Cloud Identity-Aware Proxy (Cloud IAP)](https://cloud.google.com/iap/). Cloud IAP works by verifying a user’s identity and determining if that user should be allowed to access the application. The setup is as simple as heading over to [GCP console](https://console.cloud.google.com/iam-admin/iap), enabling IAP on your GAE app and adding the users who should have access to it. 57 | 58 | #### Authorization 59 | 60 | For Zorya to work, its service account requires the folling roles: 61 | 62 | * Cloud Tasks Enqueuer 63 | * Cloud Datastore User 64 | * Logs Writer 65 | 66 | For any project that Zorya is supposed to be managing resources for, Zorya's service account requires the following additional roles: 67 | 68 | * Compute Instance Admin (v1) 69 | * Kubernetes Engine Cluster Admin 70 | * Cloud SQL Editor 71 | 72 | ![](iam.png) 73 | 74 | The name of the service account you will need to assign permissions to is as following:`@appspot.gserviceaccount.com` and will have been automatically created by Google App Engine. *NOTE:* this is done under *IAM*, selecting the account, choosing *Permissions* and then adding the roles above to it; not under *Service Accounts*. 75 | 76 | ## Flow 77 | 78 | * Every hour on the hour a cron job calls `/tasks/schedule` which loop over all the policies 79 | * We are checking the desired state vs the previous hour desired state of Zorya states. If they are not the same we will apply the change. 80 | 81 | [API Documentation](http://bit.ly/zorya_api_docs) 82 | 83 | ### Creating a Schedule 84 | 85 | ![](Zorya_schedule.png) 86 | 87 | ### Creating a Policy 88 | 89 | ![](Zorya_policies.png) 90 | 91 | App Engine Flex is now supported - in order to add an App engine flex to the policy please look at [Adding GAE Flex to Zorya policy](./App%20Engine%20Flex%20Tagging.md). 92 | -------------------------------------------------------------------------------- /client/src/modules/components/ScheduleTimeZone.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { withStyles } from '@material-ui/core/styles'; 4 | import TextField from '@material-ui/core/TextField'; 5 | import Paper from '@material-ui/core/Paper'; 6 | import MenuItem from '@material-ui/core/MenuItem'; 7 | import Downshift from 'downshift'; 8 | 9 | function renderInput(inputProps) { 10 | const { InputProps, classes, ref, ...other } = inputProps; 11 | 12 | return ( 13 | 24 | ); 25 | } 26 | 27 | function renderSuggestion(params) { 28 | const { suggestion, index, itemProps, highlightedIndex, selectedItem } = 29 | params; 30 | const isHighlighted = highlightedIndex === index; 31 | const isSelected = selectedItem === suggestion; 32 | 33 | return ( 34 | 43 | {suggestion} 44 | 45 | ); 46 | } 47 | 48 | const styles = (theme) => ({ 49 | container: { 50 | width: 250, 51 | marginBottom: theme.spacing(3), 52 | }, 53 | suggestionsContainer: { 54 | position: 'absolute', 55 | maxHeight: 200, 56 | width: 250, 57 | overflow: 'auto', 58 | zIndex: 1000, 59 | }, 60 | }); 61 | 62 | class ScheduleTimezone extends React.Component { 63 | constructor(props, context) { 64 | super(props, context); 65 | this.state = {}; 66 | } 67 | 68 | getSuggestions = (inputValue) => 69 | this.props.timezones 70 | .filter( 71 | (suggestion) => 72 | !inputValue || 73 | suggestion.toLowerCase().includes(inputValue.toLowerCase()) 74 | ) 75 | .slice(0, 20); 76 | 77 | render() { 78 | const { classes, onSelect, selected } = this.props; 79 | 80 | return ( 81 | 82 | {({ 83 | getInputProps, 84 | getItemProps, 85 | isOpen, 86 | inputValue, 87 | selectedItem, 88 | highlightedIndex, 89 | }) => ( 90 |
91 | {renderInput({ 92 | fullWidth: true, 93 | classes, 94 | InputProps: getInputProps({ 95 | placeholder: 'Search timezone', 96 | id: 'timezone', 97 | }), 98 | })} 99 | {isOpen ? ( 100 | 105 | {this.getSuggestions(inputValue).map((suggestion, index) => 106 | renderSuggestion({ 107 | suggestion, 108 | index, 109 | itemProps: getItemProps({ item: suggestion }), 110 | highlightedIndex, 111 | selectedItem, 112 | }) 113 | )} 114 | 115 | ) : null} 116 |
117 | )} 118 |
119 | ); 120 | } 121 | } 122 | 123 | ScheduleTimezone.propTypes = { 124 | classes: PropTypes.object.isRequired, 125 | }; 126 | 127 | export default withStyles(styles)(ScheduleTimezone); 128 | -------------------------------------------------------------------------------- /gcp/compute.py: -------------------------------------------------------------------------------- 1 | """Interactions with compute engine.""" 2 | import logging 3 | 4 | import backoff 5 | from googleapiclient import discovery 6 | from googleapiclient.errors import HttpError 7 | from util import gcp, utils 8 | 9 | CREDENTIALS = None 10 | 11 | 12 | class Compute(object): 13 | """Compute engine actions.""" 14 | 15 | def __init__(self, project): 16 | self.compute = discovery.build( 17 | "compute", "v1", credentials=CREDENTIALS, cache_discovery=False 18 | ) 19 | self.project = project 20 | 21 | def change_status(self, to_status, tagkey, tagvalue): 22 | """ 23 | Stop/start instance based on tags 24 | Args: 25 | to_status: 0 stop 1 start 26 | tagkey: tag key 27 | tagvalue: tag value 28 | 29 | Returns: 30 | 31 | """ 32 | tag_filter = "labels." + tagkey + "=" + tagvalue 33 | logging.debug("Filter %s", filter) 34 | for zone in gcp.get_zones(): 35 | try: 36 | instances = self.list_instances(zone, tag_filter) 37 | for instance in instances: 38 | if int(to_status) == 1: 39 | logging.info( 40 | "Starting %s in project %s tagkey %s tagvalue %s", 41 | instance["name"], 42 | self.project, 43 | tagkey, 44 | tagvalue, 45 | ) 46 | self.start_instance(zone, instance["name"]) 47 | else: 48 | logging.info( 49 | "Stopping %s in project %s tagkey %s tagvalue %s", 50 | instance["name"], 51 | self.project, 52 | tagkey, 53 | tagvalue, 54 | ) 55 | self.stop_instance(zone, instance["name"]) 56 | except HttpError as http_error: 57 | logging.error(http_error) 58 | return "Error", 500 59 | return "ok", 200 60 | 61 | @backoff.on_exception(backoff.expo, HttpError, max_tries=8, giveup=utils.fatal_code) 62 | def stop_instance(self, zone, instance): 63 | """ 64 | Stop an instance. 65 | Args: 66 | zone: zone 67 | instance: instance name 68 | 69 | Returns: 70 | 71 | """ 72 | # TODO add requestId 73 | return ( 74 | self.compute.instances() 75 | .stop(project=self.project, zone=zone, instance=instance) 76 | .execute() 77 | ) 78 | 79 | @backoff.on_exception(backoff.expo, HttpError, max_tries=8, giveup=utils.fatal_code) 80 | def start_instance(self, zone, instance): 81 | """ 82 | Start an instance. 83 | Args: 84 | zone: zone 85 | instance: instance name 86 | 87 | Returns: 88 | 89 | """ 90 | # TODO add requestId 91 | return ( 92 | self.compute.instances() 93 | .start(project=self.project, zone=zone, instance=instance) 94 | .execute() 95 | ) 96 | 97 | @backoff.on_exception(backoff.expo, HttpError, max_tries=8, giveup=utils.fatal_code) 98 | def list_instances(self, zone, tags_filter=None): 99 | """ 100 | List all instances in zone with the requested tags 101 | Args: 102 | zone: zone 103 | tags_filter: tags 104 | 105 | Returns: 106 | 107 | """ 108 | result = ( 109 | self.compute.instances() 110 | .list(project=self.project, zone=zone, filter=tags_filter) 111 | .execute() 112 | ) 113 | if "items" in result: 114 | return result["items"] 115 | else: 116 | return [] 117 | -------------------------------------------------------------------------------- /docs/api_curl.txt: -------------------------------------------------------------------------------- 1 | 2 | API docs are also available at bit.ly/zorya_api_docs 3 | 4 | curl --request POST \ 5 | --url http://localhost:8080/api/v1/add_schedule \ 6 | --header 'Content-Type: application/json' \ 7 | --data '{ 8 | "Shape": [ 9 | 7, 10 | 24 11 | ], 12 | "timezone": "Europe/Amsterdam", 13 | "Corder": true, 14 | "__ndarray__": [ 15 | [ 16 | 0, 17 | 1, 18 | 1, 19 | 1, 20 | 1, 21 | 1, 22 | 0, 23 | 0, 24 | 0, 25 | 0, 26 | 0, 27 | 0, 28 | 0, 29 | 0, 30 | 0, 31 | 0, 32 | 0, 33 | 0, 34 | 0, 35 | 0, 36 | 0, 37 | 0, 38 | 0, 39 | 0 40 | ], 41 | [ 42 | 0, 43 | 0, 44 | 0, 45 | 0, 46 | 0, 47 | 0, 48 | 0, 49 | 0, 50 | 1, 51 | 1, 52 | 1, 53 | 1, 54 | 1, 55 | 1, 56 | 1, 57 | 1, 58 | 1, 59 | 1, 60 | 0, 61 | 0, 62 | 0, 63 | 0, 64 | 0, 65 | 0 66 | ], 67 | [ 68 | 0, 69 | 0, 70 | 0, 71 | 0, 72 | 0, 73 | 0, 74 | 0, 75 | 0, 76 | 0, 77 | 0, 78 | 0, 79 | 1, 80 | 1, 81 | 0, 82 | 0, 83 | 0, 84 | 0, 85 | 0, 86 | 0, 87 | 0, 88 | 0, 89 | 0, 90 | 0, 91 | 0 92 | ], 93 | [ 94 | 0, 95 | 0, 96 | 0, 97 | 0, 98 | 0, 99 | 0, 100 | 0, 101 | 0, 102 | 0, 103 | 0, 104 | 0, 105 | 0, 106 | 0, 107 | 0, 108 | 0, 109 | 0, 110 | 0, 111 | 0, 112 | 0, 113 | 0, 114 | 0, 115 | 0, 116 | 0, 117 | 0 118 | ], 119 | [ 120 | 0, 121 | 0, 122 | 0, 123 | 0, 124 | 0, 125 | 0, 126 | 0, 127 | 0, 128 | 0, 129 | 0, 130 | 0, 131 | 0, 132 | 0, 133 | 0, 134 | 1, 135 | 1, 136 | 1, 137 | 1, 138 | 1, 139 | 0, 140 | 0, 141 | 0, 142 | 0, 143 | 0 144 | ], 145 | [ 146 | 0, 147 | 0, 148 | 0, 149 | 0, 150 | 0, 151 | 0, 152 | 0, 153 | 0, 154 | 0, 155 | 0, 156 | 0, 157 | 0, 158 | 0, 159 | 0, 160 | 0, 161 | 0, 162 | 0, 163 | 0, 164 | 0, 165 | 0, 166 | 0, 167 | 0, 168 | 0, 169 | 0 170 | ], 171 | [ 172 | 0, 173 | 0, 174 | 0, 175 | 0, 176 | 0, 177 | 0, 178 | 0, 179 | 0, 180 | 0, 181 | 0, 182 | 0, 183 | 0, 184 | 0, 185 | 0, 186 | 0, 187 | 0, 188 | 0, 189 | 0, 190 | 0, 191 | 0, 192 | 0, 193 | 0, 194 | 0, 195 | 0 196 | ] 197 | ], 198 | "dtype": "int64", 199 | "name": "my schedule name" 200 | }' 201 | curl --request GET \ 202 | --url http://localhost:8080/api/v1/list_schedules 203 | 204 | curl --request GET \ 205 | --url 'http://localhost:8080/api/v1/del_schedule?schedule=my%20schedule%20name' 206 | 207 | curl --request GET \ 208 | --url 'http://localhost:8080/api/v1/del_policy?policy=My%20policy' \ 209 | --header 'Content-Type: application/json' 210 | 211 | curl --request POST \ 212 | --url http://localhost:8080/api/v1/add_policy \ 213 | --header 'Content-Type: application/json' \ 214 | --data '{ 215 | "name": "My policy", 216 | "tags": [ 217 | { 218 | "dev": "sleeper", 219 | "staging": "resting" 220 | } 221 | ], 222 | "projetcs": [ 223 | "project-x", 224 | "y-project" 225 | ], 226 | "schedulename": "my schedule name" 227 | }' 228 | 229 | curl --request GET \ 230 | --url 'http://localhost:8080/api/v1/get_policy?policy=My%20policy' \ 231 | --header 'Content-Type: application/json' 232 | 233 | curl --request GET \ 234 | --url http://localhost:8080/api/v1/list_policies \ 235 | --header 'Content-Type: application/json' 236 | 237 | curl --request GET \ 238 | --url 'http://localhost:8080/api/v1/del_policy?policy=My%20policy' \ 239 | --header 'Content-Type: application/json' -------------------------------------------------------------------------------- /gcp/gke.py: -------------------------------------------------------------------------------- 1 | """Interactions with GKE.""" 2 | 3 | import logging 4 | 5 | import backoff 6 | from google.cloud import ndb 7 | from googleapiclient import discovery 8 | from googleapiclient.errors import HttpError 9 | from model.gkenoodespoolsmodel import GkeNodePoolModel 10 | from util import gcp, utils 11 | 12 | CREDENTIALS = None 13 | 14 | 15 | class Gke(object): 16 | """GKE engine actions.""" 17 | 18 | def __init__(self, project): 19 | self.gke = discovery.build("container", "v1", cache_discovery=False) 20 | self.project = project 21 | 22 | def change_status(self, to_status, tagkey, tagvalue): 23 | logging.debug("GKE change_status") 24 | client = ndb.Client() 25 | with client.context(): 26 | try: 27 | clusters = self.list_clusters() 28 | for cluster in clusters: 29 | if ( 30 | "resourceLabels" in cluster 31 | and tagkey in cluster["resourceLabels"] 32 | and cluster["resourceLabels"][tagkey] == tagvalue 33 | ): 34 | logging.debug("GKE change_status cluster %s %s %s", cluster,cluster["resourceLabels"],cluster["resourceLabels"][tagkey]) 35 | for nodePool in cluster["nodePools"]: 36 | logging.debug(nodePool["instanceGroupUrls"]) 37 | for instanceGroup in nodePool["instanceGroupUrls"]: 38 | url = instanceGroup 39 | node_pool_name = url[url.rfind("/") + 1 :] 40 | no_of_nodes = gcp.get_instancegroup_no_of_nodes_from_url( 41 | url 42 | ) 43 | if int(to_status) == 1: 44 | logging.debug( 45 | "Sizing up node pool %s in cluster %s " 46 | "tagkey " 47 | "%s tagvalue %s", 48 | nodePool["name"], 49 | cluster["name"], 50 | tagkey, 51 | tagvalue, 52 | ) 53 | res = GkeNodePoolModel.query( 54 | GkeNodePoolModel.Name == node_pool_name 55 | ).get() 56 | logging.debug(res) 57 | if not res: 58 | continue 59 | gcp.resize_node_pool(res.NumberOfNodes, url) 60 | res.key.delete() 61 | else: 62 | logging.debug( 63 | "Sizing down node pool %s in cluster %s " 64 | "tagkey " 65 | "%s tagvalue %s", 66 | nodePool["name"], 67 | cluster["name"], 68 | tagkey, 69 | tagvalue, 70 | ) 71 | if no_of_nodes == 0: 72 | continue 73 | node_pool_model = GkeNodePoolModel() 74 | node_pool_model.Name = node_pool_name 75 | node_pool_model.NumberOfNodes = no_of_nodes 76 | node_pool_model.key = ndb.Key( 77 | "GkeNodePoolModel", node_pool_name 78 | ) 79 | node_pool_model.put() 80 | gcp.resize_node_pool(0, url) 81 | except HttpError as http_error: 82 | logging.error(http_error) 83 | return "Error", 500 84 | return "ok", 200 85 | 86 | @backoff.on_exception(backoff.expo, HttpError, max_tries=8, giveup=utils.fatal_code) 87 | def list_clusters(self): 88 | """ 89 | List all clusters with the requested tags 90 | Args: 91 | zone: zone 92 | tags_filter: tags 93 | 94 | Returns: 95 | 96 | """ 97 | parent = "projects/%s/locations/-" % self.project 98 | result = ( 99 | self.gke.projects().locations().clusters().list(parent=parent).execute() 100 | ) 101 | if "clusters" in result: 102 | return result["clusters"] 103 | else: 104 | return [] 105 | -------------------------------------------------------------------------------- /client/src/modules/components/PolicyTags.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | // Material UI 5 | import { withStyles } from '@material-ui/core/styles'; 6 | import TextField from '@material-ui/core/TextField'; 7 | import FormGroup from '@material-ui/core/FormGroup'; 8 | import Button from '@material-ui/core/Button'; 9 | import IconButton from '@material-ui/core/IconButton'; 10 | import ClearIcon from '@material-ui/icons/Clear'; 11 | 12 | // Lodash 13 | import map from 'lodash/map'; 14 | import forOwn from 'lodash/forOwn'; 15 | 16 | const TEXT_FIELD_WIDTH = 250; 17 | 18 | const styles = (theme) => ({ 19 | root: { 20 | marginBottom: theme.spacing(3), 21 | }, 22 | textField: { 23 | width: TEXT_FIELD_WIDTH, 24 | marginRight: theme.spacing(1), 25 | marginBottom: theme.spacing(1), 26 | }, 27 | iconButton: { 28 | width: 32, 29 | height: 32, 30 | }, 31 | addButton: { 32 | width: TEXT_FIELD_WIDTH * 2 + theme.spacing(1), 33 | }, 34 | sizeSmallButton: { 35 | padding: 0, 36 | minHeight: 24, 37 | }, 38 | }); 39 | 40 | class PolicyTags extends React.Component { 41 | constructor(props, context) { 42 | super(props, context); 43 | this.state = { 44 | tags: [ 45 | { 46 | key: '', 47 | value: '', 48 | }, 49 | ], 50 | }; 51 | } 52 | 53 | componentDidMount() { 54 | if (this.props.tags && this.props.tags.length > 0) { 55 | let tags = []; 56 | this.props.tags.forEach((tag) => { 57 | forOwn(tag, (value, key) => { 58 | tags.push({ 59 | key, 60 | value, 61 | }); 62 | }); 63 | }); 64 | this.setState({ 65 | tags, 66 | }); 67 | } 68 | } 69 | 70 | publishChanges = (shouldUpdateErrors) => { 71 | const tags = map(this.state.tags, (tag) => ({ 72 | [tag.key]: tag.value, 73 | })); 74 | this.props.onChange(tags, shouldUpdateErrors); 75 | }; 76 | 77 | handleChange = (index, name) => (event) => { 78 | const tags = this.state.tags.slice(); 79 | tags[index][name] = event.target.value; 80 | this.setState({ tags }, () => this.publishChanges(false)); 81 | }; 82 | 83 | handleClearTag = (index) => (event) => { 84 | const tags = this.state.tags.slice(); 85 | if (tags.length > 1) { 86 | tags.splice(index, 1); 87 | this.setState({ tags }, () => this.publishChanges(true)); 88 | } 89 | }; 90 | 91 | handleAddTag = (event) => { 92 | const tags = this.state.tags.slice(); 93 | tags.push({ 94 | key: '', 95 | value: '', 96 | }); 97 | this.setState({ tags }, () => this.publishChanges(false)); 98 | }; 99 | 100 | render() { 101 | const { classes, error } = this.props; 102 | const { tags } = this.state; 103 | 104 | return ( 105 |
106 | {map(tags, (tag, index) => ( 107 | 108 | 118 | 128 | 129 | {tags.length > 1 && ( 130 | 138 | 139 | 140 | )} 141 | 142 | ))} 143 | 144 | {tags.length < 7 && ( 145 | 157 | )} 158 |
159 | ); 160 | } 161 | } 162 | PolicyTags.propTypes = { 163 | classes: PropTypes.object.isRequired, 164 | onChange: PropTypes.func.isRequired, 165 | error: PropTypes.array.isRequired, 166 | }; 167 | 168 | export default withStyles(styles)(PolicyTags); 169 | -------------------------------------------------------------------------------- /gcp/sql.py: -------------------------------------------------------------------------------- 1 | """Interactions with compute engine.""" 2 | 3 | import logging 4 | 5 | import backoff 6 | from googleapiclient import discovery 7 | from googleapiclient.errors import HttpError 8 | from util import utils 9 | 10 | CREDENTIALS = None 11 | 12 | 13 | class Sql(object): 14 | """Compute engine actions.""" 15 | 16 | def __init__(self, project): 17 | self.sql = discovery.build( 18 | "sqladmin", "v1beta4", credentials=CREDENTIALS, cache_discovery=False 19 | ) 20 | self.project = project 21 | 22 | def change_status(self, to_status, tagkey, tagvalue): 23 | """ 24 | Stop/start instance based on tags 25 | Args: 26 | to_status: 0 stop 1 start 27 | tagkey: tag key 28 | tagvalue: tag value 29 | 30 | Returns: 31 | 32 | """ 33 | tag_filter = "settings.userLabels." + tagkey + "=" + tagvalue 34 | logging.debug("Filter %s", filter) 35 | try: 36 | instances = self.list_instances(tag_filter) 37 | for instance in instances: 38 | if int(to_status) == 1: 39 | logging.info( 40 | "Starting SQL %s in project %s tagkey %s tagvalue %s", 41 | instance["name"], 42 | self.project, 43 | tagkey, 44 | tagvalue, 45 | ) 46 | self.start_instance(instance["name"]) 47 | else: 48 | logging.info( 49 | "Stopping SQL %s in project %s tagkey %s tagvalue %s", 50 | instance["name"], 51 | self.project, 52 | tagkey, 53 | tagvalue, 54 | ) 55 | self.stop_instance(instance["name"]) 56 | except HttpError as http_error: 57 | logging.error(http_error) 58 | return "Error", 500 59 | return "ok", 200 60 | 61 | @backoff.on_exception(backoff.expo, HttpError, max_tries=8, giveup=utils.fatal_code) 62 | def stop_instance(self, instance): 63 | """ 64 | Stop an instance. 65 | Args: 66 | zone: zone 67 | instance: instance name 68 | 69 | Returns: 70 | 71 | """ 72 | # TODO add requestId 73 | try: 74 | prev_instance_data = ( 75 | self.sql.instances() 76 | .get(project=self.project, instance=instance) 77 | .execute() 78 | ) 79 | 80 | patch_body = { 81 | "settings": { 82 | "settingsVersion": prev_instance_data["settings"]["settingsVersion"], 83 | "activationPolicy": "NEVER" 84 | } 85 | } 86 | 87 | res = ( 88 | self.sql.instances() 89 | .patch( 90 | project=self.project, 91 | instance=instance, 92 | body=patch_body 93 | ) 94 | .execute() 95 | ) 96 | return res 97 | except Exception as e: 98 | logging.error(e) 99 | return 100 | 101 | @backoff.on_exception(backoff.expo, HttpError, max_tries=8, giveup=utils.fatal_code) 102 | def start_instance(self, instance): 103 | """ 104 | Start an instance. 105 | Args: 106 | zone: zone 107 | instance: instance name 108 | 109 | Returns: 110 | 111 | """ 112 | try: 113 | prev_instance_data = ( 114 | self.sql.instances() 115 | .get(project=self.project, instance=instance) 116 | .execute() 117 | ) 118 | 119 | patch_body = { 120 | "settings": { 121 | "settingsVersion": prev_instance_data["settings"]["settingsVersion"], 122 | "activationPolicy": "ALWAYS" 123 | } 124 | } 125 | 126 | res = ( 127 | self.sql.instances() 128 | .patch( 129 | project=self.project, 130 | instance=instance, 131 | body=patch_body 132 | ) 133 | .execute() 134 | ) 135 | return res 136 | except Exception as e: 137 | logging.error(e) 138 | return 139 | 140 | @backoff.on_exception(backoff.expo, HttpError, max_tries=8, giveup=utils.fatal_code) 141 | def list_instances(self, tags_filter=None): 142 | """ 143 | List all instances in with the requested tags 144 | Args: 145 | zone: zone 146 | tags_filter: tags 147 | 148 | Returns: 149 | 150 | """ 151 | result = ( 152 | self.sql.instances() 153 | .list(project=self.project, filter=tags_filter) 154 | .execute() 155 | ) 156 | if "items" in result: 157 | return result["items"] 158 | else: 159 | return [] 160 | -------------------------------------------------------------------------------- /client/src/modules/components/AppFrame.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | 5 | // Recompose 6 | import { compose } from 'react-recompose'; 7 | 8 | // Router 9 | import { withRouter } from 'react-router-dom'; 10 | 11 | import { withStyles } from '@material-ui/core/styles'; 12 | import Drawer from '@material-ui/core/Drawer'; 13 | import AppBar from '@material-ui/core/AppBar'; 14 | import Toolbar from '@material-ui/core/Toolbar'; 15 | import List from '@material-ui/core/List'; 16 | import Typography from '@material-ui/core/Typography'; 17 | import Divider from '@material-ui/core/Divider'; 18 | import ListItem from '@material-ui/core/ListItem'; 19 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 20 | import ListItemText from '@material-ui/core/ListItemText'; 21 | import IconButton from '@material-ui/core/IconButton'; 22 | import Hidden from '@material-ui/core/Hidden'; 23 | 24 | import PolicyIcon from '@material-ui/icons/LibraryBooks'; 25 | import ScheduleIcon from '@material-ui/icons/Schedule'; 26 | import MenuIcon from '@material-ui/icons/Menu'; 27 | 28 | // Lodash 29 | import map from 'lodash/map'; 30 | import find from 'lodash/find'; 31 | import startsWith from 'lodash/startsWith'; 32 | 33 | // Project 34 | import logo from '../../assets/zorya.png'; 35 | 36 | const drawerWidth = 210; 37 | 38 | const links = [ 39 | { 40 | primary: 'Schedules', 41 | path: '/schedules/browser', 42 | icon: , 43 | }, 44 | { 45 | primary: 'Policies', 46 | path: '/policies/browser', 47 | icon: , 48 | }, 49 | ]; 50 | 51 | const styles = (theme) => ({ 52 | root: { 53 | width: '100%', 54 | height: '100%', 55 | zIndex: 1, 56 | overflow: 'scroll', 57 | display: 'flex', 58 | }, 59 | appBar: { 60 | marginLeft: drawerWidth, 61 | [theme.breakpoints.up('md')]: { 62 | width: `calc(100% - ${drawerWidth}px)`, 63 | }, 64 | }, 65 | navIconHide: { 66 | [theme.breakpoints.up('md')]: { 67 | display: 'none', 68 | }, 69 | }, 70 | drawerHeader: { 71 | display: 'flex', 72 | alignItems: 'center', 73 | ...theme.mixins.toolbar, 74 | }, 75 | drawerPaper: { 76 | width: drawerWidth, 77 | [theme.breakpoints.up('md')]: { 78 | position: 'relative', 79 | height: '100%', 80 | }, 81 | }, 82 | drawerDocked: { 83 | height: '100%', 84 | }, 85 | content: { 86 | backgroundColor: theme.palette.background.default, 87 | width: '100%', 88 | height: 'calc(100% - 56px)', 89 | marginTop: 56, 90 | [theme.breakpoints.up('sm')]: { 91 | height: 'calc(100% - 64px)', 92 | marginTop: 64, 93 | }, 94 | }, 95 | }); 96 | 97 | class AppFrame extends React.Component { 98 | constructor(props, context) { 99 | super(props, context); 100 | this.state = { 101 | title: '', 102 | mobileOpen: false, 103 | }; 104 | } 105 | 106 | componentDidMount() { 107 | const { history } = this.props; 108 | const currentLink = find(links, (link) => 109 | startsWith(history.location.pathname, link.path) 110 | ); 111 | if (currentLink) { 112 | this.setState({ 113 | title: currentLink.primary, 114 | }); 115 | } 116 | } 117 | 118 | handleClickLink = (link) => (event) => { 119 | const { history } = this.props; 120 | history.push(link.path); 121 | this.setState({ 122 | title: `${link.primary}`, 123 | }); 124 | }; 125 | 126 | handleDrawerToggle = () => { 127 | this.setState((prevState, props) => ({ 128 | mobileOpen: !prevState.mobileOpen, 129 | })); 130 | }; 131 | 132 | render() { 133 | const { classes, history, children } = this.props; 134 | const { title, mobileOpen } = this.state; 135 | 136 | const drawer = ( 137 |
138 |
139 | Zorya 140 |
141 | 142 | 143 | {map(links, (link, index) => ( 144 | 155 | {link.icon} 156 | 157 | 158 | ))} 159 | 160 |
161 | ); 162 | 163 | return ( 164 |
165 | 172 | 173 | 179 | 180 | 181 | 182 | {title} 183 | 184 | 185 | 186 | 187 | 188 | 200 | {drawer} 201 | 202 | 203 | 204 | 205 | 214 | {drawer} 215 | 216 | 217 | 218 |
{children}
219 |
220 | ); 221 | } 222 | } 223 | 224 | AppFrame.propTypes = { 225 | classes: PropTypes.object.isRequired, 226 | }; 227 | 228 | export default compose(withRouter, withStyles(styles))(AppFrame); 229 | -------------------------------------------------------------------------------- /client/src/pages/Schedule/ScheduleEdit.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Material UI 4 | import { withStyles } from '@material-ui/core/styles'; 5 | import Typography from '@material-ui/core/Typography'; 6 | import Button from '@material-ui/core/Button'; 7 | import ArrowBackIcon from '@material-ui/icons/ArrowBack'; 8 | import IconButton from '@material-ui/core/IconButton'; 9 | import TextField from '@material-ui/core/TextField'; 10 | 11 | // Project 12 | import ScheduleTimeTable from '../../modules/components/ScheduleTimeTable'; 13 | import ScheduleTimeZone from '../../modules/components/ScheduleTimeZone'; 14 | import AppPageContent from '../../modules/components/AppPageContent'; 15 | import AppPageActions from '../../modules/components/AppPageActions'; 16 | import ScheduleService from '../../modules/api/schedule'; 17 | 18 | const styles = (theme) => ({ 19 | root: { 20 | height: '100%', 21 | }, 22 | button: { 23 | marginRight: theme.spacing(2), 24 | }, 25 | textField: { 26 | width: 350, 27 | marginBottom: theme.spacing(3), 28 | }, 29 | }); 30 | 31 | class ScheduleEdit extends React.Component { 32 | constructor(props, context) { 33 | super(props, context); 34 | this.state = { 35 | schedule: null, 36 | isLoading: false, 37 | showBackendError: false, 38 | backendErrorTitle: null, 39 | backendErrorMessage: null, 40 | exitPage: null, 41 | }; 42 | 43 | this.scheduleService = new ScheduleService(); 44 | } 45 | 46 | async componentDidMount() { 47 | const { match } = this.props; 48 | this.setState({ isLoading: true }); 49 | try { 50 | const schedule = await this.scheduleService.get(match.params.schedule); 51 | const timezones = await this.scheduleService.timezones(); 52 | this.setState({ 53 | schedule, 54 | timezones: timezones.Timezones, 55 | isLoading: false, 56 | }); 57 | } catch (error) { 58 | this.handleBackendError( 59 | 'Loading Failed:', 60 | error.message, 61 | '/schedules/browser' 62 | ); 63 | } 64 | } 65 | 66 | handleChange = (name) => (event) => { 67 | const { schedule } = this.state; 68 | schedule[name] = event.target.value; 69 | this.setState({ schedule }); 70 | }; 71 | 72 | handleScheduleChange = (nextSchedule) => { 73 | this.setState({ 74 | schedule: nextSchedule, 75 | }); 76 | }; 77 | 78 | handleChangeTimezone = (value) => { 79 | const { schedule } = this.state; 80 | schedule.timezone = value; 81 | this.setState({ schedule }); 82 | }; 83 | 84 | handleSave = async (event) => { 85 | try { 86 | const { history } = this.props; 87 | const { schedule } = this.state; 88 | this.setState({ isLoading: true }); 89 | await this.scheduleService.add(schedule); 90 | this.setState({ isLoading: false }); 91 | history.push('/schedules/browser'); 92 | } catch (error) { 93 | this.handleBackendError('Saving failed:', error.message); 94 | } 95 | }; 96 | 97 | handleRequestCancel = (event) => { 98 | const { history } = this.props; 99 | history.goBack(); 100 | }; 101 | 102 | handleBackendError = (title, message, exitPage) => { 103 | this.setState({ 104 | backendErrorTitle: title, 105 | backendErrorMessage: message, 106 | showBackendError: true, 107 | isLoading: false, 108 | exitPage, 109 | }); 110 | }; 111 | 112 | handleErrorClose = () => { 113 | const { history } = this.props; 114 | const { exitPage } = this.state; 115 | this.setState({ 116 | showBackendError: false, 117 | isLoading: false, 118 | }); 119 | if (exitPage) { 120 | history.push(exitPage); 121 | } 122 | }; 123 | 124 | render() { 125 | const { classes } = this.props; 126 | const { 127 | schedule, 128 | timezones, 129 | isLoading, 130 | showBackendError, 131 | backendErrorTitle, 132 | backendErrorMessage, 133 | } = this.state; 134 | 135 | return ( 136 |
137 | 138 | 143 | 144 | 145 | 146 | Edit schedule {schedule ? schedule.name : ''} 147 | 148 | 149 | 156 | {schedule && ( 157 |
158 | 166 | 167 | 175 | 176 | 181 | 182 | 186 | 187 | 196 | 205 |
206 | )} 207 |
208 |
209 | ); 210 | } 211 | } 212 | 213 | export default withStyles(styles)(ScheduleEdit); 214 | -------------------------------------------------------------------------------- /client/src/pages/Schedule/ScheduleCreate.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Material UI 4 | import { withStyles } from '@material-ui/core/styles'; 5 | import Typography from '@material-ui/core/Typography'; 6 | import Button from '@material-ui/core/Button'; 7 | import ArrowBackIcon from '@material-ui/icons/ArrowBack'; 8 | import IconButton from '@material-ui/core/IconButton'; 9 | import TextField from '@material-ui/core/TextField'; 10 | 11 | // Project 12 | import ScheduleTimeTable from '../../modules/components/ScheduleTimeTable'; 13 | import ScheduleTimeZone from '../../modules/components/ScheduleTimeZone'; 14 | import AppPageContent from '../../modules/components/AppPageContent'; 15 | import AppPageActions from '../../modules/components/AppPageActions'; 16 | import ScheduleService from '../../modules/api/schedule'; 17 | import { getDefaultSchedule } from '../../modules/utils/schedule'; 18 | 19 | const styles = (theme) => ({ 20 | root: { 21 | height: '100%', 22 | }, 23 | button: { 24 | marginRight: theme.spacing(2), 25 | }, 26 | textField: { 27 | minWidth: 250, 28 | marginBottom: theme.spacing(3), 29 | marginRight: theme.spacing(2), 30 | }, 31 | }); 32 | 33 | class ScheduleCreate extends React.Component { 34 | constructor(props, context) { 35 | super(props, context); 36 | this.state = { 37 | schedule: getDefaultSchedule(), 38 | nameError: false, 39 | timezones: [], 40 | isLoading: false, 41 | showBackendError: false, 42 | backendErrorTitle: null, 43 | backendErrorMessage: null, 44 | exitPage: null, 45 | }; 46 | 47 | this.scheduleService = new ScheduleService(); 48 | } 49 | 50 | async componentDidMount() { 51 | try { 52 | this.setState({ isLoading: true }); 53 | const response = await this.scheduleService.timezones(); 54 | this.setState({ 55 | timezones: response.Timezones, 56 | isLoading: false, 57 | }); 58 | } catch (error) { 59 | this.handleBackendError( 60 | 'Loading timezones failed:', 61 | error.message, 62 | '/schedules/browser' 63 | ); 64 | } 65 | } 66 | 67 | handleChange = (name) => (event) => { 68 | const { schedule } = this.state; 69 | schedule[name] = event.target.value; 70 | this.setState({ schedule }); 71 | }; 72 | 73 | handleChangeTimezone = (value) => { 74 | const { schedule } = this.state; 75 | schedule.timezone = value; 76 | this.setState({ schedule }); 77 | }; 78 | 79 | handleScheduleChange = (nextSchedule) => { 80 | this.setState({ 81 | schedule: nextSchedule, 82 | }); 83 | }; 84 | 85 | handleCreate = async (event) => { 86 | try { 87 | const { history } = this.props; 88 | const { schedule } = this.state; 89 | const nameRe = /^[a-zA-Z][\w-]*[a-zA-Z0-9]$/; 90 | if (!nameRe.test(schedule.name)) { 91 | this.setState({ 92 | nameError: true, 93 | }); 94 | return; 95 | } 96 | this.setState({ isLoading: true }); 97 | await this.scheduleService.add(schedule); 98 | this.setState({ isLoading: false }); 99 | history.push('/schedules/browser'); 100 | } catch (error) { 101 | this.handleBackendError('Saving failed:', error.message); 102 | } 103 | }; 104 | 105 | handleRequestCancel = (event) => { 106 | const { history } = this.props; 107 | history.goBack(); 108 | }; 109 | 110 | handleBackendError = (title, message, exitPage) => { 111 | this.setState({ 112 | backendErrorTitle: title, 113 | backendErrorMessage: message, 114 | showBackendError: true, 115 | isLoading: false, 116 | exitPage, 117 | }); 118 | }; 119 | 120 | handleErrorClose = () => { 121 | const { history } = this.props; 122 | const { exitPage } = this.state; 123 | this.setState({ 124 | showBackendError: false, 125 | isLoading: false, 126 | }); 127 | if (exitPage) { 128 | history.push(exitPage); 129 | } 130 | }; 131 | 132 | render() { 133 | const { classes } = this.props; 134 | const { 135 | schedule, 136 | timezones, 137 | nameError, 138 | isLoading, 139 | showBackendError, 140 | backendErrorTitle, 141 | backendErrorMessage, 142 | } = this.state; 143 | 144 | return ( 145 |
146 | 147 | 152 | 153 | 154 | 155 | Create a schedule 156 | 157 | 158 | 159 | 166 | 177 | 178 | 187 | 188 | 193 | 194 | 198 | 199 | 208 | 217 | 218 |
219 | ); 220 | } 221 | } 222 | 223 | export default withStyles(styles)(ScheduleCreate); 224 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """Entry point to Zoyra.""" 2 | import logging 3 | import json 4 | 5 | from google.cloud import ndb 6 | from flask import Flask, request 7 | from model.policymodel import PolicyModel 8 | from model.schedulesmodel import SchedulesModel 9 | from tasks import policy_tasks, schedule_tasks 10 | from util import tz 11 | 12 | import google.cloud.logging 13 | 14 | log_client = google.cloud.logging.Client() 15 | log_client.setup_logging() 16 | 17 | 18 | API_VERSION = "/api/v1" 19 | app = Flask(__name__) 20 | client = ndb.Client() 21 | 22 | 23 | @app.route("/tasks/change_state", methods=["POST"]) 24 | def change_state(): 25 | """ 26 | Initiate change state. 27 | Returns: 28 | 29 | """ 30 | payload = json.loads(request.get_data(as_text=False) or "(empty payload)") 31 | logging.debug( 32 | "Starting change_state action %s project %s tagkey %s tagvalue %s", 33 | payload["action"], 34 | payload["project"], 35 | payload["tagkey"], 36 | payload["tagvalue"], 37 | ) 38 | schedule_tasks.change_state( 39 | payload["tagkey"], payload["tagvalue"], payload["action"], payload["project"] 40 | ) 41 | return "ok", 200 42 | 43 | 44 | @app.route("/tasks/schedule", methods=["GET"]) 45 | def schedule(): 46 | """ 47 | Checks if it's time to run a schedule. 48 | Returns: 49 | 50 | """ 51 | logging.debug("From Cron start /tasks/schedule") 52 | 53 | with client.context(): 54 | keys = PolicyModel.query().fetch(keys_only=True) 55 | for key in keys: 56 | logging.debug("Creating deferred task for %s", key.id()) 57 | policy_tasks.policy_checker(key.id()) 58 | return "ok", 200 59 | 60 | 61 | @app.route(API_VERSION + "/time_zones", methods=["GET"]) 62 | def time_zones(): 63 | """ 64 | Get all time zones. 65 | :return: all time zone in the world wide world. 66 | """ 67 | return json.dumps({"Timezones": tz.get_all_timezones()}) 68 | 69 | 70 | @app.route(API_VERSION + "/add_schedule", methods=["POST"]) 71 | def add_schedule(): 72 | """ 73 | Add a schedule. 74 | Returns: 75 | 76 | """ 77 | with client.context(): 78 | schedules_model = SchedulesModel() 79 | schedules_model.Schedule = { 80 | "dtype": request.json["dtype"], 81 | "Corder": request.json["Corder"], 82 | "Shape": request.json["Shape"], 83 | "__ndarray__": request.json["__ndarray__"], 84 | } 85 | 86 | schedules_model.Name = request.json["name"] 87 | schedules_model.DisplayName = request.json.get( 88 | "displayname", request.json.get("name")) 89 | schedules_model.Timezone = request.json["timezone"] 90 | schedules_model.key = ndb.Key("SchedulesModel", request.json["name"]) 91 | schedules_model.put() 92 | return "ok", 200 93 | 94 | 95 | @app.route(API_VERSION + "/get_schedule", methods=["GET"]) 96 | def get_schedule(): 97 | """ 98 | Get a schedule. 99 | Returns: schedule json 100 | 101 | """ 102 | name = request.args.get("schedule") 103 | schedule = {} 104 | with client.context(): 105 | res = SchedulesModel.query(SchedulesModel.Name == name).get() 106 | if not res: 107 | return "not found", 404 108 | schedule.update({"name": res.Name}) 109 | schedule.update({"displayname": res.DisplayName or res.Name}) 110 | schedule.update(res.Schedule) 111 | schedule.update({"timezone": res.Timezone}) 112 | logging.debug(json.dumps(res.Schedule)) 113 | return json.dumps(schedule) 114 | 115 | 116 | @app.route(API_VERSION + "/list_schedules", methods=["GET"]) 117 | def list_schedules(): 118 | """ 119 | Get all schedules. 120 | Returns: A list of schedules 121 | 122 | """ 123 | schedules_list = [] 124 | with client.context(): 125 | verbose = request.args.get("verbose") == "true" 126 | if verbose: 127 | schedules = SchedulesModel.query().fetch() 128 | for schedule in schedules: 129 | schedules_list.append( 130 | {"name": schedule.Name, "displayName": schedule.DisplayName} 131 | ) 132 | else: 133 | keys = SchedulesModel.query().fetch(keys_only=True) 134 | for key in keys: 135 | schedules_list.append(key.id()) 136 | return json.dumps(schedules_list) 137 | 138 | 139 | @app.route(API_VERSION + "/del_schedule", methods=["GET"]) 140 | def del_schedule(): 141 | """ 142 | Delete a schedule. 143 | Returns: 144 | 145 | """ 146 | name = request.args.get("schedule") 147 | with client.context(): 148 | res = SchedulesModel.query(SchedulesModel.Name == name).get() 149 | if not res: 150 | return "Schedule '{}' not found.".format(name), 404 151 | policy = PolicyModel.query(PolicyModel.Schedule == name).get() 152 | if policy: 153 | return "Forbidden. Schedule '{}' is in use by policy '{}'.".format(name, policy.Name), 403 154 | res.key.delete() 155 | return "ok", 200 156 | 157 | 158 | @app.route(API_VERSION + "/add_policy", methods=["POST"]) 159 | def add_policy(): 160 | """ 161 | Add policy. 162 | Returns: 163 | 164 | """ 165 | logging.debug(json.dumps(request.json)) 166 | name = request.json["name"] 167 | display_name = request.json.get("displayname", name) 168 | tags = request.json["tags"] 169 | projects = request.json["projects"] 170 | schedule_name = request.json["schedulename"] 171 | with client.context(): 172 | res = SchedulesModel.query(SchedulesModel.Name == schedule_name).get() 173 | if not res: 174 | return "Schedule '{}' not found.".format(schedule_name), 404 175 | 176 | policy_model = PolicyModel() 177 | policy_model.Name = name 178 | policy_model.DisplayName = display_name 179 | policy_model.Tags = tags 180 | policy_model.Projects = projects 181 | policy_model.Schedule = schedule_name 182 | policy_model.key = ndb.Key("PolicyModel", name) 183 | policy_model.put() 184 | return "ok", 200 185 | 186 | 187 | @app.route(API_VERSION + "/get_policy", methods=["GET"]) 188 | def get_policy(): 189 | """ 190 | Get policy. 191 | Returns: policy json 192 | 193 | """ 194 | policy = {} 195 | name = request.args.get("policy") 196 | with client.context(): 197 | res = PolicyModel.query(PolicyModel.Name == name).get() 198 | logging.debug(res) 199 | if not res: 200 | return "Policy '{}' not found.".format(name), 404 201 | policy.update({"name": res.Name}) 202 | policy.update({"displayname": res.DisplayName or res.Name}) 203 | policy.update({"schedulename": res.Schedule}) 204 | policy.update({"tags": res.Tags}) 205 | policy.update({"projects": res.Projects}) 206 | return json.dumps(policy) 207 | 208 | 209 | @app.route(API_VERSION + "/list_policies", methods=["GET"]) 210 | def list_policies(): 211 | """ 212 | Get all polices. 213 | Returns: List of policies 214 | 215 | """ 216 | policies_list = [] 217 | with client.context(): 218 | verbose = request.args.get("verbose") == "true" 219 | if verbose: 220 | policies = PolicyModel.query().fetch() 221 | for policy in policies: 222 | policies_list.append( 223 | {"name": policy.Name, "displayName": policy.DisplayName} 224 | ) 225 | else: 226 | keys = PolicyModel.query().fetch(keys_only=True) 227 | for key in keys: 228 | policies_list.append(key.id()) 229 | 230 | return json.dumps(policies_list) 231 | 232 | 233 | @app.route(API_VERSION + "/del_policy", methods=["GET"]) 234 | def del_policy(): 235 | """ 236 | Delete a policy 237 | Returns: 238 | 239 | """ 240 | name = request.args.get("policy") 241 | with client.context(): 242 | res = PolicyModel.query(PolicyModel.Name == name).get() 243 | if not res: 244 | return "Policy '{}' not found.".format(name), 404 245 | res.key.delete() 246 | return "ok", 200 247 | 248 | 249 | @app.route("/") 250 | def index(): 251 | """ 252 | Main Page 253 | :return: 254 | """ 255 | return "ok", 200 256 | 257 | 258 | if __name__ == "__main__": 259 | logging.getLogger("googleapiclient.discovery_cache").setLevel(logging.INFO) 260 | app.run(debug=False) 261 | -------------------------------------------------------------------------------- /client/src/pages/Policy/PolicyList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Recompose 4 | import { compose } from 'react-recompose'; 5 | 6 | // Router 7 | import { withRouter } from 'react-router-dom'; 8 | 9 | // Material UI 10 | import { withStyles } from '@material-ui/core/styles'; 11 | import Table from '@material-ui/core/Table'; 12 | import TableBody from '@material-ui/core/TableBody'; 13 | import TableCell from '@material-ui/core/TableCell'; 14 | import TableHead from '@material-ui/core/TableHead'; 15 | import TableRow from '@material-ui/core/TableRow'; 16 | import TableSortLabel from '@material-ui/core/TableSortLabel'; 17 | 18 | import Tooltip from '@material-ui/core/Tooltip'; 19 | import Button from '@material-ui/core/Button'; 20 | import Checkbox from '@material-ui/core/Checkbox'; 21 | import AddIcon from '@material-ui/icons/Add'; 22 | import RefreshIcon from '@material-ui/icons/Refresh'; 23 | import EditIcon from '@material-ui/icons/Edit'; 24 | import DeleteIcon from '@material-ui/icons/Delete'; 25 | 26 | // Lodash 27 | import map from 'lodash/map'; 28 | import indexOf from 'lodash/indexOf'; 29 | 30 | // Project 31 | import PolicyService from '../../modules/api/policy'; 32 | import AppPageContent from '../../modules/components/AppPageContent'; 33 | import AppPageActions from '../../modules/components/AppPageActions'; 34 | 35 | const styles = (theme) => ({ 36 | root: { 37 | height: '100%', 38 | }, 39 | button: { 40 | marginRight: theme.spacing(2), 41 | }, 42 | leftIcon: { 43 | marginRight: theme.spacing(1), 44 | }, 45 | link: { 46 | '&:hover': { 47 | textDecoration: 'underline', 48 | cursor: 'pointer', 49 | }, 50 | }, 51 | checkboxCell: { 52 | width: 48, 53 | }, 54 | }); 55 | 56 | class PolicyList extends React.Component { 57 | constructor(props, context) { 58 | super(props, context); 59 | this.state = { 60 | policies: [], 61 | selected: [], 62 | order: 'asc', 63 | isLoading: false, 64 | showBackendError: false, 65 | backendErrorTitle: null, 66 | backendErrorMessage: null, 67 | }; 68 | 69 | this.policyService = new PolicyService(); 70 | } 71 | 72 | componentDidMount() { 73 | this.refreshList(); 74 | } 75 | 76 | handleRequestSort = (event) => { 77 | this.setState((prevState, props) => { 78 | let order = 'desc'; 79 | if (prevState.order === 'desc') { 80 | order = 'asc'; 81 | } 82 | 83 | const policies = 84 | order === 'desc' 85 | ? prevState.policies.sort((a, b) => 86 | b.displayname || b.name < a.displayname || a.name ? -1 : 1 87 | ) 88 | : prevState.policies.sort((a, b) => 89 | a.displayname || a.name < b.displayname || b.name ? -1 : 1 90 | ); 91 | 92 | return { 93 | policies, 94 | order, 95 | }; 96 | }); 97 | }; 98 | 99 | handleClickNavigate = (path) => (event) => { 100 | const { history } = this.props; 101 | history.push(path); 102 | }; 103 | 104 | handleClickRefresh = (event) => { 105 | this.refreshList(); 106 | }; 107 | 108 | refreshList = async () => { 109 | this.setState({ isLoading: true }); 110 | try { 111 | const policies = await this.policyService.list(); 112 | this.setState({ 113 | policies, 114 | isLoading: false, 115 | }); 116 | } catch (error) { 117 | this.handleBackendError('Loading Failed:', error.message); 118 | } 119 | }; 120 | 121 | handleClick = (event, policy) => { 122 | const { selected } = this.state; 123 | const selectedIndex = indexOf(selected, policy.name); 124 | let newSelected = []; 125 | 126 | if (selectedIndex === -1) { 127 | newSelected = newSelected.concat(selected, policy.name); 128 | } else if (selectedIndex === 0) { 129 | newSelected = newSelected.concat(selected.slice(1)); 130 | } else if (selectedIndex === selected.length - 1) { 131 | newSelected = newSelected.concat(selected.slice(0, -1)); 132 | } else if (selectedIndex > 0) { 133 | newSelected = newSelected.concat( 134 | selected.slice(0, selectedIndex), 135 | selected.slice(selectedIndex + 1) 136 | ); 137 | } 138 | 139 | this.setState({ selected: newSelected }); 140 | }; 141 | 142 | handleSelectAllClick = (event, checked) => { 143 | if (checked) { 144 | this.setState({ selected: this.state.policies.map((p) => p.name) }); 145 | } else { 146 | this.setState({ selected: [] }); 147 | } 148 | }; 149 | 150 | handleDeleteClick = async (event) => { 151 | try { 152 | const { selected } = this.state; 153 | if (selected.length > 0) { 154 | const promises = []; 155 | this.setState({ isLoading: true }); 156 | selected.forEach((policy) => { 157 | promises.push( 158 | this.policyService.delete(policy).catch((error) => error) 159 | ); 160 | }); 161 | const responses = await Promise.all(promises); 162 | const errorMessages = responses 163 | .filter((response) => response instanceof Error) 164 | .map((error) => error.message); 165 | if (errorMessages.length) { 166 | throw Error(errorMessages.join('; ')); 167 | } 168 | this.setState( 169 | { 170 | selected: [], 171 | isLoading: false, 172 | }, 173 | () => { 174 | this.refreshList(); 175 | } 176 | ); 177 | } 178 | } catch (error) { 179 | this.handleBackendError('Deletion failed:', error.message); 180 | } 181 | }; 182 | 183 | handleBackendError = (title, message) => { 184 | this.setState({ 185 | backendErrorTitle: title, 186 | backendErrorMessage: message, 187 | showBackendError: true, 188 | isLoading: false, 189 | }); 190 | }; 191 | 192 | handleErrorClose = () => { 193 | this.setState({ 194 | showBackendError: false, 195 | isLoading: false, 196 | }); 197 | }; 198 | 199 | render() { 200 | const { classes } = this.props; 201 | const { 202 | policies, 203 | selected, 204 | order, 205 | isLoading, 206 | backendErrorTitle, 207 | backendErrorMessage, 208 | showBackendError, 209 | } = this.state; 210 | 211 | const rowCount = policies.length; 212 | const numSelected = selected.length; 213 | 214 | return ( 215 |
216 | 217 | 226 | 235 | 247 | 257 | 258 | 265 | 266 | 267 | 268 | 269 | 0 && numSelected < rowCount} 271 | checked={rowCount > 0 && numSelected === rowCount} 272 | onChange={this.handleSelectAllClick} 273 | /> 274 | 275 | 276 | 281 | 286 | Policies 287 | 288 | 289 | 290 | 291 | 292 | 293 | {map(policies, (policy) => { 294 | const isSelected = indexOf(selected, policy.name) !== -1; 295 | return ( 296 | 304 | 305 | this.handleClick(event, policy)} 308 | /> 309 | 310 | 311 | 312 | 318 | {policy.displayName || policy.name} 319 | 320 | 321 | 322 | ); 323 | })} 324 | 325 |
326 |
327 |
328 | ); 329 | } 330 | } 331 | 332 | export default compose(withRouter, withStyles(styles))(PolicyList); 333 | -------------------------------------------------------------------------------- /client/src/pages/Schedule/ScheduleList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | // Recompose 4 | import { compose } from 'react-recompose'; 5 | 6 | // Router 7 | import { withRouter } from 'react-router-dom'; 8 | 9 | // Material UI 10 | import { withStyles } from '@material-ui/core/styles'; 11 | import Table from '@material-ui/core/Table'; 12 | import TableBody from '@material-ui/core/TableBody'; 13 | import TableCell from '@material-ui/core/TableCell'; 14 | import TableHead from '@material-ui/core/TableHead'; 15 | import TableRow from '@material-ui/core/TableRow'; 16 | import TableSortLabel from '@material-ui/core/TableSortLabel'; 17 | import Tooltip from '@material-ui/core/Tooltip'; 18 | import Button from '@material-ui/core/Button'; 19 | import Checkbox from '@material-ui/core/Checkbox'; 20 | import AddIcon from '@material-ui/icons/Add'; 21 | import RefreshIcon from '@material-ui/icons/Refresh'; 22 | import EditIcon from '@material-ui/icons/Edit'; 23 | import DeleteIcon from '@material-ui/icons/Delete'; 24 | 25 | // Lodash 26 | import map from 'lodash/map'; 27 | import indexOf from 'lodash/indexOf'; 28 | 29 | // Project 30 | import ScheduleService from '../../modules/api/schedule'; 31 | import AppPageContent from '../../modules/components/AppPageContent'; 32 | import AppPageActions from '../../modules/components/AppPageActions'; 33 | 34 | const styles = (theme) => ({ 35 | root: { 36 | height: '100%', 37 | }, 38 | button: { 39 | marginRight: theme.spacing(2), 40 | }, 41 | leftIcon: { 42 | marginRight: theme.spacing(1), 43 | }, 44 | link: { 45 | '&:hover': { 46 | textDecoration: 'underline', 47 | cursor: 'pointer', 48 | }, 49 | }, 50 | checkboxCell: { 51 | width: 48, 52 | }, 53 | }); 54 | 55 | class ScheduleList extends React.Component { 56 | constructor(props, context) { 57 | super(props, context); 58 | this.state = { 59 | schedules: [], 60 | selected: [], 61 | order: 'asc', 62 | isLoading: false, 63 | showBackendError: false, 64 | backendErrorTitle: null, 65 | backendErrorMessage: null, 66 | }; 67 | 68 | this.scheduleService = new ScheduleService(); 69 | } 70 | 71 | componentDidMount() { 72 | this.refreshList(); 73 | } 74 | 75 | handleRequestSort = (event) => { 76 | this.setState((prevState, props) => { 77 | let order = 'desc'; 78 | if (prevState.order === 'desc') { 79 | order = 'asc'; 80 | } 81 | 82 | const schedules = 83 | order === 'desc' 84 | ? prevState.schedules.sort((a, b) => 85 | b.displayname || b.name < a.displayname || a.name ? -1 : 1 86 | ) 87 | : prevState.schedules.sort((a, b) => 88 | a.displayname || a.name < b.displayname || b.name ? -1 : 1 89 | ); 90 | 91 | return { 92 | schedules, 93 | order, 94 | }; 95 | }); 96 | }; 97 | 98 | handleClickNavigate = (path) => (event) => { 99 | const { history } = this.props; 100 | history.push(path); 101 | }; 102 | 103 | handleClickRefresh = (event) => { 104 | this.refreshList(); 105 | }; 106 | 107 | refreshList = async () => { 108 | this.setState({ isLoading: true }); 109 | try { 110 | const schedules = await this.scheduleService.list(); 111 | this.setState({ 112 | schedules, 113 | isLoading: false, 114 | }); 115 | } catch (error) { 116 | this.handleBackendError('Listing failed:', error.message); 117 | } 118 | }; 119 | 120 | handleClick = (event, schedule) => { 121 | const { selected } = this.state; 122 | const selectedIndex = indexOf(selected, schedule.name); 123 | let newSelected = []; 124 | 125 | if (selectedIndex === -1) { 126 | newSelected = newSelected.concat(selected, schedule.name); 127 | } else if (selectedIndex === 0) { 128 | newSelected = newSelected.concat(selected.slice(1)); 129 | } else if (selectedIndex === selected.length - 1) { 130 | newSelected = newSelected.concat(selected.slice(0, -1)); 131 | } else if (selectedIndex > 0) { 132 | newSelected = newSelected.concat( 133 | selected.slice(0, selectedIndex), 134 | selected.slice(selectedIndex + 1) 135 | ); 136 | } 137 | 138 | this.setState({ selected: newSelected }); 139 | }; 140 | 141 | handleSelectAllClick = (event, checked) => { 142 | if (checked) { 143 | this.setState({ selected: this.state.schedules.map((p) => p.name) }); 144 | } else { 145 | this.setState({ selected: [] }); 146 | } 147 | }; 148 | 149 | handleDeleteClick = async (event) => { 150 | try { 151 | const { selected } = this.state; 152 | if (selected.length > 0) { 153 | const promises = []; 154 | this.setState({ isLoading: true }); 155 | selected.forEach((schedule) => { 156 | promises.push( 157 | this.scheduleService.delete(schedule).catch((error) => error) 158 | ); 159 | }); 160 | const responses = await Promise.all(promises); 161 | const errorMessages = responses 162 | .filter((response) => response instanceof Error) 163 | .map((error) => error.message); 164 | if (errorMessages.length) { 165 | throw Error(errorMessages.join('; ')); 166 | } 167 | this.setState( 168 | { 169 | selected: [], 170 | isLoading: false, 171 | }, 172 | () => { 173 | this.refreshList(); 174 | } 175 | ); 176 | } 177 | } catch (error) { 178 | this.handleBackendError('Deletion failed:', error.message); 179 | } 180 | }; 181 | 182 | handleBackendError = (title, message) => { 183 | this.setState({ 184 | backendErrorTitle: title, 185 | backendErrorMessage: message, 186 | showBackendError: true, 187 | isLoading: false, 188 | }); 189 | }; 190 | 191 | handleErrorClose = () => { 192 | this.setState({ 193 | showBackendError: false, 194 | isLoading: false, 195 | }); 196 | }; 197 | 198 | render() { 199 | const { classes } = this.props; 200 | const { 201 | schedules, 202 | selected, 203 | order, 204 | isLoading, 205 | backendErrorTitle, 206 | backendErrorMessage, 207 | showBackendError, 208 | } = this.state; 209 | 210 | const rowCount = schedules.length; 211 | const numSelected = selected.length; 212 | 213 | return ( 214 |
215 | 216 | 225 | 234 | 246 | 256 | 257 | 258 | 265 | 266 | 267 | 268 | 269 | 0 && numSelected < rowCount} 271 | checked={rowCount > 0 && numSelected === rowCount} 272 | onChange={this.handleSelectAllClick} 273 | /> 274 | 275 | 276 | 281 | 286 | Schedules 287 | 288 | 289 | 290 | 291 | 292 | 293 | {map(schedules, (schedule) => { 294 | const isSelected = indexOf(selected, schedule.name) !== -1; 295 | return ( 296 | 304 | 305 | this.handleClick(event, schedule)} 308 | /> 309 | 310 | 311 | 312 | 318 | {schedule.displayName || schedule.name} 319 | 320 | 321 | 322 | ); 323 | })} 324 | 325 |
326 |
327 |
328 | ); 329 | } 330 | } 331 | 332 | export default compose(withRouter, withStyles(styles))(ScheduleList); 333 | -------------------------------------------------------------------------------- /docs/schedule_example.json: -------------------------------------------------------------------------------- 1 | 2 | Public 3 | { 4 | "Shape": [ 5 | 7, 6 | 24 7 | ], 8 | "timezone": "Europe/Amsterdam", 9 | "Corder": true, 10 | "__ndarray__": [ 11 | [ 12 | 0, 13 | 1, 14 | 1, 15 | 1, 16 | 1, 17 | 1, 18 | 0, 19 | 0, 20 | 0, 21 | 0, 22 | 0, 23 | 0, 24 | 0, 25 | 0, 26 | 0, 27 | 0, 28 | 0, 29 | 0, 30 | 0, 31 | 0, 32 | 0, 33 | 0, 34 | 0, 35 | 0 36 | ], 37 | [ 38 | 0, 39 | 0, 40 | 0, 41 | 0, 42 | 0, 43 | 0, 44 | 0, 45 | 0, 46 | 1, 47 | 1, 48 | 1, 49 | 1, 50 | 1, 51 | 1, 52 | 1, 53 | 1, 54 | 1, 55 | 1, 56 | 0, 57 | 0, 58 | 0, 59 | 0, 60 | 0, 61 | 0 62 | ], 63 | [ 64 | 0, 65 | 0, 66 | 0, 67 | 0, 68 | 0, 69 | 0, 70 | 0, 71 | 0, 72 | 0, 73 | 0, 74 | 0, 75 | 1, 76 | 1, 77 | 0, 78 | 0, 79 | 0, 80 | 0, 81 | 0, 82 | 0, 83 | 0, 84 | 0, 85 | 0, 86 | 0, 87 | 0 88 | ], 89 | [ 90 | 0, 91 | 0, 92 | 0, 93 | 0, 94 | 0, 95 | 0, 96 | 0, 97 | 0, 98 | 0, 99 | 0, 100 | 0, 101 | 0, 102 | 0, 103 | 0, 104 | 0, 105 | 0, 106 | 0, 107 | 0, 108 | 0, 109 | 0, 110 | 0, 111 | 0, 112 | 0, 113 | 0 114 | ], 115 | [ 116 | 0, 117 | 0, 118 | 0, 119 | 0, 120 | 0, 121 | 0, 122 | 0, 123 | 0, 124 | 0, 125 | 0, 126 | 0, 127 | 0, 128 | 0, 129 | 0, 130 | 1, 131 | 1, 132 | 1, 133 | 1, 134 | 1, 135 | 0, 136 | 0, 137 | 0, 138 | 0, 139 | 0 140 | ], 141 | [ 142 | 0, 143 | 0, 144 | 0, 145 | 0, 146 | 0, 147 | 0, 148 | 0, 149 | 0, 150 | 0, 151 | 0, 152 | 0, 153 | 0, 154 | 0, 155 | 0, 156 | 0, 157 | 0, 158 | 0, 159 | 0, 160 | 0, 161 | 0, 162 | 0, 163 | 0, 164 | 0, 165 | 0 166 | ], 167 | [ 168 | 0, 169 | 0, 170 | 0, 171 | 0, 172 | 0, 173 | 0, 174 | 0, 175 | 0, 176 | 0, 177 | 0, 178 | 0, 179 | 0, 180 | 0, 181 | 0, 182 | 0, 183 | 0, 184 | 0, 185 | 0, 186 | 0, 187 | 0, 188 | 0, 189 | 0, 190 | 0, 191 | 0 192 | ] 193 | ], 194 | "dtype": "int64", 195 | "name": "schedule_name" 196 | } 197 | Zorya API Docs 198 | 199 | Zorya GCP Instance Scheduler 200 | 201 | Language 202 | POST Add a schedule 203 | http://localhost:8080/api/v1/add_schedule 204 | HEADERS 205 | Content-Typeapplication/json 206 | BODY 207 | { 208 | "Shape": [ 209 | 7, 210 | 24 211 | ], 212 | "timezone": "Europe/Amsterdam", 213 | "Corder": true, 214 | "__ndarray__": [ 215 | [ 216 | 0, 217 | 1, 218 | 1, 219 | 1, 220 | 1, 221 | 1, 222 | 0, 223 | 0, 224 | 0, 225 | 0, 226 | 0, 227 | 0, 228 | 0, 229 | 0, 230 | 0, 231 | 0, 232 | 0, 233 | 0, 234 | 0, 235 | 0, 236 | 0, 237 | 0, 238 | 0, 239 | 0 240 | ], 241 | [ 242 | 0, 243 | 0, 244 | 0, 245 | 0, 246 | 0, 247 | 0, 248 | 0, 249 | 0, 250 | 1, 251 | 1, 252 | 1, 253 | 1, 254 | 1, 255 | 1, 256 | 1, 257 | 1, 258 | 1, 259 | 1, 260 | 0, 261 | 0, 262 | 0, 263 | 0, 264 | 0, 265 | 0 266 | ], 267 | [ 268 | 0, 269 | 0, 270 | 0, 271 | 0, 272 | 0, 273 | 0, 274 | 0, 275 | 0, 276 | 0, 277 | 0, 278 | 0, 279 | 1, 280 | 1, 281 | 0, 282 | 0, 283 | 0, 284 | 0, 285 | 0, 286 | 0, 287 | 0, 288 | 0, 289 | 0, 290 | 0, 291 | 0 292 | ], 293 | [ 294 | 0, 295 | 0, 296 | 0, 297 | 0, 298 | 0, 299 | 0, 300 | 0, 301 | 0, 302 | 0, 303 | 0, 304 | 0, 305 | 0, 306 | 0, 307 | 0, 308 | 0, 309 | 0, 310 | 0, 311 | 0, 312 | 0, 313 | 0, 314 | 0, 315 | 0, 316 | 0, 317 | 0 318 | ], 319 | [ 320 | 0, 321 | 0, 322 | 0, 323 | 0, 324 | 0, 325 | 0, 326 | 0, 327 | 0, 328 | 0, 329 | 0, 330 | 0, 331 | 0, 332 | 0, 333 | 0, 334 | 1, 335 | 1, 336 | 1, 337 | 1, 338 | 1, 339 | 0, 340 | 0, 341 | 0, 342 | 0, 343 | 0 344 | ], 345 | [ 346 | 0, 347 | 0, 348 | 0, 349 | 0, 350 | 0, 351 | 0, 352 | 0, 353 | 0, 354 | 0, 355 | 0, 356 | 0, 357 | 0, 358 | 0, 359 | 0, 360 | 0, 361 | 0, 362 | 0, 363 | 0, 364 | 0, 365 | 0, 366 | 0, 367 | 0, 368 | 0, 369 | 0 370 | ], 371 | [ 372 | 0, 373 | 0, 374 | 0, 375 | 0, 376 | 0, 377 | 0, 378 | 0, 379 | 0, 380 | 0, 381 | 0, 382 | 0, 383 | 0, 384 | 0, 385 | 0, 386 | 0, 387 | 0, 388 | 0, 389 | 0, 390 | 0, 391 | 0, 392 | 0, 393 | 0, 394 | 0, 395 | 0 396 | ] 397 | ], 398 | "dtype": "int64", 399 | "name": "schedule_name" 400 | } 401 | 402 | 403 | Sample Request 404 | Add a schedule 405 | curl --request POST \ 406 | --url http://localhost:8080/api/v1/add_schedule \ 407 | --header 'Content-Type: application/json' \ 408 | --data '{ 409 | "Shape": [ 410 | 7, 411 | 24 412 | ], 413 | "timezone": "Europe/Amsterdam", 414 | "Corder": true, 415 | "__ndarray__": [ 416 | [ 417 | 0, 418 | 1, 419 | 1, 420 | 1, 421 | 1, 422 | 1, 423 | 0, 424 | 0, 425 | 0, 426 | 0, 427 | 0, 428 | 0, 429 | 0, 430 | 0, 431 | 0, 432 | 0, 433 | 0, 434 | 0, 435 | 0, 436 | 0, 437 | 0, 438 | 0, 439 | 0, 440 | 0 441 | ], 442 | [ 443 | 0, 444 | 0, 445 | 0, 446 | 0, 447 | 0, 448 | 0, 449 | 0, 450 | 0, 451 | 1, 452 | 1, 453 | 1, 454 | 1, 455 | 1, 456 | 1, 457 | 1, 458 | 1, 459 | 1, 460 | 1, 461 | 0, 462 | 0, 463 | 0, 464 | 0, 465 | 0, 466 | 0 467 | ], 468 | [ 469 | 0, 470 | 0, 471 | 0, 472 | 0, 473 | 0, 474 | 0, 475 | 0, 476 | 0, 477 | 0, 478 | 0, 479 | 0, 480 | 1, 481 | 1, 482 | 0, 483 | 0, 484 | 0, 485 | 0, 486 | 0, 487 | 0, 488 | 0, 489 | 0, 490 | 0, 491 | 0, 492 | 0 493 | ], 494 | [ 495 | 0, 496 | 0, 497 | 0, 498 | 0, 499 | 0, 500 | 0, 501 | 0, 502 | 0, 503 | 0, 504 | 0, 505 | 0, 506 | 0, 507 | 0, 508 | 0, 509 | 0, 510 | 0, 511 | 0, 512 | 0, 513 | 0, 514 | 0, 515 | 0, 516 | 0, 517 | 0, 518 | 0 519 | ], 520 | [ 521 | 0, 522 | 0, 523 | 0, 524 | 0, 525 | 0, 526 | 0, 527 | 0, 528 | 0, 529 | 0, 530 | 0, 531 | 0, 532 | 0, 533 | 0, 534 | 0, 535 | 1, 536 | 1, 537 | 1, 538 | 1, 539 | 1, 540 | 0, 541 | 0, 542 | 0, 543 | 0, 544 | 0 545 | ], 546 | [ 547 | 0, 548 | 0, 549 | 0, 550 | 0, 551 | 0, 552 | 0, 553 | 0, 554 | 0, 555 | 0, 556 | 0, 557 | 0, 558 | 0, 559 | 0, 560 | 0, 561 | 0, 562 | 0, 563 | 0, 564 | 0, 565 | 0, 566 | 0, 567 | 0, 568 | 0, 569 | 0, 570 | 0 571 | ], 572 | [ 573 | 0, 574 | 0, 575 | 0, 576 | 0, 577 | 0, 578 | 0, 579 | 0, 580 | 0, 581 | 0, 582 | 0, 583 | 0, 584 | 0, 585 | 0, 586 | 0, 587 | 0, 588 | 0, 589 | 0, 590 | 0, 591 | 0, 592 | 0, 593 | 0, 594 | 0, 595 | 0, 596 | 0 597 | ] 598 | ], 599 | "dtype": "int64", 600 | "name": "schedule_name" 601 | }' 602 | GET List schedules 603 | http://localhost:8080/api/v1/list_schedules 604 | 605 | 606 | Sample Request 607 | List schedules 608 | curl --request GET \ 609 | --url http://localhost:8080/api/v1/list_schedules 610 | GET Delete schedule 611 | http://localhost:8080/api/v1/del_schedule?schedule=my schedule name 612 | PARAMS 613 | schedulemy schedule name 614 | 615 | Sample Request 616 | Delete schedule 617 | curl --request GET \ 618 | --url 'http://localhost:8080/api/v1/del_schedule?schedule=my%20schedule%20name' 619 | GET Get schedule 620 | http://localhost:8080/api/v1/get_schedule?schedule=my schedule name 621 | PARAMS 622 | schedulemy schedule name 623 | 624 | Sample Request 625 | Get schedule 626 | curl --request GET \ 627 | --url 'http://localhost:8080/api/v1/get_schedule?schedule=my%20schedule%20name' 628 | POST Add policy 629 | http://localhost:8080/api/v1/add_policy 630 | HEADERS 631 | Content-Typeapplication/json 632 | BODY 633 | { 634 | "name": "My policy", 635 | "tags": [ 636 | { 637 | "dev": "sleeper", 638 | "staging": "resting" 639 | } 640 | ], 641 | "projetcs": [ 642 | "project-x", 643 | "y-project" 644 | ], 645 | "schedulename": "my schedule name" 646 | } 647 | 648 | 649 | Sample Request 650 | Add policy 651 | curl --request POST \ 652 | --url http://localhost:8080/api/v1/add_policy \ 653 | --header 'Content-Type: application/json' \ 654 | --data '{ 655 | "name": "My policy", 656 | "tags": [ 657 | { 658 | "dev": "sleeper", 659 | "staging": "resting" 660 | } 661 | ], 662 | "projetcs": [ 663 | "project-x", 664 | "y-project" 665 | ], 666 | "schedulename": "my schedule name" 667 | }' 668 | GET Get policy 669 | http://localhost:8080/api/v1/get_policy?policy=My policy 670 | HEADERS 671 | Content-Typeapplication/json 672 | PARAMS 673 | policyMy policy 674 | 675 | Sample Request 676 | Get policy 677 | curl --request GET \ 678 | --url 'http://localhost:8080/api/v1/get_policy?policy=My%20policy' \ 679 | --header 'Content-Type: application/json' 680 | GET List policys 681 | http://localhost:8080/api/v1/list_policys 682 | HEADERS 683 | Content-Typeapplication/json 684 | 685 | Sample Request 686 | List policys 687 | curl --request GET \ 688 | --url http://localhost:8080/api/v1/list_policys \ 689 | --header 'Content-Type: application/json' 690 | GET Delete policy 691 | http://localhost:8080/api/v1/del_policy?policy=My policy 692 | HEADERS 693 | Content-Typeapplication/json 694 | PARAMS 695 | policyMy policy 696 | 697 | Sample Request 698 | Delete policy 699 | curl --request GET \ 700 | --url 'http://localhost:8080/api/v1/del_policy?policy=My%20policy' \ 701 | --header 'Content-Type: application/json' 702 | ZORYA API DOCS 703 | 704 | Introduction 705 | POST 706 | Add a schedule 707 | GET 708 | List schedules 709 | GET 710 | Delete schedule 711 | GET 712 | Get schedule 713 | POST 714 | Add policy 715 | GET 716 | Get policy 717 | GET 718 | List policys 719 | GET 720 | Delete policy 721 | -------------------------------------------------------------------------------- /client/src/pages/Policy/Policy.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | // Material UI 5 | import { withStyles } from '@material-ui/core/styles'; 6 | import Typography from '@material-ui/core/Typography'; 7 | import Button from '@material-ui/core/Button'; 8 | import ArrowBackIcon from '@material-ui/icons/ArrowBack'; 9 | import IconButton from '@material-ui/core/IconButton'; 10 | import TextField from '@material-ui/core/TextField'; 11 | import InputLabel from '@material-ui/core/InputLabel'; 12 | import FormGroup from '@material-ui/core/FormGroup'; 13 | import FormControl from '@material-ui/core/FormControl'; 14 | import Select from '@material-ui/core/Select'; 15 | 16 | // Lodash 17 | import map from 'lodash/map'; 18 | import find from 'lodash/find'; 19 | import forOwn from 'lodash/forOwn'; 20 | 21 | // Project 22 | import PolicyTags from '../../modules/components/PolicyTags'; 23 | import AppPageContent from '../../modules/components/AppPageContent'; 24 | import AppPageActions from '../../modules/components/AppPageActions'; 25 | import PolicyService from '../../modules/api/policy'; 26 | import ScheduleService from '../../modules/api/schedule'; 27 | import { getDefaultPolicy } from '../../modules/utils/policy'; 28 | 29 | const styles = (theme) => ({ 30 | root: { 31 | height: '100%', 32 | }, 33 | button: { 34 | marginRight: theme.spacing(2), 35 | }, 36 | textField: { 37 | width: 550, 38 | marginBottom: theme.spacing(3), 39 | marginRight: theme.spacing(2), 40 | }, 41 | formControl: { 42 | width: 550, 43 | marginBottom: theme.spacing(4), 44 | }, 45 | }); 46 | 47 | class Policy extends React.Component { 48 | constructor(props, context) { 49 | super(props, context); 50 | this.state = { 51 | policy: null, 52 | schedules: null, 53 | isLoading: false, 54 | 55 | nameError: false, 56 | scheduleError: false, 57 | projectsError: false, 58 | tagsError: [], 59 | 60 | showBackendError: false, 61 | backendErrorTitle: null, 62 | backendErrorMessage: null, 63 | }; 64 | 65 | this.policyService = new PolicyService(); 66 | this.scheduleService = new ScheduleService(); 67 | } 68 | 69 | async componentDidMount() { 70 | try { 71 | const { match } = this.props; 72 | this.setState({ isLoading: true }); 73 | const schedules = await this.scheduleService.list(); 74 | if (!schedules || !schedules.length) { 75 | throw new Error('Create at least one Schedule first'); 76 | } 77 | 78 | let policy; 79 | if (match.params.policy) { 80 | policy = await this.policyService.get(match.params.policy); 81 | } else { 82 | policy = getDefaultPolicy(); 83 | if (schedules && schedules.length) { 84 | policy.schedulename = schedules[0].name; 85 | } 86 | } 87 | this.setState({ 88 | policy, 89 | schedules, 90 | isLoading: false, 91 | }); 92 | } catch (error) { 93 | this.handleBackendError( 94 | 'Loading Failed:', 95 | error.message, 96 | '/policies/browser' 97 | ); 98 | } 99 | } 100 | 101 | handleChange = (name) => (event) => { 102 | const { policy } = this.state; 103 | policy[name] = event.target.value; 104 | this.setState({ policy }); 105 | }; 106 | 107 | handleChangeTags = (tags, shouldUpdateErrors) => { 108 | const { policy } = this.state; 109 | policy.tags = tags; 110 | if (shouldUpdateErrors) { 111 | const tagsError = this.getTagsError(); 112 | this.setState({ policy, tagsError }); 113 | } else { 114 | this.setState({ policy }); 115 | } 116 | }; 117 | 118 | handleChangeProjects = (event) => { 119 | const { policy } = this.state; 120 | policy.projects = event.target.value.replace(/\s/g, '').split(','); 121 | this.setState({ 122 | policy, 123 | }); 124 | }; 125 | 126 | getTagsError = () => { 127 | const { policy } = this.state; 128 | const tagsKeyRe = /(^[a-z][a-z0-9_-]*[a-z0-9]$)|(^@app_engine_flex$)/; // Add app App Engine identifier 129 | const tagsValRe = 130 | /(^[a-z][a-z0-9_-]*[a-z0-9]$)|(^[a-z][a-z0-9_-]*:[a-z0-9_-]+$)/; // Add app App Engine identifier 131 | let tagsError = []; 132 | for (let i = 0; i < policy.tags.length; i++) { 133 | tagsError.push([false, false]); 134 | forOwn(policy.tags[i], (value, key) => { 135 | if (!tagsKeyRe.test(key)) { 136 | tagsError[i][0] = true; 137 | } 138 | if (!tagsValRe.test(value)) { 139 | tagsError[i][1] = true; 140 | } 141 | }); 142 | } 143 | return tagsError; 144 | }; 145 | 146 | handleSubmit = async (event) => { 147 | try { 148 | const { history } = this.props; 149 | const { policy } = this.state; 150 | 151 | const nameRe = /^[a-zA-Z][\w-]*[a-zA-Z0-9]$/; 152 | const projectsRe = /^[a-z][a-z0-9-]+[a-z0-9]$/; 153 | 154 | let nameError = false; 155 | let projectsError = !policy.projects.length; 156 | const scheduleError = !policy.schedulename; 157 | const tagsError = this.getTagsError(); 158 | 159 | if (!nameRe.test(policy.name)) { 160 | nameError = true; 161 | } 162 | 163 | for (let i = 0; i < policy.projects.length && !projectsError; i++) { 164 | if (!projectsRe.test(policy.projects[i])) { 165 | projectsError = true; 166 | } 167 | } 168 | 169 | if ( 170 | nameError || 171 | projectsError || 172 | scheduleError || 173 | find(tagsError, (tagErrors) => tagErrors[0] || tagErrors[1]) 174 | ) { 175 | this.setState({ 176 | nameError, 177 | scheduleError, 178 | projectsError, 179 | tagsError, 180 | }); 181 | } else { 182 | this.setState({ isLoading: true }); 183 | await this.policyService.add(policy); 184 | this.setState({ isLoading: false }); 185 | history.push('/policies/browser'); 186 | } 187 | } catch (error) { 188 | this.handleBackendError('Update failed:', error.message); 189 | } 190 | }; 191 | 192 | handleRequestCancel = (event) => { 193 | const { history } = this.props; 194 | history.goBack(); 195 | }; 196 | 197 | handleBackendError = (title, message, exitPage) => { 198 | this.setState({ 199 | backendErrorTitle: title, 200 | backendErrorMessage: message, 201 | showBackendError: true, 202 | isLoading: false, 203 | exitPage, 204 | }); 205 | }; 206 | 207 | handleErrorClose = () => { 208 | const { history } = this.props; 209 | const { exitPage } = this.state; 210 | this.setState({ 211 | showBackendError: false, 212 | isLoading: false, 213 | }); 214 | if (exitPage) { 215 | history.push(exitPage); 216 | } 217 | }; 218 | 219 | render() { 220 | const { classes, edit } = this.props; 221 | const { 222 | policy, 223 | schedules, 224 | nameError, 225 | scheduleError, 226 | projectsError, 227 | tagsError, 228 | isLoading, 229 | backendErrorTitle, 230 | backendErrorMessage, 231 | showBackendError, 232 | } = this.state; 233 | 234 | return ( 235 |
236 | 237 | 242 | 243 | 244 | 245 | {edit ? ( 246 | 247 | Edit policy {policy ? policy.name : ''} 248 | 249 | ) : ( 250 | 251 | Create a policy 252 | 253 | )} 254 | 255 | 256 | 263 | {policy && ( 264 |
265 | 266 | 281 | 282 | 294 | 295 | 296 | 301 | Schedule name 302 | 303 | 318 | 319 | 320 | 333 | 334 | 339 | 340 | 341 | 350 | 359 |
360 | )} 361 |
362 |
363 | ); 364 | } 365 | } 366 | 367 | Policy.propTypes = { 368 | classes: PropTypes.object.isRequired, 369 | }; 370 | 371 | export default withStyles(styles)(Policy); 372 | -------------------------------------------------------------------------------- /client/src/modules/components/ScheduleTimeTable.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | 5 | // Material UI 6 | import { withStyles } from '@material-ui/core/styles'; 7 | import Paper from '@material-ui/core/Paper'; 8 | import Button from '@material-ui/core/Button'; 9 | 10 | // Lodash 11 | import map from 'lodash/map'; 12 | import find from 'lodash/find'; 13 | import flatten from 'lodash/flatten'; 14 | 15 | // react-lineto 16 | import { Line } from 'react-lineto'; 17 | 18 | const days = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT']; 19 | const hours = [...Array(24).keys()]; 20 | const gutters = 4; 21 | const boxSize = 36; 22 | 23 | const getSelectionRect = (mouseDown, mouseUp) => { 24 | const selectionRect = {}; 25 | if (mouseDown.x <= mouseUp.x) { 26 | selectionRect.left = mouseDown.x; 27 | selectionRect.right = mouseUp.x; 28 | if (mouseDown.y <= mouseUp.y) { 29 | selectionRect.top = mouseDown.y; 30 | selectionRect.bottom = mouseUp.y; 31 | } else { 32 | selectionRect.top = mouseUp.y; 33 | selectionRect.bottom = mouseDown.y; 34 | } 35 | } else { 36 | selectionRect.left = mouseUp.x; 37 | selectionRect.right = mouseDown.x; 38 | if (mouseDown.y <= mouseUp.y) { 39 | selectionRect.top = mouseDown.y; 40 | selectionRect.bottom = mouseUp.y; 41 | } else { 42 | selectionRect.top = mouseUp.y; 43 | selectionRect.bottom = mouseDown.y; 44 | } 45 | } 46 | return selectionRect; 47 | }; 48 | 49 | const intersects = (rectA, rectB) => { 50 | return ( 51 | rectA.left < rectB.right && 52 | rectA.right > rectB.left && 53 | rectA.top < rectB.bottom && 54 | rectA.bottom > rectB.top 55 | ); 56 | }; 57 | 58 | const styles = (theme) => ({ 59 | root: { 60 | margin: theme.spacing(2, 0), 61 | }, 62 | row: { 63 | display: 'flex', 64 | flexDirection: 'row', 65 | }, 66 | column: { 67 | display: 'flex', 68 | flexDirection: 'column', 69 | }, 70 | hourBox: { 71 | height: boxSize, 72 | width: boxSize, 73 | margin: gutters, 74 | }, 75 | columnButtonRoot: { 76 | minHeight: boxSize, 77 | minWidth: boxSize, 78 | margin: gutters, 79 | padding: 0, 80 | }, 81 | rowButtonRoot: { 82 | minHeight: boxSize, 83 | minWidth: 48, 84 | margin: gutters, 85 | padding: 0, 86 | }, 87 | boxContent: { 88 | display: 'flex', 89 | alignItems: 'center', 90 | justifyContent: 'center', 91 | }, 92 | on: { 93 | backgroundColor: '#aed581', 94 | }, 95 | off: { 96 | backgroundColor: '#eeeeee', 97 | }, 98 | nextOn: { 99 | backgroundColor: '#7da453', 100 | }, 101 | nextOff: { 102 | backgroundColor: '#bcbcbc', 103 | }, 104 | crosshair: { 105 | cursor: 'crosshair', 106 | }, 107 | }); 108 | 109 | class ScheduleTimeTable extends React.Component { 110 | constructor(props, context) { 111 | super(props, context); 112 | this.state = { 113 | matrix: null, 114 | mouseDown: null, 115 | mouseCurrent: null, 116 | }; 117 | } 118 | 119 | async componentDidMount() { 120 | try { 121 | const matrix = map(this.props.schedule.__ndarray__, (dayArray) => 122 | map(dayArray, (hourValue) => ({ current: hourValue, next: null })) 123 | ); 124 | this.setState({ 125 | matrix, 126 | }); 127 | } catch (ex) { 128 | console.error(ex); 129 | } 130 | } 131 | 132 | publishChanges = () => { 133 | const { matrix } = this.state; 134 | const ndarray = map(matrix, (day) => map(day, (hour) => hour.current)); 135 | const schedule = { 136 | ...this.props.schedule, 137 | __ndarray__: ndarray, 138 | }; 139 | this.props.onScheduleChange(schedule); 140 | }; 141 | 142 | toggleDay = (dayIndex) => (event) => { 143 | this.setState( 144 | (prevState, props) => { 145 | const { matrix } = prevState; 146 | if (find(matrix[dayIndex], { current: 0 })) { 147 | matrix[dayIndex] = map(matrix[dayIndex], (hour) => ({ 148 | current: 1, 149 | next: null, 150 | })); 151 | } else { 152 | matrix[dayIndex] = map(matrix[dayIndex], (hour) => ({ 153 | current: 0, 154 | next: null, 155 | })); 156 | } 157 | return { matrix }; 158 | }, 159 | () => this.publishChanges() 160 | ); 161 | }; 162 | 163 | toggleHour = (hourIndex) => (event) => { 164 | this.setState( 165 | (prevState, props) => { 166 | const { matrix } = prevState; 167 | let hourIsOn = true; 168 | for (let i = 0; i < props.schedule.Shape[0]; i++) { 169 | hourIsOn = hourIsOn && !!matrix[i][hourIndex].current; 170 | } 171 | const newCurrent = hourIsOn ? 0 : 1; 172 | for (let i = 0; i < props.schedule.Shape[0]; i++) { 173 | matrix[i][hourIndex].current = newCurrent; 174 | } 175 | return { matrix }; 176 | }, 177 | () => this.publishChanges() 178 | ); 179 | }; 180 | 181 | toggleAll = (event) => { 182 | this.setState( 183 | (prevState, props) => { 184 | const matrix = prevState.matrix; 185 | const hasZero = find(flatten(matrix), { current: 0 }); 186 | const newCurrent = hasZero ? 1 : 0; 187 | for (let dayIndex = 0; dayIndex < props.schedule.Shape[0]; dayIndex++) { 188 | for ( 189 | let hourIndex = 0; 190 | hourIndex < props.schedule.Shape[1]; 191 | hourIndex++ 192 | ) { 193 | matrix[dayIndex][hourIndex].current = newCurrent; 194 | } 195 | } 196 | return { 197 | matrix, 198 | }; 199 | }, 200 | () => this.publishChanges() 201 | ); 202 | }; 203 | 204 | getDayGrid = (dayIndex, dayValues) => { 205 | const { classes } = this.props; 206 | 207 | const hourBlocks = map(dayValues, (hour, hourIndex) => ( 208 |
209 | {hour.next !== null ? ( 210 | 217 | ) : ( 218 | 225 | )} 226 |
227 | )); 228 | 229 | return hourBlocks; 230 | }; 231 | 232 | handleMouseMove = (event) => { 233 | const { mouseDown, newCurrent } = this.state; 234 | const matrix = this.state.matrix.slice(); 235 | const matrixRect = this.matrixDiv.getBoundingClientRect(); 236 | 237 | const mouseCurrent = { 238 | x: event.clientX, 239 | y: event.clientY, 240 | }; 241 | 242 | if (mouseCurrent.x < matrixRect.left) { 243 | mouseCurrent.x = matrixRect.left; 244 | } 245 | if (mouseCurrent.x > matrixRect.right) { 246 | mouseCurrent.x = matrixRect.right; 247 | } 248 | if (mouseCurrent.y < matrixRect.top) { 249 | mouseCurrent.y = matrixRect.top; 250 | } 251 | if (mouseCurrent.y > matrixRect.bottom) { 252 | mouseCurrent.y = matrixRect.bottom; 253 | } 254 | 255 | const selectionRect = getSelectionRect(mouseDown, mouseCurrent); 256 | 257 | for (let dayIndex = 0; dayIndex < 7; dayIndex++) { 258 | for (let hourIndex = 0; hourIndex < 24; hourIndex++) { 259 | const top = 260 | matrixRect.top + 261 | gutters + 262 | dayIndex * boxSize + 263 | dayIndex * gutters * 2; 264 | const left = 265 | matrixRect.left + 266 | gutters + 267 | hourIndex * boxSize + 268 | hourIndex * gutters * 2; 269 | const hourRect = { 270 | top, 271 | left, 272 | bottom: top + boxSize, 273 | right: left + boxSize, 274 | }; 275 | if (intersects(hourRect, selectionRect)) { 276 | matrix[dayIndex][hourIndex].next = newCurrent; 277 | } else { 278 | matrix[dayIndex][hourIndex].next = null; 279 | } 280 | } 281 | } 282 | 283 | this.setState({ 284 | matrix, 285 | mouseCurrent, 286 | }); 287 | }; 288 | 289 | handleMouseDown = (event) => { 290 | event.persist(); 291 | if (event.button === 0) { 292 | const { matrix } = this.state; 293 | 294 | const mouseDown = { 295 | x: event.clientX, 296 | y: event.clientY, 297 | }; 298 | 299 | const start = getSelectionRect(mouseDown, mouseDown); 300 | const matrixRect = this.matrixDiv.getBoundingClientRect(); 301 | for (let dayIndex = 0; dayIndex < 7; dayIndex++) { 302 | for (let hourIndex = 0; hourIndex < 24; hourIndex++) { 303 | const top = matrixRect.top + dayIndex * (boxSize + gutters * 2); 304 | const left = matrixRect.left + hourIndex * (boxSize + gutters * 2); 305 | const hourRect = { 306 | top, 307 | left, 308 | bottom: top + boxSize + gutters * 2, 309 | right: left + boxSize + gutters * 2, 310 | }; 311 | if (intersects(hourRect, start)) { 312 | const newCurrent = matrix[dayIndex][hourIndex].current ? 0 : 1; 313 | document.addEventListener('mousemove', this.handleMouseMove, false); 314 | document.addEventListener('mouseup', this.handleMouseUp, false); 315 | this.setState({ 316 | mouseDown, 317 | newCurrent, 318 | }); 319 | } 320 | } 321 | } 322 | } 323 | }; 324 | 325 | handleMouseUp = (event) => { 326 | const { mouseDown } = this.state; 327 | document.removeEventListener('mouseup', this.handleMouseUp, false); 328 | document.removeEventListener('mousemove', this.handleMouseMove, false); 329 | if (mouseDown) { 330 | const mouseUp = { 331 | x: event.clientX, 332 | y: event.clientY, 333 | }; 334 | 335 | const selectionRect = getSelectionRect(mouseDown, mouseUp); 336 | this.toggleSelectionBox(selectionRect); 337 | } 338 | 339 | this.setState({ 340 | mouseDown: null, 341 | mouseCurrent: null, 342 | }); 343 | }; 344 | 345 | toggleSelectionBox = (selectionRect) => { 346 | const { newCurrent } = this.state; 347 | const matrix = this.state.matrix.slice(); 348 | const matrixRect = this.matrixDiv.getBoundingClientRect(); 349 | 350 | for (let dayIndex = 0; dayIndex < 7; dayIndex++) { 351 | for (let hourIndex = 0; hourIndex < 24; hourIndex++) { 352 | const top = 353 | matrixRect.top + 354 | gutters + 355 | dayIndex * boxSize + 356 | dayIndex * gutters * 2; 357 | const left = 358 | matrixRect.left + 359 | gutters + 360 | hourIndex * boxSize + 361 | hourIndex * gutters * 2; 362 | const hourRect = { 363 | top, 364 | left, 365 | bottom: top + boxSize, 366 | right: left + boxSize, 367 | }; 368 | if (intersects(hourRect, selectionRect)) { 369 | matrix[dayIndex][hourIndex].current = newCurrent; 370 | matrix[dayIndex][hourIndex].next = null; 371 | } 372 | } 373 | } 374 | 375 | this.setState( 376 | { 377 | matrix, 378 | }, 379 | () => this.publishChanges() 380 | ); 381 | }; 382 | 383 | render() { 384 | const { classes } = this.props; 385 | const { matrix, mouseDown, mouseCurrent } = this.state; 386 | 387 | return ( 388 | matrix && ( 389 |
390 |
391 | 399 | {map(hours, (hour) => ( 400 |
401 | 410 |
411 | ))} 412 |
413 | 414 |
415 |
416 | {map(days, (day, dayIndex) => ( 417 | 427 | ))} 428 |
429 | 430 |
(this.matrixDiv = matrixDiv)} 433 | onMouseDown={this.handleMouseDown} 434 | > 435 | {mouseDown && mouseCurrent && ( 436 |
437 | 444 | 451 | 458 | 465 |
466 | )} 467 | 468 | {map(matrix, (dayValues, dayIndex) => ( 469 |
470 | {this.getDayGrid(dayIndex, dayValues)} 471 |
472 | ))} 473 |
474 |
475 |
476 | ) 477 | ); 478 | } 479 | } 480 | 481 | ScheduleTimeTable.propTypes = { 482 | classes: PropTypes.object.isRequired, 483 | }; 484 | 485 | export default withStyles(styles)(ScheduleTimeTable); 486 | --------------------------------------------------------------------------------