From ffba4f1a82afaab4f0c0963f73db6a4be2f986d0 Mon Sep 17 00:00:00 2001 From: Kyle Rockman Date: Tue, 5 Sep 2017 20:47:20 -0500 Subject: [PATCH] Add Auth plus namespace Locking --- estate/assets/js/api/auth.js | 21 +++ estate/assets/js/api/terraform.js | 97 +++++++++---- estate/assets/js/components/App.jsx | 7 +- estate/assets/js/components/Editor.jsx | 22 +-- estate/assets/js/components/Login.jsx | 52 +++++++ estate/assets/js/components/Nav.jsx | 31 +++-- .../js/components/TerraformNamespaceItem.jsx | 128 +++++++++++++++++- .../js/components/TerraformTemplateItem.jsx | 14 +- .../components/TerraformTemplateRenderer.jsx | 8 +- estate/assets/js/index.jsx | 4 + estate/assets/js/reducers/auth.js | 29 ++++ estate/assets/js/reducers/index.js | 2 + estate/assets/js/reducers/terraform.js | 11 ++ estate/core/models/base.py | 10 ++ estate/core/renderer.py | 6 +- estate/settings/drf.py | 17 ++- estate/templates/index.html | 8 ++ .../migrations/0008_auto_20170905_2028.py | 38 ++++++ estate/terraform/models/namespace.py | 14 ++ estate/terraform/terraform.py | 22 +-- estate/terraform/views/namespace.py | 58 +++++++- estate/terraform/views/template.py | 2 +- estate/urls.py | 5 +- package.json | 2 +- webpack/webpack.base.config.js | 3 +- 25 files changed, 518 insertions(+), 93 deletions(-) create mode 100644 estate/assets/js/api/auth.js create mode 100644 estate/assets/js/components/Login.jsx create mode 100644 estate/assets/js/reducers/auth.js create mode 100644 estate/terraform/migrations/0008_auto_20170905_2028.py diff --git a/estate/assets/js/api/auth.js b/estate/assets/js/api/auth.js new file mode 100644 index 0000000..a16cbe5 --- /dev/null +++ b/estate/assets/js/api/auth.js @@ -0,0 +1,21 @@ +/*global dispatch*/ +import axios from "axios" +import * as messages from "./messages" + +export function login(user, pass) { + dispatch({ type: "START_LOGIN"}) + const req = axios.post(`/api/token/`, {username: user, password: pass}) + req.then((res) => { + dispatch({ + type: "FINISH_LOGIN", + payload: res.data, + }) + }, (err) => { + dispatch({ + type: "FINISH_LOGIN", + payload: {token: null} + }) + messages.handleResponseError(err) + }) + return req +} diff --git a/estate/assets/js/api/terraform.js b/estate/assets/js/api/terraform.js index fffcc14..1f8c2f1 100644 --- a/estate/assets/js/api/terraform.js +++ b/estate/assets/js/api/terraform.js @@ -4,7 +4,7 @@ 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}`) + const req = axios.get(`/api/v1/terraform/namespace/?page=${page}&page_size=${pagesize}&search=${search}`) req.then((res) => { dispatch({ type: "LIST_NAMESPACES", @@ -21,7 +21,7 @@ export function getNamespaces(page, pagesize, search) { export function getNamespace(slug) { dispatch({ type: "LOADING_NAMESPACES"}) - const req = axios.get(`/api/terraform/namespace/?slug=${slug}`) + const req = axios.get(`/api/v1/terraform/namespace/?slug=${slug}`) req.then((res) => { if (res.data.length > 0){ dispatch({ @@ -37,7 +37,7 @@ export function getNamespace(slug) { } export function createNamespace(payload) { - const req = axios.post("/api/terraform/namespace/", payload) + const req = axios.post("/api/v1/terraform/namespace/", payload) req.then((res) => { dispatch({ type: "UPDATE_NAMESPACE", @@ -48,7 +48,7 @@ export function createNamespace(payload) { } export function updateNamespace(id, payload) { - const req = axios.patch(`/api/terraform/namespace/${id}/`, payload) + const req = axios.patch(`/api/v1/terraform/namespace/${id}/`, payload) req.then((res) => { dispatch({ type: "UPDATE_NAMESPACE", @@ -60,7 +60,7 @@ export function updateNamespace(id, payload) { } export function deleteNamespace(id) { - const req = axios.delete(`/api/terraform/namespace/${id}/`) + const req = axios.delete(`/api/v1/terraform/namespace/${id}/`) req.then(() => { dispatch({ type: "DELETE_NAMESPACE", @@ -71,7 +71,7 @@ export function deleteNamespace(id) { } export function addFileToNamespace(payload) { - const req = axios.post("/api/terraform/file/", payload) + const req = axios.post("/api/v1/terraform/file/", payload) req.then(() => { getNamespace(payload.namespace) }, messages.handleResponseError) @@ -79,7 +79,7 @@ export function addFileToNamespace(payload) { } export function updateFile(id, payload) { - const req = axios.patch(`/api/terraform/file/${id}/`, payload) + const req = axios.patch(`/api/v1/terraform/file/${id}/`, payload) req.then((res) => { getNamespace(res.data.namespace) }, messages.handleResponseError) @@ -87,7 +87,7 @@ export function updateFile(id, payload) { } export function removeFileFromNamespace(slug, id) { - const req = axios.delete(`/api/terraform/file/${id}/`) + const req = axios.delete(`/api/v1/terraform/file/${id}/`) req.then(() => { getNamespace(slug) }, messages.handleResponseError) @@ -95,7 +95,7 @@ export function removeFileFromNamespace(slug, id) { } export function addTemplateToNamespace(payload) { - const req = axios.post("/api/terraform/templateinstance/", payload) + const req = axios.post("/api/v1/terraform/templateinstance/", payload) req.then(() => { getNamespace(payload.namespace) }, messages.handleResponseError) @@ -103,7 +103,7 @@ export function addTemplateToNamespace(payload) { } export function updateTemplateInstance(id, payload) { - const req = axios.patch(`/api/terraform/templateinstance/${id}/`, payload) + const req = axios.patch(`/api/v1/terraform/templateinstance/${id}/`, payload) req.then((res) => { getNamespace(res.data.namespace) }, messages.handleResponseError) @@ -111,7 +111,7 @@ export function updateTemplateInstance(id, payload) { } export function updateTemplateOfTemplateInstance(id) { - const req = axios.post(`/api/terraform/templateinstance/${id}/update_template/`) + const req = axios.post(`/api/v1/terraform/templateinstance/${id}/update_template/`) req.then((res) => { getNamespace(res.data.namespace) }, messages.handleResponseError) @@ -119,7 +119,7 @@ export function updateTemplateOfTemplateInstance(id) { } export function removeTemplateFromNamespace(slug, id) { - const req = axios.delete(`/api/terraform/templateinstance/${id}/`) + const req = axios.delete(`/api/v1/terraform/templateinstance/${id}/`) req.then(() => { getNamespace(slug) }, messages.handleResponseError) @@ -127,7 +127,7 @@ export function removeTemplateFromNamespace(slug, id) { } export function getPlanForNamespace(id) { - const req = axios.get(`/api/terraform/namespace/${id}/plan_live/`) + const req = axios.get(`/api/v1/terraform/namespace/${id}/plan_live/`) req.then((res) => { dispatch({ type: "PLAN_NAMESPACE", @@ -140,7 +140,7 @@ export function getPlanForNamespace(id) { export function doPlanForNamespace(id) { dispatch({type: "CLEAR_PLAN_NAMESPACE"}) let loopId = setInterval(() => {getPlanForNamespace(id)}, 1000) - const req = axios.post(`/api/terraform/namespace/${id}/plan/`) + const req = axios.post(`/api/v1/terraform/namespace/${id}/plan/`) req.then((res) => { clearInterval(loopId) dispatch({ @@ -155,7 +155,7 @@ export function doPlanForNamespace(id) { } export function getApplyForNamespace(id) { - const req = axios.get(`/api/terraform/namespace/${id}/apply_live/`) + const req = axios.get(`/api/v1/terraform/namespace/${id}/apply_live/`) req.then((res) => { dispatch({ type: "APPLY_NAMESPACE", @@ -168,7 +168,7 @@ export function getApplyForNamespace(id) { 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}/`) + const req = axios.post(`/api/v1/terraform/namespace/${id}/apply/${plan_hash}/`) req.then((res) => { clearInterval(loopId) dispatch({ @@ -183,9 +183,8 @@ export function doApplyForNamespace(id, plan_hash) { } export function getStateForNamespace(id) { - const req = axios.get(`/api/terraform/state/?namespace=${id}`) + const req = axios.get(`/api/v1/terraform/state/?namespace=${id}`) req.then((res) => { - console.log(res) dispatch({ type: "UPDATE_STATEFILE", payload: res.data[0] @@ -194,9 +193,59 @@ export function getStateForNamespace(id) { return req } +export function getExperimentForNamespace(id) { + const req = axios.get(`/api/v1/terraform/namespace/${id}/experiment_live/`) + req.then((res) => { + dispatch({ + type: "EXPERIMENT_NAMESPACE", + payload: res.data + }) + }, messages.handleResponseError) + return req +} + +export function doExperimentForNamespace(id, repl_command) { + dispatch({type: "CLEAR_EXPERIMENT_NAMESPACE"}) + let loopId = setInterval(() => {getExperimentForNamespace(id)}, 1000) + const req = axios.post(`/api/v1/terraform/namespace/${id}/experiment/`, {"repl_command": repl_command}) + req.then((res) => { + clearInterval(loopId) + dispatch({ + type: "EXPERIMENT_NAMESPACE", + payload: res.data + }) + }, (err) => { + clearInterval(loopId) + messages.handleResponseError(err) + }) + return req +} + +export function lockNamespace(id) { + const req = axios.post(`/api/v1/terraform/namespace/${id}/lock/`) + req.then((res) => { + dispatch({ + type: "UPDATE_NAMESPACE", + payload: res.data + }) + }, messages.handleResponseError) + return req +} + +export function unlockNamespace(id) { + const req = axios.post(`/api/v1/terraform/namespace/${id}/unlock/`) + req.then((res) => { + dispatch({ + type: "UPDATE_NAMESPACE", + payload: res.data + }) + }, messages.handleResponseError) + 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}`) + const req = axios.get(`/api/v1/terraform/template/?page=${page}&page_size=${pagesize}&search=${search}`) req.then((res) => { dispatch({ type: "LIST_TEMPLATES", @@ -213,7 +262,7 @@ export function getTemplates(page, pagesize, search) { export function getTemplate(slug) { dispatch({ type: "LOADING_TEMPLATES"}) - const req = axios.get(`/api/terraform/template/?slug=${slug}`) + const req = axios.get(`/api/v1/terraform/template/?slug=${slug}`) req.then((res) => { if (res.data.length > 0){ dispatch({ @@ -229,7 +278,7 @@ export function getTemplate(slug) { } export function createTemplate(payload) { - const req = axios.post("/api/terraform/template/", payload) + const req = axios.post("/api/v1/terraform/template/", payload) req.then((res) => { dispatch({ type: "UPDATE_TEMPLATE", @@ -240,7 +289,7 @@ export function createTemplate(payload) { } export function updateTemplate(id, payload) { - const req = axios.patch(`/api/terraform/template/${id}/`, payload) + const req = axios.patch(`/api/v1/terraform/template/${id}/`, payload) req.then((res) => { dispatch({ type: "UPDATE_TEMPLATE", @@ -252,7 +301,7 @@ export function updateTemplate(id, payload) { } export function renderTemplate(payload) { - const req = axios.post("/api/terraform/template/render/", payload) + const req = axios.post("/api/v1/terraform/template/render/", payload) req.then((res) => { dispatch({ type: "RENDER_TEMPLATE", @@ -263,7 +312,7 @@ export function renderTemplate(payload) { } export function deleteTemplate(id) { - const req = axios.delete(`/api/terraform/template/${id}/`) + const req = axios.delete(`/api/v1/terraform/template/${id}/`) req.then(() => { dispatch({ type: "DELETE_TEMPLATE", diff --git a/estate/assets/js/components/App.jsx b/estate/assets/js/components/App.jsx index 94feee2..bd90794 100644 --- a/estate/assets/js/components/App.jsx +++ b/estate/assets/js/components/App.jsx @@ -2,6 +2,7 @@ import React from "react" import { Route } from "react-router-dom" import Nav from "./Nav" import Messages from "./Messages" +import Login from "./Login" import Home from "./Home" import TerraformRoutes from "./TerraformRoutes" @@ -14,8 +15,10 @@ export default class App extends React.Component {
- - + + + +
diff --git a/estate/assets/js/components/Editor.jsx b/estate/assets/js/components/Editor.jsx index c96fb92..e76456b 100644 --- a/estate/assets/js/components/Editor.jsx +++ b/estate/assets/js/components/Editor.jsx @@ -1,6 +1,6 @@ import React from "react" import { assign, cloneDeep } from "lodash" -import CodeMirror from "react-codemirror" +import CodeMirror from '@skidding/react-codemirror'; import "codemirror/lib/codemirror.css" import "codemirror/mode/yaml/yaml.js" import "codemirror/mode/javascript/javascript.js" @@ -49,29 +49,13 @@ 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 changed = (this.props.content.replace(re,"\n") != value) const data = { currentContent: value, changed: changed } - this.setState(data) if (this.props.onUpdateContent) { this.props.onUpdateContent(data) } @@ -91,7 +75,7 @@ export default class Editor extends React.Component {
- +
diff --git a/estate/assets/js/components/Login.jsx b/estate/assets/js/components/Login.jsx new file mode 100644 index 0000000..cb48f15 --- /dev/null +++ b/estate/assets/js/components/Login.jsx @@ -0,0 +1,52 @@ +import React from "react" +import { connect } from "react-redux" +import { Route } from "react-router-dom" +import * as api from "../api/auth" + +class Login extends React.Component { + + handleSubmit(event) { + event.preventDefault() + event.stopPropagation() + this.props.login(this.refs.user.value, this.refs.pass.value) + } + render () { + if (this.props.authenticating){ + return
Logging in...
+ } else if (this.props.token) { + return ( +
{this.props.children}
+ ) + } else { + return ( +
+
+

Please Login

+
+ + + +
+
+
+ ) + } + } +} + +let mapStateToProps = (state) => { + return { + authenticating: state.auth.authenticating, + token: state.auth.token, + } +} + +let mapDispatchToProps = (dispatch, ownProps) => { + return { + login: (user, pass) => { + api.login(user, pass) + }, + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Login) diff --git a/estate/assets/js/components/Nav.jsx b/estate/assets/js/components/Nav.jsx index 1286c00..33f90e7 100644 --- a/estate/assets/js/components/Nav.jsx +++ b/estate/assets/js/components/Nav.jsx @@ -1,8 +1,9 @@ import React from "react" +import { connect } from "react-redux" import { NavLink, Link } from "react-router-dom" import Search from "./Search" -export default class Nav extends React.Component { +class Nav extends React.Component { render () { return ( ) } } + +let mapStateToProps = (state) => { + return { + token: state.auth.token, + } +} + +let mapDispatchToProps = (dispatch, ownProps) => { + return { + logout: () => { + dispatch({type: "DO_LOGOUT"}) + }, + } +} + +export default connect(mapStateToProps, mapDispatchToProps)(Nav) diff --git a/estate/assets/js/components/TerraformNamespaceItem.jsx b/estate/assets/js/components/TerraformNamespaceItem.jsx index 0d9d050..d82da1c 100644 --- a/estate/assets/js/components/TerraformNamespaceItem.jsx +++ b/estate/assets/js/components/TerraformNamespaceItem.jsx @@ -23,6 +23,7 @@ class TerraformNamespaceItem extends React.Component { this.state = { files: [], templates: [], + experiment: "", } } componentWillMount() { @@ -34,6 +35,7 @@ class TerraformNamespaceItem extends React.Component { nextProps.getPlan(namespace.pk) nextProps.getApply(namespace.pk) nextProps.getState(namespace.pk) + nextProps.getExperiment(namespace.pk) this.mergeFiles(namespace) this.mergeTemplates(namespace) } @@ -152,6 +154,11 @@ class TerraformNamespaceItem extends React.Component { templates: templates, }) } + onExperimentChange(data) { + this.setState({ + experiment: data.currentContent, + }) + } saveNamespace() { this.isSaving = true each(this.state.files, (item) => { @@ -217,7 +224,17 @@ class TerraformNamespaceItem extends React.Component { } } } + experimentNamespace() { + this.props.experimentNamespace(this.props.namespace.pk, this.state.experiment) + } + lockNamespace() { + this.props.lockNamespace(this.props.namespace.pk) + } + unlockNamespace() { + this.props.unlockNamespace(this.props.namespace.pk) + } createFilePane(props) { + var locked = this.props.namespace.is_uneditable var index = findIndex(this.state.files, {slug: props.match.params.file}) if (index == -1){ return null @@ -227,6 +244,7 @@ class TerraformNamespaceItem extends React.Component {

{file.title} + {locked ? null :
{ file.nextDisable ?
Enable
@@ -234,12 +252,14 @@ class TerraformNamespaceItem extends React.Component {
Disable
}
+ }

- +
) } createFileList() { + var locked = this.props.namespace.is_uneditable var url = this.props.match.url var count = 0 var elements = [] @@ -256,6 +276,7 @@ class TerraformNamespaceItem extends React.Component { {item.title} + {locked ? null :
@@ -270,6 +291,7 @@ class TerraformNamespaceItem extends React.Component { callback={this.removeFileFromNamespace.bind(this, item.pk)} />
+ } )) }) @@ -285,6 +307,7 @@ class TerraformNamespaceItem extends React.Component { ) } createTemplatePane(props) { + var locked = this.props.namespace.is_uneditable var index = findIndex(this.state.templates, {slug: props.match.params.template}) if (index == -1){ return null @@ -303,6 +326,7 @@ class TerraformNamespaceItem extends React.Component {

{templateInstance.title} + {locked ? null :
{ templateInstance.nextDisable ?
Enable
@@ -312,6 +336,7 @@ class TerraformNamespaceItem extends React.Component { {/*
Update
*/} [ {templateInstance.template.title} : {templateInstance.template.version} { templateInstance.is_outdated ? this.createTemplateUpdateButton.bind(this)(templateInstance.pk) : "" } ]
+ }

@@ -319,11 +344,12 @@ class TerraformNamespaceItem extends React.Component { { templateInstance.template.description }
- + ) } createTemplateList() { + var locked = this.props.namespace.is_uneditable var url = this.props.match.url var count = 0 var elements = [] @@ -340,6 +366,7 @@ class TerraformNamespaceItem extends React.Component { {item.title} + {locked ? null :
@@ -354,6 +381,7 @@ class TerraformNamespaceItem extends React.Component { callback={this.removeTemplateFromNamespace.bind(this, item.pk)} />
+ } )) }) @@ -417,12 +445,65 @@ class TerraformNamespaceItem extends React.Component {
) } + createExperimentPane() { + var locked = this.props.namespace.is_uneditable + var data = this.props.experimentOutput + var output = join(data.output, "") + return ( +
+ {locked ? null : +
+ +
+ } +
+                    
+                        {output}
+                    
+                    { !data.running ? null : 
} +
+
+ ) + } + createLockableButtons() { + const namespace = this.props.namespace + const locked = namespace.locked + const is_unlockable = namespace.is_unlockable + if (locked){ + if (is_unlockable) { + return ( +
  • + +
  • + ) + } else { + return ( +
  • +
    Locked by User:
    {namespace.locking_user}
    +
  • + ) + } + } else { + return ( +
  • + +
  • + ) + } + } render() { if (this.props.namespace == null) { return null } const url = this.props.match.url const namespace = this.props.namespace + const locked = namespace.is_uneditable return (
    @@ -433,12 +514,16 @@ class TerraformNamespaceItem extends React.Component {

    {namespace.title}

    + {this.createLockableButtons.bind(this)()} + {locked ? null :
  • + } + {locked ? null :
  • + }
    • Last Plan -
      + {locked ? null :
      - +
      +
      + +
      + }
    • Last Apply -
      + {locked ? null :
      - +
      +
      + +
      + } +
    • +
    • + Last Experiment + {locked ? null : +
      +
      +
      + +
      +
      + }
    • Statefile @@ -471,6 +576,7 @@ class TerraformNamespaceItem extends React.Component {
    • + {locked ? null :
    • + }
      { this.createFileList.bind(this)() } @@ -496,6 +603,7 @@ class TerraformNamespaceItem extends React.Component {
      + @@ -527,9 +635,13 @@ const mapStateToProps = (state, ownProps) => { planOutput: state.terraform.planOutput, applyOutput: state.terraform.applyOutput, stateObject: state.terraform.stateObject, + experimentOutput: state.terraform.experimentOutput, getPlan: terraform.getPlanForNamespace, getApply: terraform.getApplyForNamespace, getState: terraform.getStateForNamespace, + getExperiment: terraform.getExperimentForNamespace, + lockNamespace: terraform.lockNamespace, + unlockNamespace: terraform.unlockNamespace, } } const mapDispatchToProps = (dispatch, ownProps) => { @@ -542,6 +654,10 @@ const mapDispatchToProps = (dispatch, ownProps) => { terraform.doApplyForNamespace(id, plan_hash) ownProps.history.push( urljoin(ownProps.match.url, "/apply") ) }, + experimentNamespace: (id, repl_command) => { + terraform.doExperimentForNamespace(id, repl_command) + ownProps.history.push( urljoin(ownProps.match.url, "/experiment") ) + }, removeFileFromNamespace: (id) => { var req = terraform.removeFileFromNamespace(ownProps.match.params.namespace, id) req.then(() => { diff --git a/estate/assets/js/components/TerraformTemplateItem.jsx b/estate/assets/js/components/TerraformTemplateItem.jsx index 04896aa..64d3066 100644 --- a/estate/assets/js/components/TerraformTemplateItem.jsx +++ b/estate/assets/js/components/TerraformTemplateItem.jsx @@ -159,9 +159,10 @@ class TerraformTemplateItem extends React.Component { (Uses Jinja Templating) - MODE:   + EDITOR MODE:   HCL  YAML  + JSON  @@ -177,25 +178,28 @@ class TerraformTemplateItem extends React.Component { if (this.state.templateMode === "hcl"){ options = { mode: {name: "go", statementIndent: 4}, lint: false } } + if (this.state.templateMode === "json"){ + options = {} + } return (

      Edit {template.version}

      - +
      - +
      - +
      - +
      diff --git a/estate/assets/js/components/TerraformTemplateRenderer.jsx b/estate/assets/js/components/TerraformTemplateRenderer.jsx index 05ec6e4..f881a45 100644 --- a/estate/assets/js/components/TerraformTemplateRenderer.jsx +++ b/estate/assets/js/components/TerraformTemplateRenderer.jsx @@ -17,6 +17,7 @@ class TerraformTemplateRenderer extends React.Component { inputs: {}, overrides: "", disable: false, + locked: false, } } componentWillMount() { @@ -29,6 +30,7 @@ class TerraformTemplateRenderer extends React.Component { if (nextProps.template && !isEqual(this.props.template, nextProps.template)){ this.loadTemplateIntoState(nextProps.template) } + this.setState({locked: nextProps.locked || false}) } componentDidUpdate() { if (this.rerenderTemplate){ @@ -167,10 +169,10 @@ class TerraformTemplateRenderer extends React.Component { return (
      - + - +
      @@ -182,7 +184,7 @@ class TerraformTemplateRenderer extends React.Component { return (
      {this.createErrorBars()} - {this.createForm()} + {this.createForm.bind(this)()}
      ) } diff --git a/estate/assets/js/index.jsx b/estate/assets/js/index.jsx index 4db3441..c1e0c54 100644 --- a/estate/assets/js/index.jsx +++ b/estate/assets/js/index.jsx @@ -6,10 +6,14 @@ import { createStore, compose, combineReducers } from "redux" import { Provider } from "react-redux" import persistState from "redux-localstorage" import { BrowserRouter } from "react-router-dom" +import axios from "axios" import rootReducer from "./reducers/index" import App from "./components/App" import "./estate.css" +axios.defaults.xsrfHeaderName = "X-CSRFTOKEN"; +axios.defaults.xsrfCookieName = "csrftoken"; + 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 diff --git a/estate/assets/js/reducers/auth.js b/estate/assets/js/reducers/auth.js new file mode 100644 index 0000000..ceba702 --- /dev/null +++ b/estate/assets/js/reducers/auth.js @@ -0,0 +1,29 @@ +import { set } from "lodash/fp" +import { createReducer } from "./utils" +import axios from "axios" + +var initialState = { + authenticating: false, + token: null, +} + +export default createReducer(initialState, { + ["START_LOGIN"]: (state, action) => { + state = set(["authenticating"])(true)(state) + return state + }, + + ["FINISH_LOGIN"]: (state, action) => { + state = set(["authenticating"])(false)(state) + state = set(["token"])(action.payload.token)(state) + axios.defaults.headers = {'Authorization': 'Token ' + action.payload.token} + return state + }, + + ["DO_LOGOUT"]: (state, action) => { + state = set(["authenticating"])(false)(state) + state = set(["token"])(null)(state) + axios.defaults.headers = {} + return state + }, +}) diff --git a/estate/assets/js/reducers/index.js b/estate/assets/js/reducers/index.js index 1b8351a..e215a27 100644 --- a/estate/assets/js/reducers/index.js +++ b/estate/assets/js/reducers/index.js @@ -1,10 +1,12 @@ import {reducer as notifications} from "react-notification-system-redux" import search from "./search" +import auth from "./auth" import terraform from "./terraform" export default { notifications, search, + auth, terraform, } diff --git a/estate/assets/js/reducers/terraform.js b/estate/assets/js/reducers/terraform.js index d53e19b..13a464d 100644 --- a/estate/assets/js/reducers/terraform.js +++ b/estate/assets/js/reducers/terraform.js @@ -13,6 +13,7 @@ var initialState = { planOutput: "", applyOutput: "", stateObject: {}, + experimentOutput: "", files: [], templates: [], renderedTemplate: "{}", @@ -79,6 +80,16 @@ export default createReducer(initialState, { return state }, + ["CLEAR_EXPERIMENT_NAMESPACE"]: (state) => { + state = set(["experimentOutput"])({})(state) + return state + }, + + ["EXPERIMENT_NAMESPACE"]: (state, action) => { + state = set(["experimentOutput"])(action.payload)(state) + return state + }, + ["UPDATE_FILE"]: (state, action) => { var index = findIndex(state.file, {"pk": action.payload.pk}) if (index != -1){ diff --git a/estate/core/models/base.py b/estate/core/models/base.py index 2695daa..dc7e306 100644 --- a/estate/core/models/base.py +++ b/estate/core/models/base.py @@ -1,9 +1,13 @@ from __future__ import absolute_import import logging +from django.conf import settings +from django.db.models.signals import post_save +from django.dispatch import receiver 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 rest_framework.authtoken.models import Token from .fields import SoftDeleteAwareAutoSlugField LOG = logging.getLogger(__name__) @@ -27,3 +31,9 @@ def __unicode__(self): def __repr__(self): return "<%s:%s pk:%i>" % (self.__class__.__name__, self.title, self.pk) + + +@receiver(post_save, sender=settings.AUTH_USER_MODEL) +def create_auth_token(sender, instance=None, created=False, **kwargs): + if created: + Token.objects.create(user=instance) diff --git a/estate/core/renderer.py b/estate/core/renderer.py index af029f1..ebf284f 100644 --- a/estate/core/renderer.py +++ b/estate/core/renderer.py @@ -62,12 +62,12 @@ def is_json(value): 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_hcl, data, hcl_exception = is_hcl(template) + if type_is_hcl: + return "hcl" type_is_yaml, data, yaml_exception = is_yaml(template) if type_is_yaml: if type(data) in [type(None), type({})]: diff --git a/estate/settings/drf.py b/estate/settings/drf.py index 1db3ae9..4b3f6de 100644 --- a/estate/settings/drf.py +++ b/estate/settings/drf.py @@ -21,6 +21,7 @@ def api_exception_handler(exc, context): INSTALLED_APPS += [ 'rest_framework', + 'rest_framework.authtoken', 'rest_framework_swagger', ] @@ -29,11 +30,12 @@ def api_exception_handler(exc, context): 'DEFAULT_PAGINATION_CLASS': "estate.pagination.LinkHeaderPagination", 'PAGE_SIZE': 10, 'DEFAULT_AUTHENTICATION_CLASSES': ( - #'rest_framework.authentication.BasicAuthentication', - #'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.BasicAuthentication', + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', ), 'DEFAULT_PERMISSION_CLASSES': ( - #'rest_framework.permissions.IsAuthenticated', + 'rest_framework.permissions.IsAuthenticated', ), 'DEFAULT_PARSER_CLASSES': ( 'rest_framework.parsers.JSONParser', @@ -53,10 +55,17 @@ def api_exception_handler(exc, context): } SWAGGER_SETTINGS = { + 'SECURITY_DEFINITIONS': { + 'basic': { + 'type': 'basic' + } + }, + 'LOGIN_URL': 'rest_framework:login', + 'LOGOUT_URL': 'rest_framework:logout', 'USE_SESSION_AUTH': True, 'APIS_SORTER': 'alpha', 'JSON_EDITOR': True, 'VALIDATOR_URL': None } -CORS_URLS_REGEX = r'^/api/.*$' +CORS_URLS_REGEX = r'^/api/v1/.*$' diff --git a/estate/templates/index.html b/estate/templates/index.html index 984f821..09e2758 100644 --- a/estate/templates/index.html +++ b/estate/templates/index.html @@ -1,4 +1,5 @@ +{% load staticfiles %} {% load render_bundle from webpack_loader %} @@ -25,6 +26,13 @@
      + + {% render_bundle 'main' %} diff --git a/estate/terraform/migrations/0008_auto_20170905_2028.py b/estate/terraform/migrations/0008_auto_20170905_2028.py new file mode 100644 index 0000000..1169a8e --- /dev/null +++ b/estate/terraform/migrations/0008_auto_20170905_2028.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-09-05 20:28 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('terraform', '0007_historicalstate_state'), + ] + + operations = [ + migrations.AddField( + model_name='historicalnamespace', + name='locked', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='historicalnamespace', + name='locking_user', + field=models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='namespace', + name='locked', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='namespace', + name='locking_user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/estate/terraform/models/namespace.py b/estate/terraform/models/namespace.py index b28dd11..7d9d594 100644 --- a/estate/terraform/models/namespace.py +++ b/estate/terraform/models/namespace.py @@ -1,22 +1,34 @@ from __future__ import absolute_import import logging from django.db import models +from django.conf import settings 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 +from .state import State LOG = logging.getLogger(__name__) class Namespace(EstateAbstractBase): owner = models.CharField(_('owner'), max_length=80) + locked = models.BooleanField(default=False) + locking_user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True) # TODO: Add tags history = HistoricalRecordsWithoutDelete(excluded_fields=['slug']) + def is_unlockable(self, user): + if self.locked is True: + if self.locking_user == user: + return True + else: + return False + return True + @property def terraform_files(self): output = [] @@ -32,3 +44,5 @@ def restore_related_objects(sender, instance, *args, **kwargs): F.restore() for T in TemplateInstance.deleted_objects.filter(namespace__id=instance.pk): T.restore() + for S in State.deleted_objects.filter(namespace__id=instance.pk): + S.restore() diff --git a/estate/terraform/terraform.py b/estate/terraform/terraform.py index 641865a..6278812 100644 --- a/estate/terraform/terraform.py +++ b/estate/terraform/terraform.py @@ -9,6 +9,8 @@ LOG = logging.getLogger("estate") +COMMAND_HEADER = "#!/bin/bash +ex\n" + PLAN = """#!/bin/bash +ex terraform init {TERRAFORM_EXTRA_ARGS} {TERRAFORM_INIT_EXTRA_ARGS} terraform plan {TERRAFORM_EXTRA_ARGS} {TERRAFORM_PLAN_EXTRA_ARGS} @@ -40,11 +42,12 @@ def save_plan(self, plan_hash, plan_data): class Terraform(HotDockerExecutor): - def __init__(self, action, namespace, plan_hash=None, state_obj=None): + def __init__(self, action, namespace, plan_hash=None, state_obj=None, repl_command=""): self.action = action self.namespace = namespace self.plan_hash = plan_hash self.state_obj = state_obj + self.repl_command = repl_command config = { "docker_image": settings.TERRAFORM_DOCKER_IMAGE, "name": self.namespace.slug, @@ -52,6 +55,8 @@ def __init__(self, action, namespace, plan_hash=None, state_obj=None): } if action == "plan": config["command"] = PLAN + elif action == "experiment": + config["command"] = COMMAND_HEADER + self.repl_command + "\n" else: config["command"] = APPLY config["command"] = config["command"].format( @@ -69,12 +74,12 @@ def run(self, *args, **kwargs): def write_files(self): LOG.info("[Terraform] Preparing Namespace '{0}' for action '{1}'".format(self.namespace.title, self.action)) + if self.state_obj: + LOG.info("[Terraform] Writing terraform statefile") + path = os.path.join(self.workdir, "terraform.tfstate") + with open(path, "wb") as f: + f.write(self.state_obj.content) if self.action == "plan": - if self.state_obj: - LOG.info("[Terraform] Writing terraform statefile") - path = os.path.join(self.workdir, "terraform.tfstate") - with open(path, "wb") as f: - f.write(self.state_obj.content) 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) @@ -86,11 +91,6 @@ def write_files(self): if self.action == "apply": if self.plan_hash is None: raise Exception("Unable to perform action 'apply' no plan was found!") - if self.state_obj: - LOG.info("[Terraform] Writing terraform statefile") - path = os.path.join(self.workdir, "terraform.tfstate") - with open(path, "wb") as f: - f.write(self.state_obj.content) path = os.path.join(self.workdir, "plan.tar.gz") plan_data = self.streamer.get_plan(self.plan_hash) if plan_data is None: diff --git a/estate/terraform/views/namespace.py b/estate/terraform/views/namespace.py index 8be2c7b..570a530 100644 --- a/estate/terraform/views/namespace.py +++ b/estate/terraform/views/namespace.py @@ -16,11 +16,24 @@ class NamespaceSerializer(HistoricalSerializer): 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) + locking_user = serializers.SlugRelatedField(slug_field="username", read_only=True) + is_unlockable = serializers.SerializerMethodField(read_only=True) + is_uneditable = serializers.SerializerMethodField(read_only=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") + fields = ("pk", "slug", "title", "description", "owner", "files", "templates", "locked", "locking_user", "is_unlockable", "is_uneditable", "created", "modified") + historical_fields = ("pk", "slug", "title", "description", "owner", "locked", "locking_user", "historical_files", "historical_templates") + + def get_is_unlockable(self, instance): + return instance.is_unlockable(self.context["request"].user) + + def get_is_uneditable(self, instance): + result = False + if instance.locked is True: + if instance.is_unlockable(self.context["request"].user) is not True: + result = True + return result class NamespaceFilter(filters.FilterSet): @@ -41,6 +54,33 @@ class NamespaceApiView(HistoryMixin, viewsets.ModelViewSet): search_fields = ('title',) ordering_fields = ('title', 'created', 'modified') + @decorators.detail_route(methods=["POST"]) + def lock(self, request, *args, **kwargs): + + if request.user.is_authenticated() is False: + raise Exception("Unable to perform a lock for an anonymous user! {0}".format(request.user)) + instance = self.get_object() + if instance.is_unlockable(request.user) is False: + raise Exception("{0} is not lockable, it is currently locked by {1}".format(instance, instance.locking_user)) + instance.locked = True + instance.locking_user = request.user + instance.save() + serializer = self.get_serializer(instance) + return response.Response(serializer.data) + + @decorators.detail_route(methods=["POST"]) + def unlock(self, request, *args, **kwargs): + if request.user.is_authenticated() is False: + raise Exception("Unable to perform an unlock for an anonymous user!") + instance = self.get_object() + if instance.is_unlockable(request.user) is False: + raise Exception("{0} is not unlockable, it is currently locked by {1}".format(instance, instance.locking_user)) + instance.locked = False + instance.locking_user = None + instance.save() + serializer = self.get_serializer(instance) + return response.Response(serializer.data) + @decorators.detail_route(methods=["POST"]) def plan(self, request, *args, **kwargs): instance = self.get_object() @@ -68,3 +108,17 @@ def apply_live(self, request, *args, **kwargs): instance = self.get_object() runner = Terraform("apply", instance, {}) return response.Response(runner.get_stream()) + + @decorators.detail_route(methods=["POST"]) + def experiment(self, request, *args, **kwargs): + instance = self.get_object() + state_obj, _ = State.objects.get_or_create(namespace=instance, defaults={"title": instance.title, "namespace": instance}) + runner = Terraform("experiment", instance, None, state_obj, request.data["repl_command"]) + runner.run() + return response.Response(runner.get_stream()) + + @decorators.detail_route(methods=["Get"]) + def experiment_live(self, request, *args, **kwargs): + instance = self.get_object() + runner = Terraform("experiment", instance) + return response.Response(runner.get_stream()) diff --git a/estate/terraform/views/template.py b/estate/terraform/views/template.py index a1a785a..9811df3 100644 --- a/estate/terraform/views/template.py +++ b/estate/terraform/views/template.py @@ -27,7 +27,7 @@ class Meta: 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" + return renderer.get_style(instance.body) def create(self, validated_data): version_increment = validated_data.pop("version_increment", "initial") diff --git a/estate/urls.py b/estate/urls.py index 13e6cc4..af6e78a 100644 --- a/estate/urls.py +++ b/estate/urls.py @@ -7,6 +7,7 @@ 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 +from rest_framework.authtoken.views import obtain_auth_token title = "Estate API" @@ -17,11 +18,13 @@ urlpatterns = [ url(r'^ping$', lambda x: HttpResponse('pong'), name="ping"), url(r'^admin/', admin.site.urls), + url(r'^auth/', include('rest_framework.urls', namespace="rest_framework")), + url(r'^api/token/$', obtain_auth_token), 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')), + url(r'^api/v1/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/package.json b/package.json index 5af108f..dccf392 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "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", + "@skidding/react-codemirror": "^1.0.0", "react-dom": "15.5.4", "react-jsonschema-form": "0.48.2", "react-hot-loader": "3.0.0-beta.6", diff --git a/webpack/webpack.base.config.js b/webpack/webpack.base.config.js index 06417c9..4c637c0 100644 --- a/webpack/webpack.base.config.js +++ b/webpack/webpack.base.config.js @@ -19,7 +19,8 @@ module.exports = { $: 'jquery', jQuery: 'jquery', 'window.jQuery': 'jquery' - }) + }), + new webpack.optimize.UglifyJsPlugin() ], // add all common plugins here module: {