diff --git a/estate/assets/js/api/terraform.js b/estate/assets/js/api/terraform.js
index 4fd37d0..fffcc14 100644
--- a/estate/assets/js/api/terraform.js
+++ b/estate/assets/js/api/terraform.js
@@ -182,6 +182,18 @@ export function doApplyForNamespace(id, plan_hash) {
return req
}
+export function getStateForNamespace(id) {
+ const req = axios.get(`/api/terraform/state/?namespace=${id}`)
+ req.then((res) => {
+ console.log(res)
+ dispatch({
+ type: "UPDATE_STATEFILE",
+ payload: res.data[0]
+ })
+ }, 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}`)
diff --git a/estate/assets/js/components/TerraformNamespaceAddFileModal.jsx b/estate/assets/js/components/TerraformNamespaceAddFileModal.jsx
index c272712..2c65b6e 100644
--- a/estate/assets/js/components/TerraformNamespaceAddFileModal.jsx
+++ b/estate/assets/js/components/TerraformNamespaceAddFileModal.jsx
@@ -113,13 +113,13 @@ let mapDispatchToProps = (dispatch, ownProps) => {
addFileToNamespace: (payload) => {
var req = api.addFileToNamespace(payload)
req.then((res) => {
- ownProps.history.push( urljoin(ownProps.url, "/file/", res.data.slug, "/") )
+ 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, "/") )
+ ownProps.history.push( urljoin(ownProps.url, "/template/", res.data.slug + "/") )
})
}
}
diff --git a/estate/assets/js/components/TerraformNamespaceItem.jsx b/estate/assets/js/components/TerraformNamespaceItem.jsx
index 15a177a..0d9d050 100644
--- a/estate/assets/js/components/TerraformNamespaceItem.jsx
+++ b/estate/assets/js/components/TerraformNamespaceItem.jsx
@@ -33,6 +33,7 @@ class TerraformNamespaceItem extends React.Component {
const namespace = nextProps.namespace
nextProps.getPlan(namespace.pk)
nextProps.getApply(namespace.pk)
+ nextProps.getState(namespace.pk)
this.mergeFiles(namespace)
this.mergeTemplates(namespace)
}
@@ -404,6 +405,18 @@ class TerraformNamespaceItem extends React.Component {
)
}
+ createStatePane() {
+ var data = this.props.stateObject
+ var output = join(data.content, "")
+ return (
+
+
If you need to edit this please contact an administrator
+
+ {output}
+
+
+ )
+ }
render() {
if (this.props.namespace == null) {
return null
@@ -449,6 +462,9 @@ class TerraformNamespaceItem extends React.Component {
+
+ Statefile
+
-
@@ -480,6 +496,7 @@ class TerraformNamespaceItem extends React.Component {
+
@@ -509,8 +526,10 @@ const mapStateToProps = (state, ownProps) => {
updateTemplateOfTemplateInstance: terraform.updateTemplateOfTemplateInstance,
planOutput: state.terraform.planOutput,
applyOutput: state.terraform.applyOutput,
+ stateObject: state.terraform.stateObject,
getPlan: terraform.getPlanForNamespace,
getApply: terraform.getApplyForNamespace,
+ getState: terraform.getStateForNamespace,
}
}
const mapDispatchToProps = (dispatch, ownProps) => {
diff --git a/estate/assets/js/components/TerraformNamespaceList.jsx b/estate/assets/js/components/TerraformNamespaceList.jsx
index 025f621..a0a4855 100644
--- a/estate/assets/js/components/TerraformNamespaceList.jsx
+++ b/estate/assets/js/components/TerraformNamespaceList.jsx
@@ -1,5 +1,6 @@
import { connect } from "react-redux"
import DashboardListView from "./DashboardListView"
+import urljoin from "url-join"
import * as api from "../api/terraform"
const TerraformNamespacesTableColumns = [
@@ -51,7 +52,7 @@ let mapDispatchToProps = (dispatch, ownProps) => {
createObject: (payload) => {
const req = api.createNamespace(payload)
req.then((res) => {
- ownProps.history.push("./namespaces/" + res.data.slug)
+ ownProps.history.push( urljoin(ownProps.location.pathname, res.data.slug + "/"))
})
}
}
diff --git a/estate/assets/js/components/TerraformTemplateList.jsx b/estate/assets/js/components/TerraformTemplateList.jsx
index 527db0f..2ab634e 100644
--- a/estate/assets/js/components/TerraformTemplateList.jsx
+++ b/estate/assets/js/components/TerraformTemplateList.jsx
@@ -1,5 +1,6 @@
import { connect } from "react-redux"
import DashboardListView from "./DashboardListView"
+import urljoin from "url-join"
import * as api from "../api/terraform"
const TerraformTemplatesTableColumns = [
@@ -58,7 +59,7 @@ let mapDispatchToProps = (dispatch, ownProps) => {
createObject: (payload) => {
const req = api.createTemplate(payload)
req.then((res) => {
- ownProps.history.push("./templates/" + res.data.slug)
+ ownProps.history.push( urljoin(ownProps.location.pathname, res.data.slug + "/") )
})
}
}
diff --git a/estate/assets/js/reducers/terraform.js b/estate/assets/js/reducers/terraform.js
index 119515a..d53e19b 100644
--- a/estate/assets/js/reducers/terraform.js
+++ b/estate/assets/js/reducers/terraform.js
@@ -12,6 +12,7 @@ var initialState = {
namespacesPages: 0,
planOutput: "",
applyOutput: "",
+ stateObject: {},
files: [],
templates: [],
renderedTemplate: "{}",
@@ -73,6 +74,11 @@ export default createReducer(initialState, {
return state
},
+ ["UPDATE_STATEFILE"]: (state, action) => {
+ state = set(["stateObject"])(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/HotDockerExecutor.py b/estate/core/HotDockerExecutor.py
index 65b76be..853842d 100644
--- a/estate/core/HotDockerExecutor.py
+++ b/estate/core/HotDockerExecutor.py
@@ -147,7 +147,8 @@ def run(self):
end = datetime.datetime.now()
self.duration = end - start
self.finish()
- shutil.rmtree(self.workdir)
+ if os.path.exists(self.workdir):
+ shutil.rmtree(self.workdir)
def get_escrow(self, escrow_id):
escrow_api = os.environ.get("ESCROW_API_URI")
@@ -193,20 +194,22 @@ def write_execute_files(self):
def pull_image(self):
if self.streamer is not None:
- self.streamer.log("Pulling docker image: {0}\n".format(self.docker_image))
+ self.streamer.log("Pulling docker image this may take a while...\n")
+ os.makedirs(self.workdir)
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)))
+ raise Exception(output)
def execute_command(self, command, capture=False):
output = []
stream_index = 0
- LOG.info("[HotDockerExecutor] Running command: {0}".format(" ".join(command)))
+ LOG.info("[HotDockerExecutor] Running command: '{0}' in directory '{1}'".format(" ".join(command), self.workdir))
process = subprocess.Popen(
command,
stderr=subprocess.STDOUT,
stdout=subprocess.PIPE,
+ cwd=self.workdir,
)
if capture and self.streamer is not None:
self.streamer.log("Started Execution @ {0}\n".format(datetime.datetime.utcnow()))
@@ -222,10 +225,10 @@ def execute_command(self, command, capture=False):
self.streamer.log(new_line)
process.communicate()
exit_code = process.poll()
+ if capture and self.streamer is not None:
+ self.streamer.log("\nExit Code: {0}\n".format(exit_code))
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
diff --git a/estate/core/models/base.py b/estate/core/models/base.py
index baad684..2695daa 100644
--- a/estate/core/models/base.py
+++ b/estate/core/models/base.py
@@ -22,5 +22,8 @@ class EstateAbstractBase(PermanentModel, TimeStampedModel, TitleDescriptionModel
class Meta(TimeStampedModel.Meta):
abstract = True
+ def __unicode__(self):
+ return self.title
+
def __repr__(self):
return "<%s:%s pk:%i>" % (self.__class__.__name__, self.title, self.pk)
diff --git a/estate/terraform/admin/__init__.py b/estate/terraform/admin/__init__.py
index 8aab3d4..e4ef78c 100644
--- a/estate/terraform/admin/__init__.py
+++ b/estate/terraform/admin/__init__.py
@@ -2,3 +2,4 @@
from .namespace import * # NOQA
from .file import * # NOQA
from .template import * # NOQA
+from .state import * # NOQA
diff --git a/estate/terraform/admin/state.py b/estate/terraform/admin/state.py
new file mode 100644
index 0000000..5f00a9f
--- /dev/null
+++ b/estate/terraform/admin/state.py
@@ -0,0 +1,15 @@
+from __future__ import absolute_import
+from django.contrib import admin
+from django.apps import apps
+
+State = apps.get_model('terraform.State')
+
+
+class StateAdmin(admin.ModelAdmin):
+ list_display = ['pk', 'title', 'description', 'modified']
+ list_filter = ['title']
+ search_fields = ['slug', 'title']
+ list_per_page = 10
+
+
+admin.site.register(State, StateAdmin)
diff --git a/estate/terraform/migrations/0007_historicalstate_state.py b/estate/terraform/migrations/0007_historicalstate_state.py
new file mode 100644
index 0000000..3d5c964
--- /dev/null
+++ b/estate/terraform/migrations/0007_historicalstate_state.py
@@ -0,0 +1,62 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.7 on 2017-08-31 21:38
+from __future__ import unicode_literals
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django_extensions.db.fields
+import estate.core.models.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('terraform', '0006_auto_20170804_1357'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='HistoricalState',
+ 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')),
+ ('deleted', models.DateTimeField(blank=True, default=None, editable=False, null=True)),
+ ('content', models.TextField(blank=True, verbose_name='content')),
+ ('history_id', models.AutoField(primary_key=True, serialize=False)),
+ ('history_date', models.DateTimeField()),
+ ('history_change_reason', models.CharField(max_length=100, null=True)),
+ ('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)),
+ ('namespace', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='terraform.Namespace')),
+ ],
+ options={
+ 'ordering': ('-history_date', '-history_id'),
+ 'get_latest_by': 'history_date',
+ 'verbose_name': 'historical state',
+ },
+ ),
+ migrations.CreateModel(
+ name='State',
+ 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')),
+ ('deleted', models.DateTimeField(blank=True, default=None, editable=False, null=True)),
+ ('slug', estate.core.models.fields.SoftDeleteAwareAutoSlugField(blank=True, editable=False, populate_from=b'title', verbose_name='slug')),
+ ('content', models.TextField(blank=True, verbose_name='content')),
+ ('namespace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='state', to='terraform.Namespace')),
+ ],
+ options={
+ 'ordering': ('-modified', '-created'),
+ 'abstract': False,
+ 'get_latest_by': 'modified',
+ },
+ ),
+ ]
diff --git a/estate/terraform/models/__init__.py b/estate/terraform/models/__init__.py
index f698428..7466aa6 100644
--- a/estate/terraform/models/__init__.py
+++ b/estate/terraform/models/__init__.py
@@ -1,4 +1,5 @@
from __future__ import absolute_import
from .file import * # NOQA
from .template import * # NOQA
-from .namespace import * # NOQA
\ No newline at end of file
+from .namespace import * # NOQA
+from .state import * # NOQA
diff --git a/estate/terraform/models/state.py b/estate/terraform/models/state.py
new file mode 100644
index 0000000..a8cdec2
--- /dev/null
+++ b/estate/terraform/models/state.py
@@ -0,0 +1,13 @@
+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 State(EstateAbstractBase):
+ content = models.TextField(_("content"), blank=True)
+ namespace = models.ForeignKey("terraform.Namespace", related_name="state")
+
+ history = HistoricalRecordsWithoutDelete(excluded_fields=['slug'])
diff --git a/estate/terraform/terraform.py b/estate/terraform/terraform.py
index 73d327b..641865a 100644
--- a/estate/terraform/terraform.py
+++ b/estate/terraform/terraform.py
@@ -40,10 +40,11 @@ def save_plan(self, plan_hash, plan_data):
class Terraform(HotDockerExecutor):
- def __init__(self, action, namespace, plan_hash=None):
+ def __init__(self, action, namespace, plan_hash=None, state_obj=None):
self.action = action
self.namespace = namespace
self.plan_hash = plan_hash
+ self.state_obj = state_obj
config = {
"docker_image": settings.TERRAFORM_DOCKER_IMAGE,
"name": self.namespace.slug,
@@ -67,43 +68,57 @@ def run(self, *args, **kwargs):
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":
+ 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)
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!")
+ 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:
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)
+ exit_code, _ = self.execute_command(["tar", "-xzvf", path, "-C", 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)
+ exit_code, output = self.execute_command(["tar", "-czvf", path, "./plan"])
if exit_code != 0:
- raise Exception("Unable to save plan file!")
+ raise Exception("Unable to save plan file!\n" + output)
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)
+ if self.action == "apply" and self.exit_code == 0:
+ path = os.path.join(self.workdir, "terraform.tfstate")
+ exit_code, output = self.execute_command(["cat", path])
+ if exit_code != 0:
+ raise Exception("Failed to save the terraform state!\n" + output)
+ LOG.info("[Terraform] Saving Terraform State - \n" + output)
+ self.state_obj.content = output
+ self.state_obj.save()
def get_stream(self):
return self.streamer.get()
diff --git a/estate/terraform/urls.py b/estate/terraform/urls.py
index 3515b10..a7777e4 100644
--- a/estate/terraform/urls.py
+++ b/estate/terraform/urls.py
@@ -8,6 +8,7 @@
router.register(r"template", views.TemplateApiView)
router.register(r"templateinstance", views.TemplateInstanceApiView)
router.register(r"namespace", views.NamespaceApiView)
+router.register(r"state", views.StateApiView)
router.include_root_view = True
urlpatterns = [
diff --git a/estate/terraform/views/__init__.py b/estate/terraform/views/__init__.py
index 816b447..ebe0381 100644
--- a/estate/terraform/views/__init__.py
+++ b/estate/terraform/views/__init__.py
@@ -2,3 +2,4 @@
from .file import FileApiView # NOQA
from .template import TemplateApiView, TemplateInstanceApiView # NOQA
from .namespace import NamespaceApiView # NOQA
+from .state import StateApiView # NOQA
diff --git a/estate/terraform/views/namespace.py b/estate/terraform/views/namespace.py
index 79233c9..8be2c7b 100644
--- a/estate/terraform/views/namespace.py
+++ b/estate/terraform/views/namespace.py
@@ -8,6 +8,7 @@
from ..terraform import Terraform
Namespace = apps.get_model('terraform.Namespace')
+State = apps.get_model('terraform.State')
class NamespaceSerializer(HistoricalSerializer):
@@ -43,7 +44,8 @@ class NamespaceApiView(HistoryMixin, viewsets.ModelViewSet):
@decorators.detail_route(methods=["POST"])
def plan(self, request, *args, **kwargs):
instance = self.get_object()
- runner = Terraform("plan", instance)
+ state_obj, _ = State.objects.get_or_create(namespace=instance, defaults={"title": instance.title, "namespace": instance})
+ runner = Terraform("plan", instance, None, state_obj)
runner.run()
return response.Response(runner.get_stream())
@@ -56,7 +58,8 @@ def plan_live(self, request, *args, **kwargs):
@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)
+ state_obj, _ = State.objects.get_or_create(namespace=instance, defaults={"title": instance.title, "namespace": instance})
+ runner = Terraform("apply", instance, plan_hash, state_obj)
runner.run()
return response.Response(runner.get_stream())
diff --git a/estate/terraform/views/state.py b/estate/terraform/views/state.py
new file mode 100644
index 0000000..b77b57f
--- /dev/null
+++ b/estate/terraform/views/state.py
@@ -0,0 +1,33 @@
+from __future__ import absolute_import
+from django.apps import apps
+from rest_framework import serializers, viewsets, filters
+from estate.core.views import HistoricalSerializer, HistoryMixin
+
+Namespace = apps.get_model('terraform.Namespace')
+State = apps.get_model('terraform.State')
+
+
+class StateSerializer(HistoricalSerializer):
+ description = serializers.CharField(default="", allow_blank=True)
+ namespace = serializers.SlugRelatedField(slug_field="slug", queryset=Namespace.objects.all())
+
+ class Meta:
+ model = State
+ fields = ("pk", "slug", "title", "description", "namespace", "content", "created", "modified")
+ historical_fields = ("pk", "slug", "title", "namespace", "description", "content")
+
+
+class StateFilter(filters.FilterSet):
+
+ class Meta:
+ model = State
+ fields = ["title", "namespace"]
+
+
+class StateApiView(HistoryMixin, viewsets.ModelViewSet):
+ queryset = State.objects.all()
+ serializer_class = StateSerializer
+ filter_class = StateFilter
+ filter_fields = ('slug',)
+ search_fields = ('title',)
+ ordering_fields = ('title', 'created', 'modified')