Преглед изворни кода

Merge pull request #68 from porter-dev/staging

Beta 1 Feature Set
jusrhee пре 5 година
родитељ
комит
8a8af221ea
53 измењених фајлова са 2106 додато и 248 уклоњено
  1. 1 0
      .dockerignore
  2. 33 0
      .goreleaser.yml
  3. 39 5
      README.md
  4. BIN
      app
  5. 36 0
      cli/cmd/credstore/credstore.go
  6. 5 0
      cli/cmd/credstore/credstore_darwin.go
  7. 5 0
      cli/cmd/credstore/credstore_linux.go
  8. 34 0
      cli/cmd/credstore/credstore_test.go
  9. 5 0
      cli/cmd/credstore/credstore_windows.go
  10. 219 0
      cli/cmd/docker/agent.go
  11. 29 0
      cli/cmd/docker/config.go
  12. 273 0
      cli/cmd/docker/porter.go
  13. 97 0
      cli/cmd/generate.go
  14. 63 0
      cli/cmd/helpers.go
  15. 24 0
      cli/cmd/root.go
  16. 337 0
      cli/cmd/start.go
  17. 11 0
      cli/main.go
  18. 78 1
      cmd/app/main.go
  19. 4 2
      dashboard/src/components/YamlEditor.tsx
  20. 3 0
      dashboard/src/main/Login.tsx
  21. 7 3
      dashboard/src/main/Main.tsx
  22. 0 1
      dashboard/src/main/home/dashboard/Dashboard.tsx
  23. 1 1
      dashboard/src/main/home/dashboard/chart/ChartList.tsx
  24. 45 10
      dashboard/src/main/home/dashboard/expanded-chart/ExpandedChart.tsx
  25. 6 4
      dashboard/src/main/home/dashboard/expanded-chart/GraphSection.tsx
  26. 1 0
      dashboard/src/main/home/dashboard/expanded-chart/ListSection.tsx
  27. 9 1
      dashboard/src/main/home/dashboard/expanded-chart/ResourceItem.tsx
  28. 44 21
      dashboard/src/main/home/dashboard/expanded-chart/RevisionSection.tsx
  29. 4 1
      dashboard/src/main/home/dashboard/expanded-chart/ValuesYaml.tsx
  30. 150 68
      dashboard/src/main/home/dashboard/expanded-chart/graph/GraphDisplay.tsx
  31. 98 11
      dashboard/src/main/home/dashboard/expanded-chart/graph/InfoPanel.tsx
  32. 10 5
      dashboard/src/main/home/dashboard/expanded-chart/graph/Node.tsx
  33. 10 3
      dashboard/src/shared/rosettaStone.tsx
  34. 1 0
      dashboard/src/shared/types.tsx
  35. 2 20
      docker-compose.dev.yaml
  36. 0 45
      docker-compose.yaml
  37. 3 1
      docker/.env
  38. 62 7
      docker/Dockerfile
  39. 1 1
      docker/dev.Dockerfile
  40. 28 0
      docs/DEVELOPING.md
  41. 10 5
      go.mod
  42. 21 0
      go.sum
  43. 26 2
      internal/adapter/gorm.go
  44. 13 7
      internal/config/config.go
  45. 4 7
      internal/forms/user.go
  46. 15 5
      internal/helm/grapher/object.go
  47. 14 5
      internal/helm/grapher/relation.go
  48. 160 1
      internal/kubernetes/kubeconfig.go
  49. 33 0
      package-lock.json
  50. 1 1
      server/api/helpers_test.go
  51. 1 0
      server/api/user_handler.go
  52. 11 3
      server/router/middleware/auth.go
  53. 19 1
      server/router/router.go

+ 1 - 0
.dockerignore

@@ -0,0 +1 @@
+/dashboard/node_modules

+ 33 - 0
.goreleaser.yml

@@ -0,0 +1,33 @@
+before:
+  hooks:
+    - go mod download
+builds:
+  - id: "porter-cli"
+    build: porter
+    env:
+      - CGO_ENABLED=1
+    dir: cli
+    main: ./cli/main.go
+    goos:
+      - linux
+      - windows
+      - darwin
+    flags:
+      - -tags=cli
+archives:
+  - replacements:
+      darwin: Darwin
+      linux: Linux
+      windows: Windows
+      386: i386
+      amd64: x86_64
+checksum:
+  name_template: 'checksums.txt'
+snapshot:
+  name_template: "{{ .Tag }}-next"
+changelog:
+  sort: asc
+  filters:
+    exclude:
+      - '^docs:'
+      - '^test:'

+ 39 - 5
README.md

@@ -1,13 +1,47 @@
 # Porter
 # Porter
 
 
-For development:
+Porter is a **dashboard for Helm** with support for the following features:
+- Visualization of all Helm releases with filtering by namespace
+- In-depth view of releases, including revision histories and component graphs
+- Rollback/update of existing releases, including editing of `values.yaml`
+
+**What's next for Porter?** View our [roadmap](https://github.com/porter-dev/porter/projects/1), or read our [mission statement](#mission-statement). 
+
+## Quick Start
+
+To view the dashboard locally, download our CLI and grab the latest release via:
+
+```sh
+curl "https://api.github.com/repos/porter-dev/porter/releases/latest"
+chmod +x ./porter
+sudo mv ./porter /usr/local/bin/porter
+```
+
+Then run the dashboard (Docker engine must be running on the host machine):
 
 
 ```sh
 ```sh
-docker-compose -f docker-compose.dev.yaml up --build
+porter start
 ```
 ```
 
 
-And then visit `localhost:8080` in the browser. 
+When prompted, enter the admin email/password you would like to use. After the server has started, go to `localhost:8080` and **log in with the credentials you just set**. 
+
+## Differences from Kubeapps
+
+As a disclaimer, we're big fans of [Kubeapps](https://github.com/kubeapps/kubeapps), and many of the initial features that we build out will be very similar. Currently, Porter's graph-based chart visualization is the only fundamental difference, and it should be assumed that most Kubeapps features will be supported on Porter in the near future. However, on the feature side, Porter will eventually support:
+- IDE-like tooling for chart creation, templating, and packaging
+- Deep integration with GitOps workflows and CI/CD tools
+- Visualization of lifecycle hooks and robust error tracing for deployments
+
+## Mission Statement
+
+**`kubectl` for your fundamental operations. Porter for everything else.**
+
+Our mission is to be the go-to tool for interacting with complex Kubernetes deployments as both a beginner and an expert. While our initial focus is on visualizing Helm components, we believe this visualization and editing can be extended to a number of other tools and concepts, including alternative templating tools (kustomize, Terraform), other deployment tools (CI/CD tools, Terraform), Kubernetes package repositories (ChartMuseum, JFrog Artifactory), and even popular Kubernetes packages (nginx-ingress, cert-manager, prometheus, velero). 
 
 
-### Testing
+More specifically, we have the following long-term goals:
+- **Design a visual interface for complex deployments and operations**
+- **Make deployments and operations editable by and accessible for non-Kubernetes experts**
+- **Improve the development experience for packaging and releasing Kubernetes applications**
+- **Increase interoperability of Kubernetes tooling without compromising usability**
 
 
-From the root folder, run `go test ./...` to run all tests and ensure the builds/tests pass. 
+Why did we begin with Helm? Helm is the most popular auxiliary Kubernetes tool, and can function in nearly all parts of deployment lifecycle. We think of the various features of Helm in the following manner, adapted from [Brian Grant's Helm Summit talk](https://www.youtube.com/watch?v=F-TlC8nIz8s) (slides [here](https://docs.google.com/presentation/d/10dp4hKciccincnH6pAFf7t31s82iNvtt_mwhlUbeCDw/edit#slide=id.g32690131a8_0_5)): package management, dependency management, application metadata, parameterization, templating, deployment/config revision management, lifecycle management hooks, and application probes. Along with these fundamental features, an expanding number of [command plugins](https://helm.sh/docs/community/related/#helm-plugins) for more specific use-cases have started to become popular in the Helm ecosystem. If we can build a better workflow for both application developers and application operators by improving the user experience for most of these Helm features, we can generalize and expand this workflow to support alternative tooling that exists in the [Kubernetes application management ecosystem](https://docs.google.com/spreadsheets/d/1FCgqz1Ci7_VCz_wdh8vBitZ3giBtac_H8SBw4uxnrsE/edit#gid=0). 


+ 36 - 0
cli/cmd/credstore/credstore.go

@@ -0,0 +1,36 @@
+package credstore
+
+import "github.com/docker/docker-credential-helpers/credentials"
+
+const (
+	url   = "https://github.com/porter-dev/porter"
+	label = "Porter Credentials"
+)
+
+// Set stores a given username/pw with a given credentials label in the OS-specific
+// credentials store
+func Set(username, pw string) error {
+	cr := &credentials.Credentials{
+		ServerURL: url,
+		Username:  username,
+		Secret:    pw,
+	}
+
+	credentials.SetCredsLabel(label)
+
+	return ns.Add(cr)
+}
+
+// Get retrieves a given username/pw with a given credentials label in the OS-specific
+// credentials store
+func Get() (string, string, error) {
+	credentials.SetCredsLabel(label)
+	return ns.Get(url)
+}
+
+// Del removes a given credential that uses a label in the OS-specific
+// credentials store
+func Del() error {
+	credentials.SetCredsLabel(label)
+	return ns.Delete(url)
+}

+ 5 - 0
cli/cmd/credstore/credstore_darwin.go

@@ -0,0 +1,5 @@
+package credstore
+
+import "github.com/docker/docker-credential-helpers/osxkeychain"
+
+var ns = osxkeychain.Osxkeychain{}

+ 5 - 0
cli/cmd/credstore/credstore_linux.go

@@ -0,0 +1,5 @@
+package credstore
+
+import "github.com/docker/docker-credential-helpers/pass"
+
+var ns = pass.Pass{}

+ 34 - 0
cli/cmd/credstore/credstore_test.go

@@ -0,0 +1,34 @@
+package credstore_test
+
+import (
+	"log"
+	"testing"
+
+	"github.com/porter-dev/porter/cli/cmd/credstore"
+)
+
+func TestSetGet(t *testing.T) {
+	credstore.Set("user", "password")
+
+	user, secret, err := credstore.Get()
+	if err == nil {
+		if user != "user" {
+			t.Errorf("Expecting user, got %s", user)
+		}
+
+		if secret != "password" {
+			t.Errorf("Expecting password, got %s", secret)
+		}
+	} else {
+		log.Println("got error:", err)
+	}
+
+	credstore.Del()
+
+	_, _, err = credstore.Get()
+
+	if err == nil {
+		t.Fatalf("Expecting an error, got nil")
+	}
+
+}

+ 5 - 0
cli/cmd/credstore/credstore_windows.go

@@ -0,0 +1,5 @@
+package credstore
+
+import "github.com/docker/docker-credential-helpers/wincred"
+
+var ns = wincred.Wincred{}

+ 219 - 0
cli/cmd/docker/agent.go

@@ -0,0 +1,219 @@
+package docker
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"io"
+	"strings"
+	"time"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/api/types/filters"
+	"github.com/docker/docker/api/types/network"
+	"github.com/docker/docker/api/types/volume"
+	"github.com/docker/docker/client"
+)
+
+// Agent is a Docker client for performing operations that interact
+// with the Docker engine over REST
+type Agent struct {
+	client *client.Client
+	ctx    context.Context
+	label  string
+}
+
+// CreateLocalVolumeIfNotExist creates a volume using driver type "local" with the
+// given name if it does not exist. If the volume does exist but does not contain
+// the required label (a.label), an error is thrown.
+func (a *Agent) CreateLocalVolumeIfNotExist(name string) (*types.Volume, error) {
+	volListBody, err := a.client.VolumeList(a.ctx, filters.Args{})
+
+	if err != nil {
+		return nil, a.handleDockerClientErr(err, "Could not list volumes")
+	}
+
+	for _, _vol := range volListBody.Volumes {
+		if contains, ok := _vol.Labels[a.label]; ok && contains == "true" && _vol.Name == name {
+			return _vol, nil
+		} else if !ok && _vol.Name == name {
+			return nil, fmt.Errorf("volume conflict for %s: please remove existing volume and try again", name)
+		}
+	}
+
+	return a.CreateLocalVolume(name)
+}
+
+// CreateLocalVolume creates a volume using driver type "local" with no
+// configured options. The equivalent of:
+//
+// docker volume create --driver local [name]
+func (a *Agent) CreateLocalVolume(name string) (*types.Volume, error) {
+	labels := make(map[string]string)
+	labels[a.label] = "true"
+
+	opts := volume.VolumeCreateBody{
+		Name:   name,
+		Driver: "local",
+		Labels: labels,
+	}
+
+	vol, err := a.client.VolumeCreate(a.ctx, opts)
+
+	if err != nil {
+		return nil, a.handleDockerClientErr(err, "Could not create volume "+name)
+	}
+
+	return &vol, nil
+}
+
+// CreateBridgeNetworkIfNotExist creates a volume using driver type "local" with the
+// given name if it does not exist. If the volume does exist but does not contain
+// the required label (a.label), an error is thrown.
+func (a *Agent) CreateBridgeNetworkIfNotExist(name string) (id string, err error) {
+	networks, err := a.client.NetworkList(a.ctx, types.NetworkListOptions{})
+
+	if err != nil {
+		return "", a.handleDockerClientErr(err, "Could not list volumes")
+	}
+
+	for _, net := range networks {
+		if contains, ok := net.Labels[a.label]; ok && contains == "true" && net.Name == name {
+			return net.ID, nil
+		} else if !ok && net.Name == name {
+			return "", fmt.Errorf("network conflict for %s: please remove existing network and try again", name)
+		}
+	}
+
+	return a.CreateBridgeNetwork(name)
+}
+
+// CreateBridgeNetwork creates a volume using the default driver type (bridge)
+// with the CLI label attached
+func (a *Agent) CreateBridgeNetwork(name string) (id string, err error) {
+	labels := make(map[string]string)
+	labels[a.label] = "true"
+
+	opts := types.NetworkCreate{
+		Labels:     labels,
+		Attachable: true,
+	}
+
+	net, err := a.client.NetworkCreate(a.ctx, name, opts)
+
+	if err != nil {
+		return "", a.handleDockerClientErr(err, "Could not create network "+name)
+	}
+
+	return net.ID, nil
+}
+
+// ConnectContainerToNetwork attaches a container to a specified network
+func (a *Agent) ConnectContainerToNetwork(networkID, containerID, containerName string) error {
+	// check if the container is connected already
+	net, err := a.client.NetworkInspect(a.ctx, networkID, types.NetworkInspectOptions{})
+
+	if err != nil {
+		return a.handleDockerClientErr(err, "Could not inspect network"+networkID)
+	}
+
+	for _, cont := range net.Containers {
+		// if container is connected, just return
+		if cont.Name == containerName {
+			return nil
+		}
+	}
+
+	return a.client.NetworkConnect(a.ctx, networkID, containerID, &network.EndpointSettings{})
+}
+
+// PullImageEvent represents a response from the Docker API with an image pull event
+type PullImageEvent struct {
+	Status         string `json:"status"`
+	Error          string `json:"error"`
+	Progress       string `json:"progress"`
+	ProgressDetail struct {
+		Current int `json:"current"`
+		Total   int `json:"total"`
+	} `json:"progressDetail"`
+}
+
+// PullImage pulls an image specified by the image string
+func (a *Agent) PullImage(image string) error {
+	fmt.Println("Pulling image:", image)
+
+	// pull the specified image
+	out, err := a.client.ImagePull(a.ctx, image, types.ImagePullOptions{})
+
+	if err != nil {
+		return a.handleDockerClientErr(err, "Could not pull image"+image)
+	}
+
+	decoder := json.NewDecoder(out)
+
+	var event *PullImageEvent
+
+	for {
+		if err := decoder.Decode(&event); err != nil {
+			if err == io.EOF {
+				break
+			}
+
+			return err
+		}
+	}
+
+	fmt.Println("Finished pulling image:", image)
+
+	return nil
+}
+
+// WaitForContainerStop waits until a container has stopped to exit
+func (a *Agent) WaitForContainerStop(id string) error {
+	// wait for container to stop before exit
+	statusCh, errCh := a.client.ContainerWait(a.ctx, id, container.WaitConditionNotRunning)
+
+	select {
+	case err := <-errCh:
+		if err != nil {
+			return a.handleDockerClientErr(err, "Error waiting for stopped container")
+		}
+	case <-statusCh:
+	}
+
+	return nil
+}
+
+// WaitForContainerHealthy waits until a container is returning a healthy status. Streak
+// is the maximum number of failures in a row, while timeout is the length of time between
+// checks.
+func (a *Agent) WaitForContainerHealthy(id string, streak int) error {
+	for {
+		cont, err := a.client.ContainerInspect(a.ctx, id)
+
+		if err != nil {
+			return a.handleDockerClientErr(err, "Error waiting for stopped container")
+		}
+
+		health := cont.State.Health
+
+		if health == nil || health.Status == "healthy" || health.FailingStreak >= streak {
+			break
+		}
+
+		time.Sleep(time.Second)
+	}
+
+	return nil
+}
+
+// ------------------------- AGENT HELPER FUNCTIONS ------------------------- //
+
+func (a *Agent) handleDockerClientErr(err error, errPrefix string) error {
+	if strings.Contains(err.Error(), "Cannot connect to the Docker daemon") {
+		return fmt.Errorf("The Docker daemon must be running in order to start Porter: connection to %s failed", a.client.DaemonHost())
+	}
+
+	return fmt.Errorf("%s:%s", errPrefix, err.Error())
+}

+ 29 - 0
cli/cmd/docker/config.go

@@ -0,0 +1,29 @@
+package docker
+
+import (
+	"context"
+
+	"github.com/docker/docker/client"
+)
+
+const label = "CreatedByPorterCLI"
+
+// NewAgentFromEnv creates a new Docker agent using the environment variables set
+// on the host
+func NewAgentFromEnv() (*Agent, error) {
+	ctx := context.Background()
+	cli, err := client.NewClientWithOpts(
+		client.FromEnv,
+		client.WithAPIVersionNegotiation(),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &Agent{
+		client: cli,
+		ctx:    ctx,
+		label:  label,
+	}, nil
+}

+ 273 - 0
cli/cmd/docker/porter.go

@@ -0,0 +1,273 @@
+package docker
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/docker/docker/api/types"
+	"github.com/docker/docker/api/types/container"
+	"github.com/docker/docker/api/types/mount"
+	"github.com/docker/go-connections/nat"
+)
+
+// PorterStartOpts are the options for starting the Porter container
+type PorterStartOpts struct {
+	Name          string
+	Image         string
+	StartCmd      []string
+	HostPort      uint
+	ContainerPort uint
+	Mounts        []mount.Mount
+	VolumeMap     map[string]struct{}
+	Env           []string
+	NetworkID     string
+}
+
+// StartPorterContainer pulls a specific Porter image and starts a container
+// using the Docker engine. It returns the container ID
+func (a *Agent) StartPorterContainer(opts PorterStartOpts) (string, error) {
+	id, err := a.upsertPorterContainer(opts)
+
+	if err != nil {
+		return "", err
+	}
+
+	err = a.startPorterContainer(id)
+
+	if err != nil {
+		return "", err
+	}
+
+	// attach container to network
+	err = a.ConnectContainerToNetwork(opts.NetworkID, id, opts.Name)
+
+	if err != nil {
+		return "", err
+	}
+
+	return id, nil
+}
+
+// detect if container exists and is running, and stop
+// if spec has changed, remove and recreate container
+// if container does not exist, create the container
+// otherwise, return stopped container
+func (a *Agent) upsertPorterContainer(opts PorterStartOpts) (id string, err error) {
+	containers, err := a.getContainersCreatedByStart()
+
+	// remove the matching container
+	for _, container := range containers {
+		if len(container.Names) > 0 && container.Names[0] == "/"+opts.Name {
+			timeout, _ := time.ParseDuration("15s")
+
+			err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+
+			if err != nil {
+				return "", a.handleDockerClientErr(err, "Could not stop container "+container.ID)
+			}
+
+			err = a.client.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
+
+			if err != nil {
+				return "", a.handleDockerClientErr(err, "Could not remove container "+container.ID)
+			}
+		}
+	}
+
+	return a.pullAndCreatePorterContainer(opts)
+}
+
+// create the container and return its id
+func (a *Agent) pullAndCreatePorterContainer(opts PorterStartOpts) (id string, err error) {
+	a.PullImage(opts.Image)
+
+	// format the port array for binding to host machine
+	ports := []string{fmt.Sprintf("127.0.0.1:%d:%d/tcp", opts.HostPort, opts.ContainerPort)}
+
+	_, portBindings, err := nat.ParsePortSpecs(ports)
+
+	if err != nil {
+		return "", fmt.Errorf("Unable to parse port specification %s", ports)
+	}
+
+	labels := make(map[string]string)
+	labels[a.label] = "true"
+
+	// create the container with a label specifying this was created via the CLI
+	resp, err := a.client.ContainerCreate(a.ctx, &container.Config{
+		Image:   opts.Image,
+		Cmd:     opts.StartCmd,
+		Tty:     false,
+		Labels:  labels,
+		Volumes: opts.VolumeMap,
+		Env:     opts.Env,
+	}, &container.HostConfig{
+		PortBindings: portBindings,
+		Mounts:       opts.Mounts,
+	}, nil, opts.Name)
+
+	if err != nil {
+		return "", a.handleDockerClientErr(err, "Could not create Porter container")
+	}
+
+	return resp.ID, nil
+}
+
+// start the container
+func (a *Agent) startPorterContainer(id string) error {
+	if err := a.client.ContainerStart(a.ctx, id, types.ContainerStartOptions{}); err != nil {
+		return a.handleDockerClientErr(err, "Could not start Porter container")
+	}
+
+	return nil
+}
+
+// PostgresOpts are the options for starting the Postgres DB
+type PostgresOpts struct {
+	Name      string
+	Image     string
+	Env       []string
+	VolumeMap map[string]struct{}
+	Mounts    []mount.Mount
+	NetworkID string
+}
+
+// StartPostgresContainer pulls a specific Porter image and starts a container
+// using the Docker engine
+func (a *Agent) StartPostgresContainer(opts PostgresOpts) (string, error) {
+	id, err := a.upsertPostgresContainer(opts)
+
+	if err != nil {
+		return "", err
+	}
+
+	err = a.startPostgresContainer(id)
+
+	if err != nil {
+		return "", err
+	}
+
+	// attach container to network
+	err = a.ConnectContainerToNetwork(opts.NetworkID, id, opts.Name)
+
+	if err != nil {
+		return "", err
+	}
+
+	return id, nil
+}
+
+// detect if container exists and is running, and stop
+// if it is running, stop it
+// if it is stopped, return id
+// if it does not exist, create it and return it
+func (a *Agent) upsertPostgresContainer(opts PostgresOpts) (id string, err error) {
+	containers, err := a.getContainersCreatedByStart()
+
+	// stop the matching container and return it
+	for _, container := range containers {
+		if len(container.Names) > 0 && container.Names[0] == "/"+opts.Name {
+			timeout, _ := time.ParseDuration("15s")
+
+			err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+
+			if err != nil {
+				return "", a.handleDockerClientErr(err, "Could not stop postgres container "+container.ID)
+			}
+
+			return container.ID, nil
+		}
+	}
+
+	return a.pullAndCreatePostgresContainer(opts)
+}
+
+// create the container and return it
+func (a *Agent) pullAndCreatePostgresContainer(opts PostgresOpts) (id string, err error) {
+	a.PullImage(opts.Image)
+
+	labels := make(map[string]string)
+	labels[a.label] = "true"
+
+	// create the container with a label specifying this was created via the CLI
+	resp, err := a.client.ContainerCreate(a.ctx, &container.Config{
+		Image:   opts.Image,
+		Tty:     false,
+		Labels:  labels,
+		Volumes: opts.VolumeMap,
+		Env:     opts.Env,
+		ExposedPorts: nat.PortSet{
+			"5432": struct{}{},
+		},
+		Healthcheck: &container.HealthConfig{
+			Test:     []string{"CMD-SHELL", "pg_isready"},
+			Interval: 10 * time.Second,
+			Timeout:  5 * time.Second,
+			Retries:  3,
+		},
+	}, &container.HostConfig{
+		Mounts: opts.Mounts,
+	}, nil, opts.Name)
+
+	if err != nil {
+		return "", a.handleDockerClientErr(err, "Could not create Porter container")
+	}
+
+	return resp.ID, nil
+}
+
+// start the container in the background
+func (a *Agent) startPostgresContainer(id string) error {
+	if err := a.client.ContainerStart(a.ctx, id, types.ContainerStartOptions{}); err != nil {
+		return a.handleDockerClientErr(err, "Could not start Postgres container")
+	}
+
+	return nil
+}
+
+// StopPorterContainers finds all containers that were started via the CLI and stops them
+// without removal.
+func (a *Agent) StopPorterContainers() error {
+	fmt.Println("Stopping containers...")
+
+	containers, err := a.getContainersCreatedByStart()
+
+	if err != nil {
+		return err
+	}
+
+	// remove all Porter containers
+	for _, container := range containers {
+		timeout, _ := time.ParseDuration("15s")
+
+		err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+
+		if err != nil {
+			return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
+		}
+	}
+
+	return nil
+}
+
+// getContainersCreatedByStart gets all containers that were created by the "porter start"
+// command by looking for the label "CreatedByPorterCLI" (or .label of the agent)
+func (a *Agent) getContainersCreatedByStart() ([]types.Container, error) {
+	containers, err := a.client.ContainerList(a.ctx, types.ContainerListOptions{
+		All: true,
+	})
+
+	if err != nil {
+		return nil, a.handleDockerClientErr(err, "Could not list containers")
+	}
+
+	res := make([]types.Container, 0)
+
+	for _, container := range containers {
+		if contains, ok := container.Labels[a.label]; ok && contains == "true" {
+			res = append(res, container)
+		}
+	}
+
+	return res, nil
+}

+ 97 - 0
cli/cmd/generate.go

@@ -0,0 +1,97 @@
+package cmd
+
+import (
+	"fmt"
+	"path/filepath"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"k8s.io/client-go/tools/clientcmd"
+	"k8s.io/client-go/util/homedir"
+
+	"github.com/spf13/cobra"
+)
+
+var (
+	outputFile     string
+	kubeconfigPath string
+	print          *bool
+	contexts       *[]string
+)
+
+// generateCmd represents the generate command
+var generateCmd = &cobra.Command{
+	Use:   "generate",
+	Short: "Generates a kubeconfig with certificate data added",
+	Run: func(cmd *cobra.Command, args []string) {
+		generate(kubeconfigPath, outputFile, *print, *contexts)
+	},
+}
+
+func init() {
+	home := homedir.HomeDir()
+
+	rootCmd.AddCommand(generateCmd)
+
+	generateCmd.PersistentFlags().StringVarP(
+		&outputFile,
+		"output",
+		"o",
+		filepath.Join(home, ".porter", "porter.kubeconfig"),
+		"output file location",
+	)
+
+	generateCmd.PersistentFlags().StringVarP(
+		&kubeconfigPath,
+		"kubeconfig",
+		"k",
+		"",
+		"path to kubeconfig",
+	)
+
+	contexts = generateCmd.PersistentFlags().StringArray(
+		"contexts",
+		nil,
+		"the list of contexts to use (defaults to the current context)",
+	)
+
+	print = generateCmd.PersistentFlags().BoolP(
+		"print",
+		"p",
+		false,
+		"print result to stdout, without writing to the fs",
+	)
+}
+
+func generate(kubeconfigPath string, output string, print bool, contexts []string) error {
+	conf, err := kubernetes.GetConfigFromHostWithCertData(kubeconfigPath, contexts)
+
+	if err != nil {
+		return err
+	}
+
+	rawConf, err := conf.RawConfig()
+
+	if err != nil {
+		return err
+	}
+
+	if print {
+		bytes, err := clientcmd.Write(rawConf)
+
+		if err != nil {
+			return err
+		}
+
+		fmt.Printf(string(bytes))
+
+		return nil
+	}
+
+	err = clientcmd.WriteToFile(rawConf, output)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}

+ 63 - 0
cli/cmd/helpers.go

@@ -0,0 +1,63 @@
+package cmd
+
+import (
+	"bufio"
+	"errors"
+	"fmt"
+	"os"
+	"os/signal"
+	"strings"
+	"syscall"
+
+	"golang.org/x/crypto/ssh/terminal"
+)
+
+func closeHandler(closer func() error) {
+	sig := make(chan os.Signal)
+	signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		<-sig
+		err := closer()
+
+		if err == nil {
+			fmt.Println("shutdown successful")
+			os.Exit(0)
+		}
+
+		fmt.Printf("shutdown unsuccessful: %s\n", err.Error())
+		os.Exit(1)
+	}()
+}
+
+func promptPlaintext(prompt string) (string, error) {
+	reader := bufio.NewReader(os.Stdin)
+
+	fmt.Print(prompt)
+	text, err := reader.ReadString('\n')
+
+	if err != nil {
+		return "", err
+	}
+
+	return strings.TrimSpace(text), nil
+}
+
+func promptPasswordWithConfirmation() (string, error) {
+	fmt.Print("Password: ")
+	pw, err := terminal.ReadPassword(0)
+	fmt.Print("\r")
+
+	if err != nil {
+		return "", err
+	}
+
+	fmt.Print("Confirm password: ")
+	confirmPw, err := terminal.ReadPassword(0)
+	fmt.Print("\n")
+
+	if strings.TrimSpace(string(pw)) != strings.TrimSpace(string(confirmPw)) {
+		return "", errors.New("Passwords do not match")
+	}
+
+	return strings.TrimSpace(string(pw)), nil
+}

+ 24 - 0
cli/cmd/root.go

@@ -0,0 +1,24 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/spf13/cobra"
+)
+
+// rootCmd represents the base command when called without any subcommands
+var rootCmd = &cobra.Command{
+	Use:   "porter",
+	Short: "Porter is a dashboard for managing Kubernetes clusters.",
+	Long:  `Porter is a tool for creating, versioning, and updating Kubernetes deployments using a visual dashboard. For more information, visit github.com/porter-dev/porter`,
+}
+
+// Execute adds all child commands to the root command and sets flags appropriately.
+// This is called by main.main(). It only needs to happen once to the rootCmd.
+func Execute() {
+	if err := rootCmd.Execute(); err != nil {
+		fmt.Println(err)
+		os.Exit(1)
+	}
+}

+ 337 - 0
cli/cmd/start.go

@@ -0,0 +1,337 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"time"
+
+	"github.com/porter-dev/porter/cli/cmd/docker"
+	"k8s.io/client-go/util/homedir"
+
+	"github.com/porter-dev/porter/cli/cmd/credstore"
+
+	"github.com/spf13/cobra"
+
+	"github.com/docker/docker/api/types/mount"
+)
+
+type startOps struct {
+	insecure       *bool
+	skipKubeconfig *bool
+	kubeconfigPath string
+	contexts       *[]string
+	imageTag       string `form:"required"`
+	db             string `form:"oneof=sqlite postgres"`
+}
+
+var opts = &startOps{}
+
+// startCmd represents the start command
+var startCmd = &cobra.Command{
+	Args: func(cmd *cobra.Command, args []string) error {
+		return nil
+	},
+	Use:   "start",
+	Short: "Starts a Porter instance using the Docker engine.",
+	Run: func(cmd *cobra.Command, args []string) {
+		closeHandler(stop)
+
+		err := start(
+			opts.imageTag,
+			opts.kubeconfigPath,
+			opts.db,
+			*opts.contexts,
+			*opts.insecure,
+			*opts.skipKubeconfig,
+		)
+
+		if err != nil {
+			fmt.Println("Error running start:", err.Error())
+			fmt.Println("Shutting down...")
+
+			err = stop()
+
+			if err != nil {
+				fmt.Println("Shutdown unsuccessful:", err.Error())
+			}
+
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(startCmd)
+
+	opts.insecure = startCmd.PersistentFlags().Bool(
+		"insecure",
+		false,
+		"skip admin setup and authorization",
+	)
+
+	opts.skipKubeconfig = startCmd.PersistentFlags().Bool(
+		"skip-kubeconfig",
+		false,
+		"skip initialization of the kubeconfig",
+	)
+
+	opts.contexts = startCmd.PersistentFlags().StringArray(
+		"contexts",
+		nil,
+		"the list of contexts to use (defaults to the current context)",
+	)
+
+	startCmd.PersistentFlags().StringVar(
+		&opts.db,
+		"db",
+		"sqlite",
+		"the db to use, one of sqlite or postgres",
+	)
+
+	startCmd.PersistentFlags().StringVar(
+		&opts.kubeconfigPath,
+		"kubeconfig",
+		"",
+		"path to kubeconfig",
+	)
+
+	startCmd.PersistentFlags().StringVar(
+		&opts.imageTag,
+		"image-tag",
+		"latest",
+		"the Porter image tag to use",
+	)
+}
+
+func stop() error {
+	agent, err := docker.NewAgentFromEnv()
+
+	if err != nil {
+		return err
+	}
+
+	err = agent.StopPorterContainers()
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func start(
+	imageTag string,
+	kubeconfigPath string,
+	db string,
+	contexts []string,
+	insecure bool,
+	skipKubeconfig bool,
+) error {
+	var username, pw string
+	var err error
+	home := homedir.HomeDir()
+	outputConfPath := filepath.Join(home, ".porter", "porter.kubeconfig")
+	containerConfPath := "/porter/porter.kubeconfig"
+	port := 8080
+
+	// if not insecure, or username/pw set incorrectly, prompt for new username/pw
+	if username, pw, err = credstore.Get(); !insecure && err != nil {
+		fmt.Println("Please register your admin account with an email and password:")
+
+		username, err = promptPlaintext("Email: ")
+
+		if err != nil {
+			return err
+		}
+
+		pw, err = promptPasswordWithConfirmation()
+
+		if err != nil {
+			return err
+		}
+
+		credstore.Set(username, pw)
+	}
+
+	if !skipKubeconfig {
+		err = generate(
+			kubeconfigPath,
+			outputConfPath,
+			false,
+			contexts,
+		)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	agent, err := docker.NewAgentFromEnv()
+
+	if err != nil {
+		return err
+	}
+
+	// the volume mounts to use
+	mounts := make([]mount.Mount, 0)
+
+	// the volumes passed to the Porter container
+	volumesMap := make(map[string]struct{})
+
+	if !skipKubeconfig {
+		// add a bind mount with the kubeconfig
+		mount := mount.Mount{
+			Type:        mount.TypeBind,
+			Source:      outputConfPath,
+			Target:      containerConfPath,
+			ReadOnly:    true,
+			Consistency: mount.ConsistencyFull,
+		}
+
+		mounts = append(mounts, mount)
+	}
+
+	netID, err := agent.CreateBridgeNetworkIfNotExist("porter_network")
+
+	if err != nil {
+		return err
+	}
+
+	env := make([]string, 0)
+
+	env = append(env, []string{
+		"ADMIN_INIT=true",
+		"ADMIN_EMAIL=" + username,
+		"ADMIN_PASSWORD=" + pw,
+	}...)
+
+	switch db {
+	case "sqlite":
+		// check if sqlite volume exists, create it if not
+		vol, err := agent.CreateLocalVolumeIfNotExist("porter_sqlite")
+
+		if err != nil {
+			return err
+		}
+
+		// create mount
+		mount := mount.Mount{
+			Type:        mount.TypeVolume,
+			Source:      vol.Name,
+			Target:      "/sqlite",
+			ReadOnly:    false,
+			Consistency: mount.ConsistencyFull,
+		}
+
+		mounts = append(mounts, mount)
+		volumesMap[vol.Name] = struct{}{}
+
+		env = append(env, []string{
+			"SQL_LITE=true",
+			"SQL_LITE_PATH=/sqlite/porter.db",
+		}...)
+	case "postgres":
+		// check if postgres volume exists, create it if not
+		vol, err := agent.CreateLocalVolumeIfNotExist("porter_postgres")
+
+		if err != nil {
+			return err
+		}
+
+		// pgMount is mount for postgres container
+		pgMount := []mount.Mount{
+			mount.Mount{
+				Type:        mount.TypeVolume,
+				Source:      vol.Name,
+				Target:      "/var/lib/postgresql/data",
+				ReadOnly:    false,
+				Consistency: mount.ConsistencyFull,
+			},
+		}
+
+		// create postgres container with mount
+		startOpts := docker.PostgresOpts{
+			Name:   "porter_postgres",
+			Image:  "postgres:latest",
+			Mounts: pgMount,
+			VolumeMap: map[string]struct{}{
+				"porter_postgres": struct{}{},
+			},
+			NetworkID: netID,
+			Env: []string{
+				"POSTGRES_USER=porter",
+				"POSTGRES_PASSWORD=porter",
+				"POSTGRES_DB=porter",
+			},
+		}
+
+		pgID, err := agent.StartPostgresContainer(startOpts)
+
+		fmt.Println("Waiting for postgres:latest to be healthy...")
+		agent.WaitForContainerHealthy(pgID, 10)
+
+		if err != nil {
+			return err
+		}
+
+		env = append(env, []string{
+			"SQL_LITE=false",
+			"DB_USER=porter",
+			"DB_PASS=porter",
+			"DB_NAME=porter",
+			"DB_HOST=porter_postgres",
+			"DB_PORT=5432",
+		}...)
+
+		defer agent.WaitForContainerStop(pgID)
+	}
+
+	// create Porter container
+	// TODO -- look for unused port
+	startOpts := docker.PorterStartOpts{
+		Name:          "porter_server",
+		Image:         "porter1/porter:" + imageTag,
+		HostPort:      uint(port),
+		ContainerPort: 8080,
+		Mounts:        mounts,
+		VolumeMap:     volumesMap,
+		NetworkID:     netID,
+		Env:           env,
+	}
+
+	id, err := agent.StartPorterContainer(startOpts)
+
+	if err != nil {
+		return err
+	}
+
+	fmt.Println("Spinning up the server...")
+	time.Sleep(7 * time.Second)
+	openBrowser(fmt.Sprintf("http://localhost:%d/login?email=%s", port, username))
+	fmt.Printf("Server ready: listening on localhost:%d\n", port)
+
+	agent.WaitForContainerStop(id)
+
+	return nil
+}
+
+// openBrowser opens the specified URL in the default browser of the user.
+func openBrowser(url string) error {
+	var cmd string
+	var args []string
+
+	switch runtime.GOOS {
+	case "windows":
+		cmd = "cmd"
+		args = []string{"/c", "start"}
+	case "darwin":
+		cmd = "open"
+	default: // "linux", "freebsd", "openbsd", "netbsd"
+		cmd = "xdg-open"
+	}
+	args = append(args, url)
+	return exec.Command(cmd, args...).Start()
+}

+ 11 - 0
cli/main.go

@@ -0,0 +1,11 @@
+// +build cli
+
+package main
+
+import (
+	"github.com/porter-dev/porter/cli/cmd"
+)
+
+func main() {
+	cmd.Execute()
+}

+ 78 - 1
cmd/app/main.go

@@ -2,10 +2,16 @@ package main
 
 
 import (
 import (
 	"fmt"
 	"fmt"
+	"io/ioutil"
 	"log"
 	"log"
 	"net/http"
 	"net/http"
+	"os"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
 
 
 	"github.com/gorilla/sessions"
 	"github.com/gorilla/sessions"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
 
 	"github.com/porter-dev/porter/server/api"
 	"github.com/porter-dev/porter/server/api"
@@ -31,6 +37,15 @@ func main() {
 
 
 	repo := gorm.NewRepository(db)
 	repo := gorm.NewRepository(db)
 
 
+	// upsert admin if config requires
+	if appConf.Db.AdminInit {
+		err := upsertAdmin(repo.User, appConf.Db.AdminEmail, appConf.Db.AdminPassword)
+
+		if err != nil {
+			fmt.Println("Error while upserting admin: " + err.Error())
+		}
+	}
+
 	// declare as Store interface (methods Get, New, Save)
 	// declare as Store interface (methods Get, New, Save)
 	var store sessions.Store
 	var store sessions.Store
 	store, _ = sessionstore.NewStore(repo, appConf.Server)
 	store, _ = sessionstore.NewStore(repo, appConf.Server)
@@ -39,7 +54,7 @@ func main() {
 
 
 	a := api.New(logger, repo, validator, store, appConf.Server.CookieName, false)
 	a := api.New(logger, repo, validator, store, appConf.Server.CookieName, false)
 
 
-	appRouter := router.New(a, store, appConf.Server.CookieName)
+	appRouter := router.New(a, store, appConf.Server.CookieName, appConf.Server.StaticFilePath)
 
 
 	address := fmt.Sprintf(":%d", appConf.Server.Port)
 	address := fmt.Sprintf(":%d", appConf.Server.Port)
 
 
@@ -57,3 +72,65 @@ func main() {
 		log.Fatal("Server startup failed")
 		log.Fatal("Server startup failed")
 	}
 	}
 }
 }
+
+func upsertAdmin(repo repository.UserRepository, email, pw string) error {
+	admUser, err := repo.ReadUserByEmail(email)
+
+	// create the user in this case
+	if err != nil {
+		form := forms.CreateUserForm{
+			Email:    email,
+			Password: pw,
+		}
+
+		admUser, err = form.ToUser(repo)
+
+		if err != nil {
+			return err
+		}
+
+		admUser, err = repo.CreateUser(admUser)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	filename := "/porter/porter.kubeconfig"
+
+	// read if kubeconfig file exists, if it does update the user
+	if _, err := os.Stat(filename); !os.IsNotExist(err) {
+		fileBytes, err := ioutil.ReadFile(filename)
+
+		contexts := make([]string, 0)
+		allContexts, err := kubernetes.GetContextsFromBytes(fileBytes, []string{})
+
+		if err != nil {
+			return err
+		}
+
+		for _, context := range allContexts {
+			contexts = append(contexts, context.Name)
+		}
+
+		form := forms.UpdateUserForm{
+			ID:              admUser.ID,
+			RawKubeConfig:   string(fileBytes),
+			AllowedContexts: contexts,
+		}
+
+		admUser, err = form.ToUser(repo)
+
+		if err != nil {
+			return err
+		}
+
+		admUser, err = repo.UpdateUser(admUser)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}

+ 4 - 2
dashboard/src/components/YamlEditor.tsx

@@ -7,9 +7,10 @@ import 'ace-builds/src-noconflict/theme-terminal';
 
 
 type PropsType = {
 type PropsType = {
   value: string,
   value: string,
-  onChange: (e: any) => void,
+  onChange?: (e: any) => void, // Might be read-only
   height?: string,
   height?: string,
-  border?: boolean
+  border?: boolean,
+  readOnly?: boolean
 }
 }
 
 
 type StateType = {
 type StateType = {
@@ -52,6 +53,7 @@ class YamlEditor extends Component<PropsType, StateType> {
             theme='terminal'
             theme='terminal'
             onChange={this.props.onChange}
             onChange={this.props.onChange}
             name='codeEditor'
             name='codeEditor'
+            readOnly={this.props.readOnly}
             editorProps={{ $blockScrolling: true }}
             editorProps={{ $blockScrolling: true }}
             height={this.props.height}
             height={this.props.height}
             width='100%'
             width='100%'

+ 3 - 0
dashboard/src/main/Login.tsx

@@ -30,6 +30,9 @@ export default class Login extends Component<PropsType, StateType> {
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
+    let urlParams = new URLSearchParams(window.location.search);
+    let emailFromCLI = urlParams.get('email');
+    // emailFromCLI ? this.setState({email: emailFromCLI}) :
     document.addEventListener("keydown", this.handleKeyDown);
     document.addEventListener("keydown", this.handleKeyDown);
   }
   }
 
 

+ 7 - 3
dashboard/src/main/Main.tsx

@@ -31,8 +31,12 @@ export default class Main extends Component<PropsType, StateType> {
 
 
   componentDidMount() {
   componentDidMount() {
     let { setUserId } = this.context;
     let { setUserId } = this.context;
-    api.checkAuth('', {}, {}, (err: any, res: any) => {
-      if (res?.data) {
+    api.checkAuth('', {}, {}, (err: any, res: any) => {      
+      if (err && err.response.status == 403) {
+        this.setState({ isLoggedIn: false, loading: false })
+      }
+
+      if (res && res.data) {
         setUserId(res.data.id);
         setUserId(res.data.id);
         this.setState({ isLoggedIn: true, initialized: true, loading: false });
         this.setState({ isLoggedIn: true, initialized: true, loading: false });
       } else {
       } else {
@@ -128,4 +132,4 @@ const StyledMain = styled.div`
   left: 0;
   left: 0;
   background: #202227;
   background: #202227;
   color: white;
   color: white;
-`;
+`;

+ 0 - 1
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -37,7 +37,6 @@ export default class Dashboard extends Component<PropsType, StateType> {
   // Allows rollback to update the top-level chart
   // Allows rollback to update the top-level chart
   refreshChart = () => {
   refreshChart = () => {
     let { currentCluster } = this.props;
     let { currentCluster } = this.props;
-    console.log(currentCluster)
     api.getChart('<token>', {
     api.getChart('<token>', {
       namespace: this.state.namespace,
       namespace: this.state.namespace,
       context: currentCluster,
       context: currentCluster,

+ 1 - 1
dashboard/src/main/home/dashboard/chart/ChartList.tsx

@@ -35,7 +35,7 @@ export default class ChartList extends Component<PropsType, StateType> {
       if (this.state.loading) {
       if (this.state.loading) {
         this.setState({ loading: false, error: true });
         this.setState({ loading: false, error: true });
       }
       }
-    }, 2000);
+    }, 3000);
 
 
     api.getCharts('<token>', {
     api.getCharts('<token>', {
       namespace: this.props.namespace,
       namespace: this.props.namespace,

+ 45 - 10
dashboard/src/main/home/dashboard/expanded-chart/ExpandedChart.tsx

@@ -22,7 +22,8 @@ type PropsType = {
 type StateType = {
 type StateType = {
   showRevisions: boolean,
   showRevisions: boolean,
   currentTab: string,
   currentTab: string,
-  components: ResourceType[]
+  components: ResourceType[],
+  revisionPreview: ChartType | null
 };
 };
 
 
 const tabOptions = [
 const tabOptions = [
@@ -35,18 +36,46 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   state = {
   state = {
     showRevisions: false,
     showRevisions: false,
     currentTab: 'graph',
     currentTab: 'graph',
-    components: [] as ResourceType[]
+    components: [] as ResourceType[],
+    revisionPreview: null as (ChartType | null)
   }
   }
 
 
-  componentDidMount() {
-    let { currentCluster, setCurrentError } = this.context;
+  updateResources = () => {
+    let { currentCluster } = this.context;
     let { currentChart } = this.props;
     let { currentChart } = this.props;
 
 
     api.getChartComponents('<token>', {
     api.getChartComponents('<token>', {
       namespace: currentChart.namespace,
       namespace: currentChart.namespace,
       context: currentCluster,
       context: currentCluster,
       storage: StorageType.Secret
       storage: StorageType.Secret
-    }, { name: currentChart.name, revision: 0 }, (err: any, res: any) => {
+    }, { name: currentChart.name, revision: currentChart.version }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else {
+        this.setState({ components: res.data });
+      }
+    });
+  }
+
+  componentDidMount() {
+    this.updateResources();
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if (this.props.currentChart !== prevProps.currentChart) {
+      this.updateResources();
+    }
+  }
+
+  setRevisionPreview = (oldChart: ChartType) => {
+    let { currentCluster } = this.context;
+    this.setState({ revisionPreview: oldChart });
+
+    api.getChartComponents('<token>', {
+      namespace: oldChart.namespace,
+      context: currentCluster,
+      storage: StorageType.Secret
+    }, { name: oldChart.name, revision: oldChart.version }, (err: any, res: any) => {
       if (err) {
       if (err) {
         console.log(err)
         console.log(err)
       } else {
       } else {
@@ -73,19 +102,24 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   }
   }
 
 
   renderTabContents = () => {
   renderTabContents = () => {
-    let { currentChart, refreshChart, setSidebar} = this.props;
+    let { currentChart, refreshChart, setSidebar } = this.props;
+    let chart = this.state.revisionPreview || currentChart;
+    
     if (this.state.currentTab === 'graph') {
     if (this.state.currentTab === 'graph') {
       return (
       return (
         <GraphSection
         <GraphSection
           components={this.state.components}
           components={this.state.components}
-          currentChartName={currentChart.name}
+          currentChart={chart}
           setSidebar={setSidebar}
           setSidebar={setSidebar}
+
+          // Handle resize YAML wrapper
+          showRevisions={this.state.showRevisions}
         />
         />
       );
       );
     } else if (this.state.currentTab === 'list') {
     } else if (this.state.currentTab === 'list') {
       return (
       return (
         <ListSection
         <ListSection
-          currentChart={currentChart}
+          currentChart={chart}
           components={this.state.components}
           components={this.state.components}
         />
         />
       );
       );
@@ -93,7 +127,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 
     return (
     return (
       <ValuesYaml
       <ValuesYaml
-        currentChart={currentChart}
+        currentChart={chart}
         refreshChart={refreshChart}
         refreshChart={refreshChart}
       />
       />
     );
     );
@@ -101,7 +135,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 
   render() {
   render() {
     let { currentChart, setCurrentChart, refreshChart } = this.props;
     let { currentChart, setCurrentChart, refreshChart } = this.props;
-    let chart = currentChart;
+    let chart = this.state.revisionPreview || currentChart;
 
 
     return ( 
     return ( 
       <div>
       <div>
@@ -143,6 +177,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
               toggleShowRevisions={() => this.setState({ showRevisions: !this.state.showRevisions })}
               toggleShowRevisions={() => this.setState({ showRevisions: !this.state.showRevisions })}
               chart={chart}
               chart={chart}
               refreshChart={refreshChart}
               refreshChart={refreshChart}
+              setRevisionPreview={this.setRevisionPreview}
             />
             />
 
 
             <TabSelector
             <TabSelector

+ 6 - 4
dashboard/src/main/home/dashboard/expanded-chart/GraphSection.tsx

@@ -2,15 +2,16 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 import styled from 'styled-components';
 
 
 import { Context } from '../../../../shared/Context';
 import { Context } from '../../../../shared/Context';
-import { ResourceType } from '../../../../shared/types';
+import { ResourceType, ChartType } from '../../../../shared/types';
 
 
 import GraphDisplay from './graph/GraphDisplay';
 import GraphDisplay from './graph/GraphDisplay';
 import Loading from '../../../../components/Loading';
 import Loading from '../../../../components/Loading';
 
 
 type PropsType = {
 type PropsType = {
   components: ResourceType[],
   components: ResourceType[],
-  currentChartName: string,
-  setSidebar: (x: boolean) => void
+  currentChart: ChartType,
+  setSidebar: (x: boolean) => void,
+  showRevisions: boolean
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -29,7 +30,8 @@ export default class GraphSection extends Component<PropsType, StateType> {
           setSidebar={this.props.setSidebar}
           setSidebar={this.props.setSidebar}
           components={this.props.components}
           components={this.props.components}
           isExpanded={this.state.isExpanded}
           isExpanded={this.state.isExpanded}
-          currentChartName={this.props.currentChartName}
+          currentChart={this.props.currentChart}
+          showRevisions={this.props.showRevisions}
         />
         />
       );
       );
     }
     }

+ 1 - 0
dashboard/src/main/home/dashboard/expanded-chart/ListSection.tsx

@@ -36,6 +36,7 @@ export default class ListSection extends Component<PropsType, StateType> {
   }
   }
 
 
   renderContents = () => {
   renderContents = () => {
+    console.log('rerendered!')
     if (this.props.components && this.props.components.length > 0) {
     if (this.props.components && this.props.components.length > 0) {
       return (
       return (
         <ResourceList>
         <ResourceList>

+ 9 - 1
dashboard/src/main/home/dashboard/expanded-chart/ResourceItem.tsx

@@ -3,7 +3,6 @@ import styled from 'styled-components';
 import yaml from 'js-yaml';
 import yaml from 'js-yaml';
 
 
 import { kindToIcon } from '../../../../shared/rosettaStone';
 import { kindToIcon } from '../../../../shared/rosettaStone';
-
 import { ResourceType } from '../../../../shared/types';
 import { ResourceType } from '../../../../shared/types';
 import YamlEditor from '../../../../components/YamlEditor';
 import YamlEditor from '../../../../components/YamlEditor';
 
 
@@ -26,6 +25,13 @@ export default class ResourceItem extends Component<PropsType, StateType> {
     RawYAML: yaml.dump(this.props.resource.RawYAML)
     RawYAML: yaml.dump(this.props.resource.RawYAML)
   }
   }
 
 
+  // Handle previewing old revisions
+  componentDidUpdate(prevProps: PropsType) {
+    if (prevProps.resource.RawYAML !== this.props.resource.RawYAML) {
+      this.setState({ RawYAML: yaml.dump(this.props.resource.RawYAML) });
+    }
+  }
+
   renderIcon = (kind: string) => {
   renderIcon = (kind: string) => {
 
 
     let icon = 'tonality';
     let icon = 'tonality';
@@ -48,6 +54,8 @@ export default class ResourceItem extends Component<PropsType, StateType> {
             value={this.state.RawYAML}
             value={this.state.RawYAML}
             onChange={(e: any) => this.setState({ RawYAML: e })}
             onChange={(e: any) => this.setState({ RawYAML: e })}
             height='300px'
             height='300px'
+            border={true}
+            readOnly={true}
           />
           />
         </ExpandWrapper>
         </ExpandWrapper>
       );
       );

+ 44 - 21
dashboard/src/main/home/dashboard/expanded-chart/RevisionSection.tsx

@@ -5,26 +5,28 @@ import loading from '../../../../assets/loading.gif';
 import api from '../../../../shared/api';
 import api from '../../../../shared/api';
 import { Context } from '../../../../shared/Context';
 import { Context } from '../../../../shared/Context';
 import { ChartType, StorageType } from '../../../../shared/types';
 import { ChartType, StorageType } from '../../../../shared/types';
-import Chart from '../chart/Chart';
 
 
 type PropsType = {
 type PropsType = {
   showRevisions: boolean,
   showRevisions: boolean,
   toggleShowRevisions: () => void,
   toggleShowRevisions: () => void,
   chart: ChartType,
   chart: ChartType,
-  refreshChart: () => void
+  refreshChart: () => void,
+  setRevisionPreview: (preview: ChartType) => void
 };
 };
 
 
 type StateType = {
 type StateType = {
   revisions: ChartType[],
   revisions: ChartType[],
   rollbackRevision: number | null,
   rollbackRevision: number | null,
-  loading: boolean
+  loading: boolean,
+  maxVersion: number
 };
 };
 
 
 export default class RevisionSection extends Component<PropsType, StateType> {
 export default class RevisionSection extends Component<PropsType, StateType> {
   state = {
   state = {
     revisions: [] as ChartType[],
     revisions: [] as ChartType[],
     rollbackRevision: null as (number | null),
     rollbackRevision: null as (number | null),
-    loading: false
+    loading: false,
+    maxVersion: 0, // Track most recent version even when previewing old revisions
   }
   }
 
 
   refreshHistory = () => {
   refreshHistory = () => {
@@ -38,7 +40,8 @@ export default class RevisionSection extends Component<PropsType, StateType> {
       if (err) {
       if (err) {
         console.log(err)
         console.log(err)
       } else {
       } else {
-        this.setState({ revisions: res.data.reverse() });
+        res.data.sort((a: ChartType, b: ChartType) => { return -(a.version - b.version) });
+        this.setState({ revisions: res.data, maxVersion: res.data[0].version });
       }
       }
     });
     });
   }
   }
@@ -87,18 +90,23 @@ export default class RevisionSection extends Component<PropsType, StateType> {
   }
   }
 
 
   renderRevisionList = () => {
   renderRevisionList = () => {
-    return this.state.revisions.map((revision: any, i: number) => {
+    return this.state.revisions.map((revision: ChartType, i: number) => {
+      let isCurrent = revision.version === this.state.maxVersion;
       return (
       return (
-        <Tr key={i}>
+        <Tr
+          key={i}
+          onClick={() => this.props.setRevisionPreview(revision)}
+          selected={this.props.chart.version === revision.version}
+        >
           <Td>{revision.version}</Td>
           <Td>{revision.version}</Td>
           <Td>{this.readableDate(revision.info.last_deployed)}</Td>
           <Td>{this.readableDate(revision.info.last_deployed)}</Td>
           <Td>{revision.info.status}</Td>
           <Td>{revision.info.status}</Td>
           <Td>
           <Td>
             <RollbackButton
             <RollbackButton
-              disabled={revision.version === this.props.chart.version}
+              disabled={isCurrent}
               onClick={() => this.setState({ rollbackRevision: revision.version })}
               onClick={() => this.setState({ rollbackRevision: revision.version })}
             >
             >
-              {revision.version === this.props.chart.version ? 'Current' : 'Revert'}
+              {isCurrent ? 'Current' : 'Revert'}
             </RollbackButton>
             </RollbackButton>
           </Td>
           </Td>
         </Tr>
         </Tr>
@@ -109,17 +117,19 @@ export default class RevisionSection extends Component<PropsType, StateType> {
   renderExpanded = () => {
   renderExpanded = () => {
     if (this.props.showRevisions) {
     if (this.props.showRevisions) {
       return (
       return (
-        <RevisionsTable>
-          <tbody>
-            <Tr>
-              <Th>Revision No.</Th>
-              <Th>Timestamp</Th>
-              <Th>Status</Th>
-              <Th>Rollback</Th>
-            </Tr>
-            {this.renderRevisionList()}
-          </tbody>
-        </RevisionsTable>
+        <TableWrapper>
+          <RevisionsTable>
+            <tbody>
+              <Tr disableHover={true}>
+                <Th>Revision No.</Th>
+                <Th>Timestamp</Th>
+                <Th>Status</Th>
+                <Th>Rollback</Th>
+              </Tr>
+              {this.renderRevisionList()}
+            </tbody>
+          </RevisionsTable>
+        </TableWrapper>
       )
       )
     }
     }
   }
   }
@@ -157,13 +167,14 @@ export default class RevisionSection extends Component<PropsType, StateType> {
       )
       )
     }
     }
 
 
+    let isCurrent = this.props.chart.version === this.state.maxVersion || this.state.maxVersion === 0;
     return (
     return (
       <div>
       <div>
         <RevisionHeader
         <RevisionHeader
           showRevisions={this.props.showRevisions}
           showRevisions={this.props.showRevisions}
           onClick={this.props.toggleShowRevisions}
           onClick={this.props.toggleShowRevisions}
         >
         >
-          Current Revision - <Revision>No. {this.props.chart.version}</Revision>
+          {isCurrent ? `Current Revision` : `Previewing Revision (Not Deployed)`} - <Revision>No. {this.props.chart.version}</Revision>
           <i className="material-icons">expand_more</i>
           <i className="material-icons">expand_more</i>
         </RevisionHeader>
         </RevisionHeader>
 
 
@@ -186,6 +197,10 @@ export default class RevisionSection extends Component<PropsType, StateType> {
 
 
 RevisionSection.contextType = Context;
 RevisionSection.contextType = Context;
 
 
+const TableWrapper = styled.div`
+  padding-bottom: 20px;
+`;
+
 const LoadingPlaceholder = styled.div`
 const LoadingPlaceholder = styled.div`
   height: 40px;
   height: 40px;
   display: flex;
   display: flex;
@@ -293,17 +308,24 @@ const RollbackButton = styled.div`
 
 
 const Tr = styled.tr`
 const Tr = styled.tr`
   line-height: 1.8em;
   line-height: 1.8em;
+  cursor: ${(props: { disableHover?: boolean, selected?: boolean }) => props.disableHover ? '' : 'pointer'};
+  background: ${(props: { disableHover?: boolean, selected?: boolean  }) => props.selected ? '#ffffff11' : ''};
+  :hover {
+    background: ${(props: { disableHover?: boolean, selected?: boolean  }) => props.disableHover ? '' : '#ffffff22'};
+  }
 `;
 `;
 
 
 const Td = styled.td`
 const Td = styled.td`
   font-size: 13px;
   font-size: 13px;
   color: #ffffff;
   color: #ffffff;
+  padding-left: 32px;
 `;
 `;
 
 
 const Th = styled.td`
 const Th = styled.td`
   font-size: 13px;
   font-size: 13px;
   font-weight: 500;
   font-weight: 500;
   color: #aaaabb;
   color: #aaaabb;
+  padding-left: 32px;
 `;
 `;
 
 
 const RevisionsTable = styled.table`
 const RevisionsTable = styled.table`
@@ -312,6 +334,7 @@ const RevisionsTable = styled.table`
   padding-left: 32px;
   padding-left: 32px;
   padding-bottom: 20px;
   padding-bottom: 20px;
   min-width: 500px;
   min-width: 500px;
+  border-collapse: collapse;
 `;
 `;
 
 
 const Revision = styled.div`
 const Revision = styled.div`

+ 4 - 1
dashboard/src/main/home/dashboard/expanded-chart/ValuesYaml.tsx

@@ -26,7 +26,10 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
   }
   }
 
 
   updateValues() {
   updateValues() {
-    let values = yaml.dump(this.props.currentChart.config);
+    let values = '# Nothing here yet';
+    if (this.props.currentChart.config) {
+      values = yaml.dump(this.props.currentChart.config);
+    }
     this.setState({ values });
     this.setState({ values });
   }
   }
 
 

+ 150 - 68
dashboard/src/main/home/dashboard/expanded-chart/graph/GraphDisplay.tsx

@@ -1,7 +1,7 @@
 import React, { Component } from 'react';
 import React, { Component } from 'react';
 import styled from 'styled-components';
 import styled from 'styled-components';
 
 
-import { ResourceType, NodeType, EdgeType } from '../../../../../shared/types';
+import { ResourceType, NodeType, EdgeType, ChartType } from '../../../../../shared/types';
 
 
 import Node from './Node';
 import Node from './Node';
 import Edge from './Edge';
 import Edge from './Edge';
@@ -15,14 +15,17 @@ type PropsType = {
   components: ResourceType[],
   components: ResourceType[],
   isExpanded: boolean,
   isExpanded: boolean,
   setSidebar: (x: boolean) => void,
   setSidebar: (x: boolean) => void,
-  currentChartName: string
+  currentChart: ChartType,
+
+  // Handle revisions expansion for YAML wrapper
+  showRevisions: boolean
 };
 };
 
 
 type StateType = {
 type StateType = {
   nodes: NodeType[],
   nodes: NodeType[],
   edges: EdgeType[],
   edges: EdgeType[],
   activeIds: number[], // IDs of all currently selected nodes
   activeIds: number[], // IDs of all currently selected nodes
-  originX: number | null, 
+  originX: number | null,
   originY: number | null,
   originY: number | null,
   cursorX: number | null,
   cursorX: number | null,
   cursorY: number | null,
   cursorY: number | null,
@@ -32,14 +35,20 @@ type StateType = {
   panY: number | null, // Two-finger pan y-displacement
   panY: number | null, // Two-finger pan y-displacement
   anchorX: number | null, // Initial cursorX during region select
   anchorX: number | null, // Initial cursorX during region select
   anchorY: number | null, // Initial cursorY during region select
   anchorY: number | null, // Initial cursorY during region select
+  nodeClickX: number | null, // Initial cursorX during node click (drag vs click)
+  nodeClickY: number | null, // Initial cursorY during node click (drag vs click)
   dragBg: boolean, // Boolean to track if all nodes should move with mouse (bg drag)
   dragBg: boolean, // Boolean to track if all nodes should move with mouse (bg drag)
-  preventBgDrag: boolean, // Prevents bg drag when moving selected with mouse down
-  relocateAllowed: boolean, // Suppresses movement of selected when drawing select region
+  preventBgDrag: boolean, // Prevent bg drag when moving selected with mouse down
+  relocateAllowed: boolean, // Suppress movement of selected when drawing select region
   scale: number,
   scale: number,
   showKindLabels: boolean,
   showKindLabels: boolean,
+  isExpanded: boolean,
   currentNode: NodeType | null,
   currentNode: NodeType | null,
   currentEdge: EdgeType | null,
   currentEdge: EdgeType | null,
-  isExpanded: boolean
+  openedNode: NodeType | null,
+  suppressCloseNode: boolean, // Still click should close opened unless on a node
+  suppressDisplay: boolean, // Ignore clicks + pan/zoom on InfoPanel or ButtonSection
+  version?: number // Track in localstorage for handling updates when unmounted
 };
 };
 
 
 // TODO: region-based unselect, shift-click, multi-region
 // TODO: region-based unselect, shift-click, multi-region
@@ -58,14 +67,19 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
     panY: null as (number | null),
     panY: null as (number | null),
     anchorX: null as (number | null),
     anchorX: null as (number | null),
     anchorY: null as (number | null),
     anchorY: null as (number | null),
+    nodeClickX: null as (number | null),
+    nodeClickY: null as (number | null),
     dragBg: false,
     dragBg: false,
     preventBgDrag: false,
     preventBgDrag: false,
+    relocateAllowed: false,
     scale: 0.5,
     scale: 0.5,
     showKindLabels: true,
     showKindLabels: true,
+    isExpanded: false,
     currentNode: null as (NodeType | null),
     currentNode: null as (NodeType | null),
     currentEdge: null as (EdgeType | null),
     currentEdge: null as (EdgeType | null),
-    relocateAllowed: false,
-    isExpanded: false
+    openedNode: null as (NodeType | null),
+    suppressCloseNode: false,
+    suppressDisplay: false
   }
   }
 
 
   spaceRef: any = React.createRef();
   spaceRef: any = React.createRef();
@@ -77,7 +91,7 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
-    let { components } = this.props;
+    let { components, currentChart } = this.props;
 
 
     // Initialize origin
     // Initialize origin
     let height = this.spaceRef.offsetHeight;
     let height = this.spaceRef.offsetHeight;
@@ -91,21 +105,39 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
     this.spaceRef.addEventListener("touchmove", (e: any) => e.preventDefault());
     this.spaceRef.addEventListener("touchmove", (e: any) => e.preventDefault());
     this.spaceRef.addEventListener("mousewheel", (e: any) => e.preventDefault());
     this.spaceRef.addEventListener("mousewheel", (e: any) => e.preventDefault());
 
 
-    let graph = localStorage.getItem(`charts.${this.props.currentChartName}`)
-    let nodes = [] as NodeType[]
-    let edges = [] as EdgeType[]
+    document.addEventListener("keydown", this.handleKeyDown);
+    document.addEventListener("keyup", this.handleKeyUp);
 
 
+    // Handle graph from localstorage
+    let graph = localStorage.getItem(`charts.${currentChart.name}-${currentChart.version}`);
+    let nodes = [] as NodeType[];
+    let edges = [] as EdgeType[];
     if (!graph) {
     if (!graph) {
-      nodes = this.createNodes(components)
-      edges = this.createEdges(components)
+      nodes = this.createNodes(components);
+      edges = this.createEdges(components);
       this.setState({ nodes, edges });
       this.setState({ nodes, edges });
     } else {
     } else {
-      let storedState = JSON.parse(localStorage.getItem(`charts.${this.props.currentChartName}`))
-      this.setState(storedState)
+      let storedState = JSON.parse(localStorage.getItem(
+        `charts.${currentChart.name}-${currentChart.version}`
+      ));
+      this.setState(storedState);
     }
     }
 
 
-    document.addEventListener("keydown", this.handleKeyDown);
-    document.addEventListener("keyup", this.handleKeyUp);
+    window.onbeforeunload = () => {
+      this.storeChart();
+    }
+  }
+
+  // Live update on rollback/upgrade
+  componentDidUpdate(prevProps: PropsType) {
+    if (prevProps.components !== this.props.components) {
+      let nodes = [] as NodeType[];
+      let edges = [] as EdgeType[];
+  
+      nodes = this.createNodes(this.props.components);
+      edges = this.createEdges(this.props.components);
+      this.setState({ nodes, edges, openedNode: null });
+    }
   }
   }
 
 
   createNodes = (components: ResourceType[]) => {
   createNodes = (components: ResourceType[]) => {
@@ -115,24 +147,24 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
         case "ClusterRole":
         case "ClusterRole":
         case "RoleBinding":
         case "RoleBinding":
         case "Role":
         case "Role":
-          return { id: c.ID, name: c.Name, kind: c.Kind, x: this.getRandomIntBetweenRange(-1000, 0), y: this.getRandomIntBetweenRange(0, 500), w: 40, h: 40 };
+          return { id: c.ID, RawYAML: c.RawYAML, name: c.Name, kind: c.Kind, x: this.getRandomIntBetweenRange(-500, 0), y: this.getRandomIntBetweenRange(0, 250), w: 40, h: 40 };
         case "Deployment":
         case "Deployment":
         case "StatefulSet":
         case "StatefulSet":
         case "Pod":
         case "Pod":
         case "ServiceAccount":
         case "ServiceAccount":
-          return { id: c.ID, name: c.Name, kind: c.Kind, x: this.getRandomIntBetweenRange(0, 1000), y: this.getRandomIntBetweenRange(0, 500), w: 40, h: 40 };
+          return { id: c.ID, RawYAML: c.RawYAML, name: c.Name, kind: c.Kind, x: this.getRandomIntBetweenRange(0, 500), y: this.getRandomIntBetweenRange(0, 250), w: 40, h: 40 };
         case "Service":
         case "Service":
         case "Ingress":
         case "Ingress":
         case "ServiceAccount":
         case "ServiceAccount":
-            return { id: c.ID, name: c.Name, kind: c.Kind, x: this.getRandomIntBetweenRange(0, 1000), y: this.getRandomIntBetweenRange(-500, 0), w: 40, h: 40 };
+            return { id: c.ID, RawYAML: c.RawYAML, name: c.Name, kind: c.Kind, x: this.getRandomIntBetweenRange(0, 500), y: this.getRandomIntBetweenRange(-250, 0), w: 40, h: 40 };
         default:
         default:
-          return { id: c.ID, name: c.Name, kind: c.Kind, x: this.getRandomIntBetweenRange(-700, 0), y: this.getRandomIntBetweenRange(-500, 0), w: 40, h: 40 };
+          return { id: c.ID, RawYAML: c.RawYAML, name: c.Name, kind: c.Kind, x: this.getRandomIntBetweenRange(-400, 0), y: this.getRandomIntBetweenRange(-250, 0), w: 40, h: 40 };
         }
         }
     });
     });
   }
   }
 
 
   createEdges = (components: ResourceType[]) => {
   createEdges = (components: ResourceType[]) => {
-    let edges = [] as EdgeType[]
+    let edges = [] as EdgeType[];
     components.map((c: ResourceType) => {
     components.map((c: ResourceType) => {
       c.Relations?.ControlRels?.map((rel: any) => {
       c.Relations?.ControlRels?.map((rel: any) => {
         if (rel.Source == c.ID) {
         if (rel.Source == c.ID) {
@@ -150,19 +182,31 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
         }
         }
       })
       })
     });
     });
-    return edges
+    return edges;
   }
   }
 
 
-  componentWillUnmount() {
-    let graph = this.state;
-    console.log("unmounting...", graph)
-    // flush non-persistent data
+  storeChart = () => {
+    let { currentChart } = this.props;
+    let graph = JSON.parse(JSON.stringify(this.state));
+
+    // Flush non-persistent data
     graph.activeIds = [];
     graph.activeIds = [];
     graph.currentNode = null;
     graph.currentNode = null;
     graph.currentEdge = null;
     graph.currentEdge = null;
     graph.isExpanded = false;
     graph.isExpanded = false;
+    graph.openedNode = null;
+    graph.suppressDisplay = false;
+    graph.suppressCloseNode = false;
 
 
-    localStorage.setItem(`charts.${this.props.currentChartName}`, JSON.stringify(graph))
+    localStorage.setItem(
+      `charts.${currentChart.name}-${currentChart.version}`,
+      JSON.stringify(graph)
+    );
+  }
+
+  componentWillUnmount() {
+    this.storeChart();
+    
     this.spaceRef.removeEventListener("touchmove", (e: any) => e.preventDefault());
     this.spaceRef.removeEventListener("touchmove", (e: any) => e.preventDefault());
     this.spaceRef.removeEventListener("mousewheel", (e: any) => e.preventDefault());
     this.spaceRef.removeEventListener("mousewheel", (e: any) => e.preventDefault());
     document.removeEventListener("keydown", this.handleKeyDown);
     document.removeEventListener("keydown", this.handleKeyDown);
@@ -201,8 +245,13 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
     }
     }
   }
   }
 
 
-  // Push to activeIds if not already present
   handleClickNode = (clickedId: number) => {
   handleClickNode = (clickedId: number) => {
+    let { cursorX, cursorY } = this.state;
+
+    // Store position for distinguishing click vs drag on release
+    this.setState({ nodeClickX: cursorX, nodeClickY: cursorY, suppressCloseNode: true });
+
+    // Push to activeIds if not already present
     let holding = this.state.activeIds;
     let holding = this.state.activeIds;
     if (!holding.includes(clickedId)) {
     if (!holding.includes(clickedId)) {
       holding.push(clickedId);
       holding.push(clickedId);
@@ -211,28 +260,51 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
     // Track and store offset to grab node from anywhere (must store)
     // Track and store offset to grab node from anywhere (must store)
     this.state.nodes.forEach((node: NodeType) => {
     this.state.nodes.forEach((node: NodeType) => {
       if (this.state.activeIds.includes(node.id)) {
       if (this.state.activeIds.includes(node.id)) {
-        if (!node.toCursorX && !node.toCursorY) {
-          node.toCursorX = node.x - this.state.cursorX;
-          node.toCursorY = node.y - this.state.cursorY;
-        } else {
-          node.toCursorX = 0;
-          node.toCursorY = 0;
-        }
+        node.toCursorX = node.x - cursorX;
+        node.toCursorY = node.y - cursorY;
       }
       }
     });
     });
 
 
     this.setState({ activeIds: holding, preventBgDrag: true, relocateAllowed: true });
     this.setState({ activeIds: holding, preventBgDrag: true, relocateAllowed: true });
   }
   }
 
 
-  handleReleaseNode = () => {
+  handleReleaseNode = (node: NodeType) => {
+    let { cursorX, cursorY, nodeClickX, nodeClickY } = this.state;
     this.setState({ activeIds: [], preventBgDrag: false });
     this.setState({ activeIds: [], preventBgDrag: false });
 
 
-    // Only update dot position state on release for all active
-    let { activeIds, nodes} = this.state;
-    for (var i=0; i < activeIds.length; i++) {
-      var a = activeIds[i];
-      nodes[a].toCursorX = 0;
-      nodes[a].toCursorY = 0;
+    // Distinguish node click vs drag (can't use onClick since drag counts)
+    if (cursorX === nodeClickX && cursorY === nodeClickY) {
+      this.setState({ openedNode: node });
+    }
+  }
+
+  handleMouseDown = () => {
+    let { cursorX, cursorY } = this.state;
+
+    // Store position for distinguishing click vs drag on release
+    this.setState({ nodeClickX: cursorX, nodeClickY: cursorY });
+
+    this.setState({
+      dragBg: true,
+
+      // Suppress drifting on repeated click
+      deltaX: null,
+      deltaY: null,
+      panX: null,
+      panY: null,
+      scale: 1
+    })
+  }
+
+  handleMouseUp = () => {
+    let { cursorX, nodeClickX, cursorY, nodeClickY, suppressCloseNode } = this.state;
+    this.setState({ dragBg: false, activeIds: [] });
+
+    // Distinguish bg click vs drag for setting closing opened node
+    if (!suppressCloseNode && cursorX === nodeClickX && cursorY === nodeClickY) {
+      this.setState({ openedNode: null });
+    } else if (this.state.suppressCloseNode) {
+      this.setState({ suppressCloseNode: false });
     }
     }
   }
   }
 
 
@@ -271,17 +343,21 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
   // Handle pan XOR zoom (two-finger gestures count as onWheel)
   // Handle pan XOR zoom (two-finger gestures count as onWheel)
   handleWheel = (e: any) => {
   handleWheel = (e: any) => {
 
 
-    // Pinch/zoom sets e.ctrlKey to true
-    if (e.ctrlKey) {
-  
-      // Clip deltaY for extreme mousewheel values
-      let deltaY = e.deltaY >= 0 ? Math.min(40, e.deltaY) : Math.max(-40, e.deltaY);
+    // Prevent nav gestures if mouse is over InfoPanel or ButtonSection
+    if (!this.state.suppressDisplay) {
 
 
-      let scale = 1;
-      scale -= deltaY * zoomConstant;
-      this.setState({ scale, panX: 0, panY: 0 });
-    } else {
-      this.setState({ panX: e.deltaX, panY: e.deltaY, scale: 1 });
+      // Pinch/zoom sets e.ctrlKey to true
+      if (e.ctrlKey) {
+  
+        // Clip deltaY for extreme mousewheel values
+        let deltaY = e.deltaY >= 0 ? Math.min(40, e.deltaY) : Math.max(-40, e.deltaY);
+
+        let scale = 1;
+        scale -= deltaY * zoomConstant;
+        this.setState({ scale, panX: 0, panY: 0 });
+      } else {
+        this.setState({ panX: e.deltaX, panY: e.deltaY, scale: 1 });
+      }
     }
     }
   };
   };
 
 
@@ -340,9 +416,10 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
           originX={originX}
           originX={originX}
           originY={originY}
           originY={originY}
           nodeMouseDown={() => this.handleClickNode(node.id)}
           nodeMouseDown={() => this.handleClickNode(node.id)}
-          nodeMouseUp={this.handleReleaseNode}
+          nodeMouseUp={() => this.handleReleaseNode(node)}
           isActive={activeIds.includes(node.id)}
           isActive={activeIds.includes(node.id)}
           showKindLabels={this.state.showKindLabels}
           showKindLabels={this.state.showKindLabels}
+          isOpen={node === this.state.openedNode}
 
 
           // Parameterized to allow setting to null
           // Parameterized to allow setting to null
           setCurrentNode={(node: NodeType) => this.setState({ currentNode: node })}
           setCurrentNode={(node: NodeType) => this.setState({ currentNode: node })}
@@ -390,24 +467,18 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
         isExpanded={this.state.isExpanded}
         isExpanded={this.state.isExpanded}
         ref={element => this.spaceRef = element}
         ref={element => this.spaceRef = element}
         onMouseMove={this.handleMouseMove}
         onMouseMove={this.handleMouseMove}
-        onMouseDown={() => this.setState({
-          dragBg: true,
-
-          // Suppress drifting on repeated click
-          deltaX: null,
-          deltaY: null,
-          panX: null,
-          panY: null,
-          scale: 1
-        })}
-        onMouseUp={() => this.setState({ dragBg: false, activeIds: [] })}
+        onMouseDown={this.state.suppressDisplay ? null : this.handleMouseDown}
+        onMouseUp={this.state.suppressDisplay ? null : this.handleMouseUp}
         onWheel={this.handleWheel}
         onWheel={this.handleWheel}
       >
       >
         {this.renderNodes()}
         {this.renderNodes()}
         {this.renderEdges()}
         {this.renderEdges()}
         {this.renderSelectRegion()}
         {this.renderSelectRegion()}
 
 
-        <ButtonSection>
+        <ButtonSection
+          onMouseEnter={() => this.setState({ suppressDisplay: true })}
+          onMouseLeave={() => this.setState({ suppressDisplay: false })}
+        >
           <ToggleLabel
           <ToggleLabel
             onClick={() => this.setState({ showKindLabels: !this.state.showKindLabels })}
             onClick={() => this.setState({ showKindLabels: !this.state.showKindLabels })}
           >
           >
@@ -425,8 +496,17 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
           </ExpandButton>
           </ExpandButton>
         </ButtonSection>
         </ButtonSection>
         <InfoPanel
         <InfoPanel
+          setSuppressDisplay={(x: boolean) => this.setState({ suppressDisplay: x })}
           currentNode={this.state.currentNode}
           currentNode={this.state.currentNode}
           currentEdge={this.state.currentEdge}
           currentEdge={this.state.currentEdge}
+          openedNode={this.state.openedNode}
+
+          // InfoPanel won't trigger onMouseLeave for unsuppressing if close is clicked
+          closeNode={() => this.setState({ openedNode: null, suppressDisplay: false })}
+
+          // For YAML wrapper to trigger resize
+          isExpanded={this.state.isExpanded}
+          showRevisions={this.props.showRevisions}
         />
         />
       </StyledGraphDisplay>
       </StyledGraphDisplay>
     );
     );
@@ -475,10 +555,12 @@ const ToggleLabel = styled.div`
 
 
 const ButtonSection = styled.div`
 const ButtonSection = styled.div`
   position: absolute;
   position: absolute;
-  top: 17px;
+  top: 15px;
   right: 15px;
   right: 15px;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
+  z-index: 999;
+  cursor: pointer;
 `;
 `;
 
 
 const ExpandButton = styled.div`
 const ExpandButton = styled.div`

+ 98 - 11
dashboard/src/main/home/dashboard/expanded-chart/graph/InfoPanel.tsx

@@ -1,20 +1,29 @@
 import React, { Component } from 'react';
 import React, { Component } from 'react';
 import styled from 'styled-components';
 import styled from 'styled-components';
+import yaml from 'js-yaml';
 
 
 import { kindToIcon, edgeColors } from '../../../../../shared/rosettaStone';
 import { kindToIcon, edgeColors } from '../../../../../shared/rosettaStone';
-import { NodeType, EdgeType} from '../../../../../shared/types';
-import Edge from './Edge';
+import { NodeType, EdgeType } from '../../../../../shared/types';
+
+import YamlEditor from '../../../../../components/YamlEditor';
 
 
 type PropsType = {
 type PropsType = {
   currentNode: NodeType,
   currentNode: NodeType,
-  currentEdge: EdgeType
+  currentEdge: EdgeType,
+  openedNode: NodeType,
+  setSuppressDisplay: (x: boolean) => void,
+  closeNode: () => void,
+  isExpanded: boolean,
+  showRevisions: boolean
 };
 };
 
 
 type StateType = {
 type StateType = {
+  wrapperHeight: number
 };
 };
 
 
 export default class InfoPanel extends Component<PropsType, StateType> {
 export default class InfoPanel extends Component<PropsType, StateType> {
   state = {
   state = {
+    wrapperHeight: 0
   }
   }
 
 
   renderIcon = (kind: string) => {
   renderIcon = (kind: string) => {
@@ -35,9 +44,43 @@ export default class InfoPanel extends Component<PropsType, StateType> {
     return <ColorBlock color={edgeColors[type]} />;
     return <ColorBlock color={edgeColors[type]} />;
   }
   }
 
 
+  wrapperRef: any = React.createRef();
+
+  componentDidMount() {
+    this.setState({ wrapperHeight: this.wrapperRef.offsetHeight });
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if ((prevProps.openedNode !== this.props.openedNode 
+      || prevProps.isExpanded !== this.props.isExpanded
+      || prevProps.showRevisions !== this.props.showRevisions) && this.wrapperRef
+    ) {
+      this.setState({ wrapperHeight: this.wrapperRef.offsetHeight });
+    }
+  }
+
   renderContents = () => {
   renderContents = () => {
-    let { currentNode, currentEdge } = this.props;
-    if (currentNode) {
+    let { currentNode, currentEdge, openedNode } = this.props;
+    if (openedNode) {
+      return (
+        <Wrapped>
+          <Div>
+            {this.renderIcon(openedNode.kind)}
+            {openedNode.kind}
+            <ResourceName>
+              {openedNode.name}
+            </ResourceName>
+          </Div>
+          <YamlWrapper ref={element => this.wrapperRef = element}>
+            <YamlEditor
+              value={yaml.dump(openedNode.RawYAML)}
+              readOnly={true}
+              height={this.state.wrapperHeight + 'px'}
+            />
+          </YamlWrapper>
+        </Wrapped>
+      )
+    } else if (currentNode) {
       return (
       return (
         <Div>
         <Div>
           {this.renderIcon(currentNode.kind)}
           {this.renderIcon(currentNode.kind)}
@@ -79,14 +122,38 @@ export default class InfoPanel extends Component<PropsType, StateType> {
   }
   }
 
 
   render() {
   render() {
+    let { openedNode, closeNode, setSuppressDisplay } = this.props;
+
+    // Only suppress display gestures (click, pan, and zoom) if expanded
     return (
     return (
-      <StyledInfoPanel>
+      <StyledInfoPanel
+        expanded={Boolean(openedNode)}
+        onMouseEnter={openedNode ? () => setSuppressDisplay(true) : null}
+        onMouseLeave={openedNode ? () => setSuppressDisplay(false) : null}
+      >
         {this.renderContents()}
         {this.renderContents()}
+
+        {openedNode ? <i onClick={closeNode} className="material-icons">close</i> : null}
       </StyledInfoPanel>
       </StyledInfoPanel>
     );
     );
   }
   }
 }
 }
 
 
+const Wrapped = styled.div`
+  height: 100%;
+  position: relative;
+`;
+
+const YamlWrapper = styled.div`
+  width: 100%;
+  margin-top: 7px;
+  height: calc(100% - 44px);
+  border-radius: 5px;
+  border: 1px solid #ffffff22;
+  overflow: hidden;
+  background: #000000;
+`;
+
 const ColorBlock = styled.div`
 const ColorBlock = styled.div`
   width: 15px;
   width: 15px;
   height: 15px;
   height: 15px;
@@ -101,12 +168,16 @@ const ColorBlock = styled.div`
 
 
 const Div = styled.div`
 const Div = styled.div`
   display: flex;
   display: flex;
+  padding-left: 7px;
   align-items: center;
   align-items: center;
+  padding-right: 23px;
 `;
 `;
 
 
 const EdgeInfo = styled.div`
 const EdgeInfo = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
+  padding-left: 7px;
+  padding-right: 23px;
   margin-top: 5px;
   margin-top: 5px;
 `;
 `;
 
 
@@ -138,17 +209,33 @@ const StyledInfoPanel = styled.div`
   right: 15px;
   right: 15px;
   bottom: 15px;
   bottom: 15px;
   color: #ffffff66;
   color: #ffffff66;
-  height: 40px;
-  width: 400px;
+  height: ${(props: { expanded: boolean }) => props.expanded ? 'calc(100% - 68px)' : '40px'};
+  width: ${(props: { expanded: boolean }) => props.expanded ? 'calc(50% - 68px)' : '400px'};
   max-width: 600px;
   max-width: 600px;
-  background: #44444699;
+  min-width: 400px;
+  background: #34373Cdf;
   border-radius: 3px;
   border-radius: 3px;
-  padding-left: 20px;
+  padding-left: 11px;
   display: inline-block;
   display: inline-block;
   z-index: 999;
   z-index: 999;
   padding-top: 7px;
   padding-top: 7px;
   overflow: hidden;
   overflow: hidden;
   white-space: nowrap;
   white-space: nowrap;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
-  padding-right: 13px;
+  padding-right: 11px;
+  cursor: default;
+
+  > i {
+    position: absolute;
+    padding: 5px;
+    top: 6px;
+    right: 6px;
+    border-radius: 50px;
+    font-size: 17px;
+    cursor: pointer;
+    color: white;
+    :hover {
+      background: #ffffff22;
+    }
+  }
 `;
 `;

+ 10 - 5
dashboard/src/main/home/dashboard/expanded-chart/graph/Node.tsx

@@ -13,6 +13,7 @@ type PropsType = {
   isActive: boolean,
   isActive: boolean,
   showKindLabels: boolean,
   showKindLabels: boolean,
   setCurrentNode: (node: NodeType) => void,
   setCurrentNode: (node: NodeType) => void,
+  isOpen: boolean
 };
 };
 
 
 type StateType = {
 type StateType = {
@@ -37,7 +38,6 @@ export default class Node extends Component<PropsType, StateType> {
         y={Math.round(originY - y - (h / 2))}
         y={Math.round(originY - y - (h / 2))}
         w={Math.round(w)}
         w={Math.round(w)}
         h={Math.round(h)}
         h={Math.round(h)}
-        isActive={isActive}
       >
       >
         <Kind>
         <Kind>
           {this.props.showKindLabels ? kind : null}
           {this.props.showKindLabels ? kind : null}
@@ -47,6 +47,8 @@ export default class Node extends Component<PropsType, StateType> {
           onMouseUp={nodeMouseUp}
           onMouseUp={nodeMouseUp}
           onMouseEnter={() => this.props.setCurrentNode(this.props.node)}
           onMouseEnter={() => this.props.setCurrentNode(this.props.node)}
           onMouseLeave={() => this.props.setCurrentNode(null)}
           onMouseLeave={() => this.props.setCurrentNode(null)}
+          isActive={isActive}
+          isOpen={this.props.isOpen}
         >
         >
           <i className="material-icons">{icon}</i>
           <i className="material-icons">{icon}</i>
         </NodeBlock>
         </NodeBlock>
@@ -72,6 +74,7 @@ const Kind = styled.div`
   white-space: nowrap;
   white-space: nowrap;
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
+  z-index: 0;
 `;
 `;
 
 
 const NodeLabel = styled.div`
 const NodeLabel = styled.div`
@@ -86,6 +89,7 @@ const NodeLabel = styled.div`
   white-space: nowrap;
   white-space: nowrap;
   overflow: hidden;
   overflow: hidden;
   text-overflow: ellipsis;
   text-overflow: ellipsis;
+  z-index: 0;
 `;
 `;
 
 
 const NodeBlock = styled.div`
 const NodeBlock = styled.div`
@@ -96,6 +100,9 @@ const NodeBlock = styled.div`
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
   border-radius: 100px;
   border-radius: 100px;
+  border: ${(props: { isActive: boolean, isOpen: boolean }) => props.isOpen ? '3px solid #ffffff' : ''};
+  box-shadow: ${(props: { isActive: boolean, isOpen: boolean }) => props.isActive ? '0 0 10px #ffffff66' : '0px 0px 10px 2px #00000022'};
+  z-index: 100;
   cursor: pointer;
   cursor: pointer;
   :hover {
   :hover {
     background: #555556;
     background: #555556;
@@ -113,12 +120,10 @@ const StyledNode: any = styled.div.attrs((props: NodeType) => ({
     },
     },
 }))`
 }))`
   position: absolute;
   position: absolute;
-  width: ${(props: NodeType) => props.w + 'px'};;
-  height: ${(props: NodeType) => props.h + 'px'};;
-  box-shadow: ${(props: any) => props.isActive ? '0 0 10px #ffffff66' : '0px 0px 10px 2px #00000022'};
+  width: ${(props: NodeType) => props.w + 'px'};
+  height: ${(props: NodeType) => props.h + 'px'};
   color: #ffffff22;
   color: #ffffff22;
   border-radius: 100px;
   border-radius: 100px;
-  z-index: 3;
   display: flex;
   display: flex;
   flex-direction: column;
   flex-direction: column;
   align-items: center;
   align-items: center;

+ 10 - 3
dashboard/src/shared/rosettaStone.tsx

@@ -4,11 +4,18 @@ export const kindToIcon: any = {
   'Service': 'alt_route',
   'Service': 'alt_route',
   'Ingress': 'sensor_door',
   'Ingress': 'sensor_door',
   'StatefulSet': 'location_city',
   'StatefulSet': 'location_city',
-  'Secret': 'vpn_key'
+  'Secret': 'vpn_key',
+  'ServiceAccount': 'home_repair_service',
+  'ClusterRole': 'person',
+  'ClusterRoleBinding': 'swap_horiz',
+  'Role': 'portrait',
+  'RoleBinding': 'swap_horizontal_circle',
+  'ConfigMap': 'map',
+  'PodSecurityPolicy': 'security'
 }
 }
 
 
 export const edgeColors: any = {
 export const edgeColors: any = {
-  'LabelRel': '#949EFF',
+  'LabelRel': '#32a85f',
   'ControlRel': '#fcb603',
   'ControlRel': '#fcb603',
-  'SpecRel': '#32a852'
+  'SpecRel': '#949EFF'
 };
 };

+ 1 - 0
dashboard/src/shared/types.tsx

@@ -42,6 +42,7 @@ export interface NodeType {
   id: number,
   id: number,
   name: string,
   name: string,
   kind: string,
   kind: string,
+  RawYAML?: Object,
   x: number,
   x: number,
   y: number,
   y: number,
   w: number,
   w: number,

+ 2 - 20
docker-compose.dev.yaml

@@ -32,25 +32,7 @@ services:
     ports:
     ports:
       - 5400:5432
       - 5400:5432
     volumes:
     volumes:
-      - db:/var/lib/postgresql/data
-
-#   metabase:
-#     image: metabase/metabase
-#     restart: always
-#     ports: 
-#       - 3000:3000
-#     volumes: 
-#       - metabase:/metabase-data
-#     environment:
-#       MB_DB_TYPE: postgres
-#       MB_DB_DBNAME: porter
-#       MB_DB_PORT: 5432
-#       MB_DB_USER: porter
-#       MB_DB_PASS: porter
-#       MB_DB_HOST: postgres
-#     depends_on:
-#       - postgres
-
+      - database:/var/lib/postgresql/data
   nginx:
   nginx:
     image: nginx:mainline-alpine
     image: nginx:mainline-alpine
     container_name: nginx
     container_name: nginx
@@ -64,5 +46,5 @@ services:
       - webpack    
       - webpack    
 
 
 volumes:
 volumes:
-  db:
+  database:
   metabase:
   metabase:

+ 0 - 45
docker-compose.yaml

@@ -1,45 +0,0 @@
-version: '3'
-services:
-  porter:
-    build:
-      context: .
-      dockerfile: ./docker/dev.Dockerfile
-    ports:
-      - 8080:8080
-    depends_on:
-      - postgres
-    env_file:
-      - ./docker/.env
-    restart: on-failure
-
-  postgres:
-    image: postgres:latest
-    container_name: postgres
-    environment:
-      - POSTGRES_USER=porter
-      - POSTGRES_PASSWORD=porter
-      - POSTGRES_DB=porter
-    ports:
-      - 5400:5432
-    volumes:
-      - db:/var/lib/postgresql/data
-
-  # metabase:
-  #   image: metabase/metabase
-  #   restart: always
-  #   ports: 
-  #     - 3000:3000
-  #   volumes: 
-  #     - metabase:/metabase-data
-  #   environment:
-  #     MB_DB_TYPE: postgres
-  #     MB_DB_DBNAME: porter
-  #     MB_DB_PORT: 5432
-  #     MB_DB_USER: porter
-  #     MB_DB_PASS: porter
-  #     MB_DB_HOST: postgres
-  #   depends_on:
-  #     - postgres
-  #   volumes:
-  #     db:
-  #     metabase:

+ 3 - 1
docker/.env

@@ -1,5 +1,7 @@
 DEBUG=true
 DEBUG=true
 
 
+STATIC_FILE_PATH=/porter/static
+
 SERVER_PORT=8080
 SERVER_PORT=8080
 SERVER_TIMEOUT_READ=5s
 SERVER_TIMEOUT_READ=5s
 SERVER_TIMEOUT_WRITE=10s
 SERVER_TIMEOUT_WRITE=10s
@@ -12,4 +14,4 @@ DB_PASS=porter
 DB_NAME=porter
 DB_NAME=porter
 COOKIE_SECRETS=secret
 COOKIE_SECRETS=secret
 
 
-QUICK_START=false
+SQL_LITE=false

+ 62 - 7
docker/Dockerfile

@@ -1,15 +1,70 @@
-FROM golang:1.15-alpine
+# syntax=docker/dockerfile:1.1.7-experimental
+
+# Base Go environment
+# -------------------
+FROM golang:1.15-alpine as base
 WORKDIR /porter
 WORKDIR /porter
 
 
 RUN apk update && apk add --no-cache gcc musl-dev git
 RUN apk update && apk add --no-cache gcc musl-dev git
 
 
 COPY go.mod go.sum ./
 COPY go.mod go.sum ./
-RUN go mod download
+COPY /cmd ./cmd
+COPY /internal ./internal
+COPY /server ./server
+
+RUN --mount=type=cache,target=$GOPATH/pkg/mod \
+    go mod download
+
+# Go build environment
+# --------------------
+FROM base AS build-go
+
+RUN --mount=type=cache,target=/root/.cache/go-build \
+    --mount=type=cache,target=$GOPATH/pkg/mod \
+    go build -ldflags '-w -s' -a -o ./bin/app ./cmd/app && \
+    go build -ldflags '-w -s' -a -o ./bin/migrate ./cmd/migrate
+
+# Go test environment
+# -------------------
+FROM base AS porter-test
+
+RUN --mount=type=cache,target=/root/.cache/go-build \
+    --mount=type=cache,target=$GOPATH/pkg/mod \
+    go test ./...
+
+# Webpack build environment
+# -------------------------
+FROM node:latest as build-webpack
+WORKDIR /webpack
+
+COPY ./dashboard ./
+
+RUN npm i
+
+ENV NODE_ENV=production
+
+RUN npm run build
+
+# Deployment environment
+# ----------------------
+FROM alpine
+RUN apk update
+
+COPY --from=build-go /porter/bin/app /porter/
+COPY --from=build-go /porter/bin/migrate /porter/
+COPY --from=build-webpack /webpack/build /porter/static
+
+ENV DEBUG=false
+ENV STATIC_FILE_PATH=/porter/static
+ENV SERVER_PORT=8080
+ENV SERVER_TIMEOUT_READ=5s
+ENV SERVER_TIMEOUT_WRITE=10s
+ENV SERVER_TIMEOUT_IDLE=15s
 
 
-COPY . ./
+ENV COOKIE_SECRETS=secret
 
 
-RUN go build -ldflags '-w -s' -a -o ./bin/app ./cmd/app \
-    && go build -ldflags '-w -s' -a -o ./bin/migrate ./cmd/migrate \
-    && chmod +x /porter/docker/bin/*
+ENV SQL_LITE=true
+ENV ADMIN_INIT=false
 
 
-CMD ./docker/bin/start.sh
+EXPOSE 8080
+CMD /porter/migrate && /porter/app

+ 1 - 1
docker/dev.Dockerfile

@@ -16,4 +16,4 @@ RUN go build -ldflags '-w -s' -a -o ./bin/migrate ./cmd/migrate \
 # for live reloading of go container
 # for live reloading of go container
 RUN go get github.com/cosmtrek/air
 RUN go get github.com/cosmtrek/air
 
 
-CMD /porter/bin/migrate; air -c .air.toml
+CMD air -c .air.toml

+ 28 - 0
docs/DEVELOPING.md

@@ -0,0 +1,28 @@
+### Development
+
+```sh
+docker-compose -f docker-compose.dev.yaml up --build
+```
+
+And then visit `localhost:8080` in the browser. 
+
+### Testing
+
+From the root directory, run `go test ./...` to run all tests and ensure the builds/tests pass. 
+
+### Building
+
+From the root directory, run `DOCKER_BUILDKIT=1 docker build . --file ./docker/Dockerfile -t porter`. Then you can run `docker run -p 8080:8080 porter`. 
+
+To build the test container, run `DOCKER_BUILDKIT=1 docker build . --file ./docker/Dockerfile -t porter-test --target porter-test`. 
+
+### CLI Release
+
+```sh
+docker run --rm --privileged \
+-v $PWD:/go/src/github.com/porter-dev/porter \
+-v /var/run/docker.sock:/var/run/docker.sock \
+-w /go/src/github.com/porter-dev/porter \
+-e GORELEASER_GITHUB_TOKEN='THEGITHUBTOKEN' \
+mailchain/goreleaser-xcgo ""
+```

+ 10 - 5
go.mod

@@ -6,8 +6,13 @@ require (
 	github.com/Azure/go-autorest/autorest v0.11.1 // indirect
 	github.com/Azure/go-autorest/autorest v0.11.1 // indirect
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/Masterminds/semver v1.5.0 // indirect
 	github.com/Masterminds/semver v1.5.0 // indirect
+	github.com/containerd/containerd v1.4.1
 	github.com/cosmtrek/air v1.21.2 // indirect
 	github.com/cosmtrek/air v1.21.2 // indirect
 	github.com/creack/pty v1.1.11 // indirect
 	github.com/creack/pty v1.1.11 // indirect
+	github.com/danieljoos/wincred v1.1.0 // indirect
+	github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce
+	github.com/docker/docker-credential-helpers v0.6.3
+	github.com/docker/go-connections v0.4.0
 	github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 // indirect
 	github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 // indirect
 	github.com/evanphx/json-patch v4.9.0+incompatible // indirect
 	github.com/evanphx/json-patch v4.9.0+incompatible // indirect
 	github.com/fatih/color v1.9.0 // indirect
 	github.com/fatih/color v1.9.0 // indirect
@@ -25,18 +30,19 @@ require (
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd
 	github.com/json-iterator/go v1.1.10 // indirect
 	github.com/json-iterator/go v1.1.10 // indirect
 	github.com/kr/pretty v0.2.0 // indirect
 	github.com/kr/pretty v0.2.0 // indirect
-	github.com/leodido/go-urn v1.2.0 // indirect
 	github.com/mattn/go-colorable v0.1.7 // indirect
 	github.com/mattn/go-colorable v0.1.7 // indirect
 	github.com/pelletier/go-toml v1.8.1 // indirect
 	github.com/pelletier/go-toml v1.8.1 // indirect
 	github.com/pkg/errors v0.9.1
 	github.com/pkg/errors v0.9.1
 	github.com/rs/zerolog v1.20.0
 	github.com/rs/zerolog v1.20.0
-	github.com/sirupsen/logrus v1.6.0
+	github.com/sirupsen/logrus v1.7.0
+	github.com/spf13/cobra v1.0.0
 	github.com/stretchr/testify v1.6.1
 	github.com/stretchr/testify v1.6.1
 	golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
 	golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
-	golang.org/x/net v0.0.0-20200904194848-62affa334b73 // indirect
 	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 // indirect
 	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 // indirect
-	golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 // indirect
+	golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6
 	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
 	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
+	google.golang.org/genproto v0.0.0-20201014134559-03b6142f0dc9 // indirect
+	google.golang.org/grpc v1.33.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 	gopkg.in/go-playground/validator.v9 v9.31.0
 	gopkg.in/go-playground/validator.v9 v9.31.0
 	gopkg.in/yaml.v2 v2.3.0
 	gopkg.in/yaml.v2 v2.3.0
@@ -51,7 +57,6 @@ require (
 	k8s.io/client-go v0.18.8
 	k8s.io/client-go v0.18.8
 	k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac // indirect
 	k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac // indirect
 	k8s.io/helm v2.16.12+incompatible
 	k8s.io/helm v2.16.12+incompatible
-	k8s.io/klog v1.0.0 // indirect
 	k8s.io/klog/v2 v2.2.0 // indirect
 	k8s.io/klog/v2 v2.2.0 // indirect
 	k8s.io/utils v0.0.0-20200912215256-4140de9c8800 // indirect
 	k8s.io/utils v0.0.0-20200912215256-4140de9c8800 // indirect
 	sigs.k8s.io/structured-merge-diff/v4 v4.0.1 // indirect
 	sigs.k8s.io/structured-merge-diff/v4 v4.0.1 // indirect

+ 21 - 0
go.sum

@@ -74,6 +74,7 @@ github.com/Masterminds/sprig/v3 v3.1.0/go.mod h1:ONGMf7UfYGAbMXCZmQLy8x3lCDIPrEZ
 github.com/Masterminds/squirrel v1.4.0 h1:he5i/EXixZxrBUWcxzDYMiju9WZ3ld/l7QBNuo/eN3w=
 github.com/Masterminds/squirrel v1.4.0 h1:he5i/EXixZxrBUWcxzDYMiju9WZ3ld/l7QBNuo/eN3w=
 github.com/Masterminds/squirrel v1.4.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
 github.com/Masterminds/squirrel v1.4.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA=
 github.com/Masterminds/vcs v1.13.1/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA=
 github.com/Masterminds/vcs v1.13.1/go.mod h1:N09YCmOQr6RLxC6UNHzuVwAdodYbbnycGHSmwVJjcKA=
+github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5 h1:ygIc8M6trr62pF5DucadTWGdEB4mEyvzi0e2nbcmcyA=
 github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
 github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw=
 github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ=
 github.com/Microsoft/hcsshim v0.8.7/go.mod h1:OHd7sQqRFrYd3RmSgbgji+ctCwkbq2wbEYNSzOYtcBQ=
 github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
 github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb06e3pkSAbeQ52E9H9iFoQsEEwGN64994WTCIhntQ=
@@ -150,6 +151,8 @@ github.com/containerd/containerd v1.3.0-beta.2.0.20190828155532-0293cbd26c69/go.
 github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
 github.com/containerd/containerd v1.3.2/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
 github.com/containerd/containerd v1.3.4 h1:3o0smo5SKY7H6AJCmJhsnCjR2/V2T8VmiHt7seN2/kI=
 github.com/containerd/containerd v1.3.4 h1:3o0smo5SKY7H6AJCmJhsnCjR2/V2T8VmiHt7seN2/kI=
 github.com/containerd/containerd v1.3.4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
 github.com/containerd/containerd v1.3.4/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
+github.com/containerd/containerd v1.4.1 h1:pASeJT3R3YyVn+94qEPk0SnU1OQ20Jd/T+SPKy9xehY=
+github.com/containerd/containerd v1.4.1/go.mod h1:bC6axHOhabU15QhwfG7w5PipXdVtMXFTttgp+kVtyUA=
 github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
 github.com/containerd/continuity v0.0.0-20190426062206-aaeac12a7ffc/go.mod h1:GL3xCUCBDV3CZiTSEKksMWbLE66hEyuu9qyDOOqM47Y=
 github.com/containerd/continuity v0.0.0-20200107194136-26c1120b8d41/go.mod h1:Dq467ZllaHgAtVp4p1xUQWBrFXR9s/wyoTpG8zOJGkY=
 github.com/containerd/continuity v0.0.0-20200107194136-26c1120b8d41/go.mod h1:Dq467ZllaHgAtVp4p1xUQWBrFXR9s/wyoTpG8zOJGkY=
 github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI=
 github.com/containerd/fifo v0.0.0-20190226154929-a9fb20d87448/go.mod h1:ODA38xgv3Kuk8dQz2ZQXpnv/UZZUHUCL7pnLehbXgQI=
@@ -180,6 +183,8 @@ github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
 github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/cyphar/filepath-securejoin v0.2.2 h1:jCwT2GTP+PY5nBz3c/YL5PAIbusElVrPujOBSCj8xRg=
 github.com/cyphar/filepath-securejoin v0.2.2 h1:jCwT2GTP+PY5nBz3c/YL5PAIbusElVrPujOBSCj8xRg=
 github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4=
 github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4=
+github.com/danieljoos/wincred v1.1.0 h1:3RNcEpBg4IhIChZdFRSdlQt1QjCp1sMAPIrOnm7Yf8g=
+github.com/danieljoos/wincred v1.1.0/go.mod h1:XYlo+eRTsVA9aHGp7NGjFkPla4m+DCL7hqDjlFjiygg=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -201,6 +206,7 @@ github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4Kfc
 github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker v0.7.3-0.20190327010347-be7ac8be2ae0/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce h1:KXS1Jg+ddGcWA8e1N7cupxaHHZhit5rB9tfDU+mfjyY=
 github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce h1:KXS1Jg+ddGcWA8e1N7cupxaHHZhit5rB9tfDU+mfjyY=
 github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v1.13.1 h1:IkZjBSIc8hBjLpqeAbeE5mca5mNgeatLHBy3GO78BWo=
 github.com/docker/docker-credential-helpers v0.6.3 h1:zI2p9+1NQYdnG6sMU26EX4aVGlqbInSQxQXLvzJ4RPQ=
 github.com/docker/docker-credential-helpers v0.6.3 h1:zI2p9+1NQYdnG6sMU26EX4aVGlqbInSQxQXLvzJ4RPQ=
 github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y=
 github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y=
 github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
 github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
@@ -409,6 +415,8 @@ github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm4
 github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
 github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY=
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
+github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
 github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
@@ -565,6 +573,7 @@ github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQL
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
 github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -786,6 +795,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB
 github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
 github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
 github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
 github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
 github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
 github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
+github.com/sirupsen/logrus v1.7.0 h1:ShrD1U9pZB12TX0cVy0DtePoCH97K8EtX+mg7ZARUtM=
+github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
 github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
 github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
 github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
@@ -972,9 +983,12 @@ golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/
 golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA=
 golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200904194848-62affa334b73 h1:MXfv8rhZWmFeqX3GNZRsd6vOLoaCHjYEX3qkRo3YBUA=
 golang.org/x/net v0.0.0-20200904194848-62affa334b73 h1:MXfv8rhZWmFeqX3GNZRsd6vOLoaCHjYEX3qkRo3YBUA=
 golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
 golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
+golang.org/x/net v0.0.0-20201010224723-4f7140c49acb h1:mUVeFHoDKis5nxCAzoAi7E8Ghb86EXh/RK6wtvJIqRY=
+golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1046,6 +1060,9 @@ golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 h1:DvY3Zkh7KabQE/kfzMvYvKirSiguP9Q/veMtkYyf0o8=
 golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6 h1:DvY3Zkh7KabQE/kfzMvYvKirSiguP9Q/veMtkYyf0o8=
 golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201014080544-cc95f250f6bc h1:HVFDs9bKvTxP6bh1Rj9MCSo+UmafQtI8ZWDPVwVk9g4=
+golang.org/x/sys v0.0.0-20201014080544-cc95f250f6bc/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1179,6 +1196,8 @@ google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6D
 google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 h1:PDIOdWxZ8eRizhKa1AAvY53xsvLB1cWorMjslvY3VA8=
 google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 h1:PDIOdWxZ8eRizhKa1AAvY53xsvLB1cWorMjslvY3VA8=
 google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
+google.golang.org/genproto v0.0.0-20201014134559-03b6142f0dc9 h1:fG84H9C3EXfuDlzkG+VEPDYHHExklP6scH1QZ5gQTqU=
+google.golang.org/genproto v0.0.0-20201014134559-03b6142f0dc9/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
@@ -1198,6 +1217,8 @@ google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3Iji
 google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
 google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
 google.golang.org/grpc v1.31.0 h1:T7P4R73V3SSDPhH7WW7ATbfViLtmamH0DKrP3f9AuDI=
 google.golang.org/grpc v1.31.0 h1:T7P4R73V3SSDPhH7WW7ATbfViLtmamH0DKrP3f9AuDI=
 google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
 google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
+google.golang.org/grpc v1.33.0 h1:IBKSUNL2uBS2DkJBncPP+TwT0sp9tgA8A75NjHt6umg=
+google.golang.org/grpc v1.33.0/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
 google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=

+ 26 - 2
internal/adapter/gorm.go

@@ -2,6 +2,7 @@ package gorm
 
 
 import (
 import (
 	"fmt"
 	"fmt"
+	"time"
 
 
 	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/config"
 	"gorm.io/driver/postgres"
 	"gorm.io/driver/postgres"
@@ -12,7 +13,7 @@ import (
 // New returns a new gorm database instance
 // New returns a new gorm database instance
 func New(conf *config.DBConf) (*gorm.DB, error) {
 func New(conf *config.DBConf) (*gorm.DB, error) {
 	if conf.SQLLite {
 	if conf.SQLLite {
-		return gorm.Open(sqlite.Open("./internal/porter.db"), &gorm.Config{})
+		return gorm.Open(sqlite.Open(conf.SQLLitePath), &gorm.Config{})
 	}
 	}
 
 
 	dsn := fmt.Sprintf(
 	dsn := fmt.Sprintf(
@@ -23,5 +24,28 @@ func New(conf *config.DBConf) (*gorm.DB, error) {
 		conf.Host,
 		conf.Host,
 	)
 	)
 
 
-	return gorm.Open(postgres.Open(dsn), &gorm.Config{})
+	res, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
+
+	// retry the connection 3 times
+	retryCount := 0
+	timeout, _ := time.ParseDuration("5s")
+
+	if err != nil {
+		for {
+			time.Sleep(timeout)
+			res, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
+
+			if retryCount > 3 {
+				return nil, err
+			}
+
+			if err == nil {
+				return res, nil
+			}
+
+			retryCount++
+		}
+	}
+
+	return res, err
 }
 }

+ 13 - 7
internal/config/config.go

@@ -17,12 +17,13 @@ type Conf struct {
 
 
 // ServerConf is the server configuration
 // ServerConf is the server configuration
 type ServerConf struct {
 type ServerConf struct {
-	Port         int           `env:"SERVER_PORT,default=8080"`
-	CookieName   string        `env:"COOKIE_NAME,default=porter"`
-	CookieSecret []byte        `env:"COOKIE_SECRET,default=secret"`
-	TimeoutRead  time.Duration `env:"SERVER_TIMEOUT_READ,default=5s"`
-	TimeoutWrite time.Duration `env:"SERVER_TIMEOUT_WRITE,default=10s"`
-	TimeoutIdle  time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
+	Port           int           `env:"SERVER_PORT,default=8080"`
+	StaticFilePath string        `env:"STATIC_FILE_PATH,default=/porter/static"`
+	CookieName     string        `env:"COOKIE_NAME,default=porter"`
+	CookieSecret   []byte        `env:"COOKIE_SECRET,default=secret"`
+	TimeoutRead    time.Duration `env:"SERVER_TIMEOUT_READ,default=5s"`
+	TimeoutWrite   time.Duration `env:"SERVER_TIMEOUT_WRITE,default=10s"`
+	TimeoutIdle    time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
 }
 }
 
 
 // DBConf is the database configuration: if generated from environment variables,
 // DBConf is the database configuration: if generated from environment variables,
@@ -34,7 +35,12 @@ type DBConf struct {
 	Password string `env:"DB_PASS,default=porter"`
 	Password string `env:"DB_PASS,default=porter"`
 	DbName   string `env:"DB_NAME,default=porter"`
 	DbName   string `env:"DB_NAME,default=porter"`
 
 
-	SQLLite bool `env:"QUICK_START,default=false"`
+	AdminInit     bool   `env:"ADMIN_INIT,default=true"`
+	AdminEmail    string `env:"ADMIN_EMAIL,default=admin@example.com"`
+	AdminPassword string `env:"ADMIN_PASSWORD,default=password"`
+
+	SQLLite     bool   `env:"SQL_LITE,default=false"`
+	SQLLitePath string `env:"SQL_LITE_PATH,default=/porter/porter.db"`
 }
 }
 
 
 // K8sConf is the global configuration for the k8s agents
 // K8sConf is the global configuration for the k8s agents

+ 4 - 7
internal/forms/user.go

@@ -110,13 +110,10 @@ func (uuf *UpdateUserForm) ToUser(repo repository.UserRepository) (*models.User,
 
 
 	contextsJoin := strings.Join(contexts, ",")
 	contextsJoin := strings.Join(contexts, ",")
 
 
-	return &models.User{
-		Model: gorm.Model{
-			ID: uuf.ID,
-		},
-		Contexts:      contextsJoin,
-		RawKubeConfig: rawBytes,
-	}, nil
+	savedUser.Contexts = contextsJoin
+	savedUser.RawKubeConfig = rawBytes
+
+	return savedUser, nil
 }
 }
 
 
 // DeleteUserForm represents the accepted values for deleting a user
 // DeleteUserForm represents the accepted values for deleting a user

+ 15 - 5
internal/helm/grapher/object.go

@@ -16,25 +16,35 @@ func ParseObjs(objs []map[string]interface{}) []Object {
 	objArr := []Object{}
 	objArr := []Object{}
 
 
 	for i, obj := range objs {
 	for i, obj := range objs {
-		kind := getField(obj, "kind").(string)
-		name := getField(obj, "metadata", "name").(string)
+		kind := getField(obj, "kind")
 
 
+		// ignore block comments
+		if kind == nil {
+			continue
+		}
+
+		name := getField(obj, "metadata", "name")
 		namespace := getField(obj, "metadata", "namespace")
 		namespace := getField(obj, "metadata", "namespace")
 
 
 		if namespace == nil {
 		if namespace == nil {
-			namespace = ""
+			namespace = "default"
+		}
+
+		if name == nil {
+			name = ""
 		}
 		}
 
 
 		// First add the object that appears on the YAML
 		// First add the object that appears on the YAML
 		parsedObj := Object{
 		parsedObj := Object{
 			ID:        i,
 			ID:        i,
-			Kind:      kind,
-			Name:      name,
+			Kind:      kind.(string),
+			Name:      name.(string),
 			Namespace: namespace.(string),
 			Namespace: namespace.(string),
 			RawYAML:   obj,
 			RawYAML:   obj,
 			Relations: Relations{
 			Relations: Relations{
 				ControlRels: []ControlRel{},
 				ControlRels: []ControlRel{},
 				LabelRels:   []LabelRel{},
 				LabelRels:   []LabelRel{},
+				SpecRels:    []SpecRel{},
 			},
 			},
 		}
 		}
 		objArr = append(objArr, parsedObj)
 		objArr = append(objArr, parsedObj)

+ 14 - 5
internal/helm/grapher/relation.go

@@ -64,15 +64,24 @@ func (parsed *ParsedObjs) GetControlRel() {
 	children := []Object{}
 	children := []Object{}
 	for i, obj := range parsed.Objects {
 	for i, obj := range parsed.Objects {
 		yaml := obj.RawYAML
 		yaml := obj.RawYAML
+		kind := getField(yaml, "kind")
 
 
-		switch kind := getField(yaml, "kind").(string); kind {
+		if kind == nil {
+			kind = ""
+		}
+
+		switch kind.(string) {
 		// Parse for all possible controller types
 		// Parse for all possible controller types
 		case "Deployment", "StatefulSet", "ReplicaSet", "DaemonSet", "Job":
 		case "Deployment", "StatefulSet", "ReplicaSet", "DaemonSet", "Job":
 			rs := getField(yaml, "spec", "replicas")
 			rs := getField(yaml, "spec", "replicas")
 
 
 			if rs != nil && rs.(int) > 0 {
 			if rs != nil && rs.(int) > 0 {
 				// Add Pods for controller objects
 				// Add Pods for controller objects
-				template := getField(yaml, "spec", "template").(map[string]interface{})
+				template := getField(yaml, "spec", "template")
+				if template == nil {
+					continue
+				}
+
 				for j := 0; j < rs.(int); j++ {
 				for j := 0; j < rs.(int); j++ {
 					cid := len(parsed.Objects) + len(children)
 					cid := len(parsed.Objects) + len(children)
 					crel := ControlRel{
 					crel := ControlRel{
@@ -88,7 +97,7 @@ func (parsed *ParsedObjs) GetControlRel() {
 						Kind:      "Pod",
 						Kind:      "Pod",
 						Name:      obj.Name + "-" + strconv.Itoa(j), // tentative name pre-deploy
 						Name:      obj.Name + "-" + strconv.Itoa(j), // tentative name pre-deploy
 						Namespace: obj.Namespace,
 						Namespace: obj.Namespace,
-						RawYAML:   template,
+						RawYAML:   template.(map[string]interface{}),
 						Relations: Relations{
 						Relations: Relations{
 							ControlRels: []ControlRel{
 							ControlRels: []ControlRel{
 								crel,
 								crel,
@@ -287,8 +296,8 @@ func (parsed *ParsedObjs) findRBACTargets(parentID int, yaml map[string]interfac
 			}
 			}
 
 
 			// first consider case of targets added via subjects, which are namespace scoped.
 			// first consider case of targets added via subjects, which are namespace scoped.
-			if tr["namespace"] != nil && o.Kind == tr["kind"] &&
-				o.Name == tr["name"] && o.Namespace == tr["namespace"] {
+			if tr["namespace"] != nil && o.Kind == tr["kind"] && o.Name == tr["name"] &&
+				(o.Namespace == tr["namespace"] || o.Namespace == "default") {
 
 
 				// Add bidirectional link from children as well.
 				// Add bidirectional link from children as well.
 				parsed.Objects[i].Relations.SpecRels = append(parsed.Objects[i].Relations.SpecRels, newrel)
 				parsed.Objects[i].Relations.SpecRels = append(parsed.Objects[i].Relations.SpecRels, newrel)

+ 160 - 1
internal/kubernetes/kubeconfig.go

@@ -1,9 +1,16 @@
 package kubernetes
 package kubernetes
 
 
 import (
 import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/clientcmd/api"
 	"k8s.io/client-go/tools/clientcmd/api"
+	clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
+	"k8s.io/client-go/util/homedir"
 )
 )
 
 
 // GetRestrictedClientConfigFromBytes returns a clientcmd.ClientConfig from a raw kubeconfig,
 // GetRestrictedClientConfigFromBytes returns a clientcmd.ClientConfig from a raw kubeconfig,
@@ -90,7 +97,6 @@ func GetContextsFromBytes(bytes []byte, allowedContexts []string) ([]models.Cont
 	contexts := toContexts(&rawConf, allowedContexts)
 	contexts := toContexts(&rawConf, allowedContexts)
 
 
 	return contexts, nil
 	return contexts, nil
-
 }
 }
 
 
 func toContexts(rawConf *api.Config, allowedContexts []string) []models.Context {
 func toContexts(rawConf *api.Config, allowedContexts []string) []models.Context {
@@ -137,3 +143,156 @@ func createAllowedContextMap(contexts []string) map[string]string {
 
 
 	return aContextMap
 	return aContextMap
 }
 }
+
+// GetConfigFromHostWithCertData gets the kubeconfig using default options set on the host:
+// the kubeconfig can either be retrieved from a specified path or an environment variable.
+// This function only outputs a clientcmd that uses the allowedContexts.
+//
+// This function also populates all of the certificate data that's specified as a filepath.
+func GetConfigFromHostWithCertData(kubeconfigPath string, allowedContexts []string) (clientcmd.ClientConfig, error) {
+	envVarName := clientcmd.RecommendedConfigPathEnvVar
+
+	if kubeconfigPath != "" {
+		if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) {
+			// the specified kubeconfig does not exist so fallback to other options
+			kubeconfigPath = ""
+		}
+	}
+
+	if kubeconfigPath == "" && os.Getenv(envVarName) == "" {
+		if home := homedir.HomeDir(); home != "" {
+			kubeconfigPath = filepath.Join(home, ".kube", "config")
+		}
+	}
+
+	loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
+	loadingRules.ExplicitPath = kubeconfigPath
+
+	clientConf := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{})
+	rawConf, err := clientConf.RawConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	populateCertificateRefs(&rawConf)
+
+	if len(allowedContexts) == 0 {
+		allowedContexts = []string{rawConf.CurrentContext}
+
+		if allowedContexts[0] == "" {
+			return nil, fmt.Errorf("at least one context must be specified")
+		}
+	}
+
+	res, err := stripAndValidateClientContexts(&rawConf, allowedContexts[0], allowedContexts)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return res, nil
+}
+
+// GetRestrictedClientConfigFromBytes returns a clientcmd.ClientConfig from a raw kubeconfig,
+// a context name, and the set of allowed contexts.
+func stripAndValidateClientContexts(
+	rawConf *clientcmdapi.Config,
+	currentContext string,
+	allowedContexts []string,
+) (clientcmd.ClientConfig, error) {
+	// grab a copy to get the pointer and set clusters, authinfos, and contexts to empty
+	copyConf := rawConf.DeepCopy()
+
+	copyConf.Clusters = make(map[string]*api.Cluster)
+	copyConf.AuthInfos = make(map[string]*api.AuthInfo)
+	copyConf.Contexts = make(map[string]*api.Context)
+	copyConf.CurrentContext = currentContext
+
+	// put allowed clusters in a map
+	aContextMap := createAllowedContextMap(allowedContexts)
+
+	for contextName, context := range rawConf.Contexts {
+		userName := context.AuthInfo
+		clusterName := context.Cluster
+		authInfo, userFound := rawConf.AuthInfos[userName]
+		cluster, clusterFound := rawConf.Clusters[clusterName]
+
+		// make sure the cluster is "allowed"
+		_, isAllowed := aContextMap[contextName]
+
+		if userFound && clusterFound && isAllowed {
+			copyConf.Clusters[clusterName] = cluster
+			copyConf.AuthInfos[userName] = authInfo
+			copyConf.Contexts[contextName] = context
+		}
+	}
+
+	// validate the copyConf and create a ClientConfig
+	err := clientcmd.Validate(*copyConf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	clientConf := clientcmd.NewDefaultClientConfig(*copyConf, &clientcmd.ConfigOverrides{})
+
+	return clientConf, nil
+}
+
+func populateCertificateRefs(config *clientcmdapi.Config) {
+	for _, cluster := range config.Clusters {
+		refs := clientcmd.GetClusterFileReferences(cluster)
+		for _, str := range refs {
+			// only write certificate if the file reference is CA
+			if *str != cluster.CertificateAuthority {
+				break
+			}
+
+			fileBytes, err := ioutil.ReadFile(*str)
+
+			if err != nil {
+				continue
+			}
+
+			cluster.CertificateAuthorityData = fileBytes
+			cluster.CertificateAuthority = ""
+		}
+	}
+
+	for _, authInfo := range config.AuthInfos {
+		refs := clientcmd.GetAuthInfoFileReferences(authInfo)
+		for _, str := range refs {
+			if *str == "" {
+				continue
+			}
+
+			var refType int
+
+			if authInfo.ClientCertificate == *str {
+				refType = 0
+			} else if authInfo.ClientKey == *str {
+				refType = 1
+			} else if authInfo.TokenFile == *str {
+				refType = 2
+			}
+
+			fileBytes, err := ioutil.ReadFile(*str)
+
+			if err != nil {
+				continue
+			}
+
+			if refType == 0 {
+				authInfo.ClientCertificateData = fileBytes
+				authInfo.ClientCertificate = ""
+			} else if refType == 1 {
+				authInfo.ClientKeyData = fileBytes
+				authInfo.ClientKey = ""
+			} else if refType == 2 {
+				authInfo.Token = string(fileBytes)
+				authInfo.TokenFile = ""
+			}
+		}
+	}
+}

+ 33 - 0
package-lock.json

@@ -0,0 +1,33 @@
+{
+  "requires": true,
+  "lockfileVersion": 1,
+  "dependencies": {
+    "argparse": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+      "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+      "requires": {
+        "sprintf-js": "~1.0.2"
+      }
+    },
+    "esprima": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="
+    },
+    "js-yaml": {
+      "version": "3.14.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.0.tgz",
+      "integrity": "sha512-/4IbIeHcD9VMHFqDR/gQ7EdZdLimOvW2DdcxFjdyyZ9NsbS+ccrXqVWDtab/lRl5AlUqmpBx8EhPaWR+OtY17A==",
+      "requires": {
+        "argparse": "^1.0.7",
+        "esprima": "^4.0.0"
+      }
+    },
+    "sprintf-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw="
+    }
+  }
+}

+ 1 - 1
server/api/helpers_test.go

@@ -80,7 +80,7 @@ func newTester(canQuery bool) *tester {
 
 
 	store, _ := sessionstore.NewStore(repo, appConf.Server)
 	store, _ := sessionstore.NewStore(repo, appConf.Server)
 	app := api.New(logger, repo, validator, store, appConf.Server.CookieName, true)
 	app := api.New(logger, repo, validator, store, appConf.Server.CookieName, true)
-	r := router.New(app, store, appConf.Server.CookieName)
+	r := router.New(app, store, appConf.Server.CookieName, appConf.Server.StaticFilePath)
 
 
 	return &tester{
 	return &tester{
 		app:    app,
 		app:    app,

+ 1 - 0
server/api/user_handler.go

@@ -83,6 +83,7 @@ func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 
 
 	if err != nil {
 	if err != nil {
 		app.handleErrorDataRead(err, w)
 		app.handleErrorDataRead(err, w)
+		return
 	}
 	}
 
 
 	form := &forms.LoginUserForm{}
 	form := &forms.LoginUserForm{}

+ 11 - 3
server/router/middleware/auth.go

@@ -3,6 +3,7 @@ package middleware
 import (
 import (
 	"bytes"
 	"bytes"
 	"encoding/json"
 	"encoding/json"
+	"fmt"
 	"io/ioutil"
 	"io/ioutil"
 	"net/http"
 	"net/http"
 	"strconv"
 	"strconv"
@@ -28,7 +29,7 @@ func NewAuth(
 // BasicAuthenticate just checks that a user is logged in
 // BasicAuthenticate just checks that a user is logged in
 func (auth *Auth) BasicAuthenticate(next http.Handler) http.Handler {
 func (auth *Auth) BasicAuthenticate(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if auth.isLoggedIn(r) {
+		if auth.isLoggedIn(w, r) {
 			next.ServeHTTP(w, r)
 			next.ServeHTTP(w, r)
 		} else {
 		} else {
 			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
 			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
@@ -95,8 +96,15 @@ func (auth *Auth) doesSessionMatchID(r *http.Request, id uint) bool {
 	return true
 	return true
 }
 }
 
 
-func (auth *Auth) isLoggedIn(r *http.Request) bool {
-	session, _ := auth.store.Get(r, auth.cookieName)
+func (auth *Auth) isLoggedIn(w http.ResponseWriter, r *http.Request) bool {
+	session, err := auth.store.Get(r, auth.cookieName)
+	if err != nil {
+		session.Values["authenticated"] = false
+		if err := session.Save(r, w); err != nil {
+			fmt.Println("error while saving session in isLoggedIn", err)
+		}
+		return false
+	}
 
 
 	if auth, ok := session.Values["authenticated"].(bool); !auth || !ok {
 	if auth, ok := session.Values["authenticated"].(bool); !auth || !ok {
 		return false
 		return false

+ 19 - 1
server/router/router.go

@@ -1,6 +1,9 @@
 package router
 package router
 
 
 import (
 import (
+	"net/http"
+	"os"
+
 	"github.com/go-chi/chi"
 	"github.com/go-chi/chi"
 	"github.com/gorilla/sessions"
 	"github.com/gorilla/sessions"
 	"github.com/porter-dev/porter/server/api"
 	"github.com/porter-dev/porter/server/api"
@@ -9,7 +12,12 @@ import (
 )
 )
 
 
 // New creates a new Chi router instance
 // New creates a new Chi router instance
-func New(a *api.App, store sessions.Store, cookieName string) *chi.Mux {
+func New(
+	a *api.App,
+	store sessions.Store,
+	cookieName string,
+	staticFilePath string,
+) *chi.Mux {
 	l := a.Logger()
 	l := a.Logger()
 	r := chi.NewRouter()
 	r := chi.NewRouter()
 	auth := mw.NewAuth(store, cookieName)
 	auth := mw.NewAuth(store, cookieName)
@@ -39,5 +47,15 @@ func New(a *api.App, store sessions.Store, cookieName string) *chi.Mux {
 		r.Method("GET", "/k8s/namespaces", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListNamespaces, l)))
 		r.Method("GET", "/k8s/namespaces", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleListNamespaces, l)))
 	})
 	})
 
 
+	fs := http.FileServer(http.Dir(staticFilePath))
+
+	r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
+		if _, err := os.Stat(staticFilePath + r.RequestURI); os.IsNotExist(err) {
+			http.StripPrefix(r.RequestURI, fs).ServeHTTP(w, r)
+		} else {
+			fs.ServeHTTP(w, r)
+		}
+	})
+
 	return r
 	return r
 }
 }