├── .dockerignore ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE.txt ├── README.md ├── VERSION ├── app ├── .env.sample ├── CHANGELOG.md ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── chaoshubdashboard │ ├── __init__.py │ ├── __main__.py │ ├── api │ │ ├── __init__.py │ │ ├── app.py │ │ ├── auth.py │ │ ├── model.py │ │ ├── services │ │ │ ├── __init__.py │ │ │ ├── auth.py │ │ │ ├── dashboard.py │ │ │ └── experiment.py │ │ ├── types.py │ │ └── views.py │ ├── app.py │ ├── auth │ │ ├── __init__.py │ │ ├── app.py │ │ ├── model.py │ │ ├── services │ │ │ ├── __init__.py │ │ │ ├── api.py │ │ │ └── dashboard.py │ │ ├── types.py │ │ └── views.py │ ├── cli.py │ ├── dashboard │ │ ├── __init__.py │ │ ├── app.py │ │ ├── model.py │ │ ├── services │ │ │ ├── __init__.py │ │ │ ├── auth.py │ │ │ └── experiment.py │ │ ├── types.py │ │ ├── validators.py │ │ └── views │ │ │ ├── __init__.py │ │ │ ├── account.py │ │ │ ├── org.py │ │ │ └── workspace.py │ ├── experiment │ │ ├── __init__.py │ │ ├── app.py │ │ ├── model.py │ │ ├── scheduler │ │ │ ├── __init__.py │ │ │ ├── cron.py │ │ │ └── local.py │ │ ├── services │ │ │ ├── __init__.py │ │ │ ├── auth.py │ │ │ └── dashboard.py │ │ ├── types.py │ │ └── views │ │ │ ├── __init__.py │ │ │ ├── execution.py │ │ │ ├── schedule.py │ │ │ └── workspace.py │ ├── migrate.py │ ├── model.py │ ├── settings.py │ └── utils.py ├── ci.bash ├── pytest.ini ├── requirements-dev.txt ├── requirements.txt ├── setup.cfg ├── setup.py └── tests │ └── unit │ ├── .env.test │ ├── auth │ ├── conftest.py │ ├── test_auth.py │ ├── test_auth_app.py │ ├── test_auth_model.py │ └── test_auth_views.py │ ├── conftest.py │ ├── dashboard │ ├── conftest.py │ └── test_blueprint.py │ ├── test_model.py │ └── test_serve.py ├── assets └── chaoshub.png ├── docs ├── configure.md ├── contribute.md ├── faq.md ├── install.md ├── licensing.md ├── running.md ├── setup.md ├── status.md └── use.md └── ui ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── CHANGELOG.md ├── LICENSE.txt ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── poi.config.ts ├── public ├── 404.html ├── 503.html ├── landing.html └── static │ ├── css │ ├── error.css │ └── landing.css │ └── img │ ├── android-chrome-192x192.png │ ├── android-chrome-512x512.png │ ├── apple-touch-icon.png │ ├── browserconfig.xml │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── logo-dark.svg │ ├── logo-light.svg │ ├── logo.svg │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── site.webmanifest ├── src ├── App.vue ├── assets │ └── sass │ │ ├── abstracts │ │ └── _colors.scss │ │ ├── base │ │ └── _base.scss │ │ ├── components │ │ ├── _experiment.scss │ │ └── _sign.scss │ │ └── main.scss ├── components │ ├── Activities.vue │ ├── NotFound.vue │ ├── SignIn.vue │ ├── SignUp.vue │ ├── account │ │ ├── Account.vue │ │ ├── Orgs.vue │ │ ├── Privacy.vue │ │ ├── Profile.vue │ │ ├── Tokens.vue │ │ └── Workspaces.vue │ ├── experiment │ │ ├── Breadcrumb.vue │ │ ├── Execution.vue │ │ ├── Execution │ │ │ ├── NoRuns.vue │ │ │ └── SingleRun.vue │ │ ├── Executions.vue │ │ ├── Experiment │ │ │ ├── Activity.vue │ │ │ ├── MetaInfo.vue │ │ │ ├── Pause.vue │ │ │ └── Providers │ │ │ │ ├── HTTP.vue │ │ │ │ ├── Process.vue │ │ │ │ └── Python.vue │ │ ├── List.vue │ │ ├── Main.vue │ │ ├── New.vue │ │ ├── Schedule.vue │ │ ├── Settings.vue │ │ └── Settings │ │ │ ├── Collaborators.vue │ │ │ └── General.vue │ ├── org │ │ ├── Org.vue │ │ ├── Settings.vue │ │ └── Settings │ │ │ ├── General.vue │ │ │ └── Members.vue │ ├── user │ │ └── User.vue │ └── workspace │ │ ├── New.vue │ │ ├── Settings.vue │ │ ├── Settings │ │ ├── Collaborators.vue │ │ └── General.vue │ │ └── Workspace.vue ├── index.ts ├── routes.ts └── shims.d.ts ├── tsconfig.json └── tslint.json /.dockerignore: -------------------------------------------------------------------------------- 1 | */*/__pycache__/ 2 | app/.cache/ 3 | app/build/ 4 | app/dist/ 5 | *.log 6 | app/junit-test-results.xml 7 | app/coverage.xml 8 | .git/ 9 | .gitignore 10 | app/migrations 11 | .vscode/ 12 | ./ui/.vue-static/ 13 | ./ui/node_modules 14 | ./ui/src 15 | ./ui/editorconfig 16 | ./ui/eslingtignore 17 | ./ui//eslintrc.js 18 | ./ui/package*.json 19 | ./ui/poi.config.ts 20 | ./ui/tsconfig.json 21 | ./ui/tslint.json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/code,python 3 | 4 | ### Code ### 5 | # Visual Studio Code - https://code.visualstudio.com/ 6 | .settings/ 7 | .vscode/ 8 | jsconfig.json 9 | 10 | ### Python ### 11 | # Byte-compiled / optimized / DLL files 12 | __pycache__/ 13 | *.py[cod] 14 | *$py.class 15 | 16 | # C extensions 17 | *.so 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | *.egg-info/ 34 | .installed.cfg 35 | *.egg 36 | 37 | # PyInstaller 38 | # Usually these files are written by a python script from a template 39 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 40 | *.manifest 41 | *.spec 42 | 43 | # Installer logs 44 | pip-log.txt 45 | pip-delete-this-directory.txt 46 | 47 | # Unit test / coverage reports 48 | htmlcov/ 49 | .tox/ 50 | .coverage 51 | .coverage.* 52 | .cache 53 | .pytest_cache/ 54 | nosetests.xml 55 | coverage.xml 56 | *.cover 57 | .hypothesis/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # pyenv 80 | .python-version 81 | 82 | # celery beat schedule file 83 | celerybeat-schedule.* 84 | 85 | # SageMath parsed files 86 | *.sage.py 87 | 88 | # Environments 89 | .env 90 | .venv 91 | env/ 92 | venv/ 93 | ENV/ 94 | env.bak/ 95 | venv.bak/ 96 | 97 | # Spyder project settings 98 | .spyderproject 99 | .spyproject 100 | 101 | # Rope project settings 102 | .ropeproject 103 | 104 | # mkdocs documentation 105 | /site 106 | 107 | # mypy 108 | .mypy_cache/ 109 | 110 | 111 | # End of https://www.gitignore.io/api/code,python 112 | 113 | junit-test-results.xml 114 | 115 | 116 | # Created by https://www.gitignore.io/api/node 117 | 118 | ### Node ### 119 | # Logs 120 | logs 121 | *.log 122 | npm-debug.log* 123 | yarn-debug.log* 124 | yarn-error.log* 125 | 126 | # Runtime data 127 | pids 128 | *.pid 129 | *.seed 130 | *.pid.lock 131 | 132 | # Directory for instrumented libs generated by jscoverage/JSCover 133 | lib-cov 134 | 135 | # Coverage directory used by tools like istanbul 136 | coverage 137 | 138 | # nyc test coverage 139 | .nyc_output 140 | 141 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 142 | .grunt 143 | 144 | # Bower dependency directory (https://bower.io/) 145 | bower_components 146 | 147 | # node-waf configuration 148 | .lock-wscript 149 | 150 | # Compiled binary addons (http://nodejs.org/api/addons.html) 151 | build/Release 152 | 153 | # Dependency directories 154 | node_modules/ 155 | jspm_packages/ 156 | 157 | # Typescript v1 declaration files 158 | typings/ 159 | 160 | # Optional npm cache directory 161 | .npm 162 | 163 | # Optional eslint cache 164 | .eslintcache 165 | 166 | # Optional REPL history 167 | .node_repl_history 168 | 169 | # Output of 'npm pack' 170 | *.tgz 171 | 172 | # Yarn Integrity file 173 | .yarn-integrity 174 | 175 | # dotenv environment variables file 176 | .env 177 | 178 | 179 | # End of https://www.gitignore.io/api/node 180 | 181 | 182 | dist/ 183 | .vscode/ 184 | .vue-static/ 185 | .pytest_cache/ 186 | .mypy_cache/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased][] 4 | 5 | [Unreleased]: https://github.com/chaostoolkit/chaoshub/compare/0.1.3...HEAD 6 | 7 | ### Changed 8 | 9 | - Cannot upload experiments to another account [#11][11] 10 | 11 | [11]: https://github.com/chaostoolkit/chaoshub/issues/11 12 | 13 | 14 | ## [0.1.2][] - 2018-08-24 15 | 16 | [0.1.2]: https://github.com/chaostoolkit/chaoshub/compare/0.1.1...0.1.2 17 | 18 | ### Changed 19 | 20 | - Executions are now displayed properly 21 | - Token can now be revoked [#7][7] 22 | - Specify correct domain for experiment URL [#8][8] 23 | 24 | [7]: https://github.com/chaostoolkit/chaoshub/issues/7 25 | [8]: https://github.com/chaostoolkit/chaoshub/issues/8 26 | 27 | 28 | ## [0.1.1][] - 2018-08-09 29 | 30 | [0.1.1]: https://github.com/chaostoolkit/chaoshub/compare/0.1.0...0.1.1 31 | 32 | ### Changed 33 | 34 | - Pinned Chaos Toolkit dependencies 35 | 36 | 37 | ## [0.1.0][] - 2018-08-09 38 | 39 | [0.1.0]: https://github.com/chaostoolkit/chaoshub/tree/0.1.0 40 | 41 | ### Added 42 | 43 | - Initial release -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.7-rc-alpine 2 | 3 | # metadata injected at build time 4 | ARG BUILD_DATE 5 | ARG VCS_REF 6 | ARG VERSION 7 | 8 | LABEL org.label-schema.build-date=$BUILD_DATE \ 9 | org.label-schema.name="Open Source Chaos Hub" \ 10 | org.label-schema.description="Open Source Chaos Hub" \ 11 | org.label-schema.url="https://chaoshub.org" \ 12 | org.label-schema.vcs-ref=$VCS_REF \ 13 | org.label-schema.vcs-url="https://github.com/chaostoolkit/chaoshub" \ 14 | org.label-schema.vendor="ChaosIQ, Ltd" \ 15 | org.label-schema.version=$VERSION \ 16 | org.label-schema.schema-version="1.0" 17 | 18 | 19 | VOLUME ["/var/chaoshub"] 20 | 21 | ADD app/requirements.txt requirements.txt 22 | RUN apk update && \ 23 | apk add --virtual build-deps gcc g++ git libffi-dev linux-headers \ 24 | python3-dev musl-dev && \ 25 | apk add libstdc++ postgresql-dev && \ 26 | pip install -U pip && \ 27 | pip install --no-cache-dir -r requirements.txt && \ 28 | apk del build-deps && \ 29 | rm -rf /tmp/* /root/.cache && \ 30 | mkdir /etc/chaoshub && mkdir ui 31 | 32 | ADD LICENSE.txt /etc/chaoshub/LICENSE.txt 33 | ADD app/. . 34 | ADD ui/dist ui/dist 35 | ADD app/.env.sample /etc/chaoshub/.env 36 | RUN python3 setup.py install && \ 37 | rm -rf build dist .cache *.egg-info 38 | 39 | EXPOSE 8080/tcp 40 | 41 | ENTRYPOINT ["chaoshub-dashboard"] 42 | CMD ["run", "--env-path", "/etc/chaoshub", "--create-tables"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Home of the Chaos Hub Open-Source Project 2 | 3 | ![Chaos Hub][logo] 4 | 5 | [logo]: https://github.com/chaostoolkit/chaoshub/raw/master/assets/chaoshub.png "Chaos Hub" 6 | 7 | Welcome to the [Chaos Hub][hub], the Open Source web dashboard for collaborative 8 | Chaos Engineering. The project is sponsored by [ChaosIQ][chaosiq] and 9 | licensed under the [AGPLv3+][agpl]. 10 | 11 | Chaos Hub stands on the shoulders of the [Chaos Toolkit][chaostoolkit] to 12 | provide a complete, user-friendly, platform to automate and collaborate on your 13 | Chaos Engineering and Resiliency efforts. 14 | 15 | [hub]: https://chaoshub.org 16 | [chaosiq]: https://chaosiq.io/ 17 | [agpl]: https://www.gnu.org/licenses/agpl-3.0.en.html 18 | [chaostoolkit]: https://chaostoolkit.org/ 19 | 20 | [![Requirements Status](https://requires.io/github/chaostoolkit/chaoshub/requirements.svg?branch=master)](https://requires.io/github/chaostoolkit/chaoshub/requirements/?branch=master) 21 | 22 | ## Get Started 23 | 24 | The Chaos Hub is a simple web application which can run locally, follow the next 25 | guides to get your environment ready. 26 | 27 | * [Setup][setup]: Make sure you have all the required dependencies ready to run 28 | the Chaos Hub 29 | * [Install][install]: Install the Chaos Hub in your environment 30 | * [Configure][configure]: Learn how you may configure the Chaos Hub to run it 31 | according to your needs 32 | * [Status][status]: Consider reviewing the current status of the project to 33 | fully appreciate how far you may go with it 34 | 35 | [install]: https://github.com/chaostoolkit/chaoshub/blob/master/docs/install.md 36 | [setup]: https://github.com/chaostoolkit/chaoshub/blob/master/docs/setup.md 37 | [configure]: https://github.com/chaostoolkit/chaoshub/blob/master/docs/configure.md 38 | [status]: https://github.com/chaostoolkit/chaoshub/blob/master/docs/status.md 39 | 40 | ## Run your own Chaos Hub 41 | 42 | All the fun now starts! 43 | 44 | * [Launch][run]: Run the Chaos Hub 45 | * [Use][use]: Learn how to use the Chaos Hub 46 | 47 | [run]: https://github.com/chaostoolkit/chaoshub/blob/master/docs/running.md 48 | [use]: https://github.com/chaostoolkit/chaoshub/blob/master/docs/use.md 49 | 50 | ## Contribute to the Chaos Hub 51 | 52 | The Chaos Hub is a free and open source project. Feel free to collaborate and 53 | contribute to it. 54 | 55 | * [Contribute][contribute]: Contribute to the Chaos Hub 56 | * [License][license]: Review what it means for your contributions that the 57 | Chaos Hub is under the AGPLv3+ license 58 | 59 | [contribute]: https://github.com/chaostoolkit/chaoshub/blob/master/docs/contribute.md 60 | [license]: https://github.com/chaostoolkit/chaoshub/blob/master/docs/licensing.md 61 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.1.2 2 | -------------------------------------------------------------------------------- /app/.env.sample: -------------------------------------------------------------------------------- 1 | SERVER_LISTEN_ADDR="0.0.0.0" 2 | SERVER_LISTEN_PORT=8080 3 | OAUTH_REDIRECT_BASE="http://127.0.0.1:8080" 4 | CHERRYPY_PROXY_BASE="" 5 | DB_HOST="sqlite://" 6 | DB_PORT= 7 | DB_NAME="chaoshub" 8 | DB_USER="" 9 | DB_PWD="" 10 | SECRET_KEY="whatever" 11 | SIGNER_KEY="whatever" 12 | CLAIM_SIGNER_KEY="whatever" 13 | USER_PROFILE_SECRET_KEY="whatever" 14 | GITHUB_CLIENT_ID="" 15 | GITHUB_CLIENT_SECRET="" 16 | GITLAB_CLIENT_ID="" 17 | GITLAB_CLIENT_SECRET="" 18 | GOOGLE_CLIENT_ID="" 19 | GOOGLE_CLIENT_SECRET="" 20 | BITBUCKET_CLIENT_ID="" 21 | BITBUCKET_CLIENT_SECRET="" -------------------------------------------------------------------------------- /app/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased][] 4 | 5 | [Unreleased]: https://github.com/chaostoolkit/chaoshub/compare/0.1.0...HEAD 6 | 7 | 8 | ## [0.1.0][] - 2018-09-09 9 | 10 | [0.1.0]: https://github.com/chaostoolkit/chaoshub/tree/0.1.0 11 | 12 | ### Added 13 | 14 | - Initial release -------------------------------------------------------------------------------- /app/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include requirements.txt 3 | include requirements-dev.txt 4 | include LICENSE.txt 5 | include CHANGELOG.md 6 | include pytest.ini 7 | include .env -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | # ChaosHub 2 | 3 | This repository contains the ChaosHub application and its various services. 4 | 5 | ## Overview 6 | 7 | The ChaosHub app is a single process running the various services needed to 8 | serve the application's features. Its design is a rather innane multi-tier 9 | architecture. Basically, the application is backed by a relational database 10 | and uses caching for performance. 11 | 12 | However, the application is also designed to ease the split into standalone 13 | microservices. Indeed, we have three main services in this application: 14 | 15 | * auth: the Auth service provides basic user account operations such as 16 | creation based on OAuth with external services, token creations for the 17 | ChaosHub API itself 18 | * dashboard: this is mainly the service to respond to the frontend needs 19 | * experiment: this responds to the need for experiment management, both from 20 | the frontend and API perspective 21 | 22 | While they live in the same process, they have been designed to support 23 | separation of concerns as much as we could. Mainly, each talk to its own 24 | database instance and thus their entities cannot link to one another through 25 | relationalships. 26 | 27 | Whenever one service needs data from another service, it uses an intermediary 28 | function (usually living under their respective `services` directory). For now, 29 | that function directly invokes the according function from the other service, 30 | but should we split, this would be implemented to make a remote call 31 | instead. 32 | 33 | With that in mind, all the services endpoints take a `UserClaim` payload which 34 | is encoded in a signed JWT token. That payload contains account information 35 | such as the account id. This id is the key shared across all backend databases 36 | to re-create the account's context. The payload also contains ChaosHub tokens 37 | and the status of the account. 38 | 39 | If we needed to turn one of the services into a standalone microservice, that 40 | JWT token would still be transported equally well and allow for stateless 41 | microservices. 42 | 43 | Obviously, network links would induce latency and potential failures that should 44 | be harnessed. 45 | 46 | ## Configuration 47 | 48 | The application reads its configuration from dotenv files which contain key 49 | and values for various bits and pieces such as the database connection details, 50 | or the OAuth services keys. 51 | 52 | When you run locally, a single .env file can be used, when running from 53 | Kubernetes, it's a good idea to split the sources between config map and 54 | secrets. 55 | 56 | ## Development 57 | 58 | Working against the ChaosHub application is fairly simple but requires a bit 59 | of setup. First, make sure you deploy a minikube cluster and use skaffold 60 | so that everytime your code changes, the docker image of the application is 61 | rebuilt and uploaded automatically into Kubernetes. 62 | 63 | In regards to unit testing, you should be able to run them locally. As usual, 64 | create a virtual environment for Python 3.6+ and install the dependencies 65 | through the requirements files. Then, simply run `python setup.py develop` and 66 | `pytest`. 67 | 68 | Once we have automated build, those tests will be also executed continously. 69 | 70 | The UI itself os developed in the `ui` repository. 71 | 72 | ```console 73 | $ cd ../ui 74 | $ npm run build 75 | ``` 76 | 77 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __version__ = '0.1.2' 3 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/__main__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | 4 | from chaoshubdashboard.cli import cli 5 | 6 | 7 | if __name__ == "__main__": 8 | sys.exit(cli()) 9 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/api/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from functools import wraps 3 | from typing import List, Optional, Tuple, Union 4 | 5 | from authlib.flask.oauth2 import current_token 6 | from chaoslib.extension import get_extension 7 | from flask import abort, request, current_app 8 | from chaoshubdashboard.model import db 9 | import shortuuid 10 | 11 | from .services import DashboardService, ExperimentService 12 | from .types import Experiment, Extension, UserClaim, Workspace 13 | 14 | __all__ = ["load_context"] 15 | 16 | 17 | def load_context(permissions: Tuple[str, ...] = ('view',)): 18 | """ 19 | Load org, workspace and experiment from the payload and extension context 20 | 21 | This should be called after load_payload() and load_hub_extension() 22 | """ 23 | def inner(f): 24 | @wraps(f) 25 | def wrapped(*args, **kwargs): 26 | if not current_token: 27 | raise abort(404) 28 | 29 | user_claim = { 30 | "id": current_token.account_id 31 | } 32 | 33 | org = kwargs.pop("org", None) 34 | if not org: 35 | return abort(404) 36 | 37 | workspace = kwargs.pop("workspace", None) 38 | if not workspace: 39 | return abort(404) 40 | 41 | workspace = DashboardService.get_context( 42 | user_claim, org, workspace) 43 | if not workspace: 44 | raise abort(404) 45 | 46 | acls = workspace["context"]["acls"] 47 | expected_permissions = set(permissions) 48 | if not set(acls).issuperset(expected_permissions): 49 | raise abort(404) 50 | 51 | kwargs["org"] = workspace["org"] 52 | kwargs["workspace"] = workspace 53 | return f(*args, **kwargs) 54 | return wrapped 55 | return inner 56 | 57 | 58 | def load_experiment(required: bool = True): 59 | """ 60 | Load the experiment from the request 61 | """ 62 | def inner(f): 63 | @wraps(f) 64 | def wrapped(*args, **kwargs): 65 | if not current_token: 66 | raise abort(404) 67 | 68 | user_claim = { 69 | "id": current_token.account_id 70 | } 71 | 72 | experiment_id = kwargs.pop("experiment_id", None) 73 | if not experiment_id: 74 | extension = kwargs.get("extension") or {} 75 | if required and not extension: 76 | return abort(404) 77 | 78 | experiment_id = extension.get("experiment") 79 | if required and not experiment_id: 80 | m = "Please, set the `experiment` property in the " \ 81 | "`chaoshub` extension." 82 | r = jsonify({"message": m}) 83 | r.status_code = 400 84 | raise abort(r) 85 | 86 | if experiment_id: 87 | org = kwargs["org"] 88 | workspace = kwargs["workspace"] 89 | 90 | experiment = ExperimentService.get_experiment( 91 | user_claim, org["id"], workspace["id"], experiment_id) 92 | 93 | if required and not experiment: 94 | return abort(404) 95 | 96 | kwargs["experiment"] = experiment 97 | return f(*args, **kwargs) 98 | return wrapped 99 | return inner 100 | 101 | 102 | def load_payload(): 103 | """ 104 | Load the payload from the request 105 | """ 106 | def inner(f): 107 | @wraps(f) 108 | def wrapped(*args, **kwargs): 109 | if request.method in ["POST", "PATCH", "PUT"]: 110 | payload = request.json 111 | if not payload or not isinstance(payload, dict): 112 | m = "Please, provide a payload to this request." 113 | r = jsonify({"message": m}) 114 | r.status_code = 400 115 | raise abort(r) 116 | kwargs["payload"] = payload 117 | return f(*args, **kwargs) 118 | return wrapped 119 | return inner 120 | 121 | 122 | def load_hub_extension(required: bool = False): 123 | """ 124 | Lookup the Chaos Hub extension in the payload: experiment, gameday... 125 | 126 | This should be called after load_payload() 127 | """ 128 | def inner(f): 129 | @wraps(f) 130 | def wrapped(*args, **kwargs): 131 | payload = kwargs.get("payload") 132 | if required and not payload: 133 | m = "Please, provide an extension block in " \ 134 | "the payload you sent." 135 | r = jsonify({"message": m}) 136 | r.status_code = 400 137 | raise abort(r) 138 | 139 | if payload: 140 | extension = get_extension(payload, "chaoshub") 141 | if required and not extension: 142 | m = "The Chaos Hub extension entry is missing " \ 143 | "from the payload. You must provide one for " \ 144 | "this request." 145 | r = jsonify({"message": m}) 146 | r.status_code = 400 147 | raise abort(r) 148 | 149 | kwargs["extension"] = extension 150 | return f(*args, **kwargs) 151 | return wrapped 152 | return inner 153 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/api/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from typing import Any, Dict 4 | 5 | from authlib.flask.oauth2 import ResourceProtector 6 | from authlib.flask.oauth2.sqla import create_bearer_token_validator 7 | from flask import current_app, Flask, jsonify 8 | from flask_caching import Cache 9 | 10 | from .model import db, APIAccessToken 11 | from .views import api 12 | 13 | __all__ = ["setup_service"] 14 | 15 | 16 | def setup_service(main_app: Flask, cache: Cache, init_db: bool = True): 17 | main_app.register_blueprint( 18 | api, url_prefix='/api///experiment') 19 | setup_oauth2_resource_protector(main_app) 20 | 21 | 22 | ############################################################################### 23 | # Internals 24 | ############################################################################### 25 | def setup_oauth2_resource_protector(main_app: Flask): 26 | """ 27 | Configure OAuth2 endpoints protector 28 | """ 29 | BearerTokenValidator = create_bearer_token_validator( 30 | db.session, APIAccessToken) 31 | ResourceProtector.register_token_validator(BearerTokenValidator()) 32 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/api/auth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import Any, Dict 3 | 4 | from .model import db, APIAccessToken 5 | 6 | __all__ = ["revoke_access_token", "set_access_token"] 7 | 8 | 9 | def set_access_token(access_token: Dict[str, Any]): 10 | token = APIAccessToken.from_dict(access_token) 11 | db.session.add(token) 12 | db.session.commit() 13 | 14 | 15 | def revoke_access_token(access_token: str): 16 | token = APIAccessToken.get_by_token(access_token) 17 | if token: 18 | token.revoke() 19 | db.session.add(token) 20 | db.session.commit() 21 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/api/model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime, timezone 3 | import sys 4 | from typing import Any, Dict, List, Optional, Union 5 | import uuid 6 | 7 | from authlib.flask.oauth2.sqla import OAuth2ClientMixin, OAuth2TokenMixin 8 | from flask_sqlalchemy import SQLAlchemy as SA 9 | import shortuuid 10 | from sqlalchemy.sql import func 11 | from sqlalchemy_json import NestedMutable 12 | from sqlalchemy_utils import UUIDType 13 | from sqlalchemy_utils import JSONType as JSONB 14 | 15 | from chaoshubdashboard.model import db, get_user_info_secret_key 16 | 17 | __all__ = ["db", "APIAccessToken"] 18 | 19 | 20 | class APIAccessToken(db.Model): # type: ignore 21 | __bind_key__ = 'api_service' 22 | __tablename__ = 'api_access_token' 23 | __table_args__ = ( 24 | db.UniqueConstraint( 25 | 'name', 'account_id', name='name_account_uniq' 26 | ), 27 | ) 28 | 29 | id = db.Column( 30 | UUIDType(binary=False), primary_key=True, default=uuid.uuid4) 31 | name = db.Column(db.String, nullable=False) 32 | account_id = db.Column(UUIDType(binary=False), nullable=False) 33 | last_used_on = db.Column(db.DateTime()) 34 | client_id = db.Column(db.String(48)) 35 | token_type = db.Column(db.String(40)) 36 | access_token = db.Column(db.String(255), unique=True, nullable=False) 37 | refresh_token = db.Column(db.String(255), index=True) 38 | scope = db.Column(db.Text, default='') 39 | # be conservative 40 | revoked = db.Column(db.Boolean, nullable=False, default=True) 41 | issued_at = db.Column(db.Integer, nullable=False) 42 | expires_in = db.Column(db.Integer, nullable=False, default=0) 43 | 44 | def get_scope(self): 45 | return self.scope 46 | 47 | def get_expires_in(self): 48 | return self.expires_in 49 | 50 | def get_expires_at(self): 51 | return self.issued_at + self.expires_in 52 | 53 | def is_expired(self): 54 | now = datetime.utcnow().timestamp() 55 | return self.get_expires_at() < now 56 | 57 | def is_active(self): 58 | now = datetime.utcnow().timestamp() 59 | return self.get_expires_at() >= now 60 | 61 | def revoke(self): 62 | self.revoked = True 63 | 64 | def to_dict(self): 65 | last_used = None 66 | if self.last_used_on: 67 | last_used = self.last_used_on.replace( 68 | tzinfo=timezone.utc).timestamp() 69 | 70 | return { 71 | "id": str(self.id), 72 | "account_id": str(self.account_id), 73 | "access_token": self.access_token, 74 | "refresh_token": self.refresh_token, 75 | "client_id": self.client_id, 76 | "scope": self.scope, 77 | "token_type": self.token_type, 78 | "issued_at": self.issued_at, 79 | "expires_in": self.expires_in, 80 | "last_used": last_used, 81 | "revoked": self.revoked 82 | } 83 | 84 | @staticmethod 85 | def from_dict(token: Dict[str, Any]) -> 'APIAccessToken': 86 | """ 87 | Create or update a token from the source access token. 88 | 89 | On update, only the scope, revoked and dates properties are changed. 90 | Others are left as they are. 91 | """ 92 | access_token = APIAccessToken.get_by_token( 93 | token["access_token"]) 94 | if not access_token: 95 | access_token = APIAccessToken() 96 | access_token.id = shortuuid.decode(token["id"]) 97 | access_token.account_id = shortuuid.decode(token["account_id"]) 98 | access_token.access_token = token["access_token"] 99 | access_token.client_id = token["client_id"] 100 | 101 | access_token.name = token["name"] 102 | access_token.refresh_token = token["refresh_token"] 103 | access_token.scope = token["scope"] 104 | access_token.revoked = token["revoked"] 105 | access_token.issued_at = token["issued_at"] 106 | access_token.expires_in = token["expires_in"] 107 | 108 | return access_token 109 | 110 | @staticmethod 111 | def get_by_token(access_token: str) -> Optional['APIAccessToken']: 112 | return APIAccessToken.query.filter( 113 | APIAccessToken.access_token==access_token).first() 114 | 115 | @staticmethod 116 | def get_by_id_for_account(account_id: str, 117 | token_id: str) -> Optional['APIAccessToken']: 118 | return APIAccessToken.query.filter( 119 | APIAccessToken.account_id==account_id, 120 | APIAccessToken.id==token_id).first() 121 | 122 | @staticmethod 123 | def get_all_for_account(account_id: str) -> List['APIAccessToken']: 124 | return APIAccessToken.query.filter( 125 | APIAccessToken.account_id==account_id).all() 126 | 127 | @staticmethod 128 | def get_active_for_account(account_id: str) -> List['APIAccessToken']: 129 | non_revoked_tokens = APIAccessToken.query.filter( 130 | APIAccessToken.revoked==False, 131 | APIAccessToken.account_id==account_id).all() 132 | 133 | return [token for token in non_revoked_tokens if token.is_active()] 134 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/api/services/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from . import auth as AuthService 3 | from . import dashboard as DashboardService 4 | from . import experiment as ExperimentService 5 | 6 | __all__ = ["AuthService", "DashboardService", "ExperimentService"] 7 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/api/services/auth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from flask import Flask 3 | 4 | from chaoshubdashboard.auth import update_access_token 5 | from ..types import AccessToken, UserClaim 6 | 7 | __all__ = ["updated_user_access_token"] 8 | 9 | 10 | def updated_user_access_token(token: AccessToken): 11 | update_access_token(token) 12 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/api/services/dashboard.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import List, Optional 3 | 4 | from chaoshubdashboard.dashboard import get_workspace, record_activity 5 | 6 | from ..types import Activity, UserClaim, Workspace 7 | 8 | __all__ = ["get_context"] 9 | 10 | 11 | def get_context(user_claim: UserClaim, org: str, 12 | workspace: str) -> Optional[Workspace]: 13 | """ 14 | Retrieve the org/workspace context for from their names for the given 15 | user account. 16 | """ 17 | return get_workspace(user_claim, org, workspace) 18 | 19 | 20 | def push_activity(activity: Activity): 21 | record_activity(activity) 22 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/api/services/experiment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import Any, Dict, List, Optional 3 | 4 | from ..types import UserClaim, Execution, Experiment, Workspace 5 | 6 | from chaoshubdashboard.experiment import \ 7 | get_experiment_in_workspace_for_user, store_experiment, store_execution 8 | 9 | __all__ = ["get_experiment"] 10 | 11 | 12 | def get_experiment(user_claim: UserClaim, org: str, 13 | workspace: str, experiment_id: str) -> Optional[Workspace]: 14 | """ 15 | Retrieve the org/workspace context for from their names for the given 16 | user account. 17 | """ 18 | return get_experiment_in_workspace_for_user( 19 | user_claim, org, workspace, experiment_id, include_payload=True) 20 | 21 | 22 | def create_experiment(user_claim: UserClaim, org: str, 23 | workspace: str, payload: Dict[str, Any]) -> Experiment: 24 | return store_experiment(user_claim, org, workspace, payload) 25 | 26 | 27 | def create_execution(user_claim: UserClaim, org: str, 28 | workspace: str, experiment: str, 29 | payload: Dict[str, Any]) -> Execution: 30 | return store_execution(user_claim, org, workspace, experiment, payload) 31 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/api/types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import Any, Dict 3 | 4 | __all__ = ["AccessToken", "Experiment", "Extension", "UserClaim", "Org", 5 | "Workspace", "Execution", "Activity"] 6 | 7 | 8 | AccessToken = Dict[str, Any] 9 | UserClaim = Dict[str, Any] 10 | Org = Dict[str, Any] 11 | Workspace = Dict[str, Any] 12 | Execution = Dict[str, Any] 13 | Experiment = Dict[str, Any] 14 | Extension = Dict[str, Any] 15 | Activity = Dict[str, Any] 16 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/api/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | import os.path 4 | from typing import Any, Dict, Optional 5 | import uuid 6 | 7 | from authlib.flask.oauth2 import ResourceProtector, current_token 8 | from authlib.flask.oauth2.signals import token_authenticated 9 | from flask import abort, Blueprint, current_app, jsonify, request, url_for 10 | from flask_accept import accept 11 | import shortuuid 12 | 13 | from chaoshubdashboard.model import db 14 | 15 | from . import load_experiment, load_hub_extension, load_context, load_payload 16 | from .model import APIAccessToken 17 | from .services import DashboardService, AuthService, ExperimentService 18 | from .types import Org, Workspace, Experiment, Extension 19 | 20 | __all__ = ["api"] 21 | 22 | api = Blueprint("api", __name__) 23 | require_oauth = ResourceProtector() 24 | 25 | 26 | def user_authenticated_on_api(sender: ResourceProtector, 27 | token: APIAccessToken): 28 | """ 29 | Set the timestamp of the last successful call made from the given token 30 | """ 31 | token.last_used_on = datetime.utcnow() 32 | db.session.commit() 33 | 34 | AuthService.updated_user_access_token(token.to_dict()) 35 | 36 | 37 | token_authenticated.connect(user_authenticated_on_api, require_oauth) 38 | 39 | 40 | @api.route('', methods=['POST']) 41 | @accept('application/json') 42 | @require_oauth() 43 | @load_payload() 44 | @load_hub_extension(required=False) 45 | @load_context(permissions=('view', 'write')) 46 | @load_experiment(required=False) 47 | def upload_experiment(org: Org, workspace: Workspace, payload: Dict[str, Any], 48 | experiment: Experiment = None, 49 | extension: Extension = None): 50 | status = 200 51 | if not experiment: 52 | status = 201 53 | user_claim = {"id": current_token.account_id} 54 | account_id = current_token.account_id 55 | experiment = ExperimentService.create_experiment( 56 | user_claim, org["id"], workspace["id"], payload) 57 | 58 | DashboardService.push_activity({ 59 | "title": experiment["title"], 60 | "account_id": shortuuid.encode(current_token.account_id), 61 | "type": "experiment", 62 | "info": "created", 63 | "org_id": org["id"], 64 | "workspace_id": workspace["id"], 65 | "experiment_id": experiment["id"], 66 | "visibility": "anonymous" 67 | }) 68 | 69 | x_id = experiment["id"] 70 | response = jsonify({ 71 | "id": x_id, 72 | "ref": experiment["ref"] 73 | }) 74 | response.status_code = status 75 | response.headers["Location"] = url_for( 76 | "workspace_experiment_service.index", org=org["name"], 77 | workspace=workspace["name"], experiment_id=x_id) 78 | 79 | return response 80 | 81 | 82 | @api.route('/execution', methods=['POST']) 83 | @accept('application/json') 84 | @require_oauth() 85 | @load_payload() 86 | @load_hub_extension(required=False) 87 | @load_context(permissions=('view', 'write')) 88 | @load_experiment() 89 | def upload_run(org: Org, workspace: Workspace, experiment: Experiment, 90 | payload: Dict[str, Any], extension: Extension = None): 91 | user_claim = {"id": current_token.account_id} 92 | execution = ExperimentService.create_execution( 93 | user_claim, org["id"], workspace["id"], experiment["id"], payload) 94 | 95 | x_id = execution["id"] 96 | DashboardService.push_activity({ 97 | "title": experiment["title"], 98 | "account_id": shortuuid.encode(current_token.account_id), 99 | "type": "execution", 100 | "info": payload["status"], 101 | "org_id": org["id"], 102 | "workspace_id": workspace["id"], 103 | "experiment_id": experiment["id"], 104 | "visibility": "collaborator", 105 | "timestamp": execution["timestamp"] 106 | }) 107 | 108 | response = jsonify({"id": x_id}) 109 | response.status_code = 201 110 | response.headers["Location"] = url_for( 111 | "execution_service.index", org=org["name"], 112 | workspace=workspace["name"], 113 | experiment_id=experiment["id"], 114 | timestamp=execution["timestamp"]) 115 | return response 116 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/auth/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from typing import Any, Dict 4 | 5 | from flask import Flask, jsonify 6 | from flask_caching import Cache 7 | 8 | from . import setup_oauth_backends 9 | from .views import auth_service 10 | 11 | __all__ = ["setup_service"] 12 | 13 | 14 | def setup_service(main_app: Flask, cache: Cache): 15 | """ 16 | Setup the auth service 17 | """ 18 | main_app.register_blueprint(auth_service, url_prefix="/auth") 19 | setup_oauth_backends(main_app, cache) 20 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/auth/model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | import sys 4 | import uuid 5 | 6 | from authlib.flask.oauth2.sqla import OAuth2ClientMixin, OAuth2TokenMixin 7 | from flask_sqlalchemy import SQLAlchemy as SA 8 | import shortuuid 9 | from sqlalchemy.sql import func 10 | from sqlalchemy_utils import PasswordType, UUIDType 11 | from sqlalchemy_utils import JSONType as JSONB 12 | 13 | from chaoshubdashboard.model import db, get_user_info_secret_key 14 | 15 | __all__ = ["Client", "AccessToken", "Account", "ProviderToken", "LocalAccount"] 16 | 17 | 18 | class Account(db.Model): # type: ignore 19 | __bind_key__ = 'auth_service' 20 | __table_args__ = ( 21 | db.UniqueConstraint( 22 | 'oauth_provider', 'oauth_provider_sub', 23 | name='oauth_provider_sub_uniq' 24 | ), 25 | ) 26 | 27 | id = db.Column( 28 | UUIDType(binary=False), primary_key=True, unique=True, 29 | default=uuid.uuid4) 30 | joined_on = db.Column( 31 | db.DateTime(timezone=True), server_default=func.now(), nullable=False) 32 | closed_since = db.Column(db.DateTime(timezone=True), nullable=True) 33 | inactive_since = db.Column(db.DateTime(timezone=True), nullable=True) 34 | is_closed = db.Column(db.Boolean, default=False) 35 | is_active = db.Column(db.Boolean, default=True) 36 | oauth_provider = db.Column(db.String, nullable=True) 37 | oauth_provider_sub = db.Column(db.String, nullable=True) 38 | 39 | access_tokens = db.relationship( 40 | 'AccessToken', backref='account', cascade="all, delete-orphan") 41 | client = db.relationship( 42 | 'Client', backref='account', uselist=False, 43 | cascade="all, delete-orphan") 44 | local = db.relationship( 45 | 'LocalAccount', backref='account', uselist=False, 46 | cascade="all, delete-orphan") 47 | 48 | def to_dict(self): 49 | inactive = closed = None 50 | if self.inactive_since: 51 | inactive = "{}Z".format(self.inactive_since.isoformat()) 52 | if self.closed_since: 53 | closed = "{}Z".format(self.closed_since.isoformat()) 54 | 55 | return { 56 | "id": str(self.id), 57 | "short_id": shortuuid.encode(self.id), 58 | "closed": self.is_closed, 59 | "active": self.is_active, 60 | "joined_on": "{}Z".format(self.joined_on.isoformat()), 61 | "inactive_since": inactive, 62 | "closed_since": closed, 63 | "client": self.client.to_dict() if self.client else None, 64 | "tokens": [t.to_dict() for t in self.access_tokens] 65 | } 66 | 67 | def turn_inactive(self): 68 | self.is_active = False 69 | self.inactive_since = datetime.utcnow() 70 | 71 | def turn_active(self): 72 | self.is_active = True 73 | self.inactive_since = None 74 | 75 | def close_account(self): 76 | self.is_closed = False 77 | self.closed_since = datetime.utcnow() 78 | 79 | 80 | class Client(db.Model, OAuth2ClientMixin): # type: ignore 81 | __bind_key__ = 'auth_service' 82 | id = db.Column(db.Integer, primary_key=True) 83 | account_id = db.Column( 84 | UUIDType(binary=False), db.ForeignKey( 85 | 'account.id', ondelete='CASCADE'), nullable=False) 86 | 87 | def to_dict(self): 88 | return { 89 | "id": self.id, 90 | "client_id": self.client_id, 91 | "client_secret": self.client_secret 92 | } 93 | 94 | 95 | class AccessToken(db.Model, OAuth2TokenMixin): # type: ignore 96 | __bind_key__ = 'auth_service' 97 | id = db.Column( 98 | UUIDType(binary=False), primary_key=True, unique=True, 99 | default=uuid.uuid4) 100 | name = db.Column(db.String(), nullable=False) 101 | account_id = db.Column( 102 | UUIDType(binary=False), db.ForeignKey( 103 | 'account.id', ondelete='CASCADE'), nullable=False) 104 | last_used_on = db.Column(db.DateTime()) 105 | 106 | def to_dict(self): 107 | last_used = None 108 | if self.last_used_on: 109 | last_used = "{}Z".format(self.last_used_on.isoformat()) 110 | 111 | return { 112 | "id": shortuuid.encode(self.id), 113 | "name": self.name, 114 | "account_id": shortuuid.encode(self.account_id), 115 | "access_token": self.access_token, 116 | "refresh_token": self.refresh_token, 117 | "client_id": self.client_id, 118 | "scope": self.scope, 119 | "token_type": self.token_type, 120 | "issued_at": self.issued_at, 121 | "expires_in": self.expires_in, 122 | "last_used": last_used, 123 | "revoked": self.revoked 124 | } 125 | 126 | 127 | class ProviderToken(db.Model, OAuth2TokenMixin): # type: ignore 128 | __bind_key__ = 'auth_service' 129 | id = db.Column(db.Integer, primary_key=True) 130 | name = db.Column(db.String(20), nullable=False) 131 | account_id = db.Column( 132 | UUIDType(binary=False), db.ForeignKey( 133 | 'account.id', ondelete='CASCADE')) 134 | account = db.relationship('Account') 135 | 136 | def to_dict(self): 137 | return { 138 | "id": self.id, 139 | "access_token": self.access_token, 140 | "token_type": self.token_type, 141 | "refresh_token": self.refresh_token, 142 | "expires_at": self.expires_in, 143 | "revoked": self.revoked 144 | } 145 | 146 | 147 | class LocalAccount(db.Model): # type: ignore 148 | __bind_key__ = 'auth_service' 149 | 150 | id = db.Column( 151 | UUIDType(binary=False), primary_key=True, unique=True, 152 | default=uuid.uuid4) 153 | account_id = db.Column( 154 | UUIDType(binary=False), db.ForeignKey( 155 | 'account.id', ondelete='CASCADE'), nullable=False) 156 | username = db.Column(db.String, nullable=False, unique=True, index=True) 157 | password = db.Column( 158 | PasswordType( 159 | schemes=['pbkdf2_sha512'], 160 | ), 161 | unique=False, 162 | nullable=False) 163 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/auth/services/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from . import dashboard as DashboardService 3 | from . import api as APIService 4 | 5 | __all__ = ["DashboardService", "APIService"] 6 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/auth/services/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from ..types import UserClaim 3 | 4 | from chaoshubdashboard.api.auth import set_access_token, \ 5 | revoke_access_token as _revoke_access_token 6 | 7 | from ..model import AccessToken 8 | 9 | __all__ = ["revoke_access_token", "set_access_token"] 10 | 11 | 12 | def create_access_token(access_token: AccessToken): 13 | """ 14 | Broadcast an access token to the experiment service 15 | """ 16 | token = access_token.to_dict() 17 | set_access_token(token) 18 | 19 | 20 | def revoke_access_token(access_token: AccessToken): 21 | """ 22 | Broadcast access token revocation to the experiment service 23 | """ 24 | _revoke_access_token(access_token.access_token) 25 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/auth/services/dashboard.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from authlib.specs.oidc import UserInfo as ProfileInfo 3 | 4 | from ..types import UserClaim 5 | 6 | from chaoshubdashboard.dashboard import register_user 7 | 8 | __all__ = ["register_new_user"] 9 | 10 | 11 | def register_new_user(user_claim: UserClaim, profile: ProfileInfo): 12 | """ 13 | Register a new user with the dashboard service. 14 | """ 15 | register_user(user_claim, profile) 16 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/auth/types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import Any, Dict 3 | 4 | __all__ = ["UserClaim"] 5 | 6 | UserClaim = Dict[str, Any] 7 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/auth/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os.path 3 | 4 | from authlib.client.errors import OAuthException 5 | from flask import abort, Blueprint, current_app, redirect, render_template, \ 6 | request, session, url_for, jsonify 7 | from flask_accept import accept 8 | import simplejson as json 9 | from werkzeug.wrappers import Response 10 | 11 | from . import generate_nonce_key, get_oauth_remote_app, handle_signin, \ 12 | handle_signup, get_account_by_subject, get_user_profile_info_from_oauth, \ 13 | register_local_account, sign_value, unsign_value, log_user_in 14 | from .services import DashboardService 15 | 16 | __all__ = ["auth_service"] 17 | 18 | auth_service = Blueprint("auth_service", __name__) 19 | 20 | 21 | @auth_service.route('signup/local', methods=["POST"]) 22 | @accept("application/json") 23 | def signup_local() -> str: 24 | payload = request.json 25 | 26 | username = payload.get("username") 27 | if not username or not username.strip(): 28 | r = jsonify({ 29 | "field": None, 30 | "message": "Please specify a username" 31 | }) 32 | r.status_code = 400 33 | return abort(r) 34 | 35 | password = payload.get("password") 36 | if not password or not password.strip(): 37 | r = jsonify({ 38 | "field": None, 39 | "message": "Please specify a password" 40 | }) 41 | r.status_code = 400 42 | return abort(r) 43 | 44 | account = register_local_account(username, password) 45 | if not account: 46 | r = jsonify({ 47 | "field": "username", 48 | "message": "Username not available" 49 | }) 50 | r.status_code = 400 51 | return abort(r) 52 | 53 | session['sid'] = str(account.id) 54 | session.permanent = True 55 | current_app.logger.info("User signed up: {}".format(str(account.id))) 56 | 57 | return "" 58 | 59 | 60 | @auth_service.route('signin/local', methods=["POST"]) 61 | @accept("application/json") 62 | def signin_local() -> str: 63 | payload = request.json 64 | 65 | username = payload.get("username") 66 | if not username or not username.strip(): 67 | r = jsonify({ 68 | "field": None, 69 | "message": "Please specify your username" 70 | }) 71 | r.status_code = 400 72 | return abort(r) 73 | 74 | password = payload.get("password") 75 | if not password or not password.strip(): 76 | r = jsonify({ 77 | "field": None, 78 | "message": "Please specify your password" 79 | }) 80 | r.status_code = 400 81 | return abort(r) 82 | 83 | account = log_user_in(username, password) 84 | if not account: 85 | r = jsonify({ 86 | "field": None, 87 | "message": "Failed to log you in" 88 | }) 89 | r.status_code = 400 90 | return abort(r) 91 | 92 | session['sid'] = str(account.id) 93 | session.permanent = True 94 | current_app.logger.info("User signed in: {}".format(str(account.id))) 95 | 96 | return "" 97 | 98 | 99 | @auth_service.route('signin/with/', methods=["GET"]) 100 | def signin_with(provider: str) -> Response: 101 | remote = get_oauth_remote_app(provider) 102 | if not remote: 103 | return abort(404) 104 | 105 | params = { 106 | "state": sign_value(current_app, { 107 | "via": "signin" 108 | }) 109 | } 110 | nonce = generate_nonce_key(provider) 111 | if nonce: 112 | params["nonce"] = nonce 113 | 114 | redirect_uri = url_for('.authed', provider=provider, _external=True) 115 | return remote.authorize_redirect(redirect_uri, **params) 116 | 117 | 118 | @auth_service.route('signup/with/', methods=["GET"]) 119 | def signup_with(provider: str) -> Response: 120 | remote = get_oauth_remote_app(provider) 121 | if not remote: 122 | return abort(404) 123 | 124 | params = { 125 | "state": sign_value(current_app, { 126 | "via": "signup" 127 | }) 128 | } 129 | nonce = generate_nonce_key(provider) 130 | if nonce: 131 | params["nonce"] = nonce 132 | 133 | redirect_uri = url_for('.authed', provider=provider, _external=True) 134 | return remote.authorize_redirect(redirect_uri, **params) 135 | 136 | 137 | @auth_service.route('allowed/via/', methods=["GET", "POST"]) 138 | def authed(provider: str) -> Response: 139 | signed_state = request.args.get("state") 140 | if not signed_state: 141 | return abort(400) 142 | 143 | state = unsign_value(current_app, signed_state) 144 | if not state: 145 | return abort(400) 146 | 147 | if 'via' not in state: 148 | return abort(400) 149 | 150 | via = state['via'] 151 | if via not in ["signin", "signup"]: 152 | return abort(400) 153 | 154 | remote = get_oauth_remote_app(provider) 155 | if not remote: 156 | return abort(404) 157 | 158 | token = None 159 | id_token = request.args.get('id_token') 160 | if request.args.get('code'): 161 | try: 162 | token = remote.authorize_access_token() 163 | except OAuthException as x: 164 | session.pop('sid', None) 165 | return redirect("/") 166 | 167 | if id_token: 168 | token['id_token'] = id_token 169 | elif id_token: 170 | token = {'id_token': id_token} 171 | else: 172 | return redirect("/") 173 | 174 | user_info = get_user_profile_info_from_oauth(provider, remote, token) 175 | account = get_account_by_subject(user_info.sub, provider) 176 | 177 | if via == "signin": 178 | if not account: 179 | return redirect("/signup") 180 | handle_signin(account, token, provider) 181 | 182 | elif via == "signup": 183 | if account: 184 | return redirect("/signin") 185 | handle_signup(user_info, token, provider) 186 | 187 | return redirect("/", code=303) 188 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/cli.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import cherrypy 3 | from cherrypy.process.plugins import Daemonizer, PIDFile 4 | import click 5 | 6 | from chaoshubdashboard import __version__ 7 | from chaoshubdashboard.app import create_app, cleanup_app 8 | from chaoshubdashboard.settings import load_settings 9 | 10 | 11 | @click.group() 12 | @click.version_option(version=__version__) 13 | def cli(): 14 | pass 15 | 16 | 17 | @cli.command() 18 | @click.option('--env-path', type=click.Path(), 19 | help='Dot env file or directory path.') 20 | @click.option('--create-tables', is_flag=True, 21 | help='Create the database tables.') 22 | def run(env_path: str, create_tables: bool = False): 23 | """ 24 | Runs the chaoshub application. 25 | """ 26 | load_settings(env_path) 27 | 28 | cherrypy.engine.subscribe( 29 | 'start', lambda: create_app( 30 | create_tables=create_tables), priority=90) 31 | cherrypy.engine.subscribe('stop', cleanup_app, priority=30) 32 | cherrypy.engine.signals.subscribe() 33 | cherrypy.engine.start() 34 | cherrypy.engine.block() 35 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/dashboard/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from typing import Any, Dict 4 | 5 | from flask import Flask, jsonify, render_template 6 | from flask_caching import Cache 7 | 8 | from .views import dashboard_service 9 | from .views.account import account_service 10 | from .views.org import org_service 11 | from .views.workspace import workspace_service 12 | 13 | __all__ = ["setup_service"] 14 | 15 | 16 | def setup_service(main_app: Flask, cache: Cache): 17 | main_app.register_blueprint(dashboard_service, url_prefix="/") 18 | main_app.register_blueprint(account_service, url_prefix="/account") 19 | main_app.register_blueprint(org_service, url_prefix="/") 20 | main_app.register_blueprint( 21 | workspace_service, url_prefix="//") 22 | 23 | set_error_pages(main_app) 24 | 25 | 26 | ############################################################################### 27 | # Internals 28 | ############################################################################### 29 | def set_error_pages(main_app: Flask): 30 | @main_app.errorhandler(404) 31 | def page_not_found(e): 32 | return render_template('404.html'), 404 33 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/dashboard/services/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from . import auth as AuthService 3 | from . import experiment as ExperimentService 4 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/dashboard/services/auth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from flask import Flask 3 | 4 | from chaoshubdashboard.auth import generate_access_token, revoke_access_token 5 | from ..types import AccessToken, UserClaim 6 | 7 | __all__ = ["new_access_token", "revoke_user_access_token"] 8 | 9 | 10 | def new_access_token(user_claim: UserClaim, token_name: str) -> AccessToken: 11 | """ 12 | Generate a new access token for that user. 13 | """ 14 | token = generate_access_token(user_claim, token_name) 15 | return token 16 | 17 | 18 | def revoke_user_access_token(user_claim: UserClaim, token_id: str): 19 | """ 20 | Revoke the given access token of that user. 21 | """ 22 | revoke_access_token(user_claim["id"], token_id) 23 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/dashboard/services/experiment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import List, Optional, Union 3 | import uuid 4 | 5 | import shortuuid 6 | 7 | from chaoshubdashboard.experiment import get_last_updated_experiments, \ 8 | get_recent_experiments_in_org, get_recent_experiments_in_workspace, \ 9 | get_recent_public_experiments_in_org, get_experiment, \ 10 | get_recent_executions_in_workspace, get_recent_executions_in_org 11 | 12 | from ..model import Workspace 13 | from ..types import Experiment, Execution, UserClaim 14 | 15 | __all__ = ["get_user_last_experiments", "get_org_last_experiments", 16 | "get_workspace_last_experiments", "get_org_last_public_experiments", 17 | "get_workspace_last_public_experiments", "get_single_experiment", 18 | "get_workspace_last_executions", "get_org_last_executions"] 19 | 20 | 21 | def get_single_experiment(experiment_id: str) -> Optional[Experiment]: 22 | """ 23 | Retrieve the last updated experiments. 24 | """ 25 | return get_experiment(experiment_id) 26 | 27 | 28 | def get_user_last_experiments(user_claim: UserClaim) -> List[Experiment]: 29 | """ 30 | Retrieve the last updated experiments. 31 | """ 32 | experiments = get_last_updated_experiments(user_claim) 33 | for e in experiments: 34 | w = Workspace.query.filter( 35 | Workspace.id==shortuuid.decode(e["workspace"])).first() 36 | e["workspace"] = w.to_dict() 37 | return experiments 38 | 39 | 40 | def get_workspace_last_experiments(workspace_id: str) -> List[Experiment]: 41 | """ 42 | Retrieve the last updated experiments in a workspace. 43 | """ 44 | experiments = get_recent_experiments_in_workspace(workspace_id) 45 | return experiments 46 | 47 | 48 | def get_org_last_experiments(org_id: str) -> List[Experiment]: 49 | """ 50 | Retrieve the last updated experiments in an organization. 51 | """ 52 | experiments = get_recent_experiments_in_org(org_id) 53 | return experiments 54 | 55 | 56 | def get_org_last_public_experiments(org_id: str, 57 | workspaces: List[str]) \ 58 | -> List[Experiment]: 59 | """ 60 | Retrieve the last updated public experiments in an organization. 61 | """ 62 | experiments = get_recent_public_experiments_in_org(org_id, workspaces) 63 | return experiments 64 | 65 | 66 | def get_org_last_executions(org_id: str, visibility: str = "status") \ 67 | -> List[Execution]: 68 | """ 69 | Retrieve the last executions in an organization. 70 | """ 71 | executions = get_recent_executions_in_org(org_id, visibility=visibility) 72 | return executions 73 | 74 | 75 | def get_workspace_last_executions(workspace_id: str, 76 | visibility: str = "status") \ 77 | -> List[Execution]: 78 | """ 79 | Retrieve the last executions in a workspace. 80 | """ 81 | executions = get_recent_executions_in_workspace( 82 | workspace_id, visibility=visibility) 83 | return executions 84 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/dashboard/types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import Any, Dict 3 | 4 | __all__ = ["AccessToken", "Experiment", "ProfileInfo", "UserClaim", 5 | "Workspace"] 6 | 7 | 8 | AccessToken = Dict[str, Any] 9 | ProfileInfo = Dict[str, str] 10 | UserClaim = Dict[str, Any] 11 | Workspace = Dict[str, Any] 12 | Experiment = Dict[str, Any] 13 | Execution = Dict[str, Any] 14 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/dashboard/validators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import Any, Dict 3 | 4 | from flask import abort, jsonify 5 | 6 | from .model import Org, Workspace 7 | 8 | __all__ = ["validate_org_name", "validate_workspace_name"] 9 | 10 | 11 | def validate_org_name(org: Dict[str, Any]) -> str: 12 | """ 13 | Validate the organization's name and raise a failure response if it does 14 | not meet the expectations, or return the org name otherwise. 15 | """ 16 | org_name = (org.get("name") or "").strip() 17 | if not org_name: 18 | r = jsonify({ 19 | "errors": [{ 20 | "field": "name", 21 | "message": "A name is required" 22 | }] 23 | }) 24 | r.status_code = 400 25 | raise abort(r) 26 | 27 | if org_name.lower() in ("settings",): 28 | r = jsonify({ 29 | "errors": [{ 30 | "field": "name", 31 | "message": "Name is not available" 32 | }] 33 | }) 34 | r.status_code = 400 35 | raise abort(r) 36 | 37 | o = Org.query.filter(Org.name_lower==org_name.lower()).first() 38 | if o: 39 | r = jsonify({ 40 | "errors": [{ 41 | "field": "name", 42 | "message": "Name is not available" 43 | }] 44 | }) 45 | r.status_code = 409 46 | raise abort(r) 47 | 48 | return org_name 49 | 50 | 51 | def validate_workspace_name(workspace: Dict[str, Any]) -> str: 52 | """ 53 | Validate the workspace's name and raise a failure response if it does 54 | not meet the expectations, or return the workspace name otherwise. 55 | """ 56 | workspace_name = (workspace.get("name") or "").strip() 57 | if not workspace_name: 58 | r = jsonify({ 59 | "errors": [{ 60 | "field": "name", 61 | "message": "A name is required" 62 | }] 63 | }) 64 | r.status_code = 400 65 | raise abort(r) 66 | 67 | reserved = ("settings", "experiment", "gameday", "incident") 68 | if workspace_name.lower() in reserved: 69 | r = jsonify({ 70 | "errors": [{ 71 | "field": "name", 72 | "message": "Name is not available" 73 | }] 74 | }) 75 | r.status_code = 400 76 | raise abort(r) 77 | 78 | w = Workspace.query.filter( 79 | Workspace.name_lower==workspace_name.lower()).first() 80 | if w: 81 | r = jsonify({ 82 | "errors": [{ 83 | "field": "name", 84 | "message": "Name is not available" 85 | }] 86 | }) 87 | r.status_code = 409 88 | raise abort(r) 89 | 90 | return workspace_name 91 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/dashboard/views/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os.path 3 | from typing import Any, Dict 4 | 5 | from authlib.client.errors import OAuthException 6 | from flask import abort, Blueprint, current_app, jsonify, redirect, \ 7 | render_template, request, session, url_for 8 | import shortuuid 9 | from sqlalchemy import or_ 10 | 11 | from chaoshubdashboard.utils import get_user_claim, load_user 12 | 13 | from .. import get_account_activities, lookup_users 14 | from ..services import ExperimentService 15 | from ..model import Org, UserAccount, UserInfo, Workspace 16 | from ..types import UserClaim 17 | 18 | __all__ = ["dashboard_service"] 19 | 20 | dashboard_service = Blueprint("dashboard_service", __name__) 21 | 22 | 23 | @dashboard_service.route('/', defaults={'path': ''}, methods=["GET", "HEAD"]) 24 | @dashboard_service.route('/', methods=["GET", "HEAD"]) 25 | @load_user(allow_anonymous=True) 26 | def index(user_claim: Dict[str, Any], path: str) -> str: 27 | if user_claim: 28 | return render_template('index.html') 29 | 30 | return render_template('landing.html') 31 | 32 | 33 | @dashboard_service.route('/dashboard', methods=["GET", "HEAD"]) 34 | @load_user(allow_anonymous=False) 35 | def dashboard(user_claim: Dict[str, Any]) -> str: 36 | if request.headers.get('Accept') != 'application/json': 37 | return abort(405) 38 | 39 | account_id = user_claim["id"] 40 | account = UserAccount.query.filter(UserAccount.id==account_id).first() 41 | if not account: 42 | return abort(404) 43 | 44 | caller = account.to_short_dict() 45 | org = account.personal_org 46 | activities = get_account_activities(account.id, caller) 47 | exps = ExperimentService.get_user_last_experiments(user_claim) 48 | info = { 49 | "requested_by": caller, 50 | "activities": activities, 51 | "experiments": exps 52 | } 53 | info.update(account.to_public_dict()) 54 | 55 | return jsonify(info) 56 | 57 | 58 | @dashboard_service.route('/status/live', methods=["GET", "HEAD"]) 59 | def status_live() -> str: 60 | return "OK" 61 | 62 | 63 | @dashboard_service.route('/status/health', methods=["GET", "HEAD"]) 64 | def status_health() -> str: 65 | return "OK" 66 | 67 | 68 | @dashboard_service.route('/signout') 69 | @load_user(allow_anonymous=False) 70 | def signout(user_claim: UserClaim): 71 | session.pop('sid', None) 72 | return redirect(current_app.config.get("OAUTH_REDIRECT_BASE")) 73 | 74 | 75 | @dashboard_service.route('/signup', methods=["GET"]) 76 | def signup(): 77 | return render_template('index.html') 78 | 79 | 80 | @dashboard_service.route('/signin', methods=["GET"]) 81 | def signin() -> str: 82 | return render_template('index.html') 83 | 84 | 85 | @dashboard_service.route('/signed', methods=["GET"]) 86 | @load_user(allow_anonymous=True) 87 | def signed(user_claim: UserClaim) -> str: 88 | if request.headers.get('Accept') != 'application/json': 89 | return abort(405) 90 | 91 | return jsonify(user_claim is not None) 92 | 93 | 94 | @dashboard_service.route('users/lookup', methods=["GET"]) 95 | @load_user(allow_anonymous=False) 96 | def find_member(user_claim: UserClaim): 97 | if request.headers.get('Accept') != 'application/json': 98 | return abort(405) 99 | 100 | return jsonify(lookup_users(request.args.get("user"))) 101 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/experiment/app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | from typing import Any, Dict 4 | 5 | from flask import current_app, Flask, jsonify 6 | from flask_caching import Cache 7 | 8 | from .views import experiment_service 9 | from .views.execution import execution_service 10 | from .views.schedule import schedule_experiment_service 11 | from .views.workspace import workspace_experiment_service 12 | 13 | __all__ = ["setup_service"] 14 | 15 | 16 | def setup_service(main_app: Flask, cache: Cache): 17 | main_app.register_blueprint(experiment_service, url_prefix="/experiment") 18 | main_app.register_blueprint( 19 | workspace_experiment_service, 20 | url_prefix="///experiment") 21 | main_app.register_blueprint( 22 | execution_service, 23 | url_prefix="///experiment" 24 | "//execution") 25 | main_app.register_blueprint( 26 | schedule_experiment_service, 27 | url_prefix="///experiment" 28 | "//schedule") 29 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/experiment/scheduler/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import Any, Dict, List 3 | 4 | import pkg_resources 5 | 6 | from ..types import Scheduler, ScheduleContext 7 | 8 | __all__ = ["register_schedulers", "schedule", "schedulers", 9 | "shutdown_schedulers", "is_scheduler_registered"] 10 | 11 | # once this has been set, this shouldn't change so making it global is fair 12 | _schedulers: Dict[str, Scheduler] = {} 13 | 14 | 15 | def schedule(scheduler: str, context: ScheduleContext) -> str: 16 | """ 17 | Schedule the given experiment execution context with the provided 18 | scheduler. 19 | """ 20 | if scheduler not in _schedulers: 21 | raise KeyError("Invalid scheduler '{}'".format(scheduler)) 22 | 23 | sched = _schedulers[scheduler] 24 | return sched.schedule(context) 25 | 26 | 27 | def register_schedulers(config: Dict[str, Any]) -> Dict[str, Scheduler]: 28 | """ 29 | Register all the installed experiment schedulers 30 | """ 31 | _schedulers.clear() 32 | for entry_point in pkg_resources.iter_entry_points('chaoshub.scheduling'): 33 | klass = entry_point.load() 34 | 35 | current_configs = { 36 | k.replace(klass.settings_key_prefix, "").lower(): v 37 | for (k, v) in config.items() 38 | if k.startswith(klass.settings_key_prefix) 39 | } 40 | _schedulers[klass.name] = klass(**current_configs) 41 | 42 | return _schedulers 43 | 44 | 45 | def schedulers() -> Dict[str, Scheduler]: 46 | """ 47 | Return all schedulers 48 | """ 49 | return _schedulers 50 | 51 | 52 | def shutdown_schedulers(): 53 | """ 54 | Terminate all registered schedulers and ask each one to cleanup their 55 | current state. This is synchronous, thus blocking the main process. 56 | """ 57 | for name, scheduler in _schedulers.items(): 58 | shutdown = getattr(scheduler, "shutdown", None) 59 | if shutdown: 60 | shutdown() 61 | 62 | 63 | def is_scheduler_registered(name: str) -> bool: 64 | """ 65 | Check if the given shceduler is registered 66 | """ 67 | return name in _schedulers 68 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/experiment/scheduler/cron.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from tempfile import NamedTemporaryFile 3 | import uuid 4 | 5 | from crontab import CronTab 6 | 7 | from ..types import ScheduleContext, ScheduleInfo 8 | 9 | __all__ = ["CronScheduler"] 10 | 11 | 12 | class CronScheduler: 13 | name = "cron" 14 | description = "Cron scheduler for local repeatable executions" 15 | version = "0.1.0" 16 | settings_key_prefix = "SCHED_CRON_" 17 | 18 | def __init__(self): 19 | self.cron = CronTab(user=True) 20 | self.jobs = {} 21 | 22 | def shutdown(self): 23 | for job in self.jobs.items(): 24 | job.clear() 25 | self.cron.write() 26 | 27 | def cancel(self, job_id: str): 28 | job = self.jobs.pop(job_id, None) 29 | if job: 30 | job.clear() 31 | self.cron.write() 32 | 33 | def schedule(self, context: ScheduleContext) -> ScheduleInfo: 34 | org_name = context.get("org", {}).get("name") 35 | workspace_name = context.get("workspace", {}).get("name") 36 | experiment = context.get("experiment") 37 | 38 | with NamedTemporaryFile() as f: 39 | f.write(experiment) 40 | job = self.cron.new( 41 | command='chaos run --org "{}" --workspace "{}" {}'.format( 42 | org_name, workspace_name, f.name 43 | ) 44 | ) 45 | job_id = str(uuid.uuid4) 46 | self.jobs[job_id] = job 47 | self.cron.write() 48 | 49 | return { 50 | "scheduler": CronScheduler.name, 51 | "job_id": job_id 52 | } 53 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/experiment/scheduler/local.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import os 4 | import subprocess 5 | from tempfile import TemporaryDirectory 6 | import threading 7 | from typing import Dict 8 | import uuid 9 | 10 | from chaoshub.settings import set_chaos_hub_settings 11 | from chaoslib.settings import load_settings, save_settings 12 | 13 | from ..types import ScheduleContext, ScheduleInfo 14 | 15 | __all__ = ["LocalScheduler"] 16 | 17 | Tasks = Dict[str, 'LocalExecution'] 18 | 19 | 20 | class LocalScheduler: 21 | name = "local" 22 | description = "Local scheduler for one-shot executions" 23 | version = "0.1.0" 24 | settings_key_prefix = "SCHED_LOCAL_" 25 | 26 | def __init__(self, chaostoolkit_cli_path: str = "chaos") -> None: 27 | self.chaostoolkit_cli_path = os.path.expanduser( 28 | chaostoolkit_cli_path) 29 | self.tasks: Tasks = {} 30 | 31 | def shutdown(self): 32 | for pid, task in self.tasks.items(): 33 | task.terminate() 34 | self.tasks.clear() 35 | 36 | def cancel(self, id: str): 37 | task = self.tasks.pop(id, None) 38 | if task: 39 | task.terminate() 40 | 41 | def schedule(self, context: ScheduleContext) -> ScheduleInfo: 42 | execution = LocalExecution(self.chaostoolkit_cli_path, context) 43 | self.tasks[execution.id] = execution 44 | execution.start() 45 | 46 | return { 47 | "scheduler": LocalScheduler.name, 48 | "id": execution.id 49 | } 50 | 51 | 52 | class LocalExecution(threading.Thread): 53 | def __init__(self, chaostoolkit_cli_path: str, 54 | context: ScheduleContext) -> None: 55 | threading.Thread.__init__(self) 56 | self.chaostoolkit_cli_path = chaostoolkit_cli_path 57 | self.id = str(uuid.uuid4()) 58 | self.context = context 59 | self.proc = None 60 | 61 | def terminate(self): 62 | if self.proc and self.proc.poll() is not None: 63 | self.proc.terminate() 64 | 65 | def run(self): 66 | hub_url = self.context.get("hub_url") 67 | token = self.context.get("token") 68 | org_name = self.context.get("org", {}).get("name") 69 | workspace_name = self.context.get("workspace", {}).get("name") 70 | experiment = self.context.get("experiment") 71 | 72 | with TemporaryDirectory() as dname: 73 | settings_path = os.path.join(dname, "settings.yaml") 74 | settings = {} 75 | set_chaos_hub_settings(hub_url, token, settings) 76 | save_settings(settings, settings_path) 77 | 78 | experiment_path = os.path.join(dname, "experiment.json") 79 | with open(experiment_path, "w") as f: 80 | f.write(json.dumps(experiment["payload"])) 81 | 82 | cmd = [ 83 | self.chaostoolkit_cli_path, 84 | '--settings', 85 | settings_path, 86 | 'run', 87 | '--org', org_name, 88 | '--workspace', workspace_name, 89 | experiment_path 90 | ] 91 | 92 | self.proc = subprocess.Popen( 93 | cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, 94 | env=os.environ, cwd=dname) 95 | self.proc.wait() 96 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/experiment/services/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from . import dashboard as DashboardService 3 | from . import auth as AuthService 4 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/experiment/services/auth.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import List, Optional 3 | 4 | from chaoshubdashboard.auth import get_active_access_tokens, get_access_token 5 | 6 | from ..types import AccessToken, UserClaim 7 | 8 | 9 | __all__ = ["get_user_access_tokens"] 10 | 11 | 12 | def get_user_access_tokens(user_claim: UserClaim) -> List[AccessToken]: 13 | return get_active_access_tokens(user_claim) 14 | 15 | 16 | def get_user_access_token(user_claim: UserClaim, 17 | token_id: str) -> Optional[AccessToken]: 18 | return get_access_token(user_claim, token_id) 19 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/experiment/services/dashboard.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import List, Optional 3 | 4 | from chaoshubdashboard.dashboard import get_workspace, get_workspace_by_id, \ 5 | get_workspaces, record_activity, get_caller_info 6 | 7 | from ..types import Activity, UserAccount, UserClaim, Workspace 8 | 9 | 10 | __all__ = ["get_user_workspaces", "get_user_workspace", "get_user_details", 11 | "get_experiment_workspace", "push_activity"] 12 | 13 | 14 | def get_user_details(user_claim: UserClaim, org_id: str, 15 | workspace_id: str) -> Optional[UserAccount]: 16 | return get_caller_info(user_claim, org_id, workspace_id) 17 | 18 | 19 | def get_user_workspaces(user_claim: UserClaim) -> List[Workspace]: 20 | """ 21 | Retrieve the user's workspaces. 22 | """ 23 | return get_workspaces(user_claim) 24 | 25 | 26 | def get_user_workspace(user_claim: UserClaim, org_name: str, 27 | workspace_name: str) -> Optional[Workspace]: 28 | """ 29 | Retrieve a workspace. 30 | """ 31 | return get_workspace(user_claim, org_name, workspace_name) 32 | 33 | 34 | def get_experiment_workspace(user_claim: UserClaim, 35 | workspace_id: str) -> Optional[Workspace]: 36 | """ 37 | Retrieve a workspace by its id. 38 | """ 39 | return get_workspace_by_id(user_claim, workspace_id) 40 | 41 | 42 | def push_activity(activity: Activity): 43 | record_activity(activity) 44 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/experiment/types.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import Any, Dict, TypeVar 3 | 4 | __all__ = ["AccessToken", "Experiment", "Extension", "UserClaim", "Workspace", 5 | "Run", "Scheduler", "ScheduleContext", "ScheduleInfo", "Activity", 6 | "UserAccount", "Execution"] 7 | 8 | 9 | AccessToken = Dict[str, Any] 10 | UserClaim = Dict[str, Any] 11 | Org = Dict[str, Any] 12 | Workspace = Dict[str, Any] 13 | Experiment = Dict[str, Any] 14 | Extension = Dict[str, Any] 15 | Execution = Dict[str, Any] 16 | Run = Dict[str, Any] 17 | ScheduleContext = Dict[str, Any] 18 | ScheduleInfo = Dict[str, Any] 19 | Scheduler = Any 20 | Activity = Dict[str, Any] 21 | UserAccount = Dict[str, Any] 22 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/experiment/views/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os.path 3 | from typing import Any, Dict 4 | import uuid 5 | 6 | from flask import abort, Blueprint, current_app, redirect, render_template, \ 7 | jsonify, request, session, url_for 8 | import shortuuid 9 | import simplejson as json 10 | 11 | from chaoshubdashboard.model import db 12 | from chaoshubdashboard.utils import load_user 13 | 14 | from .. import can_write_to_workspace 15 | from ..model import Experiment 16 | from ..services import DashboardService 17 | from ..types import UserClaim 18 | 19 | __all__ = ["experiment_service"] 20 | 21 | experiment_service = Blueprint("experiment_service", __name__) 22 | 23 | 24 | @experiment_service.route('/', methods=["GET", "HEAD"]) 25 | @load_user(allow_anonymous=True) 26 | def index(user_claim: Dict[str, Any]) -> str: 27 | if request.headers.get('Accept') != 'application/json': 28 | return render_template('index.html') 29 | 30 | account_id = user_claim["id"] 31 | result = [] 32 | exps = Experiment.query.filter(Experiment.account_id==account_id).all() 33 | for exp in exps: 34 | e = exp.to_public_dict(with_payload=False) 35 | e["workspace"] = DashboardService.get_experiment_workspace( 36 | user_claim, e.workspace_id) 37 | result.append(e) 38 | 39 | return jsonify(result) 40 | 41 | 42 | @experiment_service.route('new', methods=['GET', 'HEAD']) 43 | @load_user(allow_anonymous=False) 44 | def new(user_claim: UserClaim): 45 | return render_template('index.html') 46 | 47 | 48 | @experiment_service.route('new/context', methods=['GET']) 49 | @load_user(allow_anonymous=False) 50 | def context(user_claim: UserClaim): 51 | if request.headers.get('Accept') != 'application/json': 52 | return abort(405) 53 | 54 | workspaces = DashboardService.get_user_workspaces(user_claim) 55 | result = [] 56 | for w in workspaces: 57 | if can_write_to_workspace(w): 58 | w.pop("context", None) 59 | result.append(w) 60 | 61 | return jsonify({"workspaces": result}) 62 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/experiment/views/execution.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from collections import OrderedDict 3 | import copy 4 | import io 5 | import os.path 6 | from typing import Any, Dict 7 | import uuid 8 | 9 | import dateparser 10 | from flask import abort, Blueprint, current_app, redirect, render_template, \ 11 | jsonify, request, send_file, session, url_for 12 | from flask_accept import accept, accept_fallback 13 | import shortuuid 14 | import simplejson as json 15 | import yaml 16 | import yamlloader 17 | 18 | from chaoshubdashboard.model import db 19 | from chaoshubdashboard.utils import cache, load_user 20 | 21 | from .. import load_execution, load_experiment, load_org_and_workspace 22 | from ..model import Execution, Experiment, Schedule 23 | from ..scheduler import is_scheduler_registered, schedule, schedulers 24 | from ..services import AuthService, DashboardService 25 | from ..types import Org, UserClaim, Workspace 26 | 27 | __all__ = ["execution_service"] 28 | 29 | execution_service = Blueprint("execution_service", __name__) 30 | 31 | 32 | @execution_service.route('', methods=['GET']) 33 | @load_user(allow_anonymous=True) 34 | @load_org_and_workspace(permissions=('read',)) 35 | @load_experiment() 36 | @load_execution() 37 | def index(user_claim: UserClaim, org: Org, workspace: Workspace, 38 | experiment: Experiment, execution: Execution): 39 | if request.headers.get('Accept') != 'application/json': 40 | return render_template('index.html') 41 | 42 | 43 | @execution_service.route('', methods=['GET']) 44 | @load_user(allow_anonymous=True) 45 | @load_org_and_workspace(permissions=('read',)) 46 | @load_experiment() 47 | def executions(user_claim: UserClaim, org: Org, workspace: Workspace, 48 | experiment: Experiment): 49 | if request.headers.get('Accept') != 'application/json': 50 | return render_template('index.html') 51 | 52 | executions = Execution.query.filter( 53 | Execution.experiment_id==experiment.id).order_by( 54 | Execution.timestamp.desc()).all() 55 | 56 | visibilities = workspace["settings"]["visibility"]["execution"] 57 | if not user_claim: 58 | visibility = visibilities["anonymous"] 59 | else: 60 | visibility = visibilities["members"] 61 | 62 | result = [] 63 | for execution in executions: 64 | result.append(execution.to_dict(visibility)) 65 | 66 | return jsonify(result) 67 | 68 | 69 | @execution_service.route('/context', methods=['GET']) 70 | @accept("application/json") 71 | @load_user(allow_anonymous=True) 72 | @load_org_and_workspace(permissions=('read',)) 73 | @load_experiment() 74 | @load_execution() 75 | def context(user_claim: UserClaim, org: Org, workspace: Workspace, 76 | experiment: Experiment, execution: Execution): 77 | visibilities = workspace["settings"]["visibility"]["execution"] 78 | if not user_claim: 79 | visibility = visibilities["anonymous"] 80 | else: 81 | visibility = visibilities["members"] 82 | 83 | return jsonify(execution.to_dict(visibility)) 84 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/experiment/views/schedule.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from collections import OrderedDict 3 | import io 4 | import os.path 5 | from typing import Any, Dict 6 | import uuid 7 | 8 | import dateparser 9 | from flask import abort, Blueprint, current_app, redirect, render_template, \ 10 | jsonify, request, send_file, session, url_for 11 | from flask_accept import accept, accept_fallback 12 | import shortuuid 13 | import simplejson as json 14 | import yaml 15 | import yamlloader 16 | 17 | from chaoshubdashboard.model import db 18 | from chaoshubdashboard.utils import cache, load_user 19 | 20 | from .. import load_experiment, load_org_and_workspace 21 | from ..model import Experiment, Schedule 22 | from ..scheduler import is_scheduler_registered, schedule, schedulers 23 | from ..services import AuthService, DashboardService 24 | from ..types import Org, UserClaim, Workspace 25 | 26 | __all__ = ["schedule_experiment_service"] 27 | 28 | schedule_experiment_service = Blueprint( 29 | "schedule_experiment_service", __name__) 30 | 31 | 32 | @schedule_experiment_service.route('', methods=['GET']) 33 | @load_user(allow_anonymous=False) 34 | @load_org_and_workspace(permissions=('read', 'write')) 35 | @load_experiment() 36 | def index(user_claim: UserClaim, org: Org, workspace: Workspace, 37 | experiment: Experiment): 38 | return render_template('index.html') 39 | 40 | 41 | @schedule_experiment_service.route('with/context', methods=["GET"]) 42 | @load_user(allow_anonymous=False) 43 | @load_org_and_workspace(permissions=('read', 'write')) 44 | @load_experiment() 45 | def context(user_claim: UserClaim, org: Org, workspace: Workspace, 46 | experiment: Experiment): 47 | if request.headers.get('Accept') != 'application/json': 48 | return abort(405) 49 | 50 | context: Dict[str, Any] = { 51 | "tokens": [], 52 | "schedulers": [] 53 | } 54 | 55 | account_id = user_claim["id"] 56 | tokens = AuthService.get_user_access_tokens(user_claim) 57 | for token in tokens: 58 | context["tokens"].append({ 59 | "id": token["id"], 60 | "name": token["name"] 61 | }) 62 | 63 | registered_schedulers = schedulers() 64 | for s in sorted(registered_schedulers.keys()): 65 | sched = registered_schedulers[s] 66 | context["schedulers"].append({ 67 | "name": s, 68 | "description": sched.description, 69 | "version": sched.version 70 | }) 71 | 72 | context["schedules"] = [] 73 | schedules = Schedule.query.filter( 74 | Schedule.account_id==account_id, 75 | Schedule.experiment_id==experiment.id).all() 76 | for schedule in schedules: 77 | context["schedules"].append(schedule.to_dict()) 78 | 79 | return jsonify(context) 80 | 81 | 82 | @schedule_experiment_service.route('', methods=['POST']) 83 | @load_user(allow_anonymous=False) 84 | @load_org_and_workspace(permissions=('read', 'write')) 85 | @load_experiment() 86 | def schedule_execution(user_claim: UserClaim, org: Org, workspace: Workspace, 87 | experiment: Experiment): 88 | definition = request.json 89 | if not definition: 90 | return abort(400) 91 | 92 | account_id = user_claim["id"] 93 | 94 | scheduler = definition.get("scheduler") 95 | if not is_scheduler_registered(scheduler): 96 | r = jsonify({ 97 | "errors": [{ 98 | "field": "scheduler", 99 | "message": "Invalid scheduler" 100 | }] 101 | }) 102 | r.status_code = 400 103 | return abort(r) 104 | 105 | token_id = shortuuid.decode(definition.get("token")) 106 | token = AuthService.get_user_access_token(user_claim, token_id) 107 | if not token: 108 | r = jsonify({ 109 | "errors": [{ 110 | "field": "token", 111 | "message": "Invalid token" 112 | }] 113 | }) 114 | r.status_code = 400 115 | return abort(r) 116 | 117 | date = definition.get("date") 118 | if not date: 119 | r = jsonify({ 120 | "errors": [{ 121 | "field": "date", 122 | "message": "Specify a date" 123 | }] 124 | }) 125 | r.status_code = 400 126 | return abort(r) 127 | 128 | time = definition.get("time") 129 | if not time: 130 | r = jsonify({ 131 | "errors": [{ 132 | "field": "date", 133 | "message": "Specify a time" 134 | }] 135 | }) 136 | r.status_code = 400 137 | return abort(r) 138 | 139 | scheduled_date = dateparser.parse("{}T{}".format(date, time)) 140 | 141 | account_id = user_claim["id"] 142 | s = Schedule( 143 | account_id=account_id, 144 | org_id=shortuuid.decode(org["id"]), 145 | workspace_id=shortuuid.decode(workspace["id"]), 146 | experiment_id=experiment.id, 147 | token_id=shortuuid.decode(token["id"]), 148 | definition=definition, 149 | scheduled=scheduled_date 150 | ) 151 | db.session.add(s) 152 | db.session.commit() 153 | 154 | context = s.to_dict() 155 | context["hub_url"] = url_for( 156 | "dashboard_service.index", _external=True).rstrip('/') 157 | context["token"] = token["access_token"] 158 | context["org"] = org 159 | context["workspace"] = workspace 160 | payload = json.loads(json.dumps(experiment.payload)) 161 | context["experiment"] = { 162 | "id": shortuuid.encode(experiment.id), 163 | "payload": payload 164 | } 165 | set_chaoshub_extension_to_experiment(experiment, payload) 166 | s.info = schedule(scheduler, context) 167 | db.session.commit() 168 | 169 | DashboardService.push_activity({ 170 | "title": "Schedule", 171 | "account_id": user_claim["short_id"], 172 | "org_id": org["id"], 173 | "workspace_id": workspace["id"], 174 | "type": "schedule", 175 | "info": "created", 176 | "visibility": "collaborator" 177 | }) 178 | 179 | return jsonify(s.to_dict()), 201 180 | 181 | 182 | def set_chaoshub_extension_to_experiment(experiment: Experiment, 183 | definition: Dict[str, Any]): 184 | if "extensions" not in definition: 185 | definition["extensions"] = [] 186 | 187 | for ext in definition["extensions"]: 188 | ext_name = ext.get("name") 189 | if ext_name == "chaoshub": 190 | ext["experiment"] = shortuuid.encode(experiment.id) 191 | ext["workspace"] = shortuuid.encode(experiment.workspace_id) 192 | ext["org"] = shortuuid.encode(experiment.org_id) 193 | break 194 | else: 195 | definition["extensions"].append({ 196 | "name": "chaoshub", 197 | "experiment": shortuuid.encode(experiment.id), 198 | "workspace": shortuuid.encode(experiment.workspace_id), 199 | "org": shortuuid.encode(experiment.org_id) 200 | }) 201 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/migrate.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os.path 3 | 4 | from flask import Flask 5 | from flask_migrate import Migrate 6 | 7 | from .model import db 8 | from .app import create_app 9 | from .settings import load_settings 10 | 11 | __all__ = ["migrate_db"] 12 | 13 | 14 | def migrate_db(env_file: str = '.env') -> Flask: 15 | """ 16 | Initialize a context for database migration operations. 17 | """ 18 | env_file = os.path.normpath( 19 | os.path.join(os.path.dirname(__file__), env_file)) 20 | load_settings(env_file) 21 | app = create_app() 22 | migrate = Migrate(app, db, compare_type=True) 23 | return app 24 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | from flask import current_app, Flask 5 | from flask_sqlalchemy import SQLAlchemy as SA 6 | 7 | __all__ = ["db", "get_user_info_secret_key", "get_db_conn_uri_from_env"] 8 | 9 | 10 | class SQLAlchemy(SA): 11 | def apply_pool_defaults(self, app, options): 12 | ssl_mode = app.config.get("sslmode", "allow") 13 | if ssl_mode != "allow": 14 | options["connect_args"] = { 15 | "sslmode": ssl_mode, 16 | "sslcert": app.config.get("ssl_client_cert"), 17 | "sslkey": app.config.get("ssl_key"), 18 | "sslrootcert": app.config.get("ssl_root_cert") 19 | } 20 | SA.apply_pool_defaults(self, app, options) 21 | 22 | 23 | db = SQLAlchemy() 24 | 25 | 26 | def get_db_conn_uri_from_env() -> str: 27 | """ 28 | Create the DB connection URI to connect to the database backend. 29 | """ 30 | host = os.getenv("DB_HOST", "") 31 | if host.startswith('sqlite:'): 32 | return host 33 | 34 | port = int(os.getenv("DB_PORT", 5432)) 35 | user = os.getenv("DB_USER") 36 | pwd = os.getenv("DB_PWD") 37 | name = os.getenv("DB_NAME") 38 | return "postgresql://{u}:{w}@{h}:{p}/{n}".format( 39 | u=user, w=pwd, h=host, p=port, n=name) 40 | 41 | 42 | def get_user_info_secret_key() -> str: 43 | """ 44 | Return the key used to encrypt/decrypt users details in our storage. 45 | """ 46 | key = current_app.config.get("USER_PROFILE_SECRET_KEY") 47 | if not key: 48 | raise RuntimeError("User profile secret key not set!") 49 | return key 50 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import glob 3 | import logging 4 | import logging.handlers 5 | import os 6 | 7 | import cherrypy 8 | from dotenv import load_dotenv 9 | from flask import Flask 10 | 11 | __all__ = ["configure_app", "load_settings"] 12 | 13 | 14 | def load_settings(env_path: str): 15 | """ 16 | Load settings from the environment: 17 | 18 | * if `env_path` is a file, read it 19 | * if `env_path` is a directory, load, all its `*.env` files 20 | """ 21 | if os.path.isdir(env_path): 22 | pattern = os.path.join(env_path, '**', '.env') 23 | for env_file in glob.iglob(pattern, recursive=True): 24 | cherrypy.log("Loading: {}".format(env_file)) 25 | load_dotenv(dotenv_path=env_file) 26 | else: 27 | cherrypy.log("Loading: {}".format(env_path)) 28 | load_dotenv(dotenv_path=env_path) 29 | 30 | debug = True if os.getenv('CHAOSHUB_DEBUG') else False 31 | cherrypy.config.update({ 32 | 'server.socket_host': os.getenv('SERVER_LISTEN_ADDR'), 33 | 'server.socket_port': int(os.getenv('SERVER_LISTEN_PORT', 8080)), 34 | 'engine.autoreload.on': False, 35 | 'log.screen': debug, 36 | 'log.access_file': '', 37 | 'log.error_file': '', 38 | 'environment': '' if debug else 'production', 39 | 'tools.proxy.on': True, 40 | 'tools.proxy.base': os.getenv('CHERRYPY_PROXY_BASE') 41 | }) 42 | 43 | 44 | def configure_app(app: Flask): 45 | """ 46 | Configure the application from environmental variables. 47 | """ 48 | app.url_map.strict_slashes = False 49 | app.debug = True if os.getenv('CHAOSHUB_DEBUG') else False 50 | 51 | logger = logging.getLogger('flask.app') 52 | logger.propagate = False 53 | 54 | app.config["USER_PROFILE_SECRET_KEY"] = os.getenv( 55 | "USER_PROFILE_SECRET_KEY") 56 | app.config["SIGNER_KEY"] = os.getenv("SIGNER_KEY") 57 | app.config["CLAIM_SIGNER_KEY"] = os.getenv("CLAIM_SIGNER_KEY") 58 | app.config["SECRET_KEY"] = os.getenv("SECRET_KEY") 59 | app.config["SESSION_COOKIE_DOMAIN"] = os.getenv("SESSION_COOKIE_DOMAIN") 60 | app.config["SESSION_COOKIE_SAMESITE"] = "Lax" 61 | 62 | app.config["GCE_PROJECT_ID"] = os.getenv("GCE_PROJECT_ID") 63 | app.config["GITHUB_CLIENT_ID"] = os.getenv("GITHUB_CLIENT_ID") 64 | app.config["GITHUB_CLIENT_SECRET"] = os.getenv("GITHUB_CLIENT_SECRET") 65 | app.config["GITLAB_CLIENT_ID"] = os.getenv("GITLAB_CLIENT_ID") 66 | app.config["GITLAB_CLIENT_SECRET"] = os.getenv("GITLAB_CLIENT_SECRET") 67 | app.config["GOOGLE_CLIENT_ID"] = os.getenv("GOOGLE_CLIENT_ID") 68 | app.config["GOOGLE_CLIENT_SECRET"] = os.getenv("GOOGLE_CLIENT_SECRET") 69 | app.config["BITBUCKET_CLIENT_ID"] = os.getenv("BITBUCKET_CLIENT_ID") 70 | app.config["BITBUCKET_CLIENT_SECRET"] = os.getenv( 71 | "BITBUCKET_CLIENT_SECRET") 72 | app.config["OAUTH_REDIRECT_BASE"] = os.getenv("OAUTH_REDIRECT_BASE", "/") 73 | 74 | app.secret_key = app.config["SECRET_KEY"] 75 | 76 | app.config["CACHE_TYPE"] = os.getenv("CACHE_TYPE", "simple") 77 | if app.config["CACHE_TYPE"] == "redis": 78 | app.config["CACHE_REDIS_HOST"] = os.getenv("CACHE_REDIS_HOST") 79 | app.config["CACHE_REDIS_PORT"] = os.getenv("CACHE_REDIS_PORT", 6379) 80 | -------------------------------------------------------------------------------- /app/chaoshubdashboard/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from functools import wraps 3 | from typing import Any, Dict, Optional 4 | 5 | from flask import abort, current_app, redirect 6 | from jose import jwt 7 | from jose.exceptions import JOSEError 8 | from flask_caching import Cache 9 | 10 | from .model import db 11 | from .auth import get_current_user_claim_from_session 12 | from .auth.model import Account, ProviderToken 13 | 14 | __all__ = ["get_user_claim", "cache", "load_user"] 15 | 16 | cache = Cache() 17 | 18 | 19 | def load_user(allow_anonymous: bool = False): 20 | def wrapped(f): 21 | """ 22 | Decorate views that require the user to be authenticated to access it 23 | and inject the user's claim into the call. 24 | 25 | Otherwise, automatically redirect to the signin page. 26 | """ 27 | @wraps(f) 28 | def decorated(*args, **kwargs): 29 | signed_account_claim = get_current_user_claim_from_session() 30 | if not signed_account_claim and not allow_anonymous: 31 | signin_url = "{}/signin".format( 32 | current_app.config.get("OAUTH_REDIRECT_BASE")) 33 | raise abort(redirect(signin_url)) 34 | 35 | kwargs['user_claim'] = None 36 | if signed_account_claim: 37 | secret = current_app.config.get("CLAIM_SIGNER_KEY") 38 | if not secret: 39 | raise abort(500) 40 | 41 | try: 42 | signed_claim = jwt.decode( 43 | signed_account_claim, key=secret, algorithms='HS384') 44 | except JOSEError as x: 45 | raise abort(500) 46 | 47 | kwargs['user_claim'] = signed_claim 48 | 49 | return f(*args, **kwargs) 50 | return decorated 51 | return wrapped 52 | 53 | 54 | def get_user_claim(): 55 | signed_account_claim = get_current_user_claim_from_session() 56 | if not signed_account_claim: 57 | return None 58 | 59 | secret = current_app.config.get("CLAIM_SIGNER_KEY") 60 | if not secret: 61 | return abort(500) 62 | 63 | try: 64 | signed_claim = jwt.decode( 65 | signed_account_claim, key=secret, algorithms='HS384') 66 | except JOSEError as x: 67 | return None 68 | 69 | return signed_claim 70 | -------------------------------------------------------------------------------- /app/ci.bash: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -eo pipefail 3 | 4 | function lint () { 5 | echo "Checking the code syntax" 6 | pycodestyle --first chaoshubdashboard 7 | } 8 | 9 | function typing () { 10 | echo "Checking Python typings" 11 | mypy --ignore-missing-imports --follow-imports=skip chaoshubdashboard 12 | } 13 | 14 | function build () { 15 | echo "Building the chaoshubdashboard package" 16 | python setup.py build 17 | } 18 | 19 | function run-test () { 20 | echo "Running the tests" 21 | pytest 22 | } 23 | 24 | function release () { 25 | echo "Releasing the package" 26 | python setup.py release 27 | 28 | echo "Publishing to PyPI" 29 | pip install twine 30 | twine upload dist/* -u ${PYPI_USER_NAME} -p ${PYPI_PWD} 31 | } 32 | 33 | function main () { 34 | lint || return 1 35 | typing || return 1 36 | build || return 1 37 | run-test || return 1 38 | 39 | if [[ $TRAVIS_PYTHON_VERSION =~ ^3\.5+$ ]]; then 40 | if [[ $TRAVIS_TAG =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then 41 | echo "Releasing tag $TRAVIS_TAG with Python $TRAVIS_PYTHON_VERSION" 42 | release || return 1 43 | fi 44 | fi 45 | } 46 | 47 | main "$@" || exit 1 48 | exit 0 49 | -------------------------------------------------------------------------------- /app/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | norecursedirs=dist build htmlcov docs .eggs 3 | addopts=-v -rxs --junitxml=junit-test-results.xml --cov=chaoshubdashboard --cov-report term-missing:skip-covered --cov-report xml -------------------------------------------------------------------------------- /app/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | pycodestyle 3 | pydocstyle 4 | requests-mock 5 | coverage 6 | pytest 7 | pytest-cov 8 | pytest-sugar -------------------------------------------------------------------------------- /app/requirements.txt: -------------------------------------------------------------------------------- 1 | wheel 2 | Flask==1.0.2 3 | flask-caching==1.4.0 4 | flask-talisman==0.5.0 5 | Flask-SQLAlchemy==2.3.2 6 | Flask-Migrate==2.2.1 7 | flask-accept==0.0.6 8 | CherryPy==16.0.2 9 | psycopg2-binary==2.7.4 10 | click==6.7 11 | wsgi-request-logger==0.4.6 12 | python-dotenv==0.8.2 13 | Authlib==0.8 14 | pyyaml==3.12 15 | simplejson==3.15.0 16 | sqlalchemy==1.2.8 17 | sqlalchemy-utils==0.33.3 18 | sqlalchemy-json==0.2.1 19 | dateparser==0.7.0 20 | loginpass==0.1.1 21 | python-jose[cryptography]==3.0.0 22 | shortuuid==0.5.0 23 | yamlloader==0.5.2 24 | redis==2.10.6 25 | blinker==1.4 26 | passlib==1.7.1 27 | setuptools==40.0.0 28 | python-crontab==2.3.4 29 | chaostoolkit-lib==0.20.0 30 | chaostoolkit==0.15.0 31 | chaostoolkit-chaoshub==0.1.1 -------------------------------------------------------------------------------- /app/setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | release = sdist bdist_wheel 3 | test = pytest 4 | 5 | [wheel] 6 | universal = 0 7 | 8 | [pydocstyle] 9 | ignore = D101,D103,D200,D212 10 | 11 | [pycodestyle] 12 | ignore = E225,E129,E123,E125,E126 13 | 14 | [mypy] 15 | python_version = 3.6 16 | -------------------------------------------------------------------------------- /app/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Chaos Hub Dashboard builder and installer""" 3 | from distutils.errors import DistutilsFileError 4 | import io 5 | import os 6 | import sys 7 | 8 | import setuptools 9 | import setuptools.command.build_py 10 | 11 | 12 | def get_version_from_package() -> str: 13 | """ 14 | Read the package version from the source without importing it. 15 | """ 16 | path = os.path.join( 17 | os.path.dirname(__file__), "chaoshubdashboard/__init__.py") 18 | path = os.path.normpath(os.path.abspath(path)) 19 | with open(path) as f: 20 | for line in f: 21 | if line.startswith("__version__"): 22 | token, version = line.split(" = ", 1) 23 | version = version.replace("'", "").strip() 24 | return version 25 | raise IOError("failed to locate chaoshubdashboard sources") 26 | 27 | 28 | # since our ui assets live outside this package, we can't rely on any of the 29 | # setuptools configuration settings to copy them. Let's do it manually. 30 | # I'd rather not but this is what it is... 31 | UI_ASSETS_DIR = os.path.normpath( 32 | os.path.join(os.path.dirname(__file__), "..", "ui", "dist")) 33 | 34 | class Builder(setuptools.command.build_py.build_py): 35 | def run(self): 36 | if not self.dry_run: 37 | ui_dir = os.path.join(self.build_lib, 'chaoshubdashboard/ui') 38 | if not os.path.isdir(UI_ASSETS_DIR): 39 | raise DistutilsFileError( 40 | "Make sure you build the UI assets before creating this package") 41 | self.copy_tree(UI_ASSETS_DIR, ui_dir) 42 | setuptools.command.build_py.build_py.run(self) 43 | 44 | 45 | name = 'chaoshub-dashboard' 46 | desc = 'Chaos Hub Dashboard' 47 | 48 | with io.open('README.md', encoding='utf-8') as strm: 49 | long_desc = strm.read() 50 | 51 | classifiers = [ 52 | 'Intended Audience :: Developers', 53 | 'License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)', 54 | 'Operating System :: OS Independent', 55 | 'Programming Language :: Python', 56 | 'Programming Language :: Python :: 3', 57 | 'Programming Language :: Python :: 3.5', 58 | 'Programming Language :: Python :: 3.6', 59 | 'Programming Language :: Python :: 3.7', 60 | 'Programming Language :: Python :: Implementation', 61 | 'Programming Language :: Python :: Implementation :: CPython' 62 | ] 63 | author = 'ChaosIQ, Ltd' 64 | author_email = 'contact@chaosiq.io' 65 | url = 'https://github.com/chaostoolkit/chaoshub' 66 | packages = [ 67 | 'chaoshubdashboard', 68 | 'chaoshubdashboard.api', 69 | 'chaoshubdashboard.api.services', 70 | 'chaoshubdashboard.auth', 71 | 'chaoshubdashboard.auth.services', 72 | 'chaoshubdashboard.dashboard', 73 | 'chaoshubdashboard.dashboard.services', 74 | 'chaoshubdashboard.dashboard.views', 75 | 'chaoshubdashboard.experiment', 76 | 'chaoshubdashboard.experiment.services', 77 | 'chaoshubdashboard.experiment.scheduler', 78 | 'chaoshubdashboard.experiment.views' 79 | ] 80 | 81 | setup_params = dict( 82 | cmdclass={ 83 | 'build_py': Builder, 84 | }, 85 | name=name, 86 | version=get_version_from_package(), 87 | description=desc, 88 | long_description=long_desc, 89 | classifiers=classifiers, 90 | author=author, 91 | author_email=author_email, 92 | url=url, 93 | license=license, 94 | packages=packages, 95 | entry_points={ 96 | 'console_scripts': [ 97 | 'chaoshub-dashboard = chaoshubdashboard.__main__:cli' 98 | ], 99 | 'chaoshub.scheduling': [ 100 | 'cron = chaoshubdashboard.experiment.scheduler.cron:CronScheduler', 101 | 'local = chaoshubdashboard.experiment.scheduler.local:LocalScheduler' 102 | ] 103 | }, 104 | include_package_data=True, 105 | python_requires='>=3.5.*' 106 | ) 107 | 108 | 109 | def main(): 110 | """Package installation entry point.""" 111 | setuptools.setup(**setup_params) 112 | 113 | 114 | if __name__ == '__main__': 115 | main() -------------------------------------------------------------------------------- /app/tests/unit/.env.test: -------------------------------------------------------------------------------- 1 | SERVER_LISTEN_ADDR="0.0.0.0" 2 | SERVER_LISTEN_PORT=8080 3 | OAUTH_REDIRECT_BASE="http://127.0.0.1:8080" 4 | CHERRYPY_PROXY_BASE="" 5 | DB_HOST="sqlite://" 6 | DB_PORT= 7 | DB_NAME="chaoshub" 8 | DB_USER="" 9 | DB_PWD="" 10 | SECRET_KEY="whatever" 11 | SIGNER_KEY="whatever" 12 | CLAIM_SIGNER_KEY="whatever" 13 | USER_PROFILE_SECRET_KEY="whatever" 14 | GITHUB_CLIENT_ID="" 15 | GITHUB_CLIENT_SECRET="" 16 | GITLAB_CLIENT_ID="" 17 | GITLAB_CLIENT_SECRET="" 18 | GOOGLE_CLIENT_ID="" 19 | GOOGLE_CLIENT_SECRET="" 20 | BITBUCKET_CLIENT_ID="" 21 | BITBUCKET_CLIENT_SECRET="" -------------------------------------------------------------------------------- /app/tests/unit/auth/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | import os 4 | from typing import Callable, Dict, List 5 | from unittest.mock import MagicMock, patch 6 | 7 | import dateparser 8 | from flask import Flask 9 | from flask_caching import Cache 10 | import pytest 11 | 12 | from chaoshubdashboard.app import create_app 13 | from chaoshubdashboard.auth.model import AccessToken, Account, Client, ProviderToken 14 | from chaoshubdashboard.model import db 15 | from chaoshubdashboard.settings import load_settings 16 | 17 | 18 | @pytest.fixture(scope="session") 19 | def app() -> Flask: 20 | load_settings(os.path.join(os.path.dirname(__file__), "..", ".env.test")) 21 | application = create_app(create_tables=False) 22 | 23 | with application.app_context(): 24 | db.create_all(app=application) 25 | 26 | return application 27 | 28 | 29 | @pytest.fixture(scope="session", autouse=True) 30 | def default_dataset(app: Flask): 31 | client = Client( 32 | id=1, 33 | client_id="f5089dc580955311ab7668845183bca7", 34 | client_secret="619b82b87b70ad7b025dc0fe327b5053230dcd530c329a83366b39404bde", 35 | account_id="c1337e77-ccaf-41cf-a68c-d6e2026aef21" 36 | ) 37 | 38 | token = ProviderToken( 39 | id=1, 40 | name="github", 41 | client_id="f5089dc580955311ab7668845183bca7", 42 | token_type="bearer", 43 | access_token="2e9eee679cd0e739b07f9b1c85e5af300053278d7b946c871b7d3e95a319", 44 | refresh_token="6ce05d07a6249daa405e82a8bbb7c88f1f9a50a9878b2146d6a9c1bf8804" 45 | ) 46 | 47 | account = Account( 48 | id="c1337e77-ccaf-41cf-a68c-d6e2026aef21", 49 | joined_on=dateparser.parse("2018-04-01T18:11:48.681677Z"), 50 | oauth_provider="github", 51 | oauth_provider_sub="12345" 52 | ) 53 | 54 | access_token = AccessToken( 55 | id="127e7132-c3a8-430e-a6c2-220e3b5d7796", 56 | name="my token", 57 | access_token="whatever", 58 | account_id="c1337e77-ccaf-41cf-a68c-d6e2026aef21", 59 | last_used_on=dateparser.parse("2018-04-01T18:11:48.681677Z"), 60 | account=account 61 | ) 62 | account.access_tokens.append(access_token) 63 | account.client = client 64 | 65 | with app.app_context(): 66 | try: 67 | db.session.add(access_token) 68 | db.session.add(token) 69 | db.session.add(client) 70 | db.session.add(account) 71 | 72 | db.session.commit() 73 | db.session.expunge_all() 74 | 75 | yield db 76 | except: 77 | db.session.rollback() 78 | finally: 79 | db.session.delete(access_token) 80 | db.session.delete(token) 81 | db.session.delete(client) 82 | db.session.delete(account) 83 | 84 | db.session.commit() 85 | db.session.expunge_all() 86 | 87 | 88 | @pytest.fixture(scope="function") 89 | def simple_cache(app: Flask): 90 | return Cache(app, config={'CACHE_TYPE': 'simple'}) 91 | 92 | 93 | @pytest.fixture(scope="function") 94 | def db_envs(): 95 | try: 96 | os.environ["DB_HOST"] = "localhost" 97 | os.environ["DB_PORT"] = "5432" 98 | os.environ["DB_USER"] = "root" 99 | os.environ["DB_PWD"] = "mypwd" 100 | os.environ["DB_NAME"] = "mydb" 101 | yield 102 | finally: 103 | os.environ.pop("DB_HOST") 104 | os.environ.pop("DB_PORT") 105 | os.environ.pop("DB_USER") 106 | os.environ.pop("DB_PWD") 107 | os.environ.pop("DB_NAME") 108 | -------------------------------------------------------------------------------- /app/tests/unit/auth/test_auth_app.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from flask import Flask 3 | from flask_caching import Cache 4 | 5 | from chaoshubdashboard.app import setup_db 6 | from chaoshubdashboard.auth import OAUTH_REMOTE_APPS, OAUTH_BACKENDS 7 | from chaoshubdashboard.auth.app import setup_service 8 | from chaoshubdashboard.model import get_db_conn_uri_from_env 9 | 10 | 11 | def test_setup_service(simple_cache: Cache): 12 | app = Flask(__name__) 13 | setup_service(app, simple_cache) 14 | 15 | assert 'auth_service' in app.blueprints 16 | bp = app.blueprints['auth_service'] 17 | assert bp.url_prefix is None 18 | 19 | for backend_name in OAUTH_BACKENDS: 20 | assert backend_name in OAUTH_REMOTE_APPS 21 | 22 | assert 'sqlalchemy' not in app.extensions 23 | 24 | 25 | def test_get_db_conn_uri_from_env(db_envs): 26 | db_uri = get_db_conn_uri_from_env() 27 | assert db_uri == "postgresql://root:mypwd@localhost:5432/mydb" 28 | 29 | 30 | def test_setup_db(db_envs): 31 | app = Flask(__name__) 32 | setup_db(app) 33 | 34 | assert app.config["SQLALCHEMY_ECHO"] is False 35 | assert app.config["SQLALCHEMY_DATABASE_URI"] == \ 36 | "postgresql://root:mypwd@localhost:5432/mydb" 37 | assert app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] is False 38 | assert 'sqlalchemy' in app.extensions 39 | -------------------------------------------------------------------------------- /app/tests/unit/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os.path 3 | from unittest.mock import MagicMock, patch 4 | 5 | import dateparser 6 | from flask import Flask 7 | from flask_caching import Cache 8 | import pytest 9 | 10 | from chaoshubdashboard.app import create_app 11 | from chaoshubdashboard.settings import load_settings 12 | 13 | 14 | @pytest.fixture(scope="session") 15 | def app() -> Flask: 16 | load_settings(os.path.join(os.path.dirname(__file__), ".env.test")) 17 | application = create_app() 18 | return application 19 | 20 | 21 | @pytest.fixture(scope="session") 22 | def cache(app: Flask) -> Cache: 23 | return Cache(app, config={'CACHE_TYPE': 'simple'}) 24 | -------------------------------------------------------------------------------- /app/tests/unit/dashboard/conftest.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from datetime import datetime 3 | import os 4 | from typing import Callable, Dict, List 5 | from unittest.mock import MagicMock, patch 6 | 7 | from authlib.specs.oidc import UserInfo as ProfileInfo 8 | import dateparser 9 | from flask import Flask 10 | import pytest 11 | import simplejson as json 12 | 13 | from chaoshubdashboard.app import create_app 14 | from chaoshubdashboard.model import db 15 | from chaoshubdashboard.dashboard import create_user_account, set_user_profile, \ 16 | set_user_privacy, add_default_org_to_account, \ 17 | add_private_workspace_to_account, add_public_workspace_to_account 18 | from chaoshubdashboard.dashboard.model import UserPrivacy, UserAccount, \ 19 | UserInfo, WorkpacesMembers, Workspace, WorkspaceType 20 | from chaoshubdashboard.settings import load_settings 21 | 22 | 23 | @pytest.fixture(scope="session") 24 | def app() -> Flask: 25 | load_settings(os.path.join(os.path.dirname(__file__), "..", ".env.test")) 26 | application = create_app() 27 | 28 | with application.app_context(): 29 | db.create_all(bind='dashboard_service') 30 | 31 | return application 32 | 33 | 34 | @pytest.fixture(scope="session", autouse=True) 35 | def default_dataset(app: Flask): 36 | with app.app_context(): 37 | profile_info = ProfileInfo( 38 | sub="12345", 39 | preferred_username="TheDude", 40 | email="the@dude.com", 41 | name="Jon Doe" 42 | ) 43 | 44 | claim = { 45 | "id": "c1337e77-ccaf-41cf-a68c-d6e2026aef21" 46 | } 47 | 48 | account = create_user_account(claim) 49 | profile = set_user_profile(account, profile_info) 50 | privacy = set_user_privacy(account) 51 | org = add_default_org_to_account(account, "TheDude") 52 | personal_workspace = add_private_workspace_to_account(account, org) 53 | public_workspace = add_public_workspace_to_account(account, org) 54 | 55 | profile.id = "9c5c0aff-4cd2-482c-a25b-7611d6f4496a" 56 | profile.last_updated=dateparser.parse("2018-04-01T18:12:48.681677Z") 57 | profile.details = json.dumps(profile_info) 58 | 59 | privacy.id = "d9b8def4-04de-4de1-837c-329f59c63b6f" 60 | 61 | account.id = "c1337e77-ccaf-41cf-a68c-d6e2026aef21" 62 | account.joined_dt =dateparser.parse("2018-04-01T18:11:48.681677Z") 63 | 64 | personal_workspace.id = "b393802e-182d-464f-9747-1a642953fd1d" 65 | public_workspace.id = "08faab84-2302-4f89-bc85-444bd43d1195" 66 | 67 | db.session.commit() 68 | -------------------------------------------------------------------------------- /app/tests/unit/dashboard/test_blueprint.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | import os.path 4 | from unittest.mock import patch 5 | 6 | import cherrypy 7 | from flask import Flask 8 | from flask_caching import Cache 9 | 10 | from chaoshubdashboard.app import setup_db 11 | from chaoshubdashboard.model import db, get_db_conn_uri_from_env 12 | from chaoshubdashboard.settings import load_settings 13 | 14 | 15 | def test_get_db_conn_uri_from_env(): 16 | try: 17 | os.environ.update({ 18 | "DB_HOST": "192.168.1.10", 19 | "DB_PORT": "5432", 20 | "DB_USER": "user", 21 | "DB_PWD": "pwd", 22 | "DB_NAME": "chaos" 23 | }) 24 | uri = get_db_conn_uri_from_env() 25 | 26 | assert uri == "postgresql://user:pwd@192.168.1.10:5432/chaos" 27 | finally: 28 | os.environ.pop("DB_HOST") 29 | os.environ.pop("DB_PORT") 30 | os.environ.pop("DB_USER") 31 | os.environ.pop("DB_PWD") 32 | os.environ.pop("DB_NAME") 33 | 34 | 35 | @patch('chaoshubdashboard.app.get_db_conn_uri_from_env', autospec=False) 36 | def test_setup_db(get_db_conn_uri_from_env): 37 | load_settings(os.path.join(os.path.dirname(__file__), "..", ".env.test")) 38 | get_db_conn_uri_from_env.return_value = os.getenv("DB_HOST") 39 | 40 | app = Flask(__name__) 41 | 42 | with app.app_context(): 43 | app.config["SQLALCHEMY_ECHO"] = True if os.getenv("DB_DEBUG") else False 44 | app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False 45 | app.config["SQLALCHEMY_DATABASE_URI"] = get_db_conn_uri_from_env() 46 | app.config["SQLALCHEMY_BINDS"] = { 47 | 'dashboard_service': app.config["SQLALCHEMY_DATABASE_URI"] 48 | } 49 | db.init_app(app) 50 | engine = db.get_engine(app, bind='dashboard_service') 51 | assert engine.dialect.has_table(engine, "user_account") is False 52 | 53 | setup_db(app) 54 | 55 | with app.app_context(): 56 | db.create_all(bind='dashboard_service') 57 | engine = db.get_engine(app, bind='dashboard_service') 58 | assert engine.dialect.has_table(engine, "user_account") 59 | 60 | db.drop_all(bind='dashboard_service') 61 | -------------------------------------------------------------------------------- /app/tests/unit/test_model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from flask import Flask 3 | import pytest 4 | 5 | from chaoshubdashboard.model import get_user_info_secret_key 6 | 7 | 8 | def test_get_user_info_secret_key(app: Flask): 9 | with app.app_context(): 10 | assert get_user_info_secret_key() == "whatever" 11 | 12 | 13 | def test_get_user_info_secret_key_requires_key_to_be_set(app: Flask): 14 | with app.app_context(): 15 | app.config.pop("USER_PROFILE_SECRET_KEY", None) 16 | with pytest.raises(RuntimeError) as x: 17 | get_user_info_secret_key() 18 | assert "User profile secret key not set!" in str(x) 19 | -------------------------------------------------------------------------------- /app/tests/unit/test_serve.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os.path 3 | 4 | import cherrypy 5 | from flask import Flask, session, url_for 6 | from flask_caching import Cache 7 | from requestlogger import WSGILogger 8 | from werkzeug.contrib.fixers import ProxyFix 9 | 10 | from chaoshubdashboard.app import serve_services 11 | from chaoshubdashboard.settings import load_settings 12 | 13 | 14 | def test_serve_blueprint(): 15 | load_settings(os.path.join(os.path.dirname(__file__), ".env")) 16 | 17 | app = Flask(__name__) 18 | cache = Cache(app, config={'CACHE_TYPE': 'simple'}) 19 | 20 | with app.app_context(): 21 | serve_services(app, cache) 22 | 23 | assert "" in cherrypy.tree.apps 24 | wsgiapp = cherrypy.tree.apps[""] 25 | 26 | assert isinstance(wsgiapp, WSGILogger) 27 | 28 | wsgiapp = wsgiapp.application 29 | assert isinstance(wsgiapp, ProxyFix) 30 | -------------------------------------------------------------------------------- /assets/chaoshub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaostoolkit-attic/chaoshub-archive/c7801dd043c5956efdca34683ccee4e811549926/assets/chaoshub.png -------------------------------------------------------------------------------- /docs/configure.md: -------------------------------------------------------------------------------- 1 | # Configure the Chaos Hub 2 | 3 | The Chaos Hub has only a few settings you can, and should, configure and they 4 | all live inside the dotenv (`.env`) file you will be provided it at runtime. 5 | 6 | A default set is provided [here][defaultdotenv]. You should copy it somewhere 7 | and rename it to `.env`. 8 | 9 | [defaultdotenv]: https://github.com/chaostoolkit/chaoshub/raw/master/app/.env.sample 10 | 11 | Note that the configuration is never reloaded on the fly. You must restart 12 | the process for changes to take effect. 13 | 14 | ## Database 15 | 16 | By default, Chaos Hub relies on an in-memory SQLite instance. But, if you 17 | want to persist state, you should rely on PostgreSQL instead. 18 | 19 | To configure this, set the following keys in your .env: 20 | 21 | ``` 22 | DB_HOST="localhost" 23 | DB_PORT=5432 24 | DB_NAME="chaoshub" 25 | DB_USER="chaoshub" 26 | DB_PWD="secret" 27 | ``` 28 | 29 | A couple of notes: 30 | 31 | * The database must be created manually. While Chaos Hub can create all the 32 | tables but not the database containing them. 33 | * In the future, we will allow for better ways to TLS link to the database 34 | 35 | ## OAuth Providers 36 | 37 | By default, Chaos Hub is configured so you can sign-up and sign-in using a 38 | simple username/password scheme. However, you mays also configure OAuth2 39 | providers to offload the authentication to them. 40 | 41 | The following providers are supported out of the box: BitBucket, GitHub, GitLab 42 | and Google. More will likely come. 43 | 44 | To make them work, you need set their api/secret keys for each of the ones 45 | you wish to use. Refer to their documentation to generate those keys. 46 | 47 | The redirect uris to use are: 48 | 49 | * BitBucket: http:///auth/allowed/via/bitbucket 50 | * GitHUb: http:///auth/allowed/via/github 51 | * GitLab: http:///auth/allowed/via/gitlab 52 | * Google: http:///auth/allowed/via/google 53 | 54 | Replace by wherever you are hosting your instance. For example, 55 | 127.0.0.1:8080 56 | 57 | ## Secrets 58 | 59 | You should set the `USER_PROFILE_SECRET_KEY` to a solid random 60 | string which will be used to encrypt values in certain columns. Should you 61 | lose this secret you would not be able to read said data. If you are breached, 62 | you should change the key and encrypt all existing data. 63 | 64 | Set `SIGNER_KEY` to a random string. It is used to sign all the JWT tokens. 65 | If you change this value, all sessions will have to be refreshed by the users 66 | by signing in again. 67 | 68 | Set `SECRET_KEY` to a random string. It is used to sign the session itself. 69 | 70 | Set `CLAIM_SIGNER_KEY` to a random string to sign all exchanged user claims 71 | between the various services. If this changes while a claim is in traffic, it 72 | will be rejected on the receiving and the call will have to be remade. 73 | -------------------------------------------------------------------------------- /docs/contribute.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | Contributors to this project are welcome as this is an open-source effort that 4 | seeks [discussions][join] and continuous improvement. 5 | 6 | [join]: https://join.chaostoolkit.org/ 7 | 8 | From a code perspective, if you wish to contribute, you will need to run a 9 | Python 3.5+ environment. Then, fork this repository and submit a PR. The 10 | project cares for code readability and checks the code style to match best 11 | practices defined in [PEP8][pep8]. Please also make sure you provide tests 12 | whenever you submit a PR so we keep the code reliable. 13 | 14 | [pep8]: https://pycodestyle.readthedocs.io/en/latest/ 15 | 16 | The Chaos Hub project requires all contributors must sign a 17 | [Developer Certificate of Origin][dco] on each commit they would like to merge 18 | into the master branch of the repository. Please, make sure you can abide by 19 | the rules of the DCO before submitting a PR. Also, we suggest you read our 20 | take on the [license][] this project relies on to appreciate its rationale and 21 | impact on your contributions. 22 | 23 | [dco]: https://github.com/probot/dco#how-it-works 24 | [license]: https://github.com/chaostoolkit/chaoshub/blob/master/docs/licensing.md 25 | -------------------------------------------------------------------------------- /docs/faq.md: -------------------------------------------------------------------------------- 1 | TBD -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | 2 | # Install the Chaos Hub 3 | 4 | If you run the Chaos Hub locally (without Docker that is), either install the 5 | Chaos Hub from a published release or build and install it from your local 6 | clone. 7 | 8 | ## Install via Docker 9 | 10 | As Chaos Hub requires some dependencies, it may be the easiest to fetch the 11 | application via [Docker][docker]. 12 | 13 | [docker]: https://hub.docker.com/r/chaostoolkit/chaoshub/ 14 | 15 | Pull the image 16 | ``` 17 | $ docker pull chaostoolkit/chaoshub:0.1.2 18 | ``` 19 | 20 | ## Install from a release 21 | 22 | WARNING: No official releases have been made yet, so please install from a local 23 | clone. 24 | 25 | ``` 26 | $ source .venv/bin/activate 27 | (.venv) $ pip install -U chaoshub 28 | ``` 29 | 30 | ## Install from sources 31 | 32 | First, build the UI: 33 | 34 | ``` 35 | $ cd ui 36 | $ npm run build 37 | ``` 38 | 39 | Now, install the application in your Python virtual environment: 40 | 41 | ``` 42 | $ source .venv/bin/activate 43 | (.venv) $ cd app 44 | (.venv) $ python setup.py install 45 | (.venv) $ cd .. 46 | ``` 47 | -------------------------------------------------------------------------------- /docs/licensing.md: -------------------------------------------------------------------------------- 1 | # Licensing 2 | 3 | The followings are our interpretation of the AGPLv3 terms but they cannot be 4 | deemed legally representative. We are merely trying to convey how we intend 5 | to use this license. 6 | 7 | ## Why a copyleft license? 8 | 9 | The Chaos Hub project is under the [AGPLv3+][agpl] license. Unlike other 10 | projects such as the [Chaos Toolkit][chaostoolkit] which fall under a 11 | permissive license, the Chaos Hub uses a copyleft license. 12 | 13 | [agpl]: https://www.gnu.org/licenses/agpl-3.0.en.html 14 | [chaostoolkit]: https://chaostoolkit.org 15 | 16 | For libraries, a permissive license does make more sense to us because their 17 | goal is to be embedded as freely as much as possible. The Chaos Hub is an 18 | application, it does not aim at being embedded into a program. We 19 | obviously want anyone to freely enjoy the project, but to protect 20 | the project's community effort, it feels like the AGPLv3+ is the most 21 | appropriate license. 22 | 23 | There is also the aspect of some of our dependencies which are copyleft 24 | licenses that mean we had to choose at least something as free (or restrictive 25 | depending how you see it). 26 | 27 | ## Contribution 28 | 29 | The Chaos Hub project uses a simple [Developer Certificate of Origin][dco] for 30 | ensuring contributors certify they are allowed to submit a change, and that 31 | they understand the consequences of the license of the project. Basically, all 32 | contributions will be released under the project's license as well. 33 | 34 | But, as contributors, you still have the copyright of the changes themselves. 35 | Should the project decide to switch to a different license (non-compatible), 36 | you could deny using your changes. This is also why any contributions must 37 | be clean in the project's history, so we can easily understand what the 38 | impact is. 39 | 40 | [dco]: https://github.com/probot/dco#how-it-works 41 | 42 | ## Use 43 | 44 | You may be worried that merely using the Chaos Hub would force you to release 45 | your whole world under a compatible license. In effect, you can run the Chaos 46 | Hub in your environment without sharing your changes as long as your instance 47 | cannot be accessed from outside your own network. If you run the Chaos Hub 48 | as a SaaS, you must make your code available under a compatible license. 49 | 50 | -------------------------------------------------------------------------------- /docs/running.md: -------------------------------------------------------------------------------- 1 | # Launch the Chaos Hub 2 | 3 | ## Run with Docker 4 | 5 | If you just want to try out the Chaos Hub, you may simply run it as a container: 6 | 7 | ``` 8 | $ docker run --rm -p 8080:8080 --name chaoshub -it chaostoolkit/chaoshub:0.1.2 9 | ``` 10 | 11 | Note that all data will be lost when the container exits. 12 | 13 | ## Run Locally 14 | 15 | Ensure you have [setup][setup] your environment, and configured the settings, 16 | before you can run the Chaos Hub. 17 | 18 | [setup]: https://github.com/chaostoolkit/chaoshub/blob/master/docs/setup.md 19 | [configure]: https://github.com/chaostoolkit/chaoshub/blob/master/docs/configure.md 20 | 21 | Then launch the Chaos Hub as follows (using the default settings): 22 | 23 | ``` 24 | $ source .venv/bin/activate 25 | (.venv) $ chaoshub-dashboard run --env-path app/.env.sample --create-tables 26 | ``` 27 | 28 | By default, the Chaos Hub runs using an in-memory SQLite instance so you don't 29 | have to configure it but it also means all your data will be lost everytime 30 | you restart it. 31 | 32 | You should read the [configuration][config] section to learn how to change that 33 | behavior. 34 | 35 | [config]: https://github.com/chaostoolkit/chaoshub/blob/master/docs/configure.md 36 | -------------------------------------------------------------------------------- /docs/setup.md: -------------------------------------------------------------------------------- 1 | # Setup your Environment 2 | 3 | The Chaos Hub open source project was designed to be executed locally, with 4 | minimal requirements, by default. 5 | 6 | > **WARNING**: It is _not_ recommended run a default, non-configured, instance 7 | on a public address. 8 | 9 | To run the Chaos Hub locally you need to install the following dependencies 10 | first. 11 | 12 | ## Application Python Requirements 13 | 14 | The Chaos Hub is implemented in Python 3. It should support Python 3.5+ 15 | but has only been tested against Python 3.7. 16 | 17 | Install Python for your system: 18 | 19 | On MacOS X: 20 | 21 | ``` 22 | $ brew install python3 23 | ``` 24 | 25 | On Debian/Ubuntu: 26 | 27 | ``` 28 | $ sudo apt-get install python3 python3-venv 29 | ``` 30 | 31 | On CentOS: 32 | 33 | ``` 34 | $ sudo yum -y install https://centos7.iuscommunity.org/ius-release.rpm 35 | $ sudo yum -y install python35u 36 | ``` 37 | 38 | Notice, on CentOS, the Python 3.5 binary is named `python3.5` rather than 39 | `python3` as other systems. 40 | 41 | On Windows: 42 | 43 | [Download the latest binary installer][pywin] from the Python website. 44 | 45 | [pywin]: https://www.python.org/downloads/windows/ 46 | 47 | ## Dependencies 48 | 49 | Chaos Hub dependencies can be installed for your system via its package 50 | management system but, more likely, you may want to install them yourself in a local python virtual environment using: 51 | 52 | ``` 53 | $ python3 -m venv .venv 54 | ``` 55 | 56 | Make sure to **always** activate your virtual environment before using it: 57 | 58 | ``` 59 | $ source .venv/bin/activate 60 | (.venv) $ 61 | ``` 62 | 63 | Once activated you can install the Chaos Hub dependencies using: 64 | 65 | ``` 66 | (.venv) $ pip install -r app/requirements.txt 67 | ``` 68 | 69 | ## UI TypeScript dependencies 70 | 71 | To create and work with the UI elements for the Chaos Hub you need to install 72 | [npm][npm] on your machine and then install the required dependencies: 73 | 74 | [npm]: https://www.npmjs.com/ 75 | 76 | ``` 77 | $ cd ui 78 | $ npm -g install poi 79 | $ npm install 80 | ``` 81 | 82 | > **NOTE**: You may need to use `sudo` to install global npm dependencies. 83 | -------------------------------------------------------------------------------- /docs/status.md: -------------------------------------------------------------------------------- 1 | # Status of the Chaos Hub Project 2 | 3 | This page does not replace the list of issues tracked in the project but 4 | should offer a quick overview of the main axes of improvements, in no 5 | particular order. 6 | 7 | ## General Status 8 | 9 | Currently, the project is in early stages. It does run and can get you some 10 | way but is not be production-ready yet. It is meant to be used as-is for 11 | exploring its use case and please send the project your feedback on its missing bits. 12 | 13 | ## Features Matrix 14 | 15 | * Allow experiment editing from the UI [TODO] 16 | As of now, you still need to edit your experiment manually and the Hub only 17 | renders it. We will obviously make it so you can create and edit them from 18 | the UI. 19 | * Complete the local launcher of Chaos Toolkit instances [WIP] 20 | Currently, the local launcher built into the Chaos Hub is working but is 21 | rough around the edges. It needs to be more battle tested. 22 | * Implement the CRON launcher [TODO] 23 | The CRON launcher is not yet implemented properly and cannot be used at this 24 | stage. 25 | * Respect the Schedule [TODO] 26 | Right now, when you Schedule, the execution starts immediatly. Obviously, 27 | we want to abide by the date and time set by the user 28 | * Finish the execution view [WIP] 29 | The view of your past executions is not completed yet and will likely break 30 | to render properly. 31 | * Improve the past scheduling view [TODO] 32 | * Write end-user documentation [TODO] 33 | * Ensure user profile can be edited [TODO] 34 | * Create a Privacy editing page [TODO] 35 | 36 | ## Code Status 37 | 38 | The current status of the code is an honorable "meh". This means that we had 39 | to take a few shortcuts in keeping it elegant and well tested. 40 | 41 | * Re-work the unit tests [WIP] 42 | * Cleanup the Vue.js templates [TODO] 43 | * Fix docstrings [TODO] 44 | * Write contributor guidelines [TODO] 45 | -------------------------------------------------------------------------------- /docs/use.md: -------------------------------------------------------------------------------- 1 | # Use the Chaos Hub 2 | 3 | Once started the Chaos Hub is a regular web application. Connect to it at 4 | http://127.0.0.1:8080 by default and sign-up yourself. 5 | -------------------------------------------------------------------------------- /ui/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /ui/.eslintignore: -------------------------------------------------------------------------------- 1 | /dist/** 2 | /node_modules/** 3 | /static/** 4 | !.eslintrc.js -------------------------------------------------------------------------------- /ui/.eslintrc.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | root: true, 5 | extends: "plugin:vue/recommended", 6 | rules: { 7 | 'vue/html-indent': false, 8 | 'vue/max-attributes-per-line': [{singleline: 10}], 9 | } 10 | } -------------------------------------------------------------------------------- /ui/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [Unreleased][] 4 | 5 | [Unreleased]: https://github.com/chaostoolkit/chaoshub/compare/0.1.0...HEAD 6 | 7 | 8 | ## [0.1.0][] - 2018-09-09 9 | 10 | [0.1.0]: https://github.com/chaostoolkit/chaoshub/tree/0.1.0 11 | 12 | ### Added 13 | 14 | - Initial release -------------------------------------------------------------------------------- /ui/README.md: -------------------------------------------------------------------------------- 1 | # ChaosHub Frontend 2 | 3 | This repository contains the frontend project for the ChaosHub application. 4 | 5 | ## Requirements 6 | 7 | This requires the following packages to be installed: 8 | 9 | ```console 10 | $ sudo npm install -g poi 11 | $ npm install 12 | ``` 13 | 14 | ## Build 15 | 16 | You can build the artefacts by running: 17 | 18 | ```console 19 | $ npm run build 20 | ``` 21 | -------------------------------------------------------------------------------- /ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ChaosHub 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chaoshub-ui", 3 | "version": "0.1.2", 4 | "description": "ChaosHub Frontend UI", 5 | "main": "poi.config.js", 6 | "dependencies": { 7 | "axios": "^0.18.0", 8 | "moment": "^2.22.2", 9 | "normalize-scss": "^7.0.1", 10 | "poi": "^10.2.9", 11 | "spectre.css": "^0.5.1", 12 | "sweetalert2": "^7.25.6", 13 | "vue": "^2.5.16", 14 | "vue-highlightjs": "^1.3.3", 15 | "vue-property-decorator": "^6.0.0", 16 | "vue-router": "^3.0.1" 17 | }, 18 | "devDependencies": { 19 | "@poi/plugin-typescript": "^10.2.0", 20 | "@poi/plugin-vue-static": "^1.0.5", 21 | "awesome-typescript-loader": "^5.0.0", 22 | "css-loader": "^0.28.11", 23 | "eslint": "^4.19.1", 24 | "eslint-plugin-vue": "^4.5.0", 25 | "file-loader": "^1.1.11", 26 | "html-loader": "^0.5.5", 27 | "http-server": "^0.11.1", 28 | "json-loader": "^0.5.7", 29 | "node-sass": "^4.9.0", 30 | "poi-preset-eslint": "^9.1.1", 31 | "raw-loader": "^0.5.1", 32 | "sass-loader": "^7.0.1", 33 | "style-loader": "^0.21.0", 34 | "ts-node": "^6.0.3", 35 | "tslint": "^5.10.0", 36 | "tslint-config-standard": "^7.0.0", 37 | "tslint-loader": "^3.6.0", 38 | "typescript": "^2.8.3", 39 | "url-loader": "^1.0.1" 40 | }, 41 | "scripts": { 42 | "test": "echo \"Error: no test specified\" && exit 1", 43 | "build": "poi build", 44 | "dev": "poi", 45 | "clean": "rm -rf dist node_modules .vue-static", 46 | "lint": "tslint src/**/*.ts", 47 | "serve": "http-server dist/ -g -o" 48 | }, 49 | "author": "ChaosIQ", 50 | "license": "ISC" 51 | } 52 | -------------------------------------------------------------------------------- /ui/poi.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Options 3 | } from 'poi' 4 | 5 | const options: Options = { 6 | entry: 'src/index.ts', 7 | staticFolder: './public', 8 | sourceMap: false, 9 | extractCSS: true, 10 | html: { 11 | template: 'index.html' 12 | }, 13 | plugins: [require('@poi/plugin-typescript')()] 14 | } 15 | 16 | if (process.env.NODE_ENV === 'production') { 17 | options.filename = { 18 | js: 'static/js/[name].[chunkhash:8].js', 19 | css: 'static/css/[name].[chunkhash:8].css', 20 | image: 'static/img/[name].[ext]', 21 | font: 'static/fonts/[name].[ext]', 22 | chunk: 'static/js/[id].chunk.js' 23 | } 24 | } 25 | 26 | export default options 27 | -------------------------------------------------------------------------------- /ui/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ChaosHub: Chaos Engineering for Everyone 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 30 |
31 |
32 |
33 |
34 |

Ooops, we could not find what you were looking for!

35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
ChaosHub is an Open Source product under the 44 | AGPLv3+ license
45 |
© 2018 - ChaosIQ
46 |
47 |
48 |
49 | 50 | -------------------------------------------------------------------------------- /ui/public/503.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ChaosHub: Chaos Engineering for Everyone 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 30 |
31 |
32 |
33 |
34 |

We are totally down, probably breaking some stuff somewhere. Hang tight!

35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
ChaosHub is an Open Source product under the 44 | AGPLv3+ license
45 |
© 2018 - ChaosIQ
46 |
47 |
48 |
49 | 50 | -------------------------------------------------------------------------------- /ui/public/landing.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | ChaosHub: Chaos Engineering for Everyone 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 | Chaos Engineering Collaboration For Everyone 47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | 60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
ChaosHub is an Open Source product under the 72 | AGPLv3+ license
73 |
© 2018 - ChaosIQ
74 |
75 |
76 |
77 | 78 | 79 | 80 | -------------------------------------------------------------------------------- /ui/public/static/css/error.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | line-height: 1.5; 5 | } 6 | 7 | body { 8 | font-family: 'IBM Plex Sans', sans-serif; 9 | background-color: #fbfbff; 10 | } 11 | 12 | main { 13 | min-height: calc(100vh - 90px); 14 | } 15 | 16 | header.navbar { 17 | height: 3rem; 18 | background-color: #24221f; 19 | padding: .5em 0 1em; 20 | } 21 | 22 | header.navbar a { 23 | color: #fbfbff; 24 | } 25 | 26 | footer { 27 | min-height: 10em; 28 | background-color: #03161E; 29 | } 30 | 31 | .panel { 32 | background-color: white; 33 | } 34 | -------------------------------------------------------------------------------- /ui/public/static/css/landing.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | line-height: 1.5; 5 | } 6 | 7 | body { 8 | font-family: 'IBM Plex Sans', sans-serif; 9 | background-color: #fbfbff; 10 | } 11 | 12 | main { 13 | min-height: calc(150vh - 20px); 14 | } 15 | 16 | header { 17 | min-height: 50px !important; 18 | padding-top: 5px; 19 | } 20 | 21 | .logo-big { 22 | height: 4rem; 23 | width: 20rem; 24 | background: url("/static/img/logo-light.svg") no-repeat 50%/auto; 25 | background-size: contain; 26 | } 27 | 28 | 29 | footer { 30 | background-color: #24221f; 31 | color: white; 32 | } 33 | 34 | footer a { 35 | color: #ffb13bff; 36 | } 37 | 38 | footer a:hover { 39 | color: #ffb13bff; 40 | } 41 | footer a:visited { 42 | color: #ffb13bff; 43 | } 44 | 45 | .panel { 46 | background-color: white; 47 | } 48 | 49 | .account-content { 50 | background-color: white; 51 | } 52 | 53 | .modal-container { 54 | background-color: #fbfbff; 55 | } 56 | 57 | .hero { 58 | color: white; 59 | background-color: #373839; 60 | min-height: 30rem; 61 | } 62 | 63 | .hero-header { 64 | display: flex; 65 | flex-direction: column; 66 | justify-content: center; 67 | } 68 | 69 | .hero-title { 70 | font-size: 56px; 71 | } 72 | 73 | .hero-subtitle { 74 | font-size: 28px; 75 | } 76 | 77 | .register { 78 | display: flex; 79 | flex-direction: column; 80 | justify-content: center; 81 | } 82 | 83 | .register-btn { 84 | font-size: 24px; 85 | background-color: #ffb13bff; 86 | border-color:#ffb13bff; 87 | color: white; 88 | font-weight: bold; 89 | min-height: 4rem; 90 | } 91 | 92 | .register-btn:hover { 93 | background-color: #fbfbff; 94 | border-color: #fbfbff; 95 | color: #373839; 96 | } 97 | 98 | .register-link:hover { 99 | text-decoration: none; 100 | } 101 | 102 | .signin { 103 | background-color: #373839; 104 | border-color:#373839; 105 | color: white; 106 | padding-left: 40px; 107 | padding-right: 40px; 108 | } 109 | 110 | .signin:hover { 111 | background-color: #ffb13bff; 112 | border-color:#ffb13bff; 113 | color: 24221f; 114 | } 115 | -------------------------------------------------------------------------------- /ui/public/static/img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaostoolkit-attic/chaoshub-archive/c7801dd043c5956efdca34683ccee4e811549926/ui/public/static/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /ui/public/static/img/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaostoolkit-attic/chaoshub-archive/c7801dd043c5956efdca34683ccee4e811549926/ui/public/static/img/android-chrome-512x512.png -------------------------------------------------------------------------------- /ui/public/static/img/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaostoolkit-attic/chaoshub-archive/c7801dd043c5956efdca34683ccee4e811549926/ui/public/static/img/apple-touch-icon.png -------------------------------------------------------------------------------- /ui/public/static/img/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /ui/public/static/img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaostoolkit-attic/chaoshub-archive/c7801dd043c5956efdca34683ccee4e811549926/ui/public/static/img/favicon-16x16.png -------------------------------------------------------------------------------- /ui/public/static/img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaostoolkit-attic/chaoshub-archive/c7801dd043c5956efdca34683ccee4e811549926/ui/public/static/img/favicon-32x32.png -------------------------------------------------------------------------------- /ui/public/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaostoolkit-attic/chaoshub-archive/c7801dd043c5956efdca34683ccee4e811549926/ui/public/static/img/favicon.ico -------------------------------------------------------------------------------- /ui/public/static/img/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaostoolkit-attic/chaoshub-archive/c7801dd043c5956efdca34683ccee4e811549926/ui/public/static/img/mstile-150x150.png -------------------------------------------------------------------------------- /ui/public/static/img/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /ui/public/static/img/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ChaosHub", 3 | "short_name": "ChaosHub", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /ui/src/App.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 82 | 83 | 146 | -------------------------------------------------------------------------------- /ui/src/assets/sass/abstracts/_colors.scss: -------------------------------------------------------------------------------- 1 | // palette https://coolors.co/20bf55-0b4f6c-01baef-fbfbff-757575 2 | 3 | // override spectre.css 4 | $primary-color: #363f45 !default; 5 | $secondary-color: #5e7c88 !default; 6 | $dark-color: #24221f !default; 7 | $gray-color: lighten($dark-color, 40%) !default; 8 | $gray-color-dark: darken($gray-color, 25%) !default; 9 | $gray-color-light: lighten($gray-color, 20%) !default; 10 | $text-safran: #feb41c; -------------------------------------------------------------------------------- /ui/src/assets/sass/base/_base.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | font-family: 'IBM Plex Sans', sans-serif; 5 | } 6 | 7 | main { 8 | background-color: #fbfbff; 9 | min-height: calc(150vh); 10 | padding-top: 20px; 11 | } 12 | 13 | header.navbar { 14 | height: 3rem; 15 | background-color: $primary-color; 16 | padding: .5em 0 1em; 17 | } 18 | 19 | header.navbar a { 20 | color: #fbfbff; 21 | } 22 | 23 | footer { 24 | color: white; 25 | background-color: #24221f; 26 | } 27 | 28 | footer a { 29 | color: #ffb13bff; 30 | } 31 | 32 | footer a:hover { 33 | color: #ffb13bff; 34 | } 35 | footer a:visited { 36 | color: #ffb13bff; 37 | } 38 | 39 | .panel { 40 | background-color: white; 41 | } 42 | 43 | .account-content { 44 | background-color: white; 45 | } 46 | 47 | .modal-container { 48 | background-color: #fbfbff; 49 | } 50 | 51 | .register-btn { 52 | background-color: #20bf55; 53 | border-color:#20bf55; 54 | color: white; 55 | } 56 | 57 | .register-btn:hover { 58 | background-color: white; 59 | border-color: white; 60 | color: #0B4F6C; 61 | } 62 | 63 | .register-link:hover { 64 | text-decoration: none; 65 | color: #0B4F6C; 66 | } 67 | 68 | .menu-item>a:hover { 69 | color: white !important; 70 | } 71 | 72 | .menu-item>a.active { 73 | color: white !important; 74 | } 75 | 76 | .logo { 77 | height: 1.8rem; 78 | width: 30rem; 79 | background: url("/static/img/logo-light.svg") no-repeat; 80 | background-size: contain; 81 | } 82 | 83 | 84 | .logo-link { 85 | font-size: 0; 86 | display: block; 87 | color: transparent; 88 | height: 100%; 89 | } 90 | 91 | .logo-big { 92 | height: 4rem; 93 | width: 20rem; 94 | background: url("/static/img/logo-dark.svg") no-repeat 50%/auto; 95 | background-size: contain; 96 | } 97 | -------------------------------------------------------------------------------- /ui/src/assets/sass/components/_experiment.scss: -------------------------------------------------------------------------------- 1 | .activity-header { 2 | &:hover { 3 | background: #fbfbff; 4 | } 5 | } 6 | 7 | .download-exp { 8 | min-width: 380px !important; 9 | } 10 | 11 | .timeline-status-success { 12 | background-color: #32b643 !important; 13 | } 14 | 15 | .timeline-status-fail { 16 | background-color: #e85600 !important; 17 | } 18 | 19 | .timeline-status-other { 20 | background-color: #ffb700 !important; 21 | } -------------------------------------------------------------------------------- /ui/src/assets/sass/components/_sign.scss: -------------------------------------------------------------------------------- 1 | 2 | $gitlab-color: #e24329; 3 | $gitlab-color-dark: darken($gitlab-color, 5%) !important; 4 | 5 | .gitlab-btn { 6 | background-color: $gitlab-color !important; 7 | border-color: $gitlab-color !important; 8 | color: white !important; 9 | padding: .30rem .4rem !important; 10 | 11 | &:hover { 12 | background-color: $gitlab-color-dark; 13 | border-color: $gitlab-color-dark; 14 | } 15 | } 16 | 17 | 18 | $github-color: #181717; 19 | $github-color-dark: lighten($github-color, 5%) !important; 20 | 21 | .github-btn { 22 | background-color: $github-color !important; 23 | border-color: $github-color !important; 24 | color: white !important; 25 | padding: .30rem .4rem !important; 26 | 27 | &:hover { 28 | background-color: $github-color-dark; 29 | border-color: $github-color-dark; 30 | } 31 | } 32 | 33 | 34 | $google-color: #4285F4; 35 | $google-color-dark: lighten($google-color, 5%) !important; 36 | 37 | .google-btn { 38 | background-color: $google-color !important; 39 | border-color: $google-color !important; 40 | color: white !important; 41 | padding: .30rem .4rem !important; 42 | 43 | &:hover { 44 | background-color: $google-color-dark; 45 | border-color: $google-color-dark; 46 | } 47 | } 48 | 49 | 50 | $bitbucket-color: #0052CC; 51 | $bitbucket-color-dark: lighten($bitbucket-color, 5%) !important; 52 | 53 | .bitbucket-btn { 54 | background-color: $bitbucket-color !important; 55 | border-color: $bitbucket-color !important; 56 | color: white !important; 57 | padding: .30rem .4rem !important; 58 | 59 | &:hover { 60 | background-color: $bitbucket-color-dark; 61 | border-color: $bitbucket-color-dark; 62 | } 63 | } -------------------------------------------------------------------------------- /ui/src/assets/sass/main.scss: -------------------------------------------------------------------------------- 1 | @charset 'UTF-8'; 2 | @import 'abstracts/colors'; 3 | @import 'components/experiment'; 4 | @import 'components/sign'; 5 | @import "~normalize-scss"; 6 | @import '~spectre.css/src/spectre.scss'; 7 | @import '~spectre.css/src/spectre-icons.scss'; 8 | @import '~spectre.css/src/spectre-exp.scss'; 9 | @import url('https://fonts.googleapis.com/css?family=Fira+Sans:500,500i|Montserrat:300,300i,600,600i'); 10 | @import url('https://fonts.googleapis.com/css?family=IBM+Plex+Sans:400,400i,600,600i'); 11 | @import 'base/base'; 12 | -------------------------------------------------------------------------------- /ui/src/components/Activities.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 97 | -------------------------------------------------------------------------------- /ui/src/components/NotFound.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 17 | -------------------------------------------------------------------------------- /ui/src/components/account/Account.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 54 | -------------------------------------------------------------------------------- /ui/src/components/account/Privacy.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 21 | -------------------------------------------------------------------------------- /ui/src/components/account/Profile.vue: -------------------------------------------------------------------------------- 1 |