diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..32e8dea --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["stage-2","react"], + "plugins": ["react-hot-loader/babel"] +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0a764a4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +env diff --git a/.gitignore b/.gitignore index 7bbc71c..5134235 100644 --- a/.gitignore +++ b/.gitignore @@ -99,3 +99,11 @@ ENV/ # mypy .mypy_cache/ + +# Mac OSX +.DS_Store + +# Project Specific +webpack-stats.json +reports +local.py diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..bbdf158 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +FROM amazonlinux:2017.03 + +WORKDIR /usr/local/service + +ENV DJANGO_SETTINGS_MODULE=estate.settings \ + PYTHONPATH=/usr/local/service \ + PATH=/usr/local/service/node_modules/.bin/:$PATH + +RUN yum update -y && \ + yum install -y ca-certificates gcc libffi-devel libyaml-devel postgresql94-devel python27-devel python27-pip unzip docker git && \ + mkdir -p /usr/local/service + +COPY ./TERRAFORM_URL.txt /usr/local/service/TERRAFORM_URL.txt +RUN curl -L --silent $(cat /usr/local/service/TERRAFORM_URL.txt) > /terraform.zip && \ + unzip /terraform.zip -d /bin/ && \ + rm /terraform.zip + +ENV NODE_VERSION 6.10.2 +RUN curl -sLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION-linux-x64.tar.xz" && \ + tar -xJf "node-v$NODE_VERSION-linux-x64.tar.xz" -C /usr/local --strip-components=1 && \ + rm "node-v$NODE_VERSION-linux-x64.tar.xz" + +RUN pip install coreapi==2.3.0 \ + boto3==1.4.4 \ + dj-database-url==0.4.1 \ + Django==1.10.7 \ + django-braces==1.11.0 \ + django-crispy-forms==1.6.1 \ + django-cors-headers==2.0.2 \ + django-extensions==1.7.8 \ + django-filter==1.0.2 \ + django-permanent==1.1.6 \ + django-rest-swagger==2.1.2 \ + django-simple-history==1.9.0 \ + django-storages==1.5.2 \ + django-webpack-loader==0.4.1 \ + djangorestframework==3.6.3 \ + gevent==1.2.1 \ + gunicorn==19.7.1 \ + hvac==0.2.17 \ + Jinja2==2.9.6 \ + markdown==2.6.8 \ + psycopg2==2.7.1 \ + pyhcl==0.3.5 \ + python-consul==0.7.0 \ + python-memcached==1.58 \ + raven==6.1.0 \ + semantic_version==2.6.0 \ + structlog==17.1.0 \ + whitenoise==3.3.0 && \ + pip install --global-option="--with-libyaml" pyyaml==3.12 + +COPY ./package.json /usr/local/service/package.json +RUN npm install + +COPY ./.babelrc /usr/local/service/.babelrc +COPY ./webpack /usr/local/service/webpack +COPY ./estate /usr/local/service/estate + +RUN webpack --bail --config webpack/webpack.prod.config.js && django-admin collectstatic --noinput + +CMD [ "gunicorn", "--config", "python:estate.gunicorn", "estate.wsgi"] diff --git a/README.md b/README.md index 17c969b..ea497f4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,131 @@ -# estate -Terraform UI +Estate (Terraform UX) +===================== + + +**Latin:** Status **Old French:** estat **English:** Esate + +> a piece of landed property or status of an owner, considered with respect to property, especially one of large extent with an elaborate house on it + +Estate is essentially at Terraform UI/UX experiance that makes Terraform easier for everyone to use. + +It it designed around a some key principles: + +* Self-service infrastructure as code for everyone +* Reduce the learning curve of Terraform +* Make the right way the easy thing to do +* Standarize usage of Terraform across an organiztion +* Get out of the way of a power user limiting impact on their productivity +* Make management of Terraform easier + +This project was presented at [HashiConf 2017 in Austin](https://www.hashiconf.com/talks/underarmour-terraform.html) + +The slides for the presentation can be found [here](http://slides.com/rocktavious/estate#/) + +Getting Started & Bootstraping +------------------------------ + +(Only for AWS Users) There is a Terraform file in the root of the repository that will provision the necessary AWS resources to run Estate + +For those who arn't using AWS or have their own deployment tooling as long as it can run a docker container then you can stand this puppy up. + +``` +docker pull underarmourconnectedfitness/estate:master + +docker run --privileged \ + -p 9200:9200 \ + -e SECRET_KEY=super_secret \ + -e DATABASE_URL=postgres://username:password@postgres.example.com:5432/estate \ + -v /var/run/docker.sock:/var/run/docker.sock \ + underarmourconnectedfitness/estate:master +``` + +The only requirements that Estate has are: +* The `DATABASE_URL` which leverages the [Django Database URL plugin](https://github.com/kennethreitz/dj-database-url) style confirguration, so if you'd like to use MySQL you can easily +* The Django [SECRET_KEY](https://docs.djangoproject.com/en/1.11/ref/settings/#std:setting-SECRET_KEY) variable +* The docker socket is needed because Estate runs terraform in the context of another other docker container that it spins up on demand - the docker socket requires the docker container to run in privileged mode + +Configuration +------------- + +Estate is a Django application, this means it can have complex configuration, many plugins added to it which add additional features and configuration. As such we've tried to keep the core configuration needed down to just environment variables. That being said we want to make it's configuration as flexiable and pluggable as possible so we've exposed a way to plugin a normal django configuration file as well. + +The main environment variables that Estate will pickup are as follows: + +* **TERRAFORM_DOCKER_IMAGE**: Specify the docker container to use as a context to run terraform in (Default: `underarmourconnectedfitness/estate:master`) +* **TERRAFORM_EXTRA_ARGS**: Extra commandline arguments that will be applied to every terraform command, except for experiment functionality (Default: `-input=false`) +* **TERRAFORM_INIT_EXTRA_ARGS**: Extra commandline arguments that will be applied only the `terraform init` command (Default ``) +* **TERRAFORM_PLAN_EXTRA_ARGS**: Extra commandline arguments that will be applied only to the `terraform plan` command (Default: `-detailed-exitcode -out=plan`) +* **TERRAFORM_APPLY_EXTRA_ARGS**: Extra commandline arguments that will be applied only to the `terraform apply` command (Default: ``) + +The following can only be applied as environment variables + +* **GUNICORN_BIND_ADDRESS**: The network interface to bind to (Default: `0.0.0.0`) +* **GUNICORN_BIND_PORT**: The network port to bind to (Default: `8000`) +* **GUNICORN_WORKER_COUNT**: The amount of gunicorn workers to run (Default: ` * 10 + 1`) +* **GUNICORN_WORKER_CLASS**: See the gunicorn documentation on worker classes for more information (Default: "gevent") +* **GUNICORN_LOG_LEVEL**: See the gunicorn documenation on log levels for more information (Default: "info") + + +As well you can configure a django settings file, which is just pure python, and mount it into the container + +contents of custom.py +``` +from . import INSTALLED_APPS, MIDDLEWARE + +# Add other django apps - IE Sentry +INSTALLED_APPS += [ + 'raven.contrib.django.raven_compat', +] + +MIDDLEWARE = ( + 'raven.contrib.django.raven_compat.middleware.Sentry404CatchMiddleware', +) + MIDDLEWARE + +# Configure estate settings as well +TERRAFORM_INIT_EXTRA_ARGS = "-input=false -backend-config 'access_token=6ae45dff-1272-4v75-8gd7-ad52bd756e66' -backend-config 'scheme=https' -backend-config 'address=consul.example.com' -backend-config 'path=estate/remote_state/{NAMESPACE}'" +``` + +Then mount this file into the container at the path `/usr/local/service/estate/settings/custom.py` +``` +docker run -v ./custom.py:/usr/local/service/estate/settings/custom.py underarmourconnectedfitness/estate:master +``` + +Running as a Cluster +-------------------- + +Estate by default is setup to only run as a single standalone service, but as your team grows you'll likely need to scale it horizontally. This is quite easy with estate it just requries 1 thing - a cache database + +Estate uses a cache database to store the output of the different terraform commands run, by default it stores them on disk inside the container, but when you start to cluster Estate this won't work, so you will need to set up something like redis or memcached and configure the Django [cache framework](https://docs.djangoproject.com/en/1.11/topics/cache/) to store the cache data in the database. + +Sentry Integration +------------------ + +Sentry integration is a first class citizen integration with Estate. There is only one variable you'll need to configure to connect to your sentry cluster + +* **SENTRY_DSN**: You can view the sentry documentation about DSN's [here](https://docs.sentry.io/quickstart/#configure-the-dsn) + +Developing Terraform +-------------------- + +If you wish to hack on Estate, you'll first need to understand its architecture. + +Estate is a single docker container that runs a [Django](https://www.djangoproject.com/) application with [Gunicorn](http://gunicorn.org/) workers. The backend leverages [Django Rest Framework](http://www.django-rest-framework.org/) to design it REST API functionality. The frontend is compiled by [Webpack](https://webpack.github.io/) using a standard single page app design that leverages [React](https://facebook.github.io/react/) + [Redux](http://redux.js.org/). + +Local development has been made a breeze, and long build/compile times have been reduced as much as possible. To get started use [Git](https://git-scm.com/) to clone this repository and run `docker-compose build` from the root of the repository. Once the build has completed you only need to run this command again if you change the Dockerfile itself, from here on out any changes you make to the codebase will be detected and use hot-reloading techniques to update the running application. + +To start up the application just use `docker-compose up` this will spin up a series of containers, as well as Estate itself, and then you can begin editing the code. + +Contributing +------------ + +* The master branch is meant to be stable. I usually work on unstable stuff on a personal branch. +* Fork the master branch ( https://github.com/underarmour/estate/fork ) +* Create your branch (git checkout -b my-branch) +* Commit your changes (git commit -am 'added fixes for something') +* Push to the branch (git push origin my-branch) +* Create a new Pull Request (Travis CI will test your changes) +* And you're done! + +Features, Bug fixes, bug reports and new documentation are all appreciated! +See the github issues page for outstanding things that could be worked on. + diff --git a/TERRAFORM_URL.txt b/TERRAFORM_URL.txt new file mode 100644 index 0000000..2499758 --- /dev/null +++ b/TERRAFORM_URL.txt @@ -0,0 +1 @@ +https://releases.hashicorp.com/terraform/0.9.11/terraform_0.9.11_linux_amd64.zip diff --git a/bootstrapping/README.md b/bootstrapping/README.md new file mode 100644 index 0000000..0152d34 --- /dev/null +++ b/bootstrapping/README.md @@ -0,0 +1,5 @@ +# Terraform Modules + +This folder contains modules for Terraform that can setup Estate for +various systems. The infrastructure provider that is used is designated +by the folder above. See the `variables.tf` file in each for more documentation. diff --git a/bootstrapping/aws/outputs.tf b/bootstrapping/aws/outputs.tf new file mode 100644 index 0000000..6204f43 --- /dev/null +++ b/bootstrapping/aws/outputs.tf @@ -0,0 +1,3 @@ +output "server_address" { + value = "${aws_instance.estate.0.public_dns}" +} diff --git a/bootstrapping/aws/resources.tf b/bootstrapping/aws/resources.tf new file mode 100644 index 0000000..8be2b85 --- /dev/null +++ b/bootstrapping/aws/resources.tf @@ -0,0 +1,150 @@ +resource "aws_vpc" "estate" { + cidr_block = "10.0.0.0/16" + + tags { + Name = "${var.tagName}-VPC" + } +} + +resource "aws_subnet" "estate" { + vpc_id = "${aws_vpc.estate.id}" + cidr_block = "10.0.0.0/24" + availability_zone = "${var.region}a" + + tags { + Name = "${var.tagName}-SUBNET" + } +} + +resource "aws_security_group" "estate" { + name = "estate_sg" + description = "Estate" + vpc_id = "${aws_vpc.estate.id}" + + tags { + Name = "${var.tagName}-SG" + } +} + +resource "aws_security_group_rule" "estate_self" { + type = "ingress" + from_port = 0 + to_port = 0 + protocol = "-1" + self = true + security_group_id = "${aws_security_group.estate.id}" +} + +resource "aws_security_group_rule" "estate_ssh" { + type = "ingress" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + security_group_id = "${aws_security_group.estate.id}" +} + +resource "aws_security_group_rule" "estate_outbound" { + type = "egress" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + security_group_id = "${aws_security_group.estate.id}" +} + +resource "aws_db_subnet_group" "estate" { + name = "estate_rds_sng" + subnet_ids = ["${aws_subnet.estate.id}"] + + tags { + Name = "${var.tagName}-RDS" + } +} + +resource "aws_db_instance" "estate" { + identifier = "estate_db" + allocated_storage = "10" + storage_type = "gp2" + engine = "postgres" + engine_version = "9.5.4" + instance_class = "db.m3.medium" + name = "estate" + username = "${db_user}" + password = "${db_password}" + vpc_security_group_ids = ["${aws_security_group.estate.name}"] + db_subnet_group_name = ${aws_db_subnet_group.estate.id} + skip_final_snapshot = "true" + backup_retention_period = 0 + copy_tags_to_snapshot = "true" + multi_az = "true" + apply_immediately = "true" + maintenance_window = "wed:04:30-wed:05:30" + tags { + Name = "${var.tagName}-RDS" + } +} + +resource "aws_elasticache_parameter_group" "estate" { + name = "estate_parameter_group" + family = "memcached1.4" + +} + +resource "aws_elasticache_subnet_group" "estate" { + name = "estate_elasticache_sng" + description = "Estate" + subnet_ids = ["${aws_subnet.estate.id}"] +} + +resource "aws_elasticache_cluster" "estate" { + cluster_id = "estate" + engine = "memcached1.4" + node_type = "cache.m3.medium" + num_cache_nodes = 2 + port = 11211 + subnet_group_name = "${aws_elasticache_subnet_group}.estate.name" + security_group_ids = ["${aws_security_group.estate.name}"] + parameter_group_name = "${aws_elasticache_parameter_group.estate.name}" + az_mode = "cross-az" + maintenance_window = "wed:04:30-wed:05:30" + tags { + Name = "${var.tagName}-RDS" + } +} + +data "user_data" "estate" { + template = "${file("${path.module}/../shared/scripts/bootstrap.sh")}" + + vars { + db_url = "postgres://${var.db_user}:${var.db_password}@${aws_db_instance.estate.endpoint}/estate" + } +} + +resource "aws_instance" "estate" { + count = "${var.servers}" + ami = "${lookup(var.ami, "${var.region}")}" + instance_type = "${var.instance_type}" + key_name = "${var.key_name}" + security_groups = ["${aws_security_group.estate.name}"] + subnet_id = ["${aws_subnet.estate.id}"] + + ebs_optimized = true + disable_api_termination = true + root_block_device { + volume_type = gp2 + volume_size = "20" + delete_on_termination = true + } + + connection { + user = "${var.user}" + private_key = "${file("${var.key_path}")}" + } + + user_data = "${data.user_data.estate.rendered}" + + tags { + Name = "${var.tagName}-${count.index}" + } +} diff --git a/bootstrapping/aws/variables.tf b/bootstrapping/aws/variables.tf new file mode 100644 index 0000000..2604672 --- /dev/null +++ b/bootstrapping/aws/variables.tf @@ -0,0 +1,54 @@ +variable "user" { + default = "ec2-user" +} + +variables "db_user" { + default = "estate" +} + +variables "db_password" { + default = "toomanysecrets" +} + +variable "ami" { + description = "AWS AMI Id, if you change, make sure it is compatible with instance type, not all AMIs allow all instance types " + + default = { + us-east-1 = "ami-fce3c696" + us-east-2 = "ami-ea87a78f" + us-west-1 = "ami-3a674d5a" + us-west-2 = "ami-aa5ebdd2" + ca-central-1 = "ami-5ac17f3e" + eu-west-1 = "ami-ebd02392" + eu-west-1 = "ami-489f8e2c" + eu-central-1 = "ami-657bd20a" + } +} + +variable "key_name" { + description = "SSH key name in your AWS account for AWS instances." +} + +variable "key_path" { + description = "Path to the private key specified by key_name." +} + +variable "region" { + default = "us-east-1" + description = "The region of AWS, for AMI lookups." +} + +variable "servers" { + default = "3" + description = "The number of Estate servers to launch." +} + +variable "instance_type" { + default = "m3.medium" + description = "AWS Instance type, if you change, make sure it is compatible with AMI, not all AMIs allow all instance types " +} + +variable "tagName" { + default = "estate" + description = "Name tag for the servers" +} diff --git a/bootstrapping/scripts/bootstrap.sh b/bootstrapping/scripts/bootstrap.sh new file mode 100644 index 0000000..d889bfa --- /dev/null +++ b/bootstrapping/scripts/bootstrap.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +set -e + +function s_enable() { + service "$@" start && chkconfig --add "$@" && chkconfig "$@" on +} + +AWSURL=http://169.254.169.254/latest/meta-data +awsget() { + curl -s "$AWSURL/$1" +} + +sudo yum update -y +sudo yum install -y python27 python27-pip docker jq +pip install supervisor + +export + +cat << EOF >> /etc/sysctl.conf +vm.max_map_count = 262144 +fs.file-max = 65536 +EOF +sysctl -p + +cat << EOF > /usr/local/estate.conf +SECRET_KEY=$(awsget ../dynamic/instance-identity/document | jq -r '.accountId') +DATABASE_URL=${db_url} +TERRAFORM_CACHE_URL=${cache_url} +EOF + +cat << EOF > /etc/supervisord2.conf +[supervisord] +logfile=/var/log/supervisor.log +loglevel=info +pidfile=/var/run/supervisord.pid + +[unix_http_server] +file=/var/tmp/supervisor.sock +chmod=0700 + +[supervisorctl] +serverurl=unix:///var/tmp/supervisor.sock + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[program:estate] +command=docker run --privileged --net="host" --env-file "/usr/local/estate.conf" -v /var/run/docker.sock:/var/run/docker.sock underarmourconnectedfitness/estate:master +stopsignal=TERM +autostart=true +autorestart=true +redirect_stderr=true +stdout_logfile=/var/log/estate.log +stdout_logfile_maxbytes=50MB + +EOF + + +cat << 'EOF' > /etc/init.d/supervisor +. /etc/rc.d/init.d/functions + +# Source system settings +if [ -f /etc/sysconfig/supervisord ]; then + . /etc/sysconfig/supervisord +fi + +# Path to the supervisorctl script, server binary, +# and short-form for messages. +supervisorctl=/usr/local/bin/supervisorctl +supervisord=/usr/local/bin/supervisord +prog=supervisord +pidfile=/var/run/supervisord.pid +lockfile=/var/lock/subsys/supervisord +STOP_TIMEOUT=60 +OPTIONS="-c /etc/supervisord.conf" +RETVAL=0 + +start() { + echo -n $"Starting $prog: " + daemon --pidfile=$pidfile $supervisord $OPTIONS + RETVAL=$? + echo + if [ $RETVAL -eq 0 ]; then + touch $lockfile + $supervisorctl $OPTIONS status + fi + return $RETVAL +} + +stop() { + echo -n $"Stopping $prog: " + killproc -p $pidfile -d $STOP_TIMEOUT $supervisord + RETVAL=$? + echo + [ $RETVAL -eq 0 ] && rm -rf $lockfile $pidfile +} + +reload() { + echo -n $"Reloading $prog: " + LSB=1 killproc -p $pidfile $supervisord -HUP + RETVAL=$? + echo + if [ $RETVAL -eq 7 ]; then + failure $"$prog reload" + else + $supervisorctl $OPTIONS status + fi +} + +restart() { + stop + start +} + +case "$1" in + start) + start + ;; + stop) + stop + ;; + status) + status -p $pidfile $supervisord + RETVAL=$? + [ $RETVAL -eq 0 ] && $supervisorctl $OPTIONS status + ;; + restart) + restart + ;; + condrestart|try-restart) + if status -p $pidfile $supervisord >&/dev/null; then + stop + start + fi + ;; + force-reload|reload) + reload + ;; + *) + echo $"Usage: $prog {start|stop|restart|condrestart|try-restart|force-reload|reload}" + RETVAL=2 +esac + +exit $RETVAL +EOF +chmod 755 /etc/init.d/supervisor + +s_enable atd +s_enable cgconfig +s_enable docker +s_enable supervisor diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..92f6e4b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +service: + build: . + dockerfile: Dockerfile + command: local + ports: + - 8000:8000 + - 3000:3000 + volumes: + - $PWD/estate:/usr/local/service/estate + - $PWD/webpack:/usr/local/service/webpack + - $PWD/local.sh:/usr/bin/local + - /var/run/docker.sock:/var/run/docker.sock + - /tmp:/tmp + environment: + - "DEBUG=True" + - "DATABASE_URL=postgres://postgres:estate@postgres:5432/estate" + links: + - postgres:postgres + - consul:consul + - vault:vault + +postgres: + image: postgres:9.5 + environment: + - "POSTGRES_PASSWORD=estate" + - "POSTGRES_DB=estate" + +consul: + image: consul:0.9.0 + ports: + - 8500:8500 + +vault: + image: vault:0.7.3 + environment: + - "VAULT_DEV_ROOT_TOKEN_ID=estate" + - "VAULT_TOKEN=estate" + - "VAULT_ADDR=http://127.0.0.1:8200" + cap_add: + - IPC_LOCK + ports: + - 8200:8200 diff --git a/estate/__init__.py b/estate/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/estate/admin.py b/estate/admin.py new file mode 100644 index 0000000..9687810 --- /dev/null +++ b/estate/admin.py @@ -0,0 +1,9 @@ +from __future__ import absolute_import +from django.contrib import admin + +# Text to put at the end of each page's . +admin.site.site_title = 'Estate' +# Text to put in each page's <h1>. +admin.site.site_header = 'Admin' +# Text to put at the top of the admin index page. +admin.site.index_title = '' diff --git a/estate/assets/js/api/messages.js b/estate/assets/js/api/messages.js new file mode 100644 index 0000000..840bbc7 --- /dev/null +++ b/estate/assets/js/api/messages.js @@ -0,0 +1,58 @@ +/*global dispatch*/ +import { isArray, each } from "lodash" +import Notifications from "react-notification-system-redux" + +var count = 1 + +export function log(level, message, timeout=0) { + dispatch(Notifications.show({ + uid: count++, + message: message, + position: "tl", + autoDismiss: timeout, + dismissible: true, + }, level)) +} + +export function success(message) { + log("success", message, 3) +} + +export function info(message) { + log("info", message, 10) +} + +export function warn(message) { + log("warning", message) +} + +export function error(message) { + log("error", message) +} + +export function handleResponseError(err) { + if (err.response) { + var message = "" + if (isArray(err.response.data.errors)) { + each(err.response.data.errors, (item) => { + message += item.detail + "\n" + }) + } else { + message += err.response.data.errors.detail + } + dispatch(Notifications.show({ + uid: count++, + title: `[${err.response.data.status_code}] ${err.response.data.status_text}`, + message: message, + position: "tl", + autoDismiss: 0, + dismissible: true, + }, "error")) + } else { + if (!err.stack) { + error("[" + err.name + "] " + err.message) + } else { + error(err.stack.split("\n")[0] + " " + err.stack.split("\n")[1]) + } + } +} diff --git a/estate/assets/js/api/terraform.js b/estate/assets/js/api/terraform.js new file mode 100644 index 0000000..4fd37d0 --- /dev/null +++ b/estate/assets/js/api/terraform.js @@ -0,0 +1,263 @@ +/*global dispatch*/ +import axios from "axios" +import * as messages from "./messages" + +export function getNamespaces(page, pagesize, search) { + dispatch({ type: "LOADING_NAMESPACES"}) + const req = axios.get(`/api/terraform/namespace/?page=${page}&page_size=${pagesize}&search=${search}`) + req.then((res) => { + dispatch({ + type: "LIST_NAMESPACES", + payload: { + namespaces: res.data, + namespacesPages: res.headers.pages, + namespacesPage: res.headers.currentpage, + } + }) + dispatch({ type: "LOADING_NAMESPACES_DONE" }) + }, messages.handleResponseError) + return req +} + +export function getNamespace(slug) { + dispatch({ type: "LOADING_NAMESPACES"}) + const req = axios.get(`/api/terraform/namespace/?slug=${slug}`) + req.then((res) => { + if (res.data.length > 0){ + dispatch({ + type: "UPDATE_NAMESPACE", + payload: res.data[0] + }) + } else { + messages.error(dispatch, "[Internal Error] Unable to find a namespace for " + slug) + } + dispatch({ type: "LOADING_NAMESPACES_DONE" }) + }, messages.handleResponseError) + return req +} + +export function createNamespace(payload) { + const req = axios.post("/api/terraform/namespace/", payload) + req.then((res) => { + dispatch({ + type: "UPDATE_NAMESPACE", + payload: res.data + }) + }, messages.handleResponseError) + return req +} + +export function updateNamespace(id, payload) { + const req = axios.patch(`/api/terraform/namespace/${id}/`, payload) + req.then((res) => { + dispatch({ + type: "UPDATE_NAMESPACE", + payload: res.data + }) + messages.success(`Successfully Saved Namespace '${res.data.title}'`) + }, messages.handleResponseError) + return req +} + +export function deleteNamespace(id) { + const req = axios.delete(`/api/terraform/namespace/${id}/`) + req.then(() => { + dispatch({ + type: "DELETE_NAMESPACE", + payload: id + }) + }, messages.handleResponseError) + return req +} + +export function addFileToNamespace(payload) { + const req = axios.post("/api/terraform/file/", payload) + req.then(() => { + getNamespace(payload.namespace) + }, messages.handleResponseError) + return req +} + +export function updateFile(id, payload) { + const req = axios.patch(`/api/terraform/file/${id}/`, payload) + req.then((res) => { + getNamespace(res.data.namespace) + }, messages.handleResponseError) + return req +} + +export function removeFileFromNamespace(slug, id) { + const req = axios.delete(`/api/terraform/file/${id}/`) + req.then(() => { + getNamespace(slug) + }, messages.handleResponseError) + return req +} + +export function addTemplateToNamespace(payload) { + const req = axios.post("/api/terraform/templateinstance/", payload) + req.then(() => { + getNamespace(payload.namespace) + }, messages.handleResponseError) + return req +} + +export function updateTemplateInstance(id, payload) { + const req = axios.patch(`/api/terraform/templateinstance/${id}/`, payload) + req.then((res) => { + getNamespace(res.data.namespace) + }, messages.handleResponseError) + return req +} + +export function updateTemplateOfTemplateInstance(id) { + const req = axios.post(`/api/terraform/templateinstance/${id}/update_template/`) + req.then((res) => { + getNamespace(res.data.namespace) + }, messages.handleResponseError) + return req +} + +export function removeTemplateFromNamespace(slug, id) { + const req = axios.delete(`/api/terraform/templateinstance/${id}/`) + req.then(() => { + getNamespace(slug) + }, messages.handleResponseError) + return req +} + +export function getPlanForNamespace(id) { + const req = axios.get(`/api/terraform/namespace/${id}/plan_live/`) + req.then((res) => { + dispatch({ + type: "PLAN_NAMESPACE", + payload: res.data + }) + }, messages.handleResponseError) + return req +} + +export function doPlanForNamespace(id) { + dispatch({type: "CLEAR_PLAN_NAMESPACE"}) + let loopId = setInterval(() => {getPlanForNamespace(id)}, 1000) + const req = axios.post(`/api/terraform/namespace/${id}/plan/`) + req.then((res) => { + clearInterval(loopId) + dispatch({ + type: "PLAN_NAMESPACE", + payload: res.data + }) + }, (err) => { + clearInterval(loopId) + messages.handleResponseError(err) + }) + return req +} + +export function getApplyForNamespace(id) { + const req = axios.get(`/api/terraform/namespace/${id}/apply_live/`) + req.then((res) => { + dispatch({ + type: "APPLY_NAMESPACE", + payload: res.data + }) + }, messages.handleResponseError) + return req +} + +export function doApplyForNamespace(id, plan_hash) { + dispatch({type: "CLEAR_APPLY_NAMESPACE"}) + let loopId = setInterval(() => {getApplyForNamespace(id)}, 1000) + const req = axios.post(`/api/terraform/namespace/${id}/apply/${plan_hash}/`) + req.then((res) => { + clearInterval(loopId) + dispatch({ + type: "APPLY_NAMESPACE", + payload: res.data + }) + }, (err) => { + clearInterval(loopId) + messages.handleResponseError(err) + }) + return req +} + +export function getTemplates(page, pagesize, search) { + dispatch({ type: "LOADING_TEMPLATES"}) + const req = axios.get(`/api/terraform/template/?page=${page}&page_size=${pagesize}&search=${search}`) + req.then((res) => { + dispatch({ + type: "LIST_TEMPLATES", + payload: { + templates: res.data, + templatesPages: res.headers.pages, + templatesPage: res.headers.currentpage, + } + }) + dispatch({ type: "LOADING_TEMPLATES_DONE" }) + }, messages.handleResponseError) + return req +} + +export function getTemplate(slug) { + dispatch({ type: "LOADING_TEMPLATES"}) + const req = axios.get(`/api/terraform/template/?slug=${slug}`) + req.then((res) => { + if (res.data.length > 0){ + dispatch({ + type: "UPDATE_TEMPLATE", + payload: res.data[0] + }) + } else { + messages.error("[Internal Error] Unable to find a template for " + slug) + } + dispatch({ type: "LOADING_TEMPLATES_DONE" }) + }, messages.handleResponseError) + return req +} + +export function createTemplate(payload) { + const req = axios.post("/api/terraform/template/", payload) + req.then((res) => { + dispatch({ + type: "UPDATE_TEMPLATE", + payload: res.data + }) + }, messages.handleResponseError) + return req +} + +export function updateTemplate(id, payload) { + const req = axios.patch(`/api/terraform/template/${id}/`, payload) + req.then((res) => { + dispatch({ + type: "UPDATE_TEMPLATE", + payload: res.data + }) + messages.success(`Successfully Saved Template '${res.data.title}' to version '${res.data.version}'`) + }, messages.handleResponseError) + return req +} + +export function renderTemplate(payload) { + const req = axios.post("/api/terraform/template/render/", payload) + req.then((res) => { + dispatch({ + type: "RENDER_TEMPLATE", + payload: res.data + }) + }, messages.handleResponseError) + return req +} + +export function deleteTemplate(id) { + const req = axios.delete(`/api/terraform/template/${id}/`) + req.then(() => { + dispatch({ + type: "DELETE_TEMPLATE", + payload: id + }) + }, messages.handleResponseError) + return req +} + diff --git a/estate/assets/js/components/App.jsx b/estate/assets/js/components/App.jsx new file mode 100644 index 0000000..94feee2 --- /dev/null +++ b/estate/assets/js/components/App.jsx @@ -0,0 +1,26 @@ +import React from "react" +import { Route } from "react-router-dom" +import Nav from "./Nav" +import Messages from "./Messages" +import Home from "./Home" +import TerraformRoutes from "./TerraformRoutes" + + +export default class App extends React.Component { + render () { + return ( + <div> + <Nav /> + <Messages /> + <div className="container-fluid"> + <div className="row"> + <Route exact path="/" component={Home} /> + <Route path="/terraform/" component={TerraformRoutes} /> + </div> + </div> + </div> + ) + } +} + + diff --git a/estate/assets/js/components/ChoiceModal.jsx b/estate/assets/js/components/ChoiceModal.jsx new file mode 100644 index 0000000..b5df9d7 --- /dev/null +++ b/estate/assets/js/components/ChoiceModal.jsx @@ -0,0 +1,95 @@ +import React from "react" +import Select, {Option} from "rc-select" +import "rc-select/assets/index.css" +import { get, map, merge } from "lodash" +import Modal from "./Modal" + +/* +<ChoiceModal choices=[{label: "label", value: "value", disabled: false}] + defaultValue="" + callback=(data) => {} + + // optional + disabled={false} + className="" + buttonText="" + titleText="" +/> +*/ +var count = 1 + +export default class ChoiceModal extends React.Component { + constructor(props, context) { + super(props, context) + this.state = { + value: props.defaultValue, + changed: false, + } + } + componentWillReceiveProps(nextProps) { + this.state = { + value: nextProps.defaultValue, + } + } + onValueChange(value) { + this.setState({ + value: value, + changed: (this.props.defaultValue != value) + }) + } + getResult() { + return { + value: this.state.value || false, + } + } + performSubmit() { + const payload = merge(this.getResult(), {changed: this.state.changed}) + this.props.callback(payload) + } + createOption(item) { + return ( + <Option + key={count++} + value={item.value} + text={item.label} + disabled={get(item, "disabled", false)} + > + {item.label} + </Option> + ) + } + createSelect() { + return ( + <Select + style={{ width: "100%" }} + dropdownMenuStyle={{ maxHeight: 300, overflow: "auto" }} + defaultActiveFirstOption={false} + defaultValue={this.props.defaultValue} + onChange={this.onValueChange.bind(this)} + optionLabelProp="text" + optionFilterProp="text" + > + {map(this.props.choices, this.createOption)} + </Select> + ) + } + render() { + return ( + <Modal + className={this.props.className} + buttonText={this.props.buttonText} + titleText={this.props.titleText} + tooltipText={this.props.tooltipText} + tooltipDelay={this.props.tooltipDelay} + disabled={this.props.disabled} + getResult={this.getResult.bind(this)} + performSubmit={this.performSubmit.bind(this)} + > + <div className="form-group"> + <label>Value</label> + { this.createSelect() } + </div> + </Modal> + ) + } +} diff --git a/estate/assets/js/components/ConfirmModal.jsx b/estate/assets/js/components/ConfirmModal.jsx new file mode 100644 index 0000000..82cf959 --- /dev/null +++ b/estate/assets/js/components/ConfirmModal.jsx @@ -0,0 +1,24 @@ +import React from "react" +import Modal from "./Modal" + +export default class ConfirmModal extends React.Component { + getResult() { + return {} + } + render() { + return( + <Modal + className={this.props.className} + buttonText={this.props.buttonText} + titleText={this.props.titleText} + tooltipText={this.props.tooltipText} + tooltipDelay={this.props.tooltipDelay} + disabled={this.props.disabled} + getResult={this.getResult.bind(this)} + performSubmit={this.props.callback} + > + <p>{this.props.message || "Are you sure?"}</p> + </Modal> + ) + } +} diff --git a/estate/assets/js/components/DashboardListView.jsx b/estate/assets/js/components/DashboardListView.jsx new file mode 100644 index 0000000..bb41b5e --- /dev/null +++ b/estate/assets/js/components/DashboardListView.jsx @@ -0,0 +1,118 @@ +import React from "react" +import { Link } from "react-router-dom" +import { each } from "lodash" +import urljoin from "url-join" +import ReactTable from "react-table" +import "react-table/react-table.css" +import Modal from "./Modal" + +class CreateObjectModal extends React.Component { + constructor(props, context) { + super(props, context) + this.state = { + title: false, + } + } + onTitleChange(value) { + this.setState({ title: value.target.value}) + } + getResult() { + return { + title: this.state.title || false, + dependencies: [], + version_increment: "initial", + } + } + performSubmit() { + this.props.createObject(this.getResult()) + } + render() { + return( + <Modal className="btn btn-default col-xs-12" buttonText="Create..." titleText={`Create New ${this.props.objectNiceName}`} getResult={this.getResult.bind(this)} performSubmit={this.performSubmit.bind(this)}> + <div className="form-group"> + <label>Title</label> + <input type="text" className="form-control" onChange={this.onTitleChange.bind(this)} /> + </div> + </Modal> + ) + } +} + +/** + * A generic list component, used to generate the Nomad/Terraform template and + * namespace list views. + * + * objectNiceName: a human-readable name for the object this is a list of + * tableColumns: an array of react-table columns + * dataLoading: + * data: the actual data to be displayed, e.g. an array of templates + * page: the current page + * pages: an array of pages + * getData: a function to populate the data + * createObject: a function to call on object creation + */ +class DashboardListView extends React.Component { + constructor(props, context) { + super(props, context) + this.state = { + pageSize: 10, + } + } + componentWillReceiveProps(nextProps) { + if (nextProps.search != this.props.search){ + this.props.getData(1, this.state.pageSize, nextProps.search) + } + } + componentWillMount() { + if (!this.props.dataLoading) { + this.props.getData(1, this.state.pageSize, this.props.search) + } + } + onPageChange(pageIndex) { + // ReactTable used a 0 based page index while DRF does not + this.props.getData(pageIndex + 1, this.state.pageSize, this.props.search) + } + onPageSizeChange(pageSize, pageIndex) { + // ReactTable used a 0 based page index while DRF does not + this.props.getData(pageIndex + 1, pageSize, this.props.search) + this.setState({pageSize: pageSize}) + } + data() { + let data = [] + each(this.props.data, (item) => { + if (item) { + item.link = <Link className="btn btn-default btn-xs glyphicon glyphicon-pencil" to={ urljoin(this.props.match.url, item.slug) } /> + item.modified = new Date(Date.parse(item.modified)).toLocaleString() + data.push(item) + } + }) + return data + } + render () { + return ( + <div> + <div className="col-xs-2 sidebar"> + <ul className="nav nav-sidebar"> + <li><CreateObjectModal createObject={this.props.createObject} objectNiceName={this.props.objectNiceName}/></li> + </ul> + </div> + <div className="col-xs-10 col-xs-offset-2 main"> + <ReactTable + manual + data={this.data()} + // ReactTable used a 0 based page index + page={this.props.dataPage - 1} + pages={this.props.dataPages} + pageSize={this.state.pageSize} + columns={this.props.tableColumns} + className={"-striped -highlight"} + onPageChange={this.onPageChange.bind(this)} + onPageSizeChange={this.onPageSizeChange.bind(this)} + /> + </div> + </div> + ) + } +} + +export default DashboardListView diff --git a/estate/assets/js/components/Editor.jsx b/estate/assets/js/components/Editor.jsx new file mode 100644 index 0000000..c96fb92 --- /dev/null +++ b/estate/assets/js/components/Editor.jsx @@ -0,0 +1,102 @@ +import React from "react" +import { assign, cloneDeep } from "lodash" +import CodeMirror from "react-codemirror" +import "codemirror/lib/codemirror.css" +import "codemirror/mode/yaml/yaml.js" +import "codemirror/mode/javascript/javascript.js" +import "codemirror/mode/markdown/markdown.js" +import "codemirror/mode/jinja2/jinja2.js" +import "codemirror/mode/go/go.js" +import "codemirror/mode/shell/shell.js" +import "codemirror/keymap/sublime.js" +import "codemirror/addon/fold/foldcode.js" +import "codemirror/addon/fold/foldgutter.js" +import "codemirror/addon/fold/foldgutter.css" +import "codemirror/addon/fold/indent-fold.js" +import "codemirror/addon/fold/comment-fold.js" +import "codemirror/addon/fold/brace-fold.js" +import "codemirror/addon/lint/lint.js" +import "codemirror/addon/lint/lint.css" +import "codemirror/addon/lint/javascript-lint.js" +import "codemirror/addon/lint/json-lint.js" +import "codemirror/addon/lint/yaml-lint.js" + + +const DefaultCodeMirrorOptions = { + lineWrapping: true, + lineNumbers: true, + matchBrackets: true, + foldGutter: true, + keyMap: "sublime", + mode: { + name: "javascript", + json: true, + statementIndent: 2, + }, + indentWithTabs: false, + tabSize: 2, + gutters: ["CodeMirror-foldgutter", "CodeMirror-linenumbers", "CodeMirror-lint-markers"], + lint: true, +} + +// Sometimes data coming in has newline chars that textarea's don't respect +// thusly it converts them - so we effectively do the conversion first +// so that we can compare properly if the content has changed +var re=/\r\n|\n\r|\n|\r/g + +var count = 0 + +export default class Editor extends React.Component { + constructor(props, context) { + super(props, context) + this.state = this.prepareContent.bind(this)(props) + this.state.changed = false + } + componentWillReceiveProps(nextProps) { + this.setState(this.prepareContent.bind(this)(nextProps)) + } + prepareContent(props) { + const currentContent = props.content.replace(re,"\n") + var initialContent = cloneDeep(currentContent) + if (props.initialContent) + initialContent = props.initialContent.replace(re,"\n") + return { + initialContent: initialContent, + currentContent: currentContent, + } + } + updateContent(value) { + const changed = (this.state.initialContent != value) + const data = { + currentContent: value, + changed: changed + } + this.setState(data) + if (this.props.onUpdateContent) { + this.props.onUpdateContent(data) + } + } + render() { + var id = `collapse_${count++}` + var options = assign({}, DefaultCodeMirrorOptions, this.props.options || {}) + return ( + <div className="panel-group"> + <div className="panel panel-default"> + <div className="panel-heading" style={{ minHeight: "40px" }}> + <h3 className="panel-title"> + <div id="accordion" className="pull-right" data-toggle="collapse" data-target={"#" + id} style={{marginLeft: "20px"}}/> + {this.props.title || " "} + </h3> + </div> + <div id={id} className="panel-collapse collapse in"> + <div className="panel-body" style={{ padding: "0px" }}> + <div style={{border: "solid", borderWidth: "1px", clear: "left"}}> + <CodeMirror ref="editor" defaultValue={this.state.currentContent} value={this.state.currentContent} onChange={this.updateContent.bind(this)} options={options} autoFocus={this.props.autoFocus} /> + </div> + </div> + </div> + </div> + </div> + ) + } +} diff --git a/estate/assets/js/components/Home.jsx b/estate/assets/js/components/Home.jsx new file mode 100644 index 0000000..ac91a2a --- /dev/null +++ b/estate/assets/js/components/Home.jsx @@ -0,0 +1,12 @@ +import React from "react" + + +export default class Home extends React.Component { + render () { + return ( + <div> + <h2>Welcome</h2> + </div> + ) + } +} diff --git a/estate/assets/js/components/Messages.jsx b/estate/assets/js/components/Messages.jsx new file mode 100644 index 0000000..072e0ab --- /dev/null +++ b/estate/assets/js/components/Messages.jsx @@ -0,0 +1,20 @@ +import React from "react" +import { connect } from "react-redux" +import Notifications from "react-notification-system-redux" + + +class Messages extends React.Component { + render() { + return ( + <Notifications notifications={this.props.notifications}/> + ) + } +} + +function mapStateToProps(state) { + return { + notifications: state.notifications, + } +} + +export default connect(mapStateToProps)(Messages) diff --git a/estate/assets/js/components/Modal.jsx b/estate/assets/js/components/Modal.jsx new file mode 100644 index 0000000..007050d --- /dev/null +++ b/estate/assets/js/components/Modal.jsx @@ -0,0 +1,87 @@ +import React from "react" +import RModal from "react-modal" +import ReactTooltip from "react-tooltip" +import { includes, values } from "lodash" + +let count = 1 + +const customStyles = { + content : { + position: "fixed", + left: "5%", + top: "5%", + width: "90%", + height: "90%", + outline: "none", + overflow: "auto", /* Enable scroll if needed */ + }, + overlay: {zIndex: 99} +} + +class Modal extends React.Component { + constructor (props, context) { + super(props, context) + this.state = { } + } + isSaveValid() { + let result = this.props.getResult() + return (!includes(values(result), false)) + } + submit() { + if (this.isSaveValid()){ + this.props.performSubmit() + } + this.closeModal() + } + openModal() { + if (!this.props.disabled) + this.setState({modalIsOpen: true}) + } + closeModal() { + this.setState({modalIsOpen: false}) + } + render() { + let classNames = this.props.className || "btn btn-default" + let buttonText = this.props.buttonText || "Open Modal" + let titleText = this.props.titleText || "Modal" + let tooltipId = `modal_button_tooltip_${count++}` + let tooltipDelay = typeof(this.props.tooltipDelay) === "undefined" ? 0 : this.props.tooltipDelay + let tooltipText = this.props.tooltipText || null + return ( + <div style={{display: "inline"}}> + <div data-for={tooltipId} data-tip={tooltipText} style={{cursor: "pointer"}} className={classNames} onClick={this.openModal.bind(this)} disabled={this.props.disabled}> + {buttonText} + </div> + <ReactTooltip id={tooltipId} className="ReactTooltipHoverDelay" delayHide={tooltipDelay} effect='solid'/> + <RModal + className="Modal__Bootstrap modal-dialog" + isOpen={this.state.modalIsOpen} + onRequestClose={this.closeModal.bind(this)} + style={customStyles} + contentLabel={titleText} + > + <div className="modal-content"> + <div className="modal-header"> + <button type="button" className="close" onClick={this.closeModal.bind(this)}> + <span aria-hidden="true">×</span> + <span className="sr-only">Close</span> + </button> + <h2 className="modal-title">{titleText}</h2> + </div> + <form className="modal-form" onSubmit={this.submit.bind(this)}> + <div className="modal-body" style={{overflow: "auto"}}> + { this.props.children } + </div> + <div className="modal-footer"> + <button className="btn btn-default" type="button" onClick={this.closeModal.bind(this)} >Close</button> + <button className={"btn btn-primary"} disabled={!this.isSaveValid()} type="submit" >Submit</button> + </div> + </form> + </div> + </RModal> + </div> + ) + } +} + +export default Modal diff --git a/estate/assets/js/components/Nav.jsx b/estate/assets/js/components/Nav.jsx new file mode 100644 index 0000000..1286c00 --- /dev/null +++ b/estate/assets/js/components/Nav.jsx @@ -0,0 +1,39 @@ +import React from "react" +import { NavLink, Link } from "react-router-dom" +import Search from "./Search" + +export default class Nav extends React.Component { + render () { + return ( + <nav className="navbar navbar-inverse navbar-fixed-top"> + <div className="navbar-header"> + <button type="button" className="navbar-toggle collapsed" data-toggle="collapse" data-target="#navbar" aria-expanded="false" aria-controls="navbar"> + <span className="sr-only">Toggle navigation</span> + <span className="icon-bar"></span> + <span className="icon-bar"></span> + <span className="icon-bar"></span> + </button> + <NavLink className="navbar-brand" to="/"> Estate </NavLink> + </div> + <div id="navbar" className="collapse navbar-collapse"> + <ul className="nav navbar-nav"> + <li className="dropdown"> + <a href="#" className="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Terraform <span className="caret"></span></a> + <ul className="dropdown-menu"> + <li><Link className="item" to="/terraform/templates">Templates</Link></li> + <li><Link className="item" to="/terraform/namespaces">Namespaces</Link></li> + <li role="separator" className="divider"></li> + <li><a href="https://www.terraform.io/docs/index.html">Documentation</a></li> + </ul> + </li> + <li><a href="/api/"> API Docs </a></li> + <li><a href="/admin/"> Administration </a></li> + </ul> + <div className="navbar-form navbar-right" style={{marginRight: "20px"}}> + <Search /> + </div> + </div> + </nav> + ) + } +} diff --git a/estate/assets/js/components/Search.jsx b/estate/assets/js/components/Search.jsx new file mode 100644 index 0000000..6ab1ed5 --- /dev/null +++ b/estate/assets/js/components/Search.jsx @@ -0,0 +1,37 @@ +import React from "react" +import { connect } from "react-redux" + + +class Search extends React.Component { + onChange(value) { + this.props.setSearchText(value.target.value) + } + render() { + return ( + <input + type="text" + className="form-control" + placeholder="Search..." + value={this.props.search_text} + onChange={this.onChange.bind(this)} + /> + ) + } +} + +const mapStateToProps = (state) => { + return { + search_text: state.search.text, + } +} +const mapDispatchToProps = (dispatch) => { + return { + setSearchText: (value) => { + dispatch({ + type: "SET_SEARCH", + payload: value + }) + }, + } +} +export default connect(mapStateToProps, mapDispatchToProps)(Search) diff --git a/estate/assets/js/components/TerraformNamespaceAddFileModal.jsx b/estate/assets/js/components/TerraformNamespaceAddFileModal.jsx new file mode 100644 index 0000000..c272712 --- /dev/null +++ b/estate/assets/js/components/TerraformNamespaceAddFileModal.jsx @@ -0,0 +1,127 @@ +import React from "react" +import { connect } from "react-redux" +import Select, {Option} from "rc-select" +import "rc-select/assets/index.css" +import { get, map, orderBy } from "lodash" +import urljoin from "url-join" +import Modal from "./Modal" +import * as api from "../api/terraform" + +class TerraformNamespaceAddFileModal extends React.Component { + constructor(props, context) { + super(props, context) + this.state = { + templateID: false, + templateName: false, + } + } + componentWillMount() { + this.props.getAllTemplates() + } + onTypeChange(value) { + this.setState({ + templateID: value, + }) + } + onNameChange(value) { + this.setState({ + templateName: value.target.value, + }) + } + getResult() { + return { + templateID: this.state.templateID || false, + templateName: this.state.templateName || false, + } + } + performSubmit() { + if (this.state.templateID == "-1") { + this.props.addFileToNamespace({ + namespace: this.props.namespace.slug, + title: this.state.templateName, + }) + } else { + this.props.addTemplateToNamespace({ + namespace: this.props.namespace.slug, + title: this.state.templateName, + templateID: this.state.templateID, + inputs: {}, + overrides: "", + }) + } + } + render() { + var count = 0 + return ( + <Modal + className={this.props.className} + buttonText={this.props.buttonText} + titleText={this.props.titleText} + tooltipText="Add file or template to namespace" + disabled={this.props.disabled} + getResult={this.getResult.bind(this)} + performSubmit={this.performSubmit.bind(this)} + > + <div className="form-group"> + <label>Type</label> + <Select + style={{ width: "100%" }} + dropdownMenuStyle={{ maxHeight: 300, overflow: "auto" }} + defaultActiveFirstOption={false} + defaultValue={this.props.defaultValue} + onChange={this.onTypeChange.bind(this)} + optionLabelProp="display" + optionFilterProp="text" + > + <Option + key={count++} + value="-1" + display="Regular File" + text="Regular File regular file" + > + <b>Regular File</b> + </Option> + {map(this.props.templates, (item) => { + return (<Option key={count++} value={item.pk.toString()} display={item.title + " - " + item.version} text={item.title + item.title.toLowerCase()} disabled={get(item, "disabled", false)}>{item.title} - {item.version}</Option>) + })} + </Select> + </div> + <div className="form-group"> + <label>Filename</label> + <input + type="text" + className="form-control" + onChange={this.onNameChange.bind(this)} + /> + </div> + </Modal> + ) + } +} +let mapStateToProps = (state) => { + return { + templates: orderBy(state.terraform.templates, ["slug", "title"], ["asc", "asc"]), + } +} + +let mapDispatchToProps = (dispatch, ownProps) => { + return { + getAllTemplates: () => { + // TODO: is this pagesize hack ok? + api.getTemplates(1, 10000000000, "") + }, + addFileToNamespace: (payload) => { + var req = api.addFileToNamespace(payload) + req.then((res) => { + ownProps.history.push( urljoin(ownProps.url, "/file/", res.data.slug, "/") ) + }) + }, + addTemplateToNamespace: (payload) => { + var req = api.addTemplateToNamespace(payload) + req.then((res) => { + ownProps.history.push( urljoin(ownProps.url, "/template/", res.data.slug, "/") ) + }) + } + } +} +export default connect(mapStateToProps, mapDispatchToProps)(TerraformNamespaceAddFileModal) diff --git a/estate/assets/js/components/TerraformNamespaceItem.jsx b/estate/assets/js/components/TerraformNamespaceItem.jsx new file mode 100644 index 0000000..15a177a --- /dev/null +++ b/estate/assets/js/components/TerraformNamespaceItem.jsx @@ -0,0 +1,542 @@ +import React from "react" +import { connect } from "react-redux" +import { Route, NavLink } from "react-router-dom" +import { includes, find, findIndex, each, cloneDeep, isEqual, join } from "lodash" +import urljoin from "url-join" +import ReactTooltip from "react-tooltip" +import Ansi from "ansi-to-react" +import Editor from "./Editor" +import ConfirmModal from "./ConfirmModal" +import TerraformNamespaceAddFileModal from "./TerraformNamespaceAddFileModal" +import TerraformTemplateRenderer from "./TerraformTemplateRenderer" +import * as terraform from "../api/terraform" +import * as messages from "../api/messages" + + + +class TerraformNamespaceItem extends React.Component { + constructor(props, context) { + super(props, context) + this.isSaving = false + this.fileToRemove = null + this.templateToRemove = null + this.state = { + files: [], + templates: [], + } + } + componentWillMount() { + this.props.getNamespace() + } + componentWillReceiveProps(nextProps) { + if (nextProps.namespace && !this.isSaving && !isEqual(this.props.namespace, nextProps.namespace)){ + const namespace = nextProps.namespace + nextProps.getPlan(namespace.pk) + nextProps.getApply(namespace.pk) + this.mergeFiles(namespace) + this.mergeTemplates(namespace) + } + } + hasChanged() { + var changes = [] + each(this.state.files, (item) => { + changes.push(item.changed) + }) + each(this.state.templates, (item) => { + changes.push(item.changed) + }) + return (includes(changes, true)) + } + mergeFiles(namespace) { + each(namespace.files, (item) => { + item.nextContent = item.content + item.nextDisable = item.disable + item.changed = false + }) + each(this.state.files, (item) => { + var index = findIndex(namespace.files, {"pk": item.pk}) + if (index != -1){ + let file = namespace.files[index] + file.nextContent = item.nextContent + file.nextDisable = item.nextDisable + file.changed = item.changed + } else { + namespace.files.push(item) + } + }) + if (this.fileToRemove) { + var index = findIndex(namespace.files, {"pk": this.fileToRemove}) + if (index != -1){ + namespace.files.pop(index) + } + this.fileToRemove = null + } + this.setState({ + files: namespace.files, + }) + } + mergeTemplates(namespace) { + each(namespace.templates, (item) => { + item.nextInputs = item.inputs + item.nextOverrides = item.overrides + item.nextDisable = item.disable + item.changed = false + }) + each(this.state.templates, (item) => { + var index = findIndex(namespace.templates, {"pk": item.pk}) + if (index != -1){ + let template = namespace.templates[index] + template.nextInputs = item.nextInputs + template.nextOverrides = item.nextOverrides + template.nextDisable = item.nextDisable + template.changed = item.changed + } else { + namespace.templates.push(item) + } + }) + if (this.templateToRemove) { + var index = findIndex(namespace.templates, {"pk": this.templateToRemove}) + if (index != -1){ + namespace.templates.pop(index) + } + this.templateToRemove = null + } + this.setState({ + templates: namespace.templates, + }) + } + hasFileChanged(file) { + return (!isEqual(file.content, file.nextContent) || !isEqual(file.disable, file.nextDisable)) + } + onFileChange(index, data) { + var files = cloneDeep(this.state.files) + files[index].nextContent = data.currentContent + files[index].changed = this.hasFileChanged(files[index]) + this.setState({ + files: files, + }) + } + onFileToggleDisable(index, value) { + var files = cloneDeep(this.state.files) + files[index].nextDisable = value + files[index].changed = this.hasFileChanged(files[index]) + this.setState({ + files: files, + }) + } + hasTemplateInstanceChanged(templateInstance) { + return (!isEqual(templateInstance.inputs, templateInstance.nextInputs) || !isEqual(templateInstance.overrides, templateInstance.nextOverrides) || !isEqual(templateInstance.disable, templateInstance.nextDisable)) + } + onInputsChange(index, data) { + var templates = cloneDeep(this.state.templates) + templates[index].nextInputs = data.formData + templates[index].changed = this.hasTemplateInstanceChanged(templates[index]) + this.setState({ + templates: templates, + }) + } + onOverridesChange(index, data) { + var templates = cloneDeep(this.state.templates) + templates[index].nextOverrides = data.currentContent + templates[index].changed = this.hasTemplateInstanceChanged(templates[index]) + this.setState({ + templates: templates, + }) + } + onTemplateToggleDisable(index, value) { + var templates = cloneDeep(this.state.templates) + templates[index].nextDisable = value + templates[index].changed = this.hasTemplateInstanceChanged(templates[index]) + this.setState({ + templates: templates, + }) + } + saveNamespace() { + this.isSaving = true + each(this.state.files, (item) => { + if (item.changed) { + item.content = item.nextContent + item.disable = item.nextDisable + item.changed = false + this.props.updateFile(item.pk, item) + } + }) + each(this.state.templates, (item) => { + if (item.changed){ + item.inputs = item.nextInputs + item.overrides = item.nextOverrides + item.disable = item.nextDisable + item.changed = false + this.props.updateTemplateInstance(item.pk, item) + } + }) + this.props.updateNamespace( + this.props.namespace.pk, + { + title: this.props.namespace.title, + description: "", + } + ) + this.isSaving = false + } + updateFile() { + var file = this.props.namespace.files[this.props.fileIndex] + this.props.updateFile( + file.pk, + { + title: file.title, + namespace: file.namespace, + content: this.state.content, + } + ) + } + removeFileFromNamespace(id) { + this.fileToRemove = id + this.props.removeFileFromNamespace(id) + } + removeTemplateFromNamespace(id) { + this.templateToRemove = id + this.props.removeTemplateFromNamespace(id) + } + planNamespace() { + if (this.hasChanged()) { + messages.warn("There are unsaved changes! Please save or revert to perform a 'plan'.") + } else { + this.props.planNamespace(this.props.namespace.pk) + } + } + applyNamespace() { + if (this.hasChanged()) { + messages.warn("There are unsaved changes! Please save or revert to perform an 'apply'.") + } else { + if (typeof this.props.planOutput.plan_hash === "undefined") { + messages.warn("There was no previous 'plan' found! Please 'plan' before you perform an 'apply'.") + } else { + this.props.applyNamespace(this.props.namespace.pk, this.props.planOutput.plan_hash) + } + } + } + createFilePane(props) { + var index = findIndex(this.state.files, {slug: props.match.params.file}) + if (index == -1){ + return null + } + var file = this.state.files[index] + return ( + <div className="col-xs-12"> + <h1 className="page-header"> + {file.title} + <div className="pull-right"> + { file.nextDisable ? + <div className="btn btn-success btn-inline" onClick={this.onFileToggleDisable.bind(this, index, false)}>Enable</div> + : + <div className="btn btn-danger btn-inline" onClick={this.onFileToggleDisable.bind(this, index, true)}>Disable</div> + } + </div> + </h1> + <Editor className="editor editor-lg" options={{ mode: "go"}} content={file.nextContent} initialContent={file.content} onUpdateContent={this.onFileChange.bind(this, index)} /> + </div> + ) + } + createFileList() { + var url = this.props.match.url + var count = 0 + var elements = [] + if (this.state.files.length > 0){ + elements.push(( + <div key={`file${count++}`} className="nav-sidebar-header"> + <span>Files</span> + </div> + )) + } + each(this.state.files, (item) =>{ + elements.push(( + <li key={`file${count++}`}> + <NavLink className="col-xs-9" to={ urljoin(url, "/file/", item.slug) } activeStyle={{fontWeight: "bold", color: "white", backgroundColor: "#337ab7"}}> + {item.title} + </NavLink> + <div className="pull-right"> + <div data-for={`file_changed_${count}`} data-place="left" data-tip="Changed" className={"glyphicon" + (item.changed ? " glyphicon-exclamation-sign" : "")} /> + <ReactTooltip id={`file_changed_${count}`} className="ReactTooltipHoverDelay" delayHide={10} effect='solid'/> + <div data-for={`file_disable_${count}`} data-place="left" data-tip="Disabled" className={"glyphicon" + (item.disable ? " glyphicon-ban-circle" : "")} /> + <ReactTooltip id={`file_disable_${count}`} className="ReactTooltipHoverDelay" delayHide={10} effect='solid'/> + <ConfirmModal + className="glyphicon glyphicon-remove text-danger" + buttonText=" " + titleText="Delete File?" + tooltipText="Delete File" + tooltipDelay={10} + callback={this.removeFileFromNamespace.bind(this, item.pk)} + /> + </div> + </li> + )) + }) + return elements + } + createTemplateUpdateButton(id) { + return ( + <ConfirmModal className="flashing text-danger glyphicon glyphicon-exclamation-sign" + buttonText=" " + titleText="Update to latest template version?" + tooltipText="Click to update to latest version" + callback={this.props.updateTemplateOfTemplateInstance.bind(null, id)} /> + ) + } + createTemplatePane(props) { + var index = findIndex(this.state.templates, {slug: props.match.params.template}) + if (index == -1){ + return null + } + var templateInstance = this.state.templates[index] + const template = { + json_schema: templateInstance.template.json_schema, + ui_schema: templateInstance.template.ui_schema, + body: templateInstance.template.body, + inputs: templateInstance.nextInputs, + overrides: templateInstance.nextOverrides, + disable: templateInstance.nextDisable, + } + return ( + <div> + <div className="col-xs-12"> + <h1 className="page-header"> + {templateInstance.title} + <div className="pull-right"> + { templateInstance.nextDisable ? + <div className="btn btn-success btn-inline" onClick={this.onTemplateToggleDisable.bind(this, index, false)}>Enable</div> + : + <div className="btn btn-danger btn-inline" onClick={this.onTemplateToggleDisable.bind(this, index, true)}>Disable</div> + } + {/*<div className="btn btn-default btn-inline">Update</div>*/} + <span> [ {templateInstance.template.title} : {templateInstance.template.version} { templateInstance.is_outdated ? this.createTemplateUpdateButton.bind(this)(templateInstance.pk) : "" } ]</span> + </div> + </h1> + </div> + <div className="col-xs-12"> + <div className="well"> + { templateInstance.template.description } + </div> + </div> + <TerraformTemplateRenderer template={template} onInputsChange={this.onInputsChange.bind(this, index)} onOverridesChange={this.onOverridesChange.bind(this, index)}/> + </div> + ) + } + createTemplateList() { + var url = this.props.match.url + var count = 0 + var elements = [] + if (this.state.templates.length > 0){ + elements.push(( + <div key={`template${count++}`} className="nav-sidebar-header"> + <span>Templates</span> + </div> + )) + } + each(this.state.templates, (item) =>{ + elements.push(( + <li key={`template${count++}`}> + <NavLink className="col-xs-9" to={ urljoin(url, "/template/", item.slug) } activeStyle={{fontWeight: "bold", color: "white", backgroundColor: "#337ab7"}}> + {item.title} + </NavLink> + <div className="pull-right"> + <div data-for={`template_changed_${count}`} data-place="left" data-tip="Changed" className={"glyphicon" + (item.changed ? " glyphicon-exclamation-sign" : "")} /> + <ReactTooltip id={`template_changed_${count}`} className="ReactTooltipHoverDelay" delayHide={10} effect='solid'/> + <div data-for={`template_changed_${count}`} data-place="left" data-tip="Disabled" className={"glyphicon" + (item.disable ? " glyphicon-ban-circle" : "")} /> + <ReactTooltip id={`template_disabled_${count}`} className="ReactTooltipHoverDelay" delayHide={10} effect='solid'/> + <ConfirmModal + className="glyphicon glyphicon-remove text-danger" + buttonText=" " + titleText="Delete Template?" + tooltipText="Delete Template" + tooltipDelay={10} + callback={this.removeTemplateFromNamespace.bind(this, item.pk)} + /> + </div> + </li> + )) + }) + return elements + } + createPlanPane() { + var data = this.props.planOutput + var exit_code = data.exit_code + var output = join(data.output, "") + var getBackgroundColor = () => { + if (exit_code == 1 && !data.running) { + return "bg-danger" + } else if (exit_code == 2 && !data.running) { + return "bg-info" + } else if (exit_code == 0 && !data.running) { + return "bg-success" + } else { + return "bg-default" + } + } + return ( + <pre className={"col-xs-12 " + getBackgroundColor()}> + <Ansi> + {output} + </Ansi> + { !data.running || typeof data.output === "undefined" ? null : <div style={{padding: " 0 5px"}} className="glyphicon glyphicon-refresh spinning" /> } + </pre> + ) + } + createApplyPane() { + var data = this.props.applyOutput + var exit_code = data.exit_code + var output = join(data.output, "") + var getBackgroundColor = () => { + if (exit_code != 0 && !data.running) { + return "bg-danger" + } else if (exit_code == 0 && !data.running) { + return "bg-success" + } else { + return "bg-default" + } + } + return ( + <pre className={"col-xs-12 " + getBackgroundColor()}> + <Ansi> + {output} + </Ansi> + { !data.running ? null : <div style={{padding: " 0 5px"}} className="glyphicon glyphicon-refresh spinning" /> } + </pre> + ) + } + render() { + if (this.props.namespace == null) { + return null + } + const url = this.props.match.url + const namespace = this.props.namespace + return ( + <div> + <div className="col-xs-2 sidebar"> + <ul className="nav nav-sidebar"> + <li className="text-center"> + <span style={{ fontSize: "20px", color: "red"}}>{this.hasChanged() ? "(Needs Save)": ""}</span> + </li> + <div className="nav-sidebar-header"> + <h4>{namespace.title}</h4> + </div> + <li> + <ConfirmModal className="btn btn-danger col-xs-12" + buttonText="Delete" + titleText="Delete Namespace" + callback={this.props.deleteNamespace} /> + </li> + <li> + <ConfirmModal className="btn btn-default col-xs-12" + buttonText="Save" + titleText="Save" + callback={this.saveNamespace.bind(this)} + disabled={this.hasChanged() == false} /> + </li> + </ul> + <ul className="nav nav-sidebar"> + <li> + <NavLink className="col-xs-11" to={ urljoin(url, "/plan") } exact activeStyle={{fontWeight: "bold", color: "white", backgroundColor: "#337ab7"}} onClick={this.props.getPlan.bind(this, namespace.pk)}>Last Plan</NavLink> + <div data-for="terraform_plan" data-place="left" data-tip="terraform plan ..." style={{cursor: "pointer"}} className="pull-right glyphicon glyphicon-play-circle text-primary" onClick={this.planNamespace.bind(this)}></div> + <div> + <ReactTooltip id="terraform_plan" className="ReactTooltipHoverDelay" delayHide={10} effect='solid'/> + </div> + </li> + <li> + <NavLink className="col-xs-11" to={ urljoin(url, "/apply") } exact activeStyle={{fontWeight: "bold", color: "white", backgroundColor: "#337ab7"}} onClick={this.props.getApply.bind(this, namespace.pk)}>Last Apply</NavLink> + <div data-for="terraform_apply" data-place="left" data-tip="terraform apply ..." style={{cursor: "pointer"}} className="pull-right glyphicon glyphicon-play-circle text-primary" onClick={this.applyNamespace.bind(this)}></div> + <div> + <ReactTooltip id="terraform_apply" className="ReactTooltipHoverDelay" delayHide={10} effect='solid'/> + </div> + </li> + </ul> + <ul className="nav nav-sidebar"> + <li> + <div className="nav-sidebar-header"> + </div> + </li> + <li> + <TerraformNamespaceAddFileModal className="btn btn-default col-xs-12" + buttonText="+" + titleText="Add File or Template" + url={url} + history={this.props.history} + namespace={namespace} /> + </li> + </ul> + <ul className="nav nav-sidebar"> + { this.createFileList.bind(this)() } + </ul> + <ul className="nav nav-sidebar"> + { this.createTemplateList.bind(this)()} + </ul> + <ul className="nav nav-sidebar"> + <li> + <div style={{height: "50px"}}></div> + </li> + </ul> + </div> + <div className="col-xs-10 col-xs-offset-2 main"> + <div className="row"> + <Route path={`${url}/plan`} render={this.createPlanPane.bind(this)} /> + <Route path={`${url}/apply`} render={this.createApplyPane.bind(this)} /> + <Route path={`${url}/file/:file`} render={this.createFilePane.bind(this)} /> + <Route path={`${url}/template/:template`} render={this.createTemplatePane.bind(this)} /> + </div> + </div> + </div> + ) + } +} + +const mapStateToProps = (state, ownProps) => { + const namespace = find(state.terraform.namespaces, {"slug": ownProps.match.params.namespace}) + return { + namespacesLoading: state.terraform.loading.namespaces, + namespace: namespace, + getNamespace: () => { + terraform.getNamespace(ownProps.match.params.namespace) + }, + updateNamespace: terraform.updateNamespace, + deleteNamespace: () => { + var req = terraform.deleteNamespace(namespace.pk) + req.then(() => { + ownProps.history.push("/terraform/namespaces") + }) + }, + updateFile: terraform.updateFile, + updateTemplateInstance: terraform.updateTemplateInstance, + updateTemplateOfTemplateInstance: terraform.updateTemplateOfTemplateInstance, + planOutput: state.terraform.planOutput, + applyOutput: state.terraform.applyOutput, + getPlan: terraform.getPlanForNamespace, + getApply: terraform.getApplyForNamespace, + } +} +const mapDispatchToProps = (dispatch, ownProps) => { + return { + planNamespace: (id) => { + terraform.doPlanForNamespace(id) + ownProps.history.push( urljoin(ownProps.match.url, "/plan") ) + }, + applyNamespace: (id, plan_hash) => { + terraform.doApplyForNamespace(id, plan_hash) + ownProps.history.push( urljoin(ownProps.match.url, "/apply") ) + }, + removeFileFromNamespace: (id) => { + var req = terraform.removeFileFromNamespace(ownProps.match.params.namespace, id) + req.then(() => { + // TODO: this doesn't work right - it should only redirect if you were viwing the file you deleted + ownProps.history.push(`${ownProps.match.url}`) + }) + }, + removeTemplateFromNamespace: (id) => { + var req = terraform.removeTemplateFromNamespace(ownProps.match.params.namespace, id) + req.then(() => { + // TODO: this doesn't work right - it should only redirect if you were viwing the file you deleted + ownProps.history.push(`${ownProps.match.url}`) + }) + } + } +} +export default connect(mapStateToProps, mapDispatchToProps)(TerraformNamespaceItem) diff --git a/estate/assets/js/components/TerraformNamespaceList.jsx b/estate/assets/js/components/TerraformNamespaceList.jsx new file mode 100644 index 0000000..025f621 --- /dev/null +++ b/estate/assets/js/components/TerraformNamespaceList.jsx @@ -0,0 +1,59 @@ +import { connect } from "react-redux" +import DashboardListView from "./DashboardListView" +import * as api from "../api/terraform" + +const TerraformNamespacesTableColumns = [ + { + Header: "", + accessor: "link", + maxWidth: 35, + sortable: false, + resizable: false, + }, + { + Header: "Namespace", + accessor: "title", + sort: "asc", + sortable: false, + maxWidth: 200, + }, + { + Header: "Description", + accessor: "description", + sortable: false, + }, + { + Header: "Modified", + accessor: "modified", + maxWidth: 200, + sortable: false, + resizable: false, + } +] + +let mapStateToProps = (state) => { + return { + objectNiceName: "Terraform Namespace", + search: state.search.text, + dataLoading: state.terraform.loading.namespaces, + data: state.terraform.namespaces, + page: state.terraform.namespacesPage, + pages: state.terraform.namespacesPages, + tableColumns: TerraformNamespacesTableColumns, + } +} + +let mapDispatchToProps = (dispatch, ownProps) => { + return { + getData: (page = 1, page_size = 10, search = "") => { + api.getNamespaces(page, page_size, search) + }, + createObject: (payload) => { + const req = api.createNamespace(payload) + req.then((res) => { + ownProps.history.push("./namespaces/" + res.data.slug) + }) + } + } +} +export default connect(mapStateToProps, mapDispatchToProps)(DashboardListView) diff --git a/estate/assets/js/components/TerraformRoutes.jsx b/estate/assets/js/components/TerraformRoutes.jsx new file mode 100644 index 0000000..e682e09 --- /dev/null +++ b/estate/assets/js/components/TerraformRoutes.jsx @@ -0,0 +1,20 @@ +import React from "react" +import { Route } from "react-router-dom" +import TerraformTemplateList from "./TerraformTemplateList" +import TerraformTemplateItem from "./TerraformTemplateItem" +import TerraformNamespaceList from "./TerraformNamespaceList" +import TerraformNamespaceItem from "./TerraformNamespaceItem" + +export default class TerraformRoutes extends React.Component { + render () { + var url = this.props.match.url + return ( + <div> + <Route exact path={`${url}/templates`} component={TerraformTemplateList} /> + <Route path={`${url}/templates/:template`} component={TerraformTemplateItem} /> + <Route exact path={`${url}/namespaces`} component={TerraformNamespaceList} /> + <Route path={`${url}/namespaces/:namespace`} component={TerraformNamespaceItem} /> + </div> + ) + } +} diff --git a/estate/assets/js/components/TerraformTemplateItem.jsx b/estate/assets/js/components/TerraformTemplateItem.jsx new file mode 100644 index 0000000..04896aa --- /dev/null +++ b/estate/assets/js/components/TerraformTemplateItem.jsx @@ -0,0 +1,278 @@ +import React from "react" +import { connect } from "react-redux" +import { Route, NavLink } from "react-router-dom" +import { RadioGroup, Radio } from "react-radio-group" +import { includes, find, merge, isEqual } from "lodash" +import urljoin from "url-join" +import WithLoading from "./WithLoading" +import Editor from "./Editor" +import ChoiceModal from "./ChoiceModal" +import ConfirmModal from "./ConfirmModal" +import TerraformTemplateRenderer from "./TerraformTemplateRenderer" +import * as api from "../api/terraform" + +const VersionChoices = [ + {label: "Major", value: "major", disabled: false}, + {label: "Minor", value: "minor", disabled: false}, + {label: "Patch", value: "patch", disabled: false}, +] + +class TerraformTemplateItem extends React.Component { + constructor(props, context) { + super(props, context) + this.state = { + previewing: false, + templateMode: "hcl", + description: "", + description_changed: false, + json_schema: "{}", + json_schema_changed: false, + ui_schema: "{}", + ui_schema_changed: false, + body: "", + body_changed: false, + inputs: {}, + overrides: "", + dependencies: [], + dependencies_changed: false, + } + } + componentWillMount() { + this.props.getTemplate() + } + componentWillReceiveProps(nextProps) { + if (nextProps.template && !isEqual(this.props.template, nextProps.template)){ + const template = nextProps.template + this.setState({ + description: template.description, + json_schema: template.json_schema || "{}", + ui_schema: template.ui_schema || "{}", + body: template.body, + templateMode: template.body_mode, + dependencies: template.dependencies, + }) + } + } + onDescriptionChange(data) { + this.setState({ + description: data.currentContent, + description_changed: data.changed, + }) + } + onJsonSchemaChange(data) { + this.setState({ + json_schema: data.currentContent, + json_schema_changed: data.changed, + }) + } + onUISchemaChange(data) { + this.setState({ + ui_schema: data.currentContent, + ui_schema_changed: data.changed, + }) + } + onBodyChange(data) { + this.setState({ + body: data.currentContent, + body_changed: data.changed, + }) + } + onInputsChange(data) { + this.setState({ + inputs: data.formData, + }) + } + onOverridesChange(data) { + this.setState({ + overrides: data.currentContent, + }) + } + onDependenciesChange() { + this.setState({ + dependencies_changed: [], + }) + } + onTemplateModeChange(value) { + this.setState({ + templateMode: value + }) + } + hasChanged() { + const changes = [ + this.state.description_changed, + this.state.json_schema_changed, + this.state.ui_schema_changed, + this.state.body_changed, + this.state.dependencies_changed, + ] + return (includes(changes, true)) + } + getResult() { + return { + title: this.state.title, + description: this.state.description, + json_schema: this.state.json_schema, + ui_schema: this.state.ui_schema, + body: this.state.body, + dependencies: this.state.dependencies, + } + } + updateTemplate(data) { + const payload = merge(this.getResult(), {version_increment: data.value}) + this.props.updateTemplate(this.props.template.pk, payload) + this.setState({ + description_changed: false, + json_schema_changed: false, + ui_schema_changed: false, + body_changed: false, + dependencies_changed: false, + }) + } + createPreview() { + const template = { + json_schema: this.state.json_schema, + ui_schema: this.state.ui_schema, + body: this.state.body, + inputs: this.state.inputs, + overrides: this.state.overrides, + disable: false, + } + return ( + <div> + <h1 className="page-header">Preview</h1> + <TerraformTemplateRenderer template={template} onInputsChange={this.onInputsChange.bind(this)} onOverridesChange={this.onOverridesChange.bind(this)}/> + </div> + ) + } + createJsonSchemaHeader() { + return ( + <span> + JSON Schema + <small className="form-text text-muted"> (Uses <a href="https://mozilla-services.github.io/react-jsonschema-form/">JsonSchemaForm</a> DSL)</small> + </span> + ) + } + createTemplateBodyHeader() { + return ( + <span> + Template Body + <small className="form-text text-muted"> (Uses <a href="http://jinja.pocoo.org/">Jinja</a> Templating)</small> + <span className="pull-right"> + <RadioGroup name="templateMode" selectedValue={this.state.templateMode} onChange={this.onTemplateModeChange.bind(this)}> + MODE:   + <Radio value="hcl" /> HCL  + <Radio value="yaml" /> YAML  + </RadioGroup> + </span> + </span> + ) + } + createEditor() { + const template = this.props.template + const loading = this.props.templatesLoading + let options = {} + if (this.state.templateMode === "yaml"){ + options = { mode: {name: "yaml", statementIndent: 2}, lint: false } + } + if (this.state.templateMode === "hcl"){ + options = { mode: {name: "go", statementIndent: 4}, lint: false } + } + return ( + <WithLoading loading={loading} reload={this.props.getTemplate}> + <h1 className="page-header">Edit <span className="pull-right">{template.version}</span></h1> + <div className="row"> + <div className="col-xs-12"> + <Editor title="Description" options={{ mode: "markdown" }} content={this.state.description} onUpdateContent={this.onDescriptionChange.bind(this)} /> + </div> + </div> + <div className="row"> + <div className="col-xs-6"> + <Editor title={this.createJsonSchemaHeader()} content={this.state.json_schema} onUpdateContent={this.onJsonSchemaChange.bind(this)} /> + </div> + <div className="col-xs-6"> + <Editor title="UI Schema" content={this.state.ui_schema} onUpdateContent={this.onUISchemaChange.bind(this)} /> + </div> + </div> + <div className="row"> + <div className="col-xs-12"> + <Editor title={this.createTemplateBodyHeader()} options={options} content={this.state.body} onUpdateContent={this.onBodyChange.bind(this)} /> + </div> + </div> + </WithLoading> + ) + } + render() { + if (this.props.template == null) { + return null + } + const url = this.props.match.url + const template = this.props.template + return ( + <div> + <div className="col-xs-2 sidebar"> + <ul className="nav nav-sidebar"> + <li className="text-center"> + <span style={{ fontSize: "20px", color: "red"}}>{this.hasChanged() ? "(Needs Save)": ""}</span> + </li> + <div className="nav-sidebar-header"> + <h4>{template.title}</h4> + </div> + <li> + <ConfirmModal + className="btn btn-danger col-xs-12" + buttonText="Delete" + titleText="Delete Template" + callback={this.props.deleteTemplate.bind(this)} + /> + </li> + <li> + <ChoiceModal + choices={VersionChoices} + defaultValue="patch" + callback={this.updateTemplate.bind(this)} + disabled={this.hasChanged() == false} + buttonText="Save" + titleText="Save" + className="btn btn-default col-xs-12" + /> + </li> + </ul> + <ul className="nav nav-sidebar"> + <div className="nav-sidebar-header"></div> + <li> + <NavLink className="col-xs-12" to={`${url}`} exact activeStyle={{fontWeight: "bold", color: "white", backgroundColor: "#337ab7"}}>Edit</NavLink> + </li> + <li> + <NavLink className="col-xs-12" to={ urljoin(url, "/preview") } activeStyle={{fontWeight: "bold", color: "white", backgroundColor: "#337ab7"}}>Preview</NavLink> + </li> + </ul> + </div> + <div className="col-xs-10 col-xs-offset-2 main"> + <Route exact path={`${url}/`} render={this.createEditor.bind(this)}/> + <Route path={`${url}/preview`} render={this.createPreview.bind(this)}/> + </div> + </div> + ) + } +} + +const mapStateToProps = (state, ownProps) => { + const template = find(state.terraform.templates, {"slug": ownProps.match.params.template}) + return { + templatesLoading: state.terraform.loading.templates, + template: template, + renderedTemplate: state.terraform.renderedTemplate, + getTemplate: () => { + api.getTemplate(ownProps.match.params.template) + }, + updateTemplate: api.updateTemplate, + deleteTemplate: () => { + var req = api.deleteTemplate(template.pk) + req.then(() => { + ownProps.history.push("/terraform/templates") + }) + }, + renderTemplate: api.renderTemplate, + } +} +export default connect(mapStateToProps)(TerraformTemplateItem) diff --git a/estate/assets/js/components/TerraformTemplateList.jsx b/estate/assets/js/components/TerraformTemplateList.jsx new file mode 100644 index 0000000..527db0f --- /dev/null +++ b/estate/assets/js/components/TerraformTemplateList.jsx @@ -0,0 +1,66 @@ +import { connect } from "react-redux" +import DashboardListView from "./DashboardListView" +import * as api from "../api/terraform" + +const TerraformTemplatesTableColumns = [ + { + Header: "", + accessor: "link", + maxWidth: 35, + sortable: false, + resizable: false, + }, + { + Header: "Template", + accessor: "title", + sort: "asc", + sortable: false, + maxWidth: 250, + }, + { + Header: "Description", + accessor: "description", + sortable: false, + }, + { + Header: "Version", + accessor: "version", + maxWidth: 80, + sortable: false, + resizable: true, + }, + { + Header: "Modified", + accessor: "modified", + maxWidth: 200, + sortable: false, + resizable: false, + } +] + +let mapStateToProps = (state) => { + return { + objectNiceName: "Terraform Template", + search: state.search.text, + dataLoading: state.terraform.loading.templates, + data: state.terraform.templates, + page: state.terraform.templatesPage, + pages: state.terraform.templatesPages, + tableColumns: TerraformTemplatesTableColumns, + } +} + +let mapDispatchToProps = (dispatch, ownProps) => { + return { + getData: (page = 1, page_size = 10, search = "") => { + api.getTemplates(page, page_size, search) + }, + createObject: (payload) => { + const req = api.createTemplate(payload) + req.then((res) => { + ownProps.history.push("./templates/" + res.data.slug) + }) + } + } +} +export default connect(mapStateToProps, mapDispatchToProps)(DashboardListView) diff --git a/estate/assets/js/components/TerraformTemplateRenderer.jsx b/estate/assets/js/components/TerraformTemplateRenderer.jsx new file mode 100644 index 0000000..05ec6e4 --- /dev/null +++ b/estate/assets/js/components/TerraformTemplateRenderer.jsx @@ -0,0 +1,197 @@ +import React from "react" +import { connect } from "react-redux" +import { isEqual, isEmpty, merge } from "lodash" +import JsonSchemaForm from "react-jsonschema-form" +import { getDefaultFormState } from "react-jsonschema-form/lib/utils" +import Editor from "./Editor" +import * as api from "../api/terraform" + +class TerraformTemplateRenderer extends React.Component { + constructor(props, context) { + super(props, context) + this.rerenderTemplate = null + this.state = { + json_schema: "{}", + ui_schema: "{}", + body: "", + inputs: {}, + overrides: "", + disable: false, + } + } + componentWillMount() { + if (this.props.template) { + this.loadTemplateIntoState(this.props.template) + this.renderTemplate() + } + } + componentWillReceiveProps(nextProps) { + if (nextProps.template && !isEqual(this.props.template, nextProps.template)){ + this.loadTemplateIntoState(nextProps.template) + } + } + componentDidUpdate() { + if (this.rerenderTemplate){ + this.renderTemplate() + this.rerenderTemplate = null + } + } + loadTemplateIntoState(template) { + var payload = {} + if (!isEqual(this.state.json_schema, template.json_schema)) + payload.json_schema = template.json_schema + + if (!isEqual(this.state.ui_schema, template.ui_schema)) + payload.ui_schema = template.ui_schema + + if (!isEqual(this.state.body, template.body)) + payload.body = template.body + + if (!isEqual(this.state.inputs, template.inputs)) + payload.inputs = template.inputs + + if (!isEqual(this.state.overrides, template.overrides)) + payload.overrides = template.overrides + + if (!isEqual(this.state.disable, template.disable)) + payload.disable = template.disable + + if (!isEmpty(payload)) { + this.rerenderTemplate = true + this.setState(payload) + } + } + isValidJsonSchema() { + try { + JSON.parse(this.state.json_schema) + } + catch(e){ + return false + } + return true + } + isValidUISchema() { + try { + JSON.parse(this.state.ui_schema) + } + catch(e){ + return false + } + return true + } + isValidBody() { + // TODO: Use the API to check if the body is good or now + //try { + // var yaml = jsyaml.safeLoad(this.state.body) + //} + //catch(e){ + // return false + //} + //if (typeof(yaml) == 'string') { + // return false + //} + return true + } + isValidOverrides() { + try { + var yaml = window.jsyaml.safeLoad(this.state.overrides) + } + catch(e){ + return false + } + if (typeof(yaml) == "string") { + return false + } + return true + } + isValid() { + return (this.isValidJsonSchema() && this.isValidUISchema() && this.isValidBody()) + } + canSave() { + return (this.hasChanged() == true && this.isValid()) + } + onInputsChange(data) { + this.state.inputs = data.formData + if (this.props.onInputsChange) { + this.props.onInputsChange(data) + } + this.rerenderTemplate = true + this.setState({ + inputs: data.formData, + }) + } + onOverridesChange(data) { + this.state.overrides = data.currentContent + if (this.props.onOverridesChange) { + this.props.onOverridesChange(data) + } + this.rerenderTemplate = true + this.setState({ + overrides: data.currentContent, + }) + } + renderTemplate() { + if (!this.isValid()){ + return null + } + if (!this.isValidOverrides()) { + return null + } + let formDefaults = getDefaultFormState(JSON.parse(this.state.json_schema)) + const payload = { + body: this.state.body, + inputs: JSON.stringify(merge(formDefaults, this.state.inputs)), + overrides: this.state.overrides, + disable: this.state.disable, + } + this.props.renderTemplate(payload) + } + createErrorBars() { + var count = 0 + var elements = [] + if (this.isValidJsonSchema() == false){ + elements.push(<button key={count++} disabled={true} className="btn btn-danger col-xs-12">Invalid JSON Schema</button>) + } + if (this.isValidUISchema() == false){ + elements.push(<button key={count++} disabled={true} className="btn btn-danger col-xs-12">Invalid UI Schema</button>) + } + if (this.isValidBody() == false){ + elements.push(<button key={count++} disabled={true} className="btn btn-danger col-xs-12">Invalid Template Body</button>) + } + return elements + } + createForm() { + if (!this.isValid()) { + return null + } + return ( + <div> + <div className="col-xs-12 col-md-6" style={{ marginBottom: "10px"}}> + <JsonSchemaForm schema={JSON.parse(this.state.json_schema)} uiSchema={JSON.parse(this.state.ui_schema)} formData={this.state.inputs} onChange={this.onInputsChange.bind(this) } liveValidate={true}> + <span/> + </JsonSchemaForm> + <Editor title="Overrides" options={{ mode: "yaml" }} content={ this.state.overrides } onUpdateContent={this.onOverridesChange.bind(this)} /> + </div> + <div className="col-xs-12 col-md-6"> + <Editor title="Rendered Output" options={{ readOnly: true }} content={ JSON.stringify(this.props.renderedTemplate, null, 2) } /> + </div> + </div> + ) + } + render() { + return ( + <div className="col-xs-12"> + {this.createErrorBars()} + {this.createForm()} + </div> + ) + } +} + +const mapStateToProps = (state) => { + return { + renderTemplate: api.renderTemplate, + renderedTemplate: state.terraform.renderedTemplate, + } +} +export default connect(mapStateToProps)(TerraformTemplateRenderer) diff --git a/estate/assets/js/components/WithLoading.jsx b/estate/assets/js/components/WithLoading.jsx new file mode 100644 index 0000000..e082a29 --- /dev/null +++ b/estate/assets/js/components/WithLoading.jsx @@ -0,0 +1,31 @@ +import React from "react" + + +class WithLoading extends React.Component { + render () { + let renderElement + if (this.props.loading === "error") { + if (this.props.reload){ + renderElement = ( + <span> + <p>Failed to load data <button className="btn btn-default" onClick={this.props.reload}>Reload</button></p> + </span> + ) + } else { + renderElement = ( + <p>Failed to load data</p> + ) + } + } else if (this.props.loading || typeof this.props.loading === "undefined"){ + renderElement = (<div className="label label-default col-xs-12"><h3><span className="glyphicon glyphicon-refresh spinning"/></h3></div>) + } else { + renderElement = this.props.children + } + return ( + <div> + {renderElement} + </div> + ) + } +} +export default WithLoading diff --git a/estate/assets/js/estate.css b/estate/assets/js/estate.css new file mode 100644 index 0000000..e1db0c1 --- /dev/null +++ b/estate/assets/js/estate.css @@ -0,0 +1,115 @@ +/* +Dashboard +*/ + +/* Move down content because we have a fixed navbar that is 50px tall */ +body { + padding-top: 50px; +} + +.sub-header { + padding-bottom: 10px; + border-bottom: 1px solid #eee; +} + + /* Hide default border to remove 1px line.*/ +.navbar-fixed-top { + border: 0; +} + +/* Hide for mobile, show later */ +.sidebar { + position: fixed; + height: 100%; + display: block; + padding: 20px; + overflow-x: hidden; + overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */ + background-color: #f5f5f5; + border-right: 1px solid #eee; +} +.sidebar .btn, +.sidebar > a { + padding: 0px; + margin: 5px 0; +} +.sidebar > .nav > li > * { + padding: 0px; + display: inline-table; +} + +.nav-sidebar-header { + padding-bottom: 5px; + margin-bottom: 5px; + border-bottom: 1px solid black; + text-align: center; +} + +.main { + padding: 20px; +} +.main .page-header { + margin-top: 0; +} + +/* +CodeMirror +*/ +.CodeMirror { + height: auto; + outline: 1px solid grey; + z-index: 0; +} +.CodeMirror-scroll { + min-height: 6em; +} + +.btn-inline { + margin-right: 5px; +} + +/* +accordion +*/ +#accordion .panel-heading:hover { + cursor: pointer; +} + +#accordion .panel-heading:before { + font-family:'Glyphicons Halflings'; + content:"\e114"; + float: right; +} +#accordion .panel-heading.collapsed:before { + content:"\e080"; +} + +/* +Animations +*/ +.flashing { + animation: blinker 0.8s linear infinite; +} +@keyframes blinker { + 50% { opacity: 0; } +} + +.spinning { + animation: spin 1s infinite linear; +} +@keyframes spin { + from { transform: scale(1) rotate(0deg); } + to { transform: scale(1) rotate(360deg); } +} + +/* +ReactTooltip +*/ +.ReactTooltipHoverDelay { + font-size: 12px !important; + pointer-events: auto !important; +} +.ReactTooltipHoverDelay:hover { + visibility: visible !important; + opacity: 1 !important; +} diff --git a/estate/assets/js/index.jsx b/estate/assets/js/index.jsx new file mode 100644 index 0000000..4db3441 --- /dev/null +++ b/estate/assets/js/index.jsx @@ -0,0 +1,40 @@ +import "react-hot-loader/patch" +import React from "react" // eslint-disable-line no-unused-vars +import ReactDOM from "react-dom" +import { AppContainer } from "react-hot-loader" +import { createStore, compose, combineReducers } from "redux" +import { Provider } from "react-redux" +import persistState from "redux-localstorage" +import { BrowserRouter } from "react-router-dom" +import rootReducer from "./reducers/index" +import App from "./components/App" +import "./estate.css" + +window.jsyaml = require("js-yaml") // eslint-disable-line no-undef + +const composeEnhancers = typeof window === "object" && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}) : compose + +export default function configureStore () { + const reducer = combineReducers(rootReducer) + const enhancer = composeEnhancers(persistState("messages")) + return createStore(reducer, window.__STATE__, enhancer) +} + +const store = configureStore() +window.dispatch = store.dispatch +store.dispatch({ type: "INIT" }) + +const render = (Component) => + ReactDOM.render( + <AppContainer> + <Provider store={store}> + <BrowserRouter> + <Component /> + </BrowserRouter> + </Provider> + </AppContainer>, + document.getElementById("root") + ) + +render(App) +if (module.hot) module.hot.accept("./components/App", () => render(App)) // eslint-disable-line no-undef diff --git a/estate/assets/js/reducers/index.js b/estate/assets/js/reducers/index.js new file mode 100644 index 0000000..1b8351a --- /dev/null +++ b/estate/assets/js/reducers/index.js @@ -0,0 +1,10 @@ +import {reducer as notifications} from "react-notification-system-redux" +import search from "./search" +import terraform from "./terraform" + + +export default { + notifications, + search, + terraform, +} diff --git a/estate/assets/js/reducers/search.js b/estate/assets/js/reducers/search.js new file mode 100644 index 0000000..fe9299a --- /dev/null +++ b/estate/assets/js/reducers/search.js @@ -0,0 +1,13 @@ +import { set } from "lodash/fp" +import { createReducer } from "./utils" + +var initialState = { + text: "" +} + +export default createReducer(initialState, { + ["SET_SEARCH"]: (state, action) => { + state = set(["text"])(action.payload)(state) + return state + }, +}) diff --git a/estate/assets/js/reducers/terraform.js b/estate/assets/js/reducers/terraform.js new file mode 100644 index 0000000..119515a --- /dev/null +++ b/estate/assets/js/reducers/terraform.js @@ -0,0 +1,131 @@ +import { set, unset } from "lodash/fp" +import { findIndex, toNumber } from "lodash" +import { createReducer } from "./utils" + +var initialState = { + loading: { + namespaces: false, + templates: false, + }, + namespaces: [], + namespacesPage: 0, + namespacesPages: 0, + planOutput: "", + applyOutput: "", + files: [], + templates: [], + renderedTemplate: "{}", + templatesPage: 0, + templatesPages: 0, +} + +export default createReducer(initialState, { + ["LOADING_NAMESPACES"]: (state) => { + state = set(["loading", "namespaces"])(true)(state) + return state + }, + ["LOADING_NAMESPACES_DONE"]: (state) => { + state = set(["loading", "namespaces"])(false)(state) + return state + }, + ["LIST_NAMESPACES"]: (state, action) => { + state = unset(["namespaces"])(state) + state = set(["namespaces"])(action.payload.namespaces)(state) + state = set(["namespacesPage"])(toNumber(action.payload.namespacesPage))(state) + state = set(["namespacesPages"])(toNumber(action.payload.namespacesPages))(state) + return state + }, + ["UPDATE_NAMESPACE"]: (state, action) => { + var index = findIndex(state.namespaces, {"slug": action.payload.slug}) + if (index != -1){ + state = set(["namespaces", index])(action.payload)(state) + } else { + state = set(["namespaces", state.namespaces.length])(action.payload)(state) + } + return state + }, + + ["DELETE_NAMESPACE"]: (state, action) => { + var index = findIndex(state.namespaces, {"pk": action.payload}) + if (index != -1){ + state = unset(["namespaces", index])(state) + } + return state + }, + + ["CLEAR_PLAN_NAMESPACE"]: (state) => { + state = set(["planOutput"])({})(state) + return state + }, + + ["PLAN_NAMESPACE"]: (state, action) => { + state = set(["planOutput"])(action.payload)(state) + return state + }, + + ["CLEAR_APPLY_NAMESPACE"]: (state) => { + state = set(["applyOutput"])({})(state) + return state + }, + + ["APPLY_NAMESPACE"]: (state, action) => { + state = set(["applyOutput"])(action.payload)(state) + return state + }, + + ["UPDATE_FILE"]: (state, action) => { + var index = findIndex(state.file, {"pk": action.payload.pk}) + if (index != -1){ + state = set(["files", index])(action.payload)(state) + } else { + state = set(["files", state.files.length])(action.payload)(state) + } + return state + }, + + ["DELETE_FILE"]: (state, action) => { + var index = findIndex(state.files, {"pk": action.payload}) + if (index != -1){ + state = unset(["files", index])(state) + } + return state + }, + + ["LOADING_TEMPLATES"]: (state) => { + state = set(["loading", "templates"])(true)(state) + return state + }, + ["LOADING_TEMPLATES_DONE"]: (state) => { + state = set(["loading", "templates"])(false)(state) + return state + }, + ["LIST_TEMPLATES"]: (state, action) => { + state = unset(["templates"])(state) + state = set(["templates"])(action.payload.templates)(state) + state = set(["templatesPage"])(toNumber(action.payload.templatesPage))(state) + state = set(["templatesPages"])(toNumber(action.payload.templatesPages))(state) + return state + }, + ["UPDATE_TEMPLATE"]: (state, action) => { + var index = findIndex(state.templates, {"slug": action.payload.slug}) + if (index != -1){ + state = set(["templates", index])(action.payload)(state) + } else { + state = set(["templates", state.templates.length])(action.payload)(state) + } + return state + }, + + ["DELETE_TEMPLATE"]: (state, action) => { + var index = findIndex(state.templates, {"pk": action.payload}) + if (index != -1){ + state = unset(["templates", index])(state) + } + return state + }, + + ["RENDER_TEMPLATE"]: (state, action) => { + state = set(["renderedTemplate"])(action.payload)(state) + return state + }, +}) diff --git a/estate/assets/js/reducers/utils.js b/estate/assets/js/reducers/utils.js new file mode 100644 index 0000000..cb4e9f4 --- /dev/null +++ b/estate/assets/js/reducers/utils.js @@ -0,0 +1,19 @@ +/** + * Utility function that lets us express reducers as + * object mapping from action types to handlers. + * + * @param {Any} initialState + * @param {Object} handlers + * @return {Function} + */ + +export function createReducer (initialState, handlers) { + return function reducer (state = initialState, action) { + if (handlers.hasOwnProperty((action.type))) { + return handlers[action.type](state, action) + } else if (action.type === "INIT") { + return { ...initialState } + } + return state + } +} diff --git a/estate/core/DjangoCacheStreamer.py b/estate/core/DjangoCacheStreamer.py new file mode 100644 index 0000000..0a02bb8 --- /dev/null +++ b/estate/core/DjangoCacheStreamer.py @@ -0,0 +1,35 @@ +from __future__ import absolute_import +import logging +from copy import deepcopy +from django.core.cache import caches +from ..core.HotDockerExecutor import HotDockerExecutorStreamer + +LOG = logging.getLogger("DjangoCacheStreamer") + + +class DjangoCacheStreamer(HotDockerExecutorStreamer): + """ + Acts as a hook to stream the subprocess output for getting real-time + process output. Sends the stream to a Django cache object. + """ + + def __init__(self, cache_name, cache_key, *args, **kwargs): + super(DjangoCacheStreamer, self).__init__(*args, **kwargs) + self.cache_name = cache_name + self.cache_key = cache_key + self.cache = caches[self.cache_name] + self.initial_state = deepcopy(self.state) + self.namespace_slug = self.cache_key.split("_")[1] + + def handle_log(self): + self.set(self.state, None) + + def clear_cache(self): + self.state = deepcopy(self.initial_state) + self.set(self.initial_state, None) + + def get(self): + return self.cache.get(self.cache_key) + + def set(self, value, ttl=None): + self.cache.set(self.cache_key, value, ttl) diff --git a/estate/core/HotDockerExecutor.py b/estate/core/HotDockerExecutor.py new file mode 100644 index 0000000..65b76be --- /dev/null +++ b/estate/core/HotDockerExecutor.py @@ -0,0 +1,239 @@ +from __future__ import absolute_import +import datetime +import json +import logging +import os +import requests +import shutil +import subprocess + +LOG = logging.getLogger("DockerHotExecutor") + + +class HotDockerExecutorStreamer(object): + """ + Acts as a hook to stream the subprocess output for getting real-time process output + Override handle_log to hook in your own stream capturer - defaults to python logging + """ + + def __init__(self, *args, **kwargs): + # HACK: this has to be set to True for now--otherwise, the spinner doesn't show up right away + self.state = {"last_updated": datetime.datetime.utcnow(), "running": True, "exit_code": 0, "output": []} + + def prepare_data(self, output, running=True, exit_code=0): + now = datetime.datetime.utcnow() + self.state["output"].append(output) + self.state["running"] = running + self.state["exit_code"] = exit_code + if ((now - self.state["last_updated"]).seconds > 10 or not running or len(self.state["output"]) == 1): + self.state["last_updated"] = now + return True + + def log(self, *args, **kwargs): + if self.prepare_data(*args, **kwargs): + self.handle_log() + + def handle_log(self): + LOG.info(json.dumps(self.state)) + + +class HotDockerExecutor(object): + """ + Params: + config = { + // Required + "docker_image": "", + "name": "", + "command": "" + // Optional + "streamer": None, + "hot": false, + "escrow": "", + } + Usage: + runner = HotDockerExecutor(config={...}) + runner.run() + """ + + def __init__(self, *args, **kwargs): + self.config = kwargs["config"] + self.docker_image = self.config["docker_image"] + self.name = self.config["name"] + self.command = self.config["command"] + self.streamer = self.config.get("streamer", None) + if not isinstance(self.streamer, HotDockerExecutorStreamer): + self.streamer = None + self.hot = self.config.get("hot", False) + self.escrow = self.config.get("escrow") + + self.duration = datetime.timedelta(0) + self.exit_code = None + self.output = "" + self.pid = os.getpid() + self.mount = os.path.join(os.environ.get("TEMP_DIR", "/tmp"), self.name) + self.workdir = os.path.join(self.mount, str(self.pid)) + if self.hot: + # The hot envfile is outside of the worker pid directory because the ENV stays the same between executions inside the container + self.envfile = os.path.join(self.mount, "hot_envfile") + self.keepalivefile = os.path.join(self.mount, "hot_keepalive") + else: + self.envfile = os.path.join(self.workdir, "envfile") + self.commandfile = os.path.join(self.workdir, "command") + self.inside_workdir = os.path.join("/workdir", str(self.pid)) + self.inside_keepalivefile = "/keepalive" + self.inside_commandfile = os.path.join(self.inside_workdir, "command") + + self.invoke = self.get_command() + self.prepare_hot() + + def is_container_running(self): + command = "docker inspect -f {{.State.Running}} " + self.name + # TODO: Wish there was a better way - Should look into this + return subprocess.check_output(command, shell=True) != 'true\n' + + def is_hot(self): + if self.hot: + return self.is_container_running() + else: + return False + + def get_command(self): + if self.is_hot(): + command = ["docker", "exec", self.name, "bash " + self.inside_commandfile] + else: + command = [ + "docker", "run", "--rm", "--net=host", + "--env-file", self.envfile, + "--entrypoint", "/bin/sh", + "-w", self.inside_workdir, + "-v", self.workdir + ":" + self.inside_workdir, + self.docker_image, self.inside_commandfile + ] + return command + + def prepare_hot(self): + if self.hot and self.is_container_running() is False: + # TODO: need to add checking of image so that hot containers can eventually be reloaded - maybe use multiprocess locking to halt executions and untill clear then bring down the hot container and reload it? + LOG.info("[HotDockerExecutor] Preparing hot container execution.") + self.pull_image() + self.write_prep_files() + command = [ + "docker", "run", "-d", "--net=host", + "--env-file", self.envfile, + "--entrypoint", "/bin/sh", + "-w", self.inside_workdir, + "-v", self.workdir + ":" + self.inside_workdir, + "-v", self.keepalivefile + ":" + self.inside_keepalivefile, + self.docker_image, self.inside_keepalivefile + ] + exit_code, output = self.execute_command(command) + if exit_code != 0: + LOG.error("".join(output + ["Exit Code: {0}".format(exit_code)])) + + def run(self): + start = datetime.datetime.now() + try: + if self.is_hot() is False: + self.pull_image() + self.write_execute_files() + self.write_files() + self.exit_code, self.output = self.execute_command(self.invoke, capture=True) + except Exception as e: + if self.streamer is not None: + self.streamer.log(str(e), running=False, exit_code=-1) + self.handle_exception(e) + LOG.exception(str(e)) + finally: + end = datetime.datetime.now() + self.duration = end - start + self.finish() + shutil.rmtree(self.workdir) + + def get_escrow(self, escrow_id): + escrow_api = os.environ.get("ESCROW_API_URI") + if escrow_api is None: + return "" + url = escrow_api + "/artifact/" + escrow_id + "/rendered/?style=docker" + response = requests.get(url, auth=(os.environ["ESCROW_USERNAME"], os.environ["ESCROW_PASSWORD"])) + if not response.ok: + return "" + else: + return response.text + + def write_prep_files(self): + if not os.path.exists(self.mount): + os.makedirs(self.mount) + LOG.info("[HotDockerExecutor] Writing file: {0}".format(self.keepalivefile)) + with open(self.keepalivefile, "wb") as f: + f.write("""while true; do sleep 100; done""") + os.chmod(self.keepalivefile, 0777) + if self.escrow: + env = self.get_escrow(self.escrow) + else: + env = "" + LOG.info("[HotDockerExecutor] Writing file: {0}".format(self.envfile)) + with open(self.envfile, "wb") as f: + f.write(env) + + def write_execute_files(self): + if os.path.exists(self.workdir): + shutil.rmtree(self.workdir) + os.makedirs(self.workdir) + LOG.info("[HotDockerExecutor] Writing file: {0}".format(self.commandfile)) + with open(self.commandfile, "wb") as f: + f.write(self.command) + os.chmod(self.commandfile, 0777) + if self.escrow: + env = self.get_escrow(self.escrow) + else: + env = "" + LOG.info("[HotDockerExecutor] Writing file: {0}".format(self.envfile)) + with open(self.envfile, "wb") as f: + f.write(env) + + def pull_image(self): + if self.streamer is not None: + self.streamer.log("Pulling docker image: {0}\n".format(self.docker_image)) + command = ["docker", "pull", self.docker_image] + exit_code, output = self.execute_command(command) + if exit_code != 0: + raise Exception("".join(output + "\nExit Code: {0}".format(exit_code))) + + def execute_command(self, command, capture=False): + output = [] + stream_index = 0 + LOG.info("[HotDockerExecutor] Running command: {0}".format(" ".join(command))) + process = subprocess.Popen( + command, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE, + ) + if capture and self.streamer is not None: + self.streamer.log("Started Execution @ {0}\n".format(datetime.datetime.utcnow())) + while process.poll() is None: + for line in iter(process.stdout.readline, ""): + output.append(line) + if capture and self.streamer is not None: + for new_line in output[stream_index:]: + self.streamer.log(new_line) + stream_index = len(output) + if capture and self.streamer is not None: + for new_line in output[stream_index:]: + self.streamer.log(new_line) + process.communicate() + exit_code = process.poll() + if capture and self.streamer is not None: + self.streamer.log("Completed Execution @ {0}".format(datetime.datetime.utcnow()), running=False, exit_code=exit_code) + if self.streamer is not None: + self.streamer.log("\nExit Code: {0}".format(exit_code)) + return exit_code, "".join(output) + + # These are the user implementation points in the overall run flow + def write_files(self): + raise NotImplementedError() + + def handle_exception(self, error): + pass + + def finish(self): + pass diff --git a/estate/core/__init__.py b/estate/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/estate/core/apps.py b/estate/core/apps.py new file mode 100644 index 0000000..aa9c350 --- /dev/null +++ b/estate/core/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class APIConfig(AppConfig): + name = 'core' diff --git a/estate/core/models/__init__.py b/estate/core/models/__init__.py new file mode 100644 index 0000000..c396168 --- /dev/null +++ b/estate/core/models/__init__.py @@ -0,0 +1 @@ +from __future__ import absolute_import diff --git a/estate/core/models/base.py b/estate/core/models/base.py new file mode 100644 index 0000000..baad684 --- /dev/null +++ b/estate/core/models/base.py @@ -0,0 +1,26 @@ +from __future__ import absolute_import +import logging +from django.utils.translation import ugettext_lazy as _ +from django_extensions.db.models import TimeStampedModel, TitleDescriptionModel +from django_permanent.models import PermanentModel +from simple_history.models import HistoricalRecords +from .fields import SoftDeleteAwareAutoSlugField + +LOG = logging.getLogger(__name__) + + +class HistoricalRecordsWithoutDelete(HistoricalRecords): + def post_delete(self, *args, **kwargs): + # Fixes issue + # https://github.com/treyhunner/django-simple-history/issues/207 + pass + + +class EstateAbstractBase(PermanentModel, TimeStampedModel, TitleDescriptionModel): + slug = SoftDeleteAwareAutoSlugField(_('slug'), populate_from='title') + + class Meta(TimeStampedModel.Meta): + abstract = True + + def __repr__(self): + return "<%s:%s pk:%i>" % (self.__class__.__name__, self.title, self.pk) diff --git a/estate/core/models/fields.py b/estate/core/models/fields.py new file mode 100644 index 0000000..ce91803 --- /dev/null +++ b/estate/core/models/fields.py @@ -0,0 +1,20 @@ +from __future__ import absolute_import +import logging +from django_extensions.db.fields import AutoSlugField + +LOG = logging.getLogger(__name__) + + +class SoftDeleteAwareAutoSlugField(AutoSlugField): + + def get_queryset(self, model_cls, slug_field): + for field, model in self._get_fields(model_cls): + if model and field == slug_field: + if hasattr(model, "all_objects"): + return model.all_objects.all() + else: + return model._default_manager.all() + if hasattr(model_cls, "all_objects"): + return model_cls.all_objects.all() + else: + return model_cls._default_manager.all() diff --git a/estate/core/renderer.py b/estate/core/renderer.py new file mode 100644 index 0000000..af029f1 --- /dev/null +++ b/estate/core/renderer.py @@ -0,0 +1,156 @@ +import hcl +import json +import logging +import yaml +from jinja2 import Environment, Undefined +from collections import OrderedDict + +LOG = logging.getLogger(__name__) + + +class NullUndefined(Undefined): + + def __int__(self): + return 0 + + def __float__(self): + return 0.0 + + +def constructor(loader, node): + omap = loader.construct_yaml_omap(node) + return OrderedDict(*omap) + + +yaml.add_constructor(u'tag:yaml.org,2002:omap', constructor) + +jinja_environment = Environment(undefined=NullUndefined) +jinja_environment.filters["load_yaml"] = yaml.load +jinja_environment.filters["dump_json"] = json.dumps + + +def apply_jinja(value, inputs=None): + inputs = inputs or {} + template = jinja_environment.from_string(value) + output = template.render(**inputs) + return output + + +def is_yaml(value): + try: + data = yaml.safe_load(value) + return True, data, None + except Exception as e: + return False, None, e + + +def is_hcl(value): + try: + data = hcl.loads(value) + return True, data, None + except Exception as e: + return False, None, e + + +def is_json(value): + try: + data = json.loads(value) + return True, data, None + except Exception as e: + return False, None, e + + +def get_style(value): + template = apply_jinja(value) + type_is_hcl, data, hcl_exception = is_hcl(template) + if type_is_hcl: + return "hcl" + type_is_json, data, json_exception = is_json(template) + if type_is_json: + return "json" + type_is_yaml, data, yaml_exception = is_yaml(template) + if type_is_yaml: + if type(data) in [type(None), type({})]: + return "yaml" + + +def is_valid_template(value): + template = apply_jinja(value) + type_is_hcl, data, hcl_exception = is_hcl(template) + if type_is_hcl: + return data + type_is_json, data, json_exception = is_json(template) + if type_is_json: + return data + type_is_yaml, data, yaml_exception = is_yaml(template) + if type_is_yaml: + if type(data) not in [type(None), type({})]: + raise Exception("Unable to parse as YAML into a valid object\n") + return data + + if type_is_hcl is False: + raise Exception("Unable to parse as hcl\n" + str(hcl_exception)) + + if type_is_json is False: + raise Exception("Unable to parse as json\n" + str(json_exception)) + + if type_is_yaml is False: + raise Exception("Unable to parse as yaml\n" + str(yaml_exception)) + + +def render_template(template_str, inputs, overrides, disable=False): + if disable is True: + return {"resource": None} + template = apply_jinja(template_str, inputs) + data = is_valid_template(template) + overrides = yaml.safe_load(overrides) or {} + return do_overrides(data, overrides) + + +def do_overrides(data, overrides): + """ + Form is: { + "foo.bar.0.star": "value", + "top_level_item": "value2", + "foo.bar.append": "value3", + } + + >>> do_overrides({}, {"foo": "bar"}) + {"foo": "bar"} + >>> do_overrides({"foo": {"bar": []}}, {"foo.bar.append": 5}) + {"foo": {"bar": [5]}} + >>> do_overrides({"foo": {"bar": [1, 3]}}, {"foo.bar.1": 4}) + {"foo": {"bar": [1, 4]}} + + Super naive path selector rules + separate path keys with periods + if we are setting/selecting into a list attempt to coerce key to an integer + if we are on the last path key and the key is "append" and the location is a list then append + Mutates passed in dictionary + """ + for path, value in overrides.items(): + + parts = path.split(".") + last_part = parts[-1] + first_parts = parts[:-1] + + item = data + for part in first_parts: + if isinstance(item, list): + part = int(part) + try: + item = item[part] + except KeyError: + raise ValueError("Invalid key: {0} in {1}".format(part, path)) + + # If the resulting "thing" is a list and the last part is the keyword "append" + # then we append to the list and continue, otherwise try to set it + if isinstance(item, list): + if last_part == "append": + item.append(value) + continue + else: + last_part = int(last_part) + item[last_part] = value + + return data diff --git a/estate/core/views/__init__.py b/estate/core/views/__init__.py new file mode 100644 index 0000000..8edb1ef --- /dev/null +++ b/estate/core/views/__init__.py @@ -0,0 +1,2 @@ +from __future__ import absolute_import +from .base import * # NOQA diff --git a/estate/core/views/base.py b/estate/core/views/base.py new file mode 100644 index 0000000..14857a2 --- /dev/null +++ b/estate/core/views/base.py @@ -0,0 +1,33 @@ +from __future__ import absolute_import +from django.db.models.query import QuerySet +from rest_framework import serializers, decorators, response + + +class HistoricalSerializer(serializers.ModelSerializer): + + def __init__(self, *args, **kwargs): + is_history = kwargs.pop("is_history", False) + super(HistoricalSerializer, self).__init__(*args, **kwargs) + if is_history: + instance = self.instance + if isinstance(instance, QuerySet): + try: + instance = instance[0] + except IndexError: + instance = None + if all([hasattr(instance, "history_id"), hasattr(instance, "history_user"), hasattr(instance, "history_date")]): + self.fields['user'] = serializers.SlugRelatedField(source="history_user", slug_field="username", read_only=True) + self.fields['date'] = serializers.DateTimeField(source="history_date", read_only=True) + allowed = set(getattr(self.Meta, "historical_fields", tuple()) + ('user', 'date')) + existing = set(self.fields.keys()) + for field_name in existing - allowed: + self.fields.pop(field_name) + + +class HistoryMixin(object): + + @decorators.detail_route(methods=["GET"]) + def history(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance.history.all(), many=True, is_history=True) + return response.Response(serializer.data) diff --git a/estate/gunicorn.py b/estate/gunicorn.py new file mode 100644 index 0000000..ea5c0e4 --- /dev/null +++ b/estate/gunicorn.py @@ -0,0 +1,11 @@ +import os +import multiprocessing + +addr = os.environ.get("GUNICORN_BIND_ADDRESS", "0.0.0.0") +port = os.environ.get("GUNICORN_BIND_PORT", "8000") +bind = "{0}:{1}".format(addr, port) +workers = os.environ.get("GUNICORN_WORKER_COUNT") or multiprocessing.cpu_count() * 10 + 1 +worker_class = os.environ.get("GUNICORN_WORKER_CLASS", "gevent") +loglevel = os.environ.get("GUNICORN_LOG_LEVEL", "info") +timeout = 0 +accesslog = "-" diff --git a/estate/pagination.py b/estate/pagination.py new file mode 100644 index 0000000..9052a06 --- /dev/null +++ b/estate/pagination.py @@ -0,0 +1,30 @@ +from __future__ import absolute_import +from rest_framework import pagination, response + + +class LinkHeaderPagination(pagination.PageNumberPagination): + page_size_query_param = "page_size" + + def get_paginated_response(self, data): + next_url = self.get_next_link() + previous_url = self.get_previous_link() + + if next_url is not None and previous_url is not None: + link = '<{next_url}>; rel="next", <{previous_url}>; rel="prev"' + elif next_url is not None: + link = '<{next_url}>; rel="next"' + elif previous_url is not None: + link = '<{previous_url}>; rel="prev"' + else: + link = '' + + link = link.format(next_url=next_url, previous_url=previous_url) + headers = {'Link': link} if link else {} + if self.page.has_previous(): + headers['PrevPage'] = self.page.previous_page_number() + headers['CurrentPage'] = self.page.number + if self.page.has_next(): + headers['NextPage'] = self.page.next_page_number() + headers['Pages'] = self.page.paginator.num_pages + + return response.Response(data, headers=headers) diff --git a/estate/settings/__init__.py b/estate/settings/__init__.py new file mode 100644 index 0000000..0d2db9b --- /dev/null +++ b/estate/settings/__init__.py @@ -0,0 +1,17 @@ +from __future__ import absolute_import +from .base import * # NOQA +from .logging import * # NOQA +from .history import * # NOQA +from .webpack import * # NOQA +from .drf import * # NOQA +from .storages import * # NOQA +from .sentry import * # NOQA +from .estate import * # NOQA +try: + from .local import * # NOQA +except ImportError: + pass +try: + from .custom import * # NOQA +except ImportError: + pass diff --git a/estate/settings/base.py b/estate/settings/base.py new file mode 100644 index 0000000..ae437f8 --- /dev/null +++ b/estate/settings/base.py @@ -0,0 +1,92 @@ +import ast +import json +import os +import dj_database_url + +PROJECT_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__), os.pardir)) + +SECRET_KEY = os.environ.get("SECRET_KEY", "UNKNOWN") + +DEBUG = ast.literal_eval(os.environ.get('DEBUG', 'False')) + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +ROOT_URLCONF = 'estate.urls' + +WSGI_APPLICATION = 'estate.wsgi.application' + +ALLOWED_HOSTS = [ + '*', +] + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'whitenoise.runserver_nostatic', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [os.path.join(PROJECT_ROOT, 'templates')], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +DATABASES = { + 'default': dj_database_url.config(default='sqlite:///%s/database.sqlite' % PROJECT_ROOT) +} + +DEFAULT_CACHES = """ +{ + "default": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache" + }, + "terraform": { + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "LOCATION": "/tmp/django_cache_terraform" + } +} +""" + +CACHES = json.loads(os.environ.get("CACHES", DEFAULT_CACHES)) + +STATIC_URL = '/static/' +STATIC_ROOT = os.path.join(PROJECT_ROOT, 'static') + +STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' + +STATICFILES_DIRS = ( + os.path.join(PROJECT_ROOT, 'assets'), +) diff --git a/estate/settings/drf.py b/estate/settings/drf.py new file mode 100644 index 0000000..1db3ae9 --- /dev/null +++ b/estate/settings/drf.py @@ -0,0 +1,62 @@ +from __future__ import absolute_import +from . import INSTALLED_APPS + + +def api_exception_handler(exc, context): + from rest_framework import views, exceptions + # Call REST framework's default exception handler first, + # to get the standard error response. + response = views.exception_handler(exc, context) + if response is None and isinstance(exc, Exception): + new_exc = exceptions.APIException(str(exc)) + response = views.exception_handler(new_exc, context) + # Now lets generate our standardized format + if response is not None: + response.data = {"errors": response.data, + "status_text": response.status_text, + "status_code": response.status_code} + + return response + + +INSTALLED_APPS += [ + 'rest_framework', + 'rest_framework_swagger', +] + +REST_FRAMEWORK = { + 'EXCEPTION_HANDLER': api_exception_handler, + 'DEFAULT_PAGINATION_CLASS': "estate.pagination.LinkHeaderPagination", + 'PAGE_SIZE': 10, + 'DEFAULT_AUTHENTICATION_CLASSES': ( + #'rest_framework.authentication.BasicAuthentication', + #'rest_framework.authentication.SessionAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + #'rest_framework.permissions.IsAuthenticated', + ), + 'DEFAULT_PARSER_CLASSES': ( + 'rest_framework.parsers.JSONParser', + 'rest_framework.parsers.FormParser', + 'rest_framework.parsers.MultiPartParser', + ), + 'DEFAULT_RENDERER_CLASSES': ( + 'rest_framework.renderers.JSONRenderer', + 'rest_framework.renderers.AdminRenderer', + 'rest_framework.renderers.HTMLFormRenderer', + ), + 'DEFAULT_FILTER_BACKENDS': ( + 'rest_framework.filters.SearchFilter', + 'rest_framework.filters.OrderingFilter', + 'rest_framework.filters.DjangoFilterBackend', + ) +} + +SWAGGER_SETTINGS = { + 'USE_SESSION_AUTH': True, + 'APIS_SORTER': 'alpha', + 'JSON_EDITOR': True, + 'VALIDATOR_URL': None +} + +CORS_URLS_REGEX = r'^/api/.*$' diff --git a/estate/settings/estate.py b/estate/settings/estate.py new file mode 100644 index 0000000..8dda676 --- /dev/null +++ b/estate/settings/estate.py @@ -0,0 +1,17 @@ +from __future__ import absolute_import +import os +from . import INSTALLED_APPS + +TERRAFORM_DOCKER_IMAGE = os.environ.get("TERRAFORM_DOCKER_IMAGE", "underarmourconnectedfitness/estate:master") + +TERRAFORM_EXTRA_ARGS = os.environ.get("TERRAFORM_EXTRA_ARGS", "-input=false") +TERRAFORM_INIT_EXTRA_ARGS = os.environ.get("TERRAFORM_INIT_EXTRA_ARGS", "") +TERRAFORM_PLAN_EXTRA_ARGS = os.environ.get("TERRAFORM_PLAN_EXTRA_ARGS", "-detailed-exitcode -out=plan") +TERRAFORM_APPLY_EXTRA_ARGS = os.environ.get("TERRAFORM_APPLY_EXTRA_ARGS", "") + + +INSTALLED_APPS += [ + "estate.core", + "estate.terraform", + "estate.nomad", +] diff --git a/estate/settings/history.py b/estate/settings/history.py new file mode 100644 index 0000000..6c88ad4 --- /dev/null +++ b/estate/settings/history.py @@ -0,0 +1,13 @@ +from __future__ import absolute_import +from . import INSTALLED_APPS # , MIDDLEWARE + +INSTALLED_APPS += [ + 'simple_history', +] + +PERMANENT_FIELD = 'deleted' + +# this doesn't work with django 1.10 +# MIDDLEWARE = MIDDLEWARE + ( +# 'simple_history.middleware.HistoryRequestMiddleware', +# ) diff --git a/estate/settings/logging.py b/estate/settings/logging.py new file mode 100644 index 0000000..511a1bd --- /dev/null +++ b/estate/settings/logging.py @@ -0,0 +1,71 @@ +from __future__ import absolute_import +import structlog + +structlog.configure( + processors=[ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.JSONRenderer() + ], + context_class=dict, + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, +) + + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': True, + 'root': { + 'level': 'WARNING', + 'handlers': ['null'], + }, + 'formatters': { + 'simple': { + 'format': '%(message)s' + }, + 'verbose': { + 'format': '[%(levelname)s] %(name)s.%(funcName)s | %(message)s' + }, + }, + 'handlers': { + 'null': { + 'level': 'DEBUG', + 'class': 'logging.NullHandler', + }, + 'stdout': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'stream': 'ext://sys.stdout', + 'formatter': 'simple' + }, + 'stderr': { + 'level': 'DEBUG', + 'class': 'logging.StreamHandler', + 'formatter': 'verbose' + } + }, + 'loggers': { + 'estate': { + 'level': 'INFO', + 'handlers': ['stdout'], + 'propagate': False, + }, + 'DockerHotExecutor': { + 'level': 'INFO', + 'handlers': ['stdout'], + 'propagate': False, + }, + 'DjangoCacheStreamer': { + 'level': 'INFO', + 'handlers': ['stdout'], + 'propagate': False, + } + } +} diff --git a/estate/settings/sentry.py b/estate/settings/sentry.py new file mode 100644 index 0000000..e218966 --- /dev/null +++ b/estate/settings/sentry.py @@ -0,0 +1,39 @@ +from __future__ import absolute_import +import os +from . import INSTALLED_APPS, LOGGING, MIDDLEWARE + +SENTRY_DSN = os.environ.get('SENTRY_DSN') + +INSTALLED_APPS += [ + 'raven.contrib.django.raven_compat', +] + +if SENTRY_DSN: + MIDDLEWARE = ( + 'raven.contrib.django.raven_compat.middleware.Sentry404CatchMiddleware', + ) + MIDDLEWARE + RAVEN_CONFIG = { + 'dsn': SENTRY_DSN, + 'release': os.environ.get('RELEASE', "UNKNOWN"), + } + LOGGING['root']['level'] = 'WARNING' + LOGGING['root']['handlers'].append('sentry') + LOGGING['handlers']['sentry'] = { + 'level': 'WARNING', + 'class': 'raven.contrib.django.raven_compat.handlers.SentryHandler', + } + LOGGING['loggers']['raven'] = { + 'level': 'DEBUG', + 'handlers': ['stderr'], + 'propagate': False, + } + LOGGING['loggers']['sentry.errors'] = { + 'level': 'DEBUG', + 'handlers': ['stderr'], + 'propagate': False, + } + LOGGING['loggers']['django.db.backends'] = { + 'level': 'ERROR', + 'handlers': ['sentry'], + 'propagate': False, + } diff --git a/estate/settings/webpack.py b/estate/settings/webpack.py new file mode 100644 index 0000000..5df228c --- /dev/null +++ b/estate/settings/webpack.py @@ -0,0 +1,16 @@ +from __future__ import absolute_import +import os +from .base import INSTALLED_APPS, PROJECT_ROOT, DEBUG + +INSTALLED_APPS += [ + 'webpack_loader', +] + +WEBPACK_LOADER = { + 'DEFAULT': { + 'CACHE': not DEBUG, + 'BUNDLE_DIR_NAME': 'bundles/', # must end with slash + 'STATS_FILE': os.path.join(PROJECT_ROOT, 'assets', 'webpack-stats.json'), + 'IGNORE': ['.+\.hot-update.js', '.+\.map'] + } +} diff --git a/estate/templates/index.html b/estate/templates/index.html new file mode 100644 index 0000000..984f821 --- /dev/null +++ b/estate/templates/index.html @@ -0,0 +1,30 @@ +<!--{% load raven %}--> +{% load render_bundle from webpack_loader %} + +<!DOCTYPE html> +<html lang="en"> + <head> + <title>Estate + + + + + + + + + + + + + + + + + + + +
+ {% render_bundle 'main' %} + + diff --git a/estate/terraform/__init__.py b/estate/terraform/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/estate/terraform/admin/__init__.py b/estate/terraform/admin/__init__.py new file mode 100644 index 0000000..8aab3d4 --- /dev/null +++ b/estate/terraform/admin/__init__.py @@ -0,0 +1,4 @@ +from __future__ import absolute_import +from .namespace import * # NOQA +from .file import * # NOQA +from .template import * # NOQA diff --git a/estate/terraform/admin/file.py b/estate/terraform/admin/file.py new file mode 100644 index 0000000..055dcd6 --- /dev/null +++ b/estate/terraform/admin/file.py @@ -0,0 +1,15 @@ +from __future__ import absolute_import +from django.contrib import admin +from django.apps import apps + +File = apps.get_model('terraform.File') + + +class FileAdmin(admin.ModelAdmin): + list_display = ['pk', 'title', 'description', 'modified'] + list_filter = ['title'] + search_fields = ['slug', 'title'] + list_per_page = 10 + + +admin.site.register(File, FileAdmin) diff --git a/estate/terraform/admin/namespace.py b/estate/terraform/admin/namespace.py new file mode 100644 index 0000000..229664d --- /dev/null +++ b/estate/terraform/admin/namespace.py @@ -0,0 +1,16 @@ +from __future__ import absolute_import +from django.contrib import admin +from django.apps import apps + +Namespace = apps.get_model('terraform.Namespace') + + +class NamespaceAdmin(admin.ModelAdmin): + list_display = ['pk', 'title', 'description', 'modified'] + list_editable = ['title', 'description'] + list_filter = ['title'] + search_fields = ['slug', 'title'] + list_per_page = 10 + + +admin.site.register(Namespace, NamespaceAdmin) diff --git a/estate/terraform/admin/template.py b/estate/terraform/admin/template.py new file mode 100644 index 0000000..d3d62b4 --- /dev/null +++ b/estate/terraform/admin/template.py @@ -0,0 +1,24 @@ +from __future__ import absolute_import +from django.contrib import admin +from django.apps import apps + +Template = apps.get_model('terraform.Template') +TemplateInstance = apps.get_model('terraform.TemplateInstance') + + +class TemplateAdmin(admin.ModelAdmin): + list_display = ['pk', 'title', 'description', 'version', 'modified'] + list_filter = ['title'] + search_fields = ['slug', 'title'] + list_per_page = 10 + + +class TemplateInstanceAdmin(admin.ModelAdmin): + list_display = ['pk', 'title', 'description', 'modified'] + list_filter = ['title'] + search_fields = ['slug', 'title'] + list_per_page = 10 + + +admin.site.register(Template, TemplateAdmin) +admin.site.register(TemplateInstance, TemplateInstanceAdmin) diff --git a/estate/terraform/apps.py b/estate/terraform/apps.py new file mode 100644 index 0000000..3a4c61f --- /dev/null +++ b/estate/terraform/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class APIConfig(AppConfig): + name = 'terraform' diff --git a/estate/terraform/migrations/0001_initial.py b/estate/terraform/migrations/0001_initial.py new file mode 100644 index 0000000..372f2a6 --- /dev/null +++ b/estate/terraform/migrations/0001_initial.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-06-26 14:59 +from __future__ import unicode_literals + +from django.conf import settings +import django.contrib.postgres.fields.jsonb +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import semantic_version.base + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='File', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug')), + ('content', models.TextField(blank=True, verbose_name='content')), + ], + options={ + 'ordering': ('-modified', '-created'), + 'abstract': False, + 'get_latest_by': 'modified', + }, + ), + migrations.CreateModel( + name='HistoricalFile', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug')), + ('content', models.TextField(blank=True, verbose_name='content')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + 'verbose_name': 'historical file', + }, + ), + migrations.CreateModel( + name='HistoricalNamespace', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug')), + ('owner', models.CharField(max_length=80, verbose_name='owner')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + 'verbose_name': 'historical namespace', + }, + ), + migrations.CreateModel( + name='HistoricalTemplate', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug')), + ('version', models.CharField(default=b'0.0.0', max_length=128, validators=[semantic_version.base.validate], verbose_name='version')), + ('json_schema', models.TextField(blank=True, verbose_name='JSONSchema')), + ('ui_schema', models.TextField(blank=True, verbose_name='UISchema')), + ('body', models.TextField(blank=True, verbose_name='body')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + 'verbose_name': 'historical template', + }, + ), + migrations.CreateModel( + name='HistoricalTemplateInstance', + fields=[ + ('id', models.IntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug')), + ('disable', models.BooleanField(default=False, verbose_name='disable')), + ('inputs', django.contrib.postgres.fields.jsonb.JSONField(verbose_name='inputs')), + ('overrides', models.TextField(blank=True, verbose_name='overrides')), + ('historical_template', models.IntegerField()), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + 'verbose_name': 'historical template instance', + }, + ), + migrations.CreateModel( + name='Namespace', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug')), + ('owner', models.CharField(max_length=80, verbose_name='owner')), + ], + options={ + 'ordering': ('-modified', '-created'), + 'abstract': False, + 'get_latest_by': 'modified', + }, + ), + migrations.CreateModel( + name='Template', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug')), + ('version', models.CharField(default=b'0.0.0', max_length=128, validators=[semantic_version.base.validate], verbose_name='version')), + ('json_schema', models.TextField(blank=True, verbose_name='JSONSchema')), + ('ui_schema', models.TextField(blank=True, verbose_name='UISchema')), + ('body', models.TextField(blank=True, verbose_name='body')), + ], + options={ + 'ordering': ('-modified', '-created'), + 'abstract': False, + 'get_latest_by': 'modified', + }, + ), + migrations.CreateModel( + name='TemplateDependency', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('condition', models.IntegerField(choices=[(1, 'Any'), (2, 'All'), (3, 'Not Any'), (4, 'Not All')], default=1)), + ('dependencies', models.ManyToManyField(related_name='asDependent', to='terraform.Template')), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dependencies', to='terraform.Template')), + ], + options={ + 'verbose_name': 'TemplateDependencies', + }, + ), + migrations.CreateModel( + name='TemplateInstance', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('slug', django_extensions.db.fields.AutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug')), + ('disable', models.BooleanField(default=False, verbose_name='disable')), + ('inputs', django.contrib.postgres.fields.jsonb.JSONField(verbose_name='inputs')), + ('overrides', models.TextField(blank=True, verbose_name='overrides')), + ('historical_template', models.IntegerField()), + ('namespace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='templates', to='terraform.Namespace')), + ], + options={ + 'ordering': ('-modified', '-created'), + 'abstract': False, + 'get_latest_by': 'modified', + }, + ), + migrations.AddField( + model_name='historicaltemplateinstance', + name='namespace', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='terraform.Namespace'), + ), + migrations.AddField( + model_name='historicalfile', + name='namespace', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='terraform.Namespace'), + ), + migrations.AddField( + model_name='file', + name='namespace', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='terraform.Namespace'), + ), + ] diff --git a/estate/terraform/migrations/0002_auto_20170627_1932.py b/estate/terraform/migrations/0002_auto_20170627_1932.py new file mode 100644 index 0000000..9ca1425 --- /dev/null +++ b/estate/terraform/migrations/0002_auto_20170627_1932.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-06-27 19:32 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('terraform', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='file', + name='disable', + field=models.BooleanField(default=False, verbose_name='disable'), + ), + migrations.AddField( + model_name='historicalfile', + name='disable', + field=models.BooleanField(default=False, verbose_name='disable'), + ), + ] diff --git a/estate/terraform/migrations/0003_auto_20170707_1414.py b/estate/terraform/migrations/0003_auto_20170707_1414.py new file mode 100644 index 0000000..3523847 --- /dev/null +++ b/estate/terraform/migrations/0003_auto_20170707_1414.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-07-07 14:14 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('terraform', '0002_auto_20170627_1932'), + ] + + operations = [ + migrations.AddField( + model_name='file', + name='deleted', + field=models.DateTimeField(blank=True, default=None, editable=False, null=True), + ), + migrations.AddField( + model_name='historicalfile', + name='deleted', + field=models.DateTimeField(blank=True, default=None, editable=False, null=True), + ), + migrations.AddField( + model_name='historicalnamespace', + name='deleted', + field=models.DateTimeField(blank=True, default=None, editable=False, null=True), + ), + migrations.AddField( + model_name='historicaltemplate', + name='deleted', + field=models.DateTimeField(blank=True, default=None, editable=False, null=True), + ), + migrations.AddField( + model_name='historicaltemplateinstance', + name='deleted', + field=models.DateTimeField(blank=True, default=None, editable=False, null=True), + ), + migrations.AddField( + model_name='namespace', + name='deleted', + field=models.DateTimeField(blank=True, default=None, editable=False, null=True), + ), + migrations.AddField( + model_name='template', + name='deleted', + field=models.DateTimeField(blank=True, default=None, editable=False, null=True), + ), + migrations.AddField( + model_name='templateinstance', + name='deleted', + field=models.DateTimeField(blank=True, default=None, editable=False, null=True), + ), + ] diff --git a/estate/terraform/migrations/0004_auto_20170711_2051.py b/estate/terraform/migrations/0004_auto_20170711_2051.py new file mode 100644 index 0000000..8c96b00 --- /dev/null +++ b/estate/terraform/migrations/0004_auto_20170711_2051.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-07-11 20:51 +from __future__ import unicode_literals + +from django.db import migrations, models +import estate.core.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('terraform', '0003_auto_20170707_1414'), + ] + + operations = [ + migrations.AddField( + model_name='historicalnamespace', + name='vault_backend', + field=models.CharField(blank=True, max_length=1024, null=True, verbose_name='vault_backend'), + ), + migrations.AddField( + model_name='namespace', + name='vault_backend', + field=models.CharField(blank=True, max_length=1024, null=True, verbose_name='vault_backend'), + ), + migrations.AlterField( + model_name='file', + name='slug', + field=estate.core.models.fields.SoftDeleteAwareAutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug'), + ), + migrations.AlterField( + model_name='historicalfile', + name='slug', + field=estate.core.models.fields.SoftDeleteAwareAutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug'), + ), + migrations.AlterField( + model_name='historicalnamespace', + name='slug', + field=estate.core.models.fields.SoftDeleteAwareAutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug'), + ), + migrations.AlterField( + model_name='historicaltemplate', + name='slug', + field=estate.core.models.fields.SoftDeleteAwareAutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug'), + ), + migrations.AlterField( + model_name='historicaltemplateinstance', + name='slug', + field=estate.core.models.fields.SoftDeleteAwareAutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug'), + ), + migrations.AlterField( + model_name='namespace', + name='slug', + field=estate.core.models.fields.SoftDeleteAwareAutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug'), + ), + migrations.AlterField( + model_name='template', + name='slug', + field=estate.core.models.fields.SoftDeleteAwareAutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug'), + ), + migrations.AlterField( + model_name='templateinstance', + name='slug', + field=estate.core.models.fields.SoftDeleteAwareAutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug'), + ), + ] diff --git a/estate/terraform/migrations/0005_auto_20170804_0146.py b/estate/terraform/migrations/0005_auto_20170804_0146.py new file mode 100644 index 0000000..f1e9199 --- /dev/null +++ b/estate/terraform/migrations/0005_auto_20170804_0146.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-08-04 01:46 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('terraform', '0004_auto_20170711_2051'), + ] + + operations = [ + migrations.RemoveField( + model_name='historicalnamespace', + name='vault_backend', + ), + migrations.RemoveField( + model_name='namespace', + name='vault_backend', + ), + ] diff --git a/estate/terraform/migrations/0006_auto_20170804_1357.py b/estate/terraform/migrations/0006_auto_20170804_1357.py new file mode 100644 index 0000000..eedd55a --- /dev/null +++ b/estate/terraform/migrations/0006_auto_20170804_1357.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-08-04 13:57 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('terraform', '0005_auto_20170804_0146'), + ] + + operations = [ + migrations.RemoveField( + model_name='historicalfile', + name='slug', + ), + migrations.RemoveField( + model_name='historicalnamespace', + name='slug', + ), + migrations.RemoveField( + model_name='historicaltemplate', + name='slug', + ), + migrations.RemoveField( + model_name='historicaltemplateinstance', + name='slug', + ), + migrations.AddField( + model_name='historicalfile', + name='history_change_reason', + field=models.CharField(max_length=100, null=True), + ), + migrations.AddField( + model_name='historicalnamespace', + name='history_change_reason', + field=models.CharField(max_length=100, null=True), + ), + migrations.AddField( + model_name='historicaltemplate', + name='history_change_reason', + field=models.CharField(max_length=100, null=True), + ), + migrations.AddField( + model_name='historicaltemplateinstance', + name='history_change_reason', + field=models.CharField(max_length=100, null=True), + ), + ] diff --git a/estate/terraform/migrations/__init__.py b/estate/terraform/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/estate/terraform/models/__init__.py b/estate/terraform/models/__init__.py new file mode 100644 index 0000000..f698428 --- /dev/null +++ b/estate/terraform/models/__init__.py @@ -0,0 +1,4 @@ +from __future__ import absolute_import +from .file import * # NOQA +from .template import * # NOQA +from .namespace import * # NOQA \ No newline at end of file diff --git a/estate/terraform/models/file.py b/estate/terraform/models/file.py new file mode 100644 index 0000000..76466c5 --- /dev/null +++ b/estate/terraform/models/file.py @@ -0,0 +1,14 @@ +import logging +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from ...core.models.base import EstateAbstractBase, HistoricalRecordsWithoutDelete + +LOG = logging.getLogger(__name__) + + +class File(EstateAbstractBase): + content = models.TextField(_("content"), blank=True) + namespace = models.ForeignKey("terraform.Namespace", related_name="files") + disable = models.BooleanField(_('disable'), default=False) + + history = HistoricalRecordsWithoutDelete(excluded_fields=['slug']) diff --git a/estate/terraform/models/namespace.py b/estate/terraform/models/namespace.py new file mode 100644 index 0000000..b28dd11 --- /dev/null +++ b/estate/terraform/models/namespace.py @@ -0,0 +1,34 @@ +from __future__ import absolute_import +import logging +from django.db import models +from django.dispatch import receiver +from django.utils.translation import ugettext_lazy as _ +from django_permanent.signals import post_restore +from ...core.models.base import EstateAbstractBase, HistoricalRecordsWithoutDelete +from .template import TemplateInstance +from .file import File + +LOG = logging.getLogger(__name__) + + +class Namespace(EstateAbstractBase): + owner = models.CharField(_('owner'), max_length=80) + # TODO: Add tags + + history = HistoricalRecordsWithoutDelete(excluded_fields=['slug']) + + @property + def terraform_files(self): + output = [] + output += [f for f in self.files.all() if f.disable is False] + output += [t for t in self.templates.all() if t.disable is False] + return output + + +@receiver(post_restore, sender=Namespace) +def restore_related_objects(sender, instance, *args, **kwargs): + LOG.info("Restoring {0} Related Objects".format(instance)) + for F in File.deleted_objects.filter(namespace__id=instance.pk): + F.restore() + for T in TemplateInstance.deleted_objects.filter(namespace__id=instance.pk): + T.restore() diff --git a/estate/terraform/models/template.py b/estate/terraform/models/template.py new file mode 100644 index 0000000..304ef09 --- /dev/null +++ b/estate/terraform/models/template.py @@ -0,0 +1,69 @@ +from __future__ import absolute_import +import json +import logging +from django.db import models +from django.utils.translation import ugettext_lazy as _ +from django.contrib.postgres.fields import JSONField +import semantic_version +from ...core.models.base import EstateAbstractBase, HistoricalRecordsWithoutDelete +from ...core.renderer import render_template + +LOG = logging.getLogger(__name__) + + +class TemplateDependency(models.Model): + CONDITIONS = ( + (1, _("Any")), + (2, _("All")), + (3, _("Not Any")), + (4, _("Not All")), + ) + + dependencies = models.ManyToManyField("terraform.Template", related_name="asDependent") + template = models.ForeignKey("terraform.Template", related_name="dependencies") + condition = models.IntegerField(choices=CONDITIONS, default=1) + + class Meta: + verbose_name = "TemplateDependencies" + + +class Template(EstateAbstractBase): + version = models.CharField(_('version'), max_length=128, default="0.0.0", validators=[semantic_version.validate]) + json_schema = models.TextField(_('JSONSchema'), blank=True) + ui_schema = models.TextField(_('UISchema'), blank=True) + body = models.TextField(_('body'), blank=True) + + history = HistoricalRecordsWithoutDelete(excluded_fields=['slug']) + + @property + def semantic_version(self): + return semantic_version.Version(self.version) + + +class TemplateInstance(EstateAbstractBase): + namespace = models.ForeignKey('terraform.Namespace', related_name="templates") + disable = models.BooleanField(_('disable'), default=False) + inputs = JSONField(_('inputs')) + overrides = models.TextField(_('overrides'), blank=True) + historical_template = models.IntegerField() + + history = HistoricalRecordsWithoutDelete(excluded_fields=['slug']) + + @property + def template(self): + return Template.history.get(pk=self.historical_template) + + @property + def semantic_version(self): + return semantic_version.Version(self.template.version) + + @property + def is_outdated(self): + template = Template.all_objects.get(pk=self.template.id) + latest = template.semantic_version + current = self.semantic_version + return latest > current + + @property + def content(self): + return json.dumps(render_template(self.template.body, self.inputs, self.overrides, self.disable)) diff --git a/estate/terraform/terraform.py b/estate/terraform/terraform.py new file mode 100644 index 0000000..73d327b --- /dev/null +++ b/estate/terraform/terraform.py @@ -0,0 +1,109 @@ +from __future__ import absolute_import +import hashlib +import logging +import os +import re +from django.conf import settings +from ..core.HotDockerExecutor import HotDockerExecutor +from ..core.DjangoCacheStreamer import DjangoCacheStreamer + +LOG = logging.getLogger("estate") + +PLAN = """#!/bin/bash +ex +terraform init {TERRAFORM_EXTRA_ARGS} {TERRAFORM_INIT_EXTRA_ARGS} +terraform plan {TERRAFORM_EXTRA_ARGS} {TERRAFORM_PLAN_EXTRA_ARGS} +""" + +APPLY = """#!/bin/bash +ex +terraform apply {TERRAFORM_EXTRA_ARGS} {TERRAFORM_APPLY_EXTRA_ARGS} plan +""" + +HAS_EXT = re.compile(".*\.([a-zA-Z]+)") + + +class TerraformStreamer(DjangoCacheStreamer): + def __init__(self, action, slug, *args, **kwargs): + super(TerraformStreamer, self).__init__("terraform", action + "_" + slug, *args, **kwargs) + + def get_plan_key(self, plan_hash): + return self.namespace_slug + "_" + plan_hash + + def get_plan(self, plan_hash): + return self.cache.get(self.get_plan_key(plan_hash)) + + def save_plan(self, plan_hash, plan_data): + self.state["output"] += ["\nPlan Hash: " + plan_hash] + self.state["plan_hash"] = plan_hash + self.set(self.state, None) + self.cache.set(self.get_plan_key(plan_hash), plan_data, None) + + +class Terraform(HotDockerExecutor): + + def __init__(self, action, namespace, plan_hash=None): + self.action = action + self.namespace = namespace + self.plan_hash = plan_hash + config = { + "docker_image": settings.TERRAFORM_DOCKER_IMAGE, + "name": self.namespace.slug, + "streamer": TerraformStreamer(action, self.namespace.slug) + } + if action == "plan": + config["command"] = PLAN + else: + config["command"] = APPLY + config["command"] = config["command"].format( + TERRAFORM_EXTRA_ARGS=settings.TERRAFORM_EXTRA_ARGS, + TERRAFORM_INIT_EXTRA_ARGS=settings.TERRAFORM_INIT_EXTRA_ARGS, + TERRAFORM_PLAN_EXTRA_ARGS=settings.TERRAFORM_PLAN_EXTRA_ARGS, + TERRAFORM_APPLY_EXTRA_ARGS=settings.TERRAFORM_APPLY_EXTRA_ARGS, + ) + config["command"] = config["command"].format(NAMESPACE=self.namespace.slug) + super(Terraform, self).__init__(config=config) + + def run(self, *args, **kwargs): + self.streamer.clear_cache() + super(Terraform, self).run(*args, **kwargs) + + def write_files(self): + if self.streamer is not None: + self.streamer.log("Preparing Namespace '{0}' for action '{1}'\n".format(self.namespace.title, self.action)) + LOG.info("[Terraform] Preparing Namespace '{0}' for action '{1}'".format(self.namespace.title, self.action)) + if self.action == "plan": + for item in self.namespace.terraform_files: + path = os.path.join(self.workdir, str(item.pk) + "_" + item.slug + ".tf") + has_ext = HAS_EXT.search(item.title) + if has_ext: + path = os.path.join(self.workdir, item.title) + if self.streamer is not None: + self.streamer.log("Writing terraform file: {0}\n".format(item.title)) + LOG.info("[Terraform] Writing file: {0}".format(path)) + with open(path, "wb") as f: + f.write(item.content) + if self.action == "apply": + if self.plan_hash is None: + raise Exception("Unable to perform action 'apply' no plan was found!") + path = os.path.join(self.workdir, "plan.tar.gz") + plan_data = self.streamer.get_plan(self.plan_hash) + if plan_data is None: + raise Exception("Unable to find plan data!") + with open(path, "wb") as f: + f.write(plan_data) + exit_code, _ = self.execute_command(["tar", "-xzvf", path, "-C", self.workdir], self.workdir) + if exit_code != 0: + raise Exception("Unable to unpack plan file!") + + def finish(self): + if self.action == "plan" and self.exit_code == 2: + path = os.path.join(self.workdir, "plan.tar.gz") + exit_code, _ = self.execute_command(["tar", "-czvf", path, "./plan"], self.workdir) + if exit_code != 0: + raise Exception("Unable to save plan file!") + with open(path, "rb") as f: + plan_data = f.read() + plan_hash = hashlib.md5(plan_data).hexdigest() + self.streamer.save_plan(plan_hash, plan_data) + + def get_stream(self): + return self.streamer.get() diff --git a/estate/terraform/urls.py b/estate/terraform/urls.py new file mode 100644 index 0000000..3515b10 --- /dev/null +++ b/estate/terraform/urls.py @@ -0,0 +1,15 @@ +from __future__ import absolute_import +from django.conf.urls import url, include +from rest_framework import routers +from . import views + +router = routers.DefaultRouter() +router.register(r"file", views.FileApiView) +router.register(r"template", views.TemplateApiView) +router.register(r"templateinstance", views.TemplateInstanceApiView) +router.register(r"namespace", views.NamespaceApiView) +router.include_root_view = True + +urlpatterns = [ + url(r'^', include(router.urls)), +] diff --git a/estate/terraform/views/__init__.py b/estate/terraform/views/__init__.py new file mode 100644 index 0000000..816b447 --- /dev/null +++ b/estate/terraform/views/__init__.py @@ -0,0 +1,4 @@ +from __future__ import absolute_import +from .file import FileApiView # NOQA +from .template import TemplateApiView, TemplateInstanceApiView # NOQA +from .namespace import NamespaceApiView # NOQA diff --git a/estate/terraform/views/file.py b/estate/terraform/views/file.py new file mode 100644 index 0000000..2c9bbc7 --- /dev/null +++ b/estate/terraform/views/file.py @@ -0,0 +1,28 @@ +from __future__ import absolute_import +from django.apps import apps +from rest_framework import serializers, viewsets +from estate.core.views import HistoricalSerializer, HistoryMixin + +Namespace = apps.get_model('terraform.Namespace') +File = apps.get_model('terraform.File') + + +###### +# File +###### +class FileSerializer(HistoricalSerializer): + description = serializers.CharField(default="", allow_blank=True) + namespace = serializers.SlugRelatedField(slug_field="slug", queryset=Namespace.objects.all()) + + class Meta: + model = File + fields = ("pk", "slug", "title", "description", "namespace", "content", "disable", "created", "modified") + historical_fields = ("pk", "slug", "title", "namespace", "description", "content", "disable") + + +class FileApiView(HistoryMixin, viewsets.ModelViewSet): + queryset = File.objects.all() + serializer_class = FileSerializer + filter_fields = ('slug',) + search_fields = ('title',) + ordering_fields = ('title', 'created', 'modified') diff --git a/estate/terraform/views/namespace.py b/estate/terraform/views/namespace.py new file mode 100644 index 0000000..79233c9 --- /dev/null +++ b/estate/terraform/views/namespace.py @@ -0,0 +1,67 @@ +from __future__ import absolute_import +import django_filters +from django.apps import apps +from rest_framework import serializers, viewsets, filters, decorators, response +from estate.core.views import HistoricalSerializer, HistoryMixin +from .file import FileSerializer +from .template import TemplateInstanceSerializer +from ..terraform import Terraform + +Namespace = apps.get_model('terraform.Namespace') + + +class NamespaceSerializer(HistoricalSerializer): + description = serializers.CharField(default="", allow_blank=True) + owner = serializers.CharField(default="", allow_blank=True) + files = FileSerializer(many=True, read_only=True, is_history=True) + templates = TemplateInstanceSerializer(many=True, read_only=True, is_history=True) + + class Meta: + model = Namespace + fields = ("pk", "slug", "title", "description", "owner", "files", "templates", "created", "modified") + historical_fields = ("pk", "slug", "title", "description", "owner", "historical_files", "historical_templates") + + +class NamespaceFilter(filters.FilterSet): + owner = django_filters.CharFilter(label="owner", method="filter_is_owner") + + class Meta: + model = Namespace + fields = ["title", "owner", "slug"] + + def filter_is_owner(self, qs, name, value): + return qs + + +class NamespaceApiView(HistoryMixin, viewsets.ModelViewSet): + queryset = Namespace.objects.all() + serializer_class = NamespaceSerializer + filter_class = NamespaceFilter + search_fields = ('title',) + ordering_fields = ('title', 'created', 'modified') + + @decorators.detail_route(methods=["POST"]) + def plan(self, request, *args, **kwargs): + instance = self.get_object() + runner = Terraform("plan", instance) + runner.run() + return response.Response(runner.get_stream()) + + @decorators.detail_route(methods=["Get"]) + def plan_live(self, request, *args, **kwargs): + instance = self.get_object() + runner = Terraform("plan", instance, {}) + return response.Response(runner.get_stream()) + + @decorators.detail_route(methods=["POST"], url_path=r'apply/(?P.*)') + def apply(self, request, plan_hash, *args, **kwargs): + instance = self.get_object() + runner = Terraform("apply", instance, plan_hash) + runner.run() + return response.Response(runner.get_stream()) + + @decorators.detail_route(methods=["Get"]) + def apply_live(self, request, *args, **kwargs): + instance = self.get_object() + runner = Terraform("apply", instance, {}) + return response.Response(runner.get_stream()) diff --git a/estate/terraform/views/template.py b/estate/terraform/views/template.py new file mode 100644 index 0000000..a1a785a --- /dev/null +++ b/estate/terraform/views/template.py @@ -0,0 +1,140 @@ +from __future__ import absolute_import +import json +from django.apps import apps +from rest_framework import serializers, viewsets, decorators, exceptions, status, response +from semantic_version import Version +from estate.core.views import HistoricalSerializer, HistoryMixin +from estate.core import renderer + +Namespace = apps.get_model("terraform.Namespace") +Template = apps.get_model("terraform.Template") +TemplateInstance = apps.get_model("terraform.TemplateInstance") + + +########## +# Template +########## +class TemplateSerializer(HistoricalSerializer): + description = serializers.CharField(default="", allow_blank=True) + version = serializers.CharField(read_only=True) + version_increment = serializers.ChoiceField(choices=["major", "minor", "patch", "initial"], write_only=True) + body = serializers.CharField(default="", allow_blank=True, validators=[renderer.is_valid_template]) + body_mode = serializers.SerializerMethodField() + + class Meta: + model = Template + fields = ("pk", "slug", "title", "description", "version", "version_increment", "json_schema", "ui_schema", "body", "body_mode", "created", "modified") + historical_fields = ("pk", "slug", "title", "description", "version", "json_schema", "ui_schema", "body") + + def get_body_mode(self, instance): + return renderer.get_style(instance.body) or "hcl" + + def create(self, validated_data): + version_increment = validated_data.pop("version_increment", "initial") + if version_increment != "initial": + raise exceptions.APIException("Unable to create new template with version_increment set to something other then 'initial'!") + validated_data["version"] = "0.0.1" + validated_data["dependencies"] = [] + return super(TemplateSerializer, self).create(validated_data) + + def update(self, instance, validated_data): + version_increment = validated_data.pop("version_increment", "patch") + if version_increment == "initial": + raise exceptions.APIException("Unable to update template with version_increment set to 'initial'!") + v = Version(instance.version) + if version_increment == "major": + validated_data["version"] = str(v.next_major()) + if version_increment == "minor": + validated_data["version"] = str(v.next_minor()) + if version_increment == "patch": + validated_data["version"] = str(v.next_patch()) + validated_data["dependencies"] = [] + return super(TemplateSerializer, self).update(instance, validated_data) + + +class TemplateRenderSerializer(serializers.Serializer): + body = serializers.CharField(allow_blank=True, write_only=True, validators=[renderer.is_valid_template]) + inputs = serializers.JSONField(write_only=True) + overrides = serializers.CharField(default="", allow_blank=True, write_only=True) + output = serializers.JSONField(read_only=True) + disable = serializers.BooleanField(default=False) + + +class TemplateApiView(HistoryMixin, viewsets.ModelViewSet): + queryset = Template.objects.all() + serializers = { + "default": TemplateSerializer, + "render": TemplateRenderSerializer, + } + filter_fields = ("slug", "version",) + search_fields = ("title", "version") + ordering_fields = ("title", "created", "modified") + + def get_serializer_class(self): + return self.serializers.get(self.action, self.serializers["default"]) + + @decorators.list_route(methods=["POST"]) + def render(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + body = serializer.validated_data["body"] + inputs = json.loads(serializer.validated_data["inputs"]) + overrides = serializer.validated_data["overrides"] + disable = serializer.validated_data["disable"] + try: + output = renderer.render_template(body, inputs, overrides, disable) + except Exception as e: + raise exceptions.APIException(str(e)) + headers = self.get_success_headers(serializer.data) + return response.Response(output, status=status.HTTP_201_CREATED, headers=headers) + + +################## +# TemplateInstance +################## +class TemplateInstanceSerializer(HistoricalSerializer): + namespace = serializers.SlugRelatedField(slug_field="slug", queryset=Namespace.objects.all()) + description = serializers.CharField(default="", allow_blank=True) + inputs = serializers.JSONField() + overrides = serializers.CharField(default="", allow_blank=True) + templateID = serializers.IntegerField(write_only=True) + template = TemplateSerializer(read_only=True, is_history=True) + is_outdated = serializers.SerializerMethodField() + + class Meta: + model = TemplateInstance + fields = ("pk", "slug", "title", "description", "namespace", "inputs", "overrides", "disable", "templateID", "template", "is_outdated", "created", "modified") + historical_fields = ("pk", "slug", "title", "namespace", "description", "inputs", "overrides", "disable") + + def get_is_outdated(self, instance): + return instance.is_outdated + + def create(self, validated_data): + template_id = validated_data.pop("templateID", False) + if template_id is False: + raise exceptions.APIException("Unable to create new templateInstance because a templateID was not given!") + validated_data["historical_template"] = Template.all_objects.get(pk=template_id).history.latest().pk + return super(TemplateInstanceSerializer, self).create(validated_data) + + def update(self, instance, validated_data): + template_id = validated_data.pop("templateID", False) + if template_id is not False: + validated_data["historical_template"] = Template.all_objects.get(pk=template_id).history.latest().pk + return super(TemplateInstanceSerializer, self).update(instance, validated_data) + + +class TemplateInstanceApiView(HistoryMixin, viewsets.ModelViewSet): + queryset = TemplateInstance.objects.all() + serializer_class = TemplateInstanceSerializer + filter_fields = ("slug",) + search_fields = ("title",) + ordering_fields = ("title", "created", "modified") + + @decorators.detail_route(methods=["POST"]) + def update_template(self, request, *args, **kwargs): + instance = self.get_object() + if instance.is_outdated: + instance.historical_template = Template.all_objects.get(pk=instance.template.id).history.latest().pk + instance.save() + serializer = self.get_serializer(instance) + return response.Response(serializer.data) diff --git a/estate/urls.py b/estate/urls.py new file mode 100644 index 0000000..13e6cc4 --- /dev/null +++ b/estate/urls.py @@ -0,0 +1,27 @@ +from __future__ import absolute_import +from django.conf.urls import url, include +from django.views.generic import RedirectView +from django.contrib import admin +from django.http import HttpResponse +from django.views.generic import TemplateView +from rest_framework.documentation import include_docs_urls +from rest_framework.schemas import get_schema_view +from rest_framework_swagger.views import get_swagger_view + +title = "Estate API" + +base_schema_view = get_schema_view(title=title) + +swagger_view = get_swagger_view(title=title) + +urlpatterns = [ + url(r'^ping$', lambda x: HttpResponse('pong'), name="ping"), + url(r'^admin/', admin.site.urls), + url(r'^api/$', RedirectView.as_view(url='/api/swagger/')), + url(r'^api/schema/$', base_schema_view), + url(r'^api/swagger/', swagger_view), + url(r'^api/docs/', include_docs_urls(title=title), name="api-docs"), + url(r'^api/terraform/', include('estate.terraform.urls')), + # Acts as a catchall for everything else and react router will take over + url(r'^', TemplateView.as_view(template_name='index.html')), +] diff --git a/estate/wsgi.py b/estate/wsgi.py new file mode 100644 index 0000000..5cb3b8f --- /dev/null +++ b/estate/wsgi.py @@ -0,0 +1,8 @@ +from __future__ import absolute_import +import os +from django.core.wsgi import get_wsgi_application +from raven.contrib.django.raven_compat.middleware.wsgi import Sentry + +application = get_wsgi_application() +if os.environ.get('RAVEN_DSN'): + application = Sentry(application) diff --git a/local.sh b/local.sh new file mode 100644 index 0000000..5e727d4 --- /dev/null +++ b/local.sh @@ -0,0 +1,6 @@ +#!/bin/bash +#webpack --hot --watch & +echo "Starting Webpack Server" +webpack-dev-server --config webpack/webpack.local.config.js & +echo "Starting Django Server" +exec django-admin runserver 0.0.0.0:8000 diff --git a/package.json b/package.json new file mode 100644 index 0000000..5af108f --- /dev/null +++ b/package.json @@ -0,0 +1,48 @@ +{ + "main": "index.js", + "scripts": {}, + "author": "Kyle Rockman", + "license": "MIT", + "dependencies": { + "ansi-to-react": "1.4.2", + "axios": "0.16.1", + "babel-core": "6.24.1", + "babel-loader": "7.1.0", + "babel-polyfill": "6.23.0", + "babel-runtime": "6.23.0", + "babel-preset-react": "6.24.1", + "babel-preset-stage-2": "6.24.1", + "css-loader": "0.28.4", + "file-loader": "0.11.2", + "js-yaml": "3.8.4", + "jshint": "2.9.4", + "jsonlint": "1.6.2", + "node-sass": "4.5.2", + "rc-select": "6.8.6", + "react": "15.5.4", + "react-codemirror": "git://github.com/skidding/react-codemirror.git#106-fix-update", + "react-dom": "15.5.4", + "react-jsonschema-form": "0.48.2", + "react-hot-loader": "3.0.0-beta.6", + "react-modal": "1.6.5", + "react-notification-system": "0.2.14", + "react-notification-system-redux": "1.1.3", + "react-radio-group": "3.0.2", + "react-redux": "5.0.4", + "react-router-dom": "4.1.1", + "react-table": "6.0.3", + "react-tooltip": "3.3.0", + "redux": "3.6.0", + "redux-devtools": "3.4.0", + "redux-localstorage": "0.4.1", + "resolve-url-loader": "2.0.3", + "sass-loader": "6.0.6", + "style-loader": "0.18.2", + "url": "0.11.0", + "url-join": "2.0.2", + "url-loader": "0.5.9", + "webpack": "2.4.1", + "webpack-bundle-tracker": "0.2.0", + "webpack-dev-server": "2.4.5" + } +} diff --git a/scripts/local.sh b/scripts/local.sh new file mode 100755 index 0000000..5e727d4 --- /dev/null +++ b/scripts/local.sh @@ -0,0 +1,6 @@ +#!/bin/bash +#webpack --hot --watch & +echo "Starting Webpack Server" +webpack-dev-server --config webpack/webpack.local.config.js & +echo "Starting Django Server" +exec django-admin runserver 0.0.0.0:8000 diff --git a/webpack/webpack.base.config.js b/webpack/webpack.base.config.js new file mode 100644 index 0000000..06417c9 --- /dev/null +++ b/webpack/webpack.base.config.js @@ -0,0 +1,46 @@ +var path = require("path") +var webpack = require('webpack') +var BundleTracker = require('webpack-bundle-tracker') + +module.exports = { + context: path.dirname(__dirname), + + entry: './estate/assets/js/index', + + output: { + path: path.resolve('./estate/assets/bundles/'), + filename: "[name]-[hash].js" + }, + + plugins: [ + new BundleTracker({filename: './estate/assets/webpack-stats.json'}), + //makes jQuery available in every module + new webpack.ProvidePlugin({ + $: 'jquery', + jQuery: 'jquery', + 'window.jQuery': 'jquery' + }) + ], // add all common plugins here + + module: { + loaders: [ + { + test: /\.ico$/, + loader: 'file-loader?name=[name].[ext]' // <-- retain original file name + }, + { + test : /\.css$/, + loaders: ['style-loader', 'css-loader', 'resolve-url-loader'] + }, + { + test : /\.scss$/, + loaders: ['style-loader', 'css-loader', 'resolve-url-loader', 'sass-loader?sourceMap'] + } + ] // add all common loaders here + }, + + resolve: { + extensions: ['.js', '.jsx'], + modules: [path.join(path.dirname(__dirname), "node_modules")] + }, +} diff --git a/webpack/webpack.local.config.js b/webpack/webpack.local.config.js new file mode 100644 index 0000000..e53197f --- /dev/null +++ b/webpack/webpack.local.config.js @@ -0,0 +1,38 @@ +var path = require("path") +var webpack = require('webpack') +var BundleTracker = require('webpack-bundle-tracker') + +var config = require('./webpack.base.config.js') + +// Use webpack dev server +config.entry = [ + 'react-hot-loader/patch', + './estate/assets/js/index' +] + +// override django's STATIC_URL for webpack bundles +config.output.publicPath = 'http://localhost:3000/assets/bundles/' + +// Add HotModuleReplacementPlugin and BundleTracker plugins +config.plugins = config.plugins.concat([ + new webpack.HotModuleReplacementPlugin(), + new webpack.NoEmitOnErrorsPlugin(), + new webpack.NamedModulesPlugin(), +]) + +// Add a loader for JSX files with react-hot enabled +config.module.loaders.push( + { test: /\.jsx?$/, exclude: /node_modules/, loader: 'babel-loader', query: { presets: ['react']} } +) + +config.devServer = { + host: '0.0.0.0', + port: 3000, + headers: { "Access-Control-Allow-Origin": "*" }, + publicPath: '/assets/bundles/', + historyApiFallback: true, + hot: true, + inline: true, +} + +module.exports = config diff --git a/webpack/webpack.prod.config.js b/webpack/webpack.prod.config.js new file mode 100644 index 0000000..cef428a --- /dev/null +++ b/webpack/webpack.prod.config.js @@ -0,0 +1,15 @@ +var webpack = require('webpack') + +var config = require('./webpack.base.config.js') + +config.plugins = config.plugins.concat([ + // keeps hashes consistent between compilations + new webpack.optimize.OccurrenceOrderPlugin(), +]) + +// Add a loader for JSX files +config.module.loaders.push( + { test: /\.jsx?$/, exclude: /node_modules/, loader: 'babel-loader' } +) + +module.exports = config