jusrhee 5 лет назад
Родитель
Сommit
ced9879582
56 измененных файлов с 2236 добавлено и 401 удалено
  1. 13 1
      .darwin.goreleaser.yml
  2. 1 1
      .gitignore
  3. 11 0
      .goreleaser.yml
  4. 2 2
      README.md
  5. 1 0
      cli/cmd/api/project.go
  6. 12 12
      cli/cmd/api/project_test.go
  7. 16 0
      cli/cmd/config.go
  8. 7 0
      cli/cmd/connect.go
  9. 17 0
      cli/cmd/connect/kubeconfig.go
  10. 150 0
      cli/cmd/github/release.go
  11. 11 1
      cli/cmd/root.go
  12. 76 19
      cli/cmd/server.go
  13. 20 0
      cmd/app/main.go
  14. 1 1
      dashboard/docker/dev.Dockerfile
  15. 5 4
      dashboard/package-lock.json
  16. 2 2
      dashboard/package.json
  17. 2 2
      dashboard/src/components/values-form/ValuesForm.tsx
  18. 65 5
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  19. 130 23
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  20. 38 9
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  21. 1 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ListSection.tsx
  22. 1 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  23. 0 109
      dashboard/src/main/home/cluster-dashboard/expanded-chart/log/LogSection.tsx
  24. 0 66
      dashboard/src/main/home/cluster-dashboard/expanded-chart/log/Logs.tsx
  25. 313 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  26. 103 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  27. 156 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  28. 6 7
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  29. 15 4
      dashboard/src/shared/api.tsx
  30. 13 0
      go.sum
  31. 2 0
      internal/config/config.go
  32. 49 0
      internal/forms/action.go
  33. 93 8
      internal/forms/action_test.go
  34. 10 2
      internal/forms/candidate.go
  35. 13 0
      internal/forms/release.go
  36. 56 0
      internal/helm/agent.go
  37. 41 0
      internal/helm/grapher/object.go
  38. 151 8
      internal/kubernetes/agent.go
  39. 1 1
      internal/kubernetes/config.go
  40. 42 18
      internal/kubernetes/kubeconfig.go
  41. 79 40
      internal/kubernetes/kubeconfig_test.go
  42. 14 7
      internal/models/action.go
  43. 4 0
      internal/models/serviceaccount.go
  44. 21 0
      node_modules/@types/js-base64/LICENSE
  45. 16 0
      node_modules/@types/js-base64/README.md
  46. 82 0
      node_modules/@types/js-base64/index.d.ts
  47. 62 0
      node_modules/@types/js-base64/package.json
  48. 4 0
      server/api/api.go
  49. 53 26
      server/api/deploy_handler.go
  50. 1 1
      server/api/helpers_test.go
  51. 65 2
      server/api/k8s_handler.go
  52. 8 1
      server/api/project_handler.go
  53. 14 14
      server/api/project_handler_test.go
  54. 129 1
      server/api/release_handler.go
  55. 37 1
      server/router/router.go
  56. 1 0
      v.yml

+ 13 - 1
.darwin.goreleaser.yml

@@ -15,7 +15,19 @@ builds:
     flags:
       - -tags=cli
     hooks:
-      post: gon gon.hcl
+      post: gon gon.cli.hcl
+  - id: "porter-server"
+    binary: portersvr
+    env:
+      - CGO_ENABLED=1
+    dir: cmd/app
+    main: ./main.go
+    goos:
+      - darwin
+    goarch:
+      - amd64
+    hooks:
+      post: gon gon.server.hcl
 archives:
   - format: binary
     replacements:

+ 1 - 1
.gitignore

@@ -5,4 +5,4 @@ app
 *.db
 test.yaml
 dist
-gon.hcl
+gon*.hcl

+ 11 - 0
.goreleaser.yml

@@ -15,6 +15,17 @@ builds:
       - amd64
     flags:
       - -tags=cli
+  - id: "porter-server"
+    binary: portersvr
+    env:
+      - CGO_ENABLED=1
+    dir: cmd/app
+    main: ./main.go
+    goos:
+      - linux
+      - windows
+    goarch:
+      - amd64
 archives:
   - format: zip
     replacements:

+ 2 - 2
README.md

@@ -36,7 +36,7 @@ Run the following command to grab the latest binary:
 
 ```sh
 {
-name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*_Darwin_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
+name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*porter_.*_Darwin_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
 name=$(basename $name)
 curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $name
 unzip -a $name
@@ -57,7 +57,7 @@ Run the following command to grab the latest binary:
 
 ```sh
 {
-name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*_Linux_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
+name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*porter_.*_Linux_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
 name=$(basename $name)
 curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $name
 unzip -a $name

+ 1 - 0
cli/cmd/api/project.go

@@ -153,6 +153,7 @@ func (c *Client) CreateProject(
 // which can be resolved to create a service account
 type CreateProjectCandidatesRequest struct {
 	Kubeconfig string `json:"kubeconfig"`
+	IsLocal    bool   `json:"is_local"`
 }
 
 // CreateProjectCandidatesResponse is the list of candidates returned after

+ 12 - 12
cli/cmd/api/project_test.go

@@ -186,8 +186,8 @@ func TestGetProjectServiceAccount(t *testing.T) {
 		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "cluster-test", resp.Clusters[0].Name)
 	}
 
-	if resp.Clusters[0].Server != "https://localhost" {
-		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "https://localhost", resp.Clusters[0].Server)
+	if resp.Clusters[0].Server != "https://10.10.10.10" {
+		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "https://10.10.10.10", resp.Clusters[0].Server)
 	}
 }
 
@@ -231,8 +231,8 @@ func TestCreateProjectCandidates(t *testing.T) {
 		t.Errorf("cluster name incorrect: expected %s, got %s\n", "cluster-test", resp[0].ClusterName)
 	}
 
-	if resp[0].ClusterEndpoint != "https://localhost" {
-		t.Errorf("cluster endpoint incorrect: expected %s, got %s\n", "https://localhost", resp[0].ClusterEndpoint)
+	if resp[0].ClusterEndpoint != "https://10.10.10.10" {
+		t.Errorf("cluster endpoint incorrect: expected %s, got %s\n", "https://10.10.10.10", resp[0].ClusterEndpoint)
 	}
 
 	// make sure correct actions need to be performed
@@ -284,8 +284,8 @@ func TestGetProjectCandidates(t *testing.T) {
 		t.Errorf("cluster name incorrect: expected %s, got %s\n", "cluster-test", resp[0].ClusterName)
 	}
 
-	if resp[0].ClusterEndpoint != "https://localhost" {
-		t.Errorf("cluster endpoint incorrect: expected %s, got %s\n", "https://localhost", resp[0].ClusterEndpoint)
+	if resp[0].ClusterEndpoint != "https://10.10.10.10" {
+		t.Errorf("cluster endpoint incorrect: expected %s, got %s\n", "https://10.10.10.10", resp[0].ClusterEndpoint)
 	}
 
 	// make sure correct actions need to be performed
@@ -355,8 +355,8 @@ func TestCreateProjectServiceAccount(t *testing.T) {
 		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "cluster-test", resp.Clusters[0].Name)
 	}
 
-	if resp.Clusters[0].Server != "https://localhost" {
-		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "https://localhost", resp.Clusters[0].Server)
+	if resp.Clusters[0].Server != "https://10.10.10.10" {
+		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "https://10.10.10.10", resp.Clusters[0].Server)
 	}
 }
 
@@ -394,8 +394,8 @@ func TestListProjectClusters(t *testing.T) {
 		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "cluster-test", resp[0].Name)
 	}
 
-	if resp[0].Server != "https://localhost" {
-		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "https://localhost", resp[0].Server)
+	if resp[0].Server != "https://10.10.10.10" {
+		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "https://10.10.10.10", resp[0].Server)
 	}
 }
 
@@ -444,7 +444,7 @@ const OIDCAuthWithoutData string = `
 apiVersion: v1
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
     certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
   name: cluster-test
 contexts:
@@ -462,7 +462,7 @@ users:
       config:
         client-id: porter-api
         id-token: token
-        idp-issuer-url: https://localhost
+        idp-issuer-url: https://10.10.10.10
         idp-certificate-authority: /fake/path/to/ca.pem
       name: oidc
 `

+ 16 - 0
cli/cmd/config.go

@@ -11,6 +11,7 @@ import (
 
 // a set of shared flags
 var (
+	driver    string
 	host      string
 	projectID uint
 	clusterID uint
@@ -85,6 +86,21 @@ func init() {
 	configCmd.AddCommand(setHostCmd)
 }
 
+func setDriver(driver string) error {
+	viper.Set("driver", driver)
+	err := viper.WriteConfig()
+	color.New(color.FgGreen).Printf("Set the current driver as %s\n", driver)
+	return err
+}
+
+func getDriver() string {
+	if driver != "" {
+		return driver
+	}
+
+	return viper.GetString("driver")
+}
+
 func setProject(id uint) error {
 	viper.Set("project", id)
 	color.New(color.FgGreen).Printf("Set the current project id as %d\n", id)

+ 7 - 0
cli/cmd/connect.go

@@ -66,10 +66,17 @@ func init() {
 }
 
 func runConnect(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	isLocal := false
+
+	if getDriver() == "local" {
+		isLocal = true
+	}
+
 	return connect.Kubeconfig(
 		client,
 		kubeconfigPath,
 		*contexts,
 		getProjectID(),
+		isLocal,
 	)
 }

+ 17 - 0
cli/cmd/connect/kubeconfig.go

@@ -26,6 +26,7 @@ func Kubeconfig(
 	kubeconfigPath string,
 	contexts []string,
 	projectID uint,
+	isLocal bool,
 ) error {
 	// if project ID is 0, ask the user to set the project ID or create a project
 	if projectID == 0 {
@@ -45,6 +46,7 @@ func Kubeconfig(
 		projectID,
 		&api.CreateProjectCandidatesRequest{
 			Kubeconfig: string(rawBytes),
+			IsLocal:    isLocal,
 		},
 	)
 
@@ -83,6 +85,14 @@ func Kubeconfig(
 						return err
 					}
 
+					resolvers = append(resolvers, resolveAction)
+				case models.ClusterLocalhostAction:
+					resolveAction, err := resolveLocalhostAction()
+
+					if err != nil {
+						return err
+					}
+
 					resolvers = append(resolvers, resolveAction)
 				case models.ClientCertDataAction:
 					absKubeconfigPath, err := local.ResolveKubeconfigPath(kubeconfigPath)
@@ -256,6 +266,13 @@ func resolveClusterCAAction(
 	}, nil
 }
 
+func resolveLocalhostAction() (*models.ServiceAccountAllActions, error) {
+	return &models.ServiceAccountAllActions{
+		Name:            models.ClusterLocalhostAction,
+		ClusterHostname: "host.docker.internal",
+	}, nil
+}
+
 // resolves a client cert data action
 func resolveClientCertAction(
 	filename string,

+ 150 - 0
cli/cmd/github/release.go

@@ -0,0 +1,150 @@
+package github
+
+import (
+	"archive/zip"
+	"context"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"regexp"
+	"runtime"
+	"strings"
+
+	"github.com/google/go-github/github"
+)
+
+func getLatestReleaseDownloadURL() (string, error) {
+	client := github.NewClient(nil)
+
+	rel, _, err := client.Repositories.GetLatestRelease(context.Background(), "porter-dev", "porter")
+
+	if err != nil {
+		return "", err
+	}
+
+	var re *regexp.Regexp
+
+	switch os := runtime.GOOS; os {
+	case "darwin":
+		re = regexp.MustCompile(`portersvr_.*_Darwin_x86_64\.zip`)
+	case "linux":
+		re = regexp.MustCompile(`portersvr_.*_Linux_x86_64\.zip`)
+	default:
+		fmt.Printf("%s.\n", os)
+	}
+
+	releaseURL := ""
+
+	// iterate through the assets
+	for _, asset := range rel.Assets {
+		if downloadURL := asset.GetBrowserDownloadURL(); re.MatchString(downloadURL) {
+			releaseURL = downloadURL
+			break
+		}
+	}
+
+	return releaseURL, nil
+}
+
+// DownloadLatestServerRelease retrieves the latest Porter server release from Github, unzips
+// it, and adds the binary to the porter directory
+func DownloadLatestServerRelease(porterDir string) error {
+	releaseURL, err := getLatestReleaseDownloadURL()
+	fmt.Println(releaseURL)
+
+	if err != nil {
+		return err
+	}
+
+	zipFile := filepath.Join(porterDir, "portersrv_latest.zip")
+
+	err = downloadToFile(releaseURL, zipFile)
+
+	if err != nil {
+		return err
+	}
+
+	err = unzipToDir(zipFile, porterDir)
+
+	return err
+}
+
+func downloadToFile(url string, filepath string) error {
+	// Get the data
+	resp, err := http.Get(url)
+
+	if err != nil {
+		return err
+	}
+
+	defer resp.Body.Close()
+
+	// Create the file
+	out, err := os.Create(filepath)
+
+	if err != nil {
+		return err
+	}
+
+	defer out.Close()
+
+	// Write the body to file
+	_, err = io.Copy(out, resp.Body)
+
+	return err
+}
+
+func unzipToDir(zipfile string, dir string) error {
+	r, err := zip.OpenReader(zipfile)
+
+	if err != nil {
+		return err
+	}
+
+	defer r.Close()
+
+	for _, f := range r.File {
+		// Store filename/path for returning and using later on
+		fpath := filepath.Join(dir, f.Name)
+
+		// Check for ZipSlip. More Info: http://bit.ly/2MsjAWE
+		if !strings.HasPrefix(fpath, filepath.Clean(dir)+string(os.PathSeparator)) {
+			return fmt.Errorf("%s: illegal file path", fpath)
+		}
+
+		if f.FileInfo().IsDir() {
+			// Make Folder
+			os.MkdirAll(fpath, os.ModePerm)
+			continue
+		}
+
+		// Make File
+		if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
+			return err
+		}
+
+		outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
+		if err != nil {
+			return err
+		}
+
+		rc, err := f.Open()
+		if err != nil {
+			return err
+		}
+
+		_, err = io.Copy(outFile, rc)
+
+		// Close the file without defer to close before next iteration of loop
+		outFile.Close()
+		rc.Close()
+
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}

+ 11 - 1
cli/cmd/root.go

@@ -23,9 +23,19 @@ var home = homedir.HomeDir()
 // 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() {
+	// check that the .porter folder exists; create if not
+	porterDir := filepath.Join(home, ".porter")
+
+	if _, err := os.Stat(porterDir); os.IsNotExist(err) {
+		os.Mkdir(porterDir, 0700)
+	} else if err != nil {
+		color.New(color.FgRed).Printf("%v\n", err)
+		os.Exit(1)
+	}
+
 	viper.SetConfigName("porter")
 	viper.SetConfigType("yaml")
-	viper.AddConfigPath(filepath.Join(home, ".porter"))
+	viper.AddConfigPath(porterDir)
 
 	err := viper.ReadInConfig()
 

+ 76 - 19
cli/cmd/server.go

@@ -3,6 +3,8 @@ package cmd
 import (
 	"fmt"
 	"os"
+	"os/exec"
+	"path/filepath"
 
 	"github.com/fatih/color"
 	"github.com/porter-dev/porter/cli/cmd/docker"
@@ -13,6 +15,7 @@ import (
 type startOps struct {
 	imageTag string `form:"required"`
 	db       string `form:"oneof=sqlite postgres"`
+	driver   string `form:"required"`
 	port     *int   `form:"required"`
 }
 
@@ -23,29 +26,72 @@ var serverCmd = &cobra.Command{
 	Short: "Commands to control a local Porter server",
 }
 
+var testCmd = &cobra.Command{
+	Use:   "test",
+	Short: "Testing",
+	Run: func(cmd *cobra.Command, args []string) {
+		setDriver("local")
+
+		// TODO -- DOWNLOAD THE LATEST RELEASE, IF NOT EXIST
+		// porterDir := filepath.Join(home, ".porter")
+
+		// err := github.DownloadLatestServerRelease(porterDir)
+
+		// if err != nil {
+		// 	color.New(color.FgRed).Println("Failed:", err.Error())
+		// 	os.Exit(1)
+		// }
+
+		cmdPath := filepath.Join(home, ".porter", "portersvr")
+		sqlLitePath := filepath.Join(home, ".porter", "porter.db")
+		staticFilePath := filepath.Join(home, ".porter", "static")
+
+		cmdPorter := exec.Command(cmdPath)
+		cmdPorter.Env = os.Environ()
+		cmdPorter.Env = append(cmdPorter.Env, []string{
+			"IS_LOCAL=true",
+			"SQL_LITE=true",
+			"SQL_LITE_PATH=" + sqlLitePath,
+			"STATIC_FILE_PATH=" + staticFilePath,
+		}...)
+
+		cmdPorter.Stdout = os.Stdout
+		cmdPorter.Stderr = os.Stderr
+
+		err := cmdPorter.Run()
+
+		if err != nil {
+			color.New(color.FgRed).Println("Failed:", err.Error())
+			os.Exit(1)
+		}
+	},
+}
+
 // startCmd represents the start command
 var startCmd = &cobra.Command{
 	Use:   "start",
 	Short: "Starts a Porter instance using the Docker engine",
 	Run: func(cmd *cobra.Command, args []string) {
-		err := start(
-			opts.imageTag,
-			opts.db,
-			*opts.port,
-		)
+		if getDriver() == "docker" {
+			err := startDocker(
+				opts.imageTag,
+				opts.db,
+				*opts.port,
+			)
 
-		if err != nil {
-			red := color.New(color.FgRed)
-			red.Println("Error running start:", err.Error())
-			red.Println("Shutting down...")
+			if err != nil {
+				red := color.New(color.FgRed)
+				red.Println("Error running start:", err.Error())
+				red.Println("Shutting down...")
 
-			err = stop()
+				err = stopDocker()
 
-			if err != nil {
-				red.Println("Shutdown unsuccessful:", err.Error())
-			}
+				if err != nil {
+					red.Println("Shutdown unsuccessful:", err.Error())
+				}
 
-			os.Exit(1)
+				os.Exit(1)
+			}
 		}
 	},
 }
@@ -54,14 +100,18 @@ var stopCmd = &cobra.Command{
 	Use:   "stop",
 	Short: "Stops a Porter instance running on the Docker engine",
 	Run: func(cmd *cobra.Command, args []string) {
-		if err := stop(); err != nil {
-			color.New(color.FgRed).Println("Shutdown unsuccessful:", err.Error())
-			os.Exit(1)
+		if getDriver() == "docker" {
+			if err := stopDocker(); err != nil {
+				color.New(color.FgRed).Println("Shutdown unsuccessful:", err.Error())
+				os.Exit(1)
+			}
 		}
 	},
 }
 
 func init() {
+	rootCmd.AddCommand(testCmd)
+
 	rootCmd.AddCommand(serverCmd)
 
 	serverCmd.AddCommand(startCmd)
@@ -74,6 +124,13 @@ func init() {
 		"the db to use, one of sqlite or postgres",
 	)
 
+	startCmd.PersistentFlags().StringVar(
+		&opts.driver,
+		"driver",
+		"local",
+		"the db to use, one of local or docker",
+	)
+
 	startCmd.PersistentFlags().StringVar(
 		&opts.imageTag,
 		"image-tag",
@@ -89,7 +146,7 @@ func init() {
 	)
 }
 
-func start(
+func startDocker(
 	imageTag string,
 	db string,
 	port int,
@@ -129,7 +186,7 @@ func start(
 	return setHost(fmt.Sprintf("http://localhost:%d", port))
 }
 
-func stop() error {
+func stopDocker() error {
 	agent, err := docker.NewAgentFromEnv()
 
 	if err != nil {

+ 20 - 0
cmd/app/main.go

@@ -6,6 +6,7 @@ import (
 	"net/http"
 
 	"github.com/gorilla/sessions"
+	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
@@ -30,6 +31,24 @@ func main() {
 		return
 	}
 
+	err = db.AutoMigrate(
+		&models.Project{},
+		&models.Role{},
+		&models.ServiceAccount{},
+		&models.ServiceAccountAction{},
+		&models.ServiceAccountCandidate{},
+		&models.Cluster{},
+		&models.TokenCache{},
+		&models.User{},
+		&models.Session{},
+		&models.RepoClient{},
+	)
+
+	if err != nil {
+		logger.Fatal().Err(err).Msg("")
+		return
+	}
+
 	var key [32]byte
 
 	for i, b := range []byte(appConf.Db.EncryptionKey) {
@@ -52,6 +71,7 @@ func main() {
 		store,
 		appConf.Server.CookieName,
 		false,
+		appConf.Server.IsLocal,
 		&oauth.Config{
 			ClientID:     appConf.Server.GithubClientID,
 			ClientSecret: appConf.Server.GithubClientSecret,

+ 1 - 1
dashboard/docker/dev.Dockerfile

@@ -5,7 +5,7 @@ WORKDIR /webpack
 
 COPY package*.json ./
 
-RUN npm i
+RUN npm install
 
 ENV NODE_ENV=development
 

+ 5 - 4
dashboard/package-lock.json

@@ -404,7 +404,8 @@
     "@types/js-base64": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/@types/js-base64/-/js-base64-3.0.0.tgz",
-      "integrity": "sha512-BnEyOcDE4H6bkg8m84xhdbkYoAoCg8sYERmAvE4Ff50U8jTfbmOinRdJpauBn1P9XsCCQgCLuSiyz3PM4WHYOA=="
+      "integrity": "sha512-BnEyOcDE4H6bkg8m84xhdbkYoAoCg8sYERmAvE4Ff50U8jTfbmOinRdJpauBn1P9XsCCQgCLuSiyz3PM4WHYOA==",
+      "dev": true
     },
     "@types/js-yaml": {
       "version": "3.12.5",
@@ -6861,9 +6862,9 @@
       "dev": true
     },
     "typescript": {
-      "version": "4.0.3",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.0.3.tgz",
-      "integrity": "sha512-tEu6DGxGgRJPb/mVPIZ48e69xCn2yRmCgYmDugAVwmJ6o+0u1RI18eO7E7WBTLYLaEVVOhwQmcdhQHweux/WPg==",
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.2.tgz",
+      "integrity": "sha512-thGloWsGH3SOxv1SoY7QojKi0tc+8FnOmiarEGMbd/lar7QOEd3hvlx3Fp5y6FlDUGl9L+pd4n2e+oToGMmhRQ==",
       "dev": true
     },
     "union-value": {

+ 2 - 2
dashboard/package.json

@@ -4,7 +4,6 @@
   "private": true,
   "dependencies": {
     "@fullstory/browser": "^1.4.5",
-    "@types/js-base64": "^3.0.0",
     "@types/js-yaml": "^3.12.5",
     "@types/markdown-to-jsx": "^6.11.3",
     "@types/qs": "^6.9.5",
@@ -33,6 +32,7 @@
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",
     "@types/jest": "^24.0.0",
+    "@types/js-base64": "^3.0.0",
     "@types/node": "^12.12.62",
     "@types/qs": "^6.9.5",
     "@types/react": "^16.9.49",
@@ -46,7 +46,7 @@
     "qs": "^6.9.4",
     "source-map-loader": "^1.1.0",
     "ts-loader": "^8.0.4",
-    "typescript": "^4.0.3",
+    "typescript": "^4.1.2",
     "webpack": "^4.44.2",
     "webpack-cli": "^3.3.12",
     "webpack-dev-server": "^3.11.0"

+ 2 - 2
dashboard/src/components/values-form/ValuesForm.tsx

@@ -16,6 +16,7 @@ type PropsType = {
   onSubmit: (formValues: any) => void,
   sections?: Section[],
   disabled?: boolean,
+  saveValuesStatus?: string | null,
 };
 
 type StateType = any;
@@ -121,7 +122,6 @@ export default class ValuesForm extends Component<PropsType, StateType> {
   renderFormContents = () => {
     if (this.state) {
       return this.props.sections.map((section: Section, i: number) => {
-
         // Hide collapsible section if deciding field is false
         if (section.show_if) {
           if (!this.state[section.show_if]) {
@@ -149,7 +149,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           disabled={this.props.disabled}
           text='Deploy'
           onClick={() => this.props.onSubmit(this.state)}
-          status={null}
+          status={this.props.saveValuesStatus}
           makeFlush={true}
         />
       </Wrapper>

+ 65 - 5
dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx

@@ -1,20 +1,40 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
 
-import { ChartType } from '../../../../shared/types';
+import { ChartType, StorageType } from '../../../../shared/types';
 import { Context } from '../../../../shared/Context';
 
 type PropsType = {
   chart: ChartType,
-  setCurrentChart: (c: ChartType) => void
+  setCurrentChart: (c: ChartType) => void,
+  controllers: Record<string, any>,
 };
 
 type StateType = {
+  expand: boolean,
+  controllers: Record<string, boolean>,
+  update: any[],
+  getAvailability: Function,
 };
 
 export default class Chart extends Component<PropsType, StateType> {
+  getAvailability = (kind: string, c: any) => {
+    switch (kind?.toLowerCase()) {
+      case "deployment":
+      case "replicaset":
+        return (c.status.availableReplicas == c.status.replicas)
+      case "statefulset":
+       return (c.status.readyReplicas == c.status.replicas)
+      case "daemonset":
+        return (c.status.numberAvailable == c.status.desiredNumberScheduled)
+      }
+  }
+
   state = {
     expand: false,
+    controllers: {} as Record<string, boolean>,
+    update: [] as any[],
+    getAvailability: this.getAvailability.bind(this),
   }
 
   renderIcon = () => {
@@ -34,9 +54,49 @@ export default class Chart extends Component<PropsType, StateType> {
     return `${time} on ${date}`;
   }
 
+  setControllerStatus = (cs: Record<string, any>) => {
+    let controllers = {} as Record<string, boolean>;
+    for (var uid in cs) {
+      let value = cs[uid];
+      controllers[uid] = this.getAvailability(value.kind, value);
+    }
+    this.setState({ controllers });
+  }
+
+  getChartStatus = (chartStatus: string) => {
+    if (chartStatus === 'deployed') {
+      for (var uid in this.state.controllers) {
+        if (!this.state.controllers[uid]) {
+          return 'not ready'
+        }
+      }
+      return 'deployed'
+    }
+    return chartStatus
+  }
+
+  static getDerivedStateFromProps(nextProps: any, prevState: any) {
+    let controllers = {} as Record<string, boolean>;
+    
+    for (var uid in nextProps.controllers) {
+      let controller = nextProps.controllers[uid]
+      controllers[uid] = prevState.getAvailability(controller.kind, controller)
+    }
+
+    return {
+      controllers,
+    };
+  }
+
+  componentDidMount () {
+    const { chart, controllers } = this.props;
+    if (chart.info.status == 'failed') return;
+    this.setControllerStatus(controllers)
+  }
+
   render() {
     let { chart, setCurrentChart } = this.props;
-
+    let status = this.getChartStatus(chart.info.status)
     return ( 
       <StyledChart
         onMouseEnter={() => this.setState({ expand: true })}
@@ -54,8 +114,8 @@ export default class Chart extends Component<PropsType, StateType> {
         <BottomWrapper>
           <InfoWrapper>
             <StatusIndicator>
-              <StatusColor status={chart.info.status} />
-              {chart.info.status}
+              <StatusColor status={status} />
+              {status}
             </StatusIndicator>
 
             <LastDeployed>

+ 130 - 23
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -16,26 +16,26 @@ type PropsType = {
 
 type StateType = {
   charts: ChartType[],
+  chartLookupTable: Record<string, string>,
+  controllers: Record<string, Record<string, any>>,
   loading: boolean,
-  error: boolean
+  error: boolean,
+  websockets: Record<string, any>,
 };
 
 export default class ChartList extends Component<PropsType, StateType> {
   state = {
     charts: [] as ChartType[],
+    chartLookupTable: {} as Record<string, string>,
+    controllers: {} as Record<string, Record<string, any>>,
     loading: false,
     error: false,
+    websockets : {} as Record<string, any>,
   }
 
-  updateCharts = () => {
-    let { currentCluster, currentProject } = this.context;
-
+  updateCharts = (callback: Function) => {
+    let { currentCluster, currentProject, setCurrentError } = this.context;
     this.setState({ loading: true });
-    setTimeout(() => {
-      if (this.state.loading) {
-        this.setState({ loading: false, error: true });
-      }
-    }, 3000);
 
     api.getCharts('<token>', {
       namespace: this.props.namespace,
@@ -45,25 +45,131 @@ export default class ChartList extends Component<PropsType, StateType> {
       limit: 20,
       skip: 0,
       byDate: false,
-      statusFilter: ['deployed']
+      statusFilter: ['deployed', 'uninstalled', 'pending', 'pending_upgrade',
+        'pending_rollback','superseded','failed']
     }, { id: currentProject.id }, (err: any, res: any) => {
-        if (err) {
+      if (err) {
         console.log(err)
-        // setCurrentError(JSON.stringify(err));
+        setCurrentError(JSON.stringify(err));
         this.setState({ loading: false, error: true });
       } else {
-        if (res.data) {
-          this.setState({ charts: res.data });
-        } else {
-          this.setState({ charts: [] });
-        }
-        this.setState({ loading: false, error: false });
+        let charts = res.data || [];
+        this.setState({ charts }, () => {
+          this.setState({ loading: false, error: false });
+        });
+        callback(charts)
       }
     });
   }
 
+  setupWebsocket = (kind: string) => {
+      let { currentCluster, currentProject } = this.context;
+      let ws = new WebSocket(`ws://localhost:8080/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
+      ws.onopen = () => {
+        console.log('connected to websocket')
+      }
+  
+      ws.onmessage = (evt: MessageEvent) => {
+        let event = JSON.parse(evt.data)
+        let object = event.Object
+        let chartKey = this.state.chartLookupTable[object.metadata.uid]
+
+        // ignore if updated object does not belong to any chart in the list.
+        if (!chartKey) {
+          return;
+        }
+
+        let chartControllers = this.state.controllers[chartKey]
+        chartControllers[object.metadata.uid] = object
+
+        this.setState({
+          controllers: {
+            ...this.state.controllers,
+            [chartKey] : chartControllers
+          }
+        })
+      }
+  
+      ws.onclose = () => {
+        console.log('closing websocket')
+      }
+  
+      ws.onerror = (err: ErrorEvent) => {
+        console.log(err)
+        ws.close()
+      }
+
+      return ws
+  }
+
+  setControllerWebsockets = (controllers: any[]) => {
+    let websockets = controllers.map((kind: string) => {
+      return this.setupWebsocket(kind)
+    })
+    this.setState({websockets})
+  }
+
+  getControllers = (charts: any[]) => {
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    charts.forEach(async (chart: any) => {
+      // don't retrieve controllers for chart that failed to even deploy.
+      if (chart.info.status == 'failed') return;
+
+      await new Promise((next: (res?: any) => void) => {
+        api.getChartControllers('<token>', {
+          namespace: chart.namespace,
+          cluster_id: currentCluster.id,
+          service_account_id: currentCluster.service_account_id,
+          storage: StorageType.Secret
+        }, {
+          id: currentProject.id,
+          name: chart.name,
+          revision: chart.version
+        }, (err: any, res: any) => {
+          if (err) {
+            setCurrentError(JSON.stringify(err));
+            return
+          }
+          // transform controller array into hash table for easy lookup during updates.
+          let chartControllers = {} as Record<string, Record<string, any>>
+          res.data.forEach((c: any) => {
+            chartControllers[c.metadata.uid] = c
+          })
+
+          res.data.forEach(async (c: any) => {
+            await new Promise((nextController: (res?: any) => void) => {
+              this.setState({
+                chartLookupTable: {
+                  ...this.state.chartLookupTable,
+                  [c.metadata.uid] : `${chart.namespace}-${chart.name}`
+                },
+                controllers: {
+                  ...this.state.controllers,
+                  [`${chart.namespace}-${chart.name}`] : chartControllers
+                }
+              }, () => {
+                nextController();
+              })
+            })
+          })
+          next();
+        });
+      })
+    })
+  }
+
   componentDidMount() {
-    this.updateCharts();
+    this.updateCharts(this.getControllers);
+    this.setControllerWebsockets(["deployment", "statefulset", "daemonset", "replicaset"]);
+  }
+
+  async componentWillUnmount () {
+    if (this.state.websockets) {
+      this.state.websockets.forEach((ws: WebSocket) => {
+        ws.close()
+      })
+    }
   }
 
   componentDidUpdate(prevProps: PropsType) {
@@ -71,7 +177,7 @@ export default class ChartList extends Component<PropsType, StateType> {
     // Ret2: Prevents reload when opening ClusterConfigModal
     if (prevProps.currentCluster !== this.props.currentCluster || 
       prevProps.namespace !== this.props.namespace) {
-      this.updateCharts();
+      this.updateCharts(this.getControllers);
     }
   }
 
@@ -94,12 +200,13 @@ export default class ChartList extends Component<PropsType, StateType> {
       );
     }
 
-    return this.state.charts.map((x: ChartType, i: number) => {
+    return this.state.charts.map((chart: ChartType, i: number) => {
       return (
         <Chart
-          key={i}
-          chart={x}
+          key={`${chart.namespace}-${chart.name}`}
+          chart={chart}
           setCurrentChart={this.props.setCurrentChart}
+          controllers={this.state.controllers[`${chart.namespace}-${chart.name}`] || {} as Record<string, any>}
         />
       )
     })

+ 38 - 9
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -13,7 +13,7 @@ import RevisionSection from './RevisionSection';
 import ValuesYaml from './ValuesYaml';
 import GraphSection from './GraphSection';
 import ListSection from './ListSection';
-import LogSection from './log/LogSection';
+import StatusSection from './status/StatusSection';
 import ValuesForm from '../../../../components/values-form/ValuesForm';
 import SettingsSection from './SettingsSection';
 
@@ -33,10 +33,11 @@ type StateType = {
   tabOptions: ChoiceType[],
   tabContents: any,
   checkTabExists: boolean,
+  saveValuesStatus: string | null,
 };
 
 // Tabs not display when previewing an old revision
-const excludedTabs = ['logs', 'settings', 'deploy'];
+const excludedTabs = ['status', 'settings', 'deploy'];
 
 /*
   TODO: consolidate revisionPreview and currentChart (currentChart can just be the initial state)
@@ -56,6 +57,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     tabOptions: [] as ChoiceType[],
     tabContents: [] as any,
     checkTabExists: false,
+    saveValuesStatus: null as (string | null),
   }
 
   updateResources = () => {
@@ -73,7 +75,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       revision: currentChart.version
     }, (err: any, res: any) => {
       if (err) {
-        // console.log(err)
+        console.log(err)
       } else {
         this.setState({ components: res.data.Objects, podSelectors: res.data.PodSelectors }, this.refreshTabs);
       }
@@ -85,12 +87,38 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     for (const file of files) { 
       if (file.name === 'form.yaml') {
         let formData = yaml.load(Base64.decode(file.data));
+        if (this.props.currentChart.config) {
+          console.log(formData)
+        }
         return formData;
       }
     };
     return null;
   }
 
+  upgradeValues = (values: any) => {
+    let { currentProject, currentCluster, setCurrentError } = this.context;
+    values = yaml.dump(values);
+    api.upgradeChartValues('<token>', {
+      namespace: this.props.currentChart.namespace,
+      storage: StorageType.Secret,
+      values,
+    }, {
+      id: currentProject.id, 
+      name: this.props.currentChart.name,
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id,
+    }, (err: any, res: any) => {
+      if (err) {
+        setCurrentError(err);
+        this.setState({ saveValuesStatus: 'error' });
+      } else {
+        this.setState({ saveValuesStatus: 'successful' });
+        this.props.refreshChart();
+      }
+    });
+  }
+
   refreshTabs = () => {
     let formData = this.getFormData();
     let tabOptions = [] as ChoiceType[];
@@ -106,7 +134,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
             <ValuesFormWrapper>
               <ValuesForm 
                 sections={tab.sections} 
-                onSubmit={(x: any) => console.log(x)}
+                onSubmit={this.upgradeValues}
+                saveValuesStatus={this.state.saveValuesStatus}
               />
             </ValuesFormWrapper>
           ),
@@ -116,7 +145,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
     // Append universal tabs
     tabOptions.push(
-      { label: 'Logs', value: 'logs' },
+      { label: 'Status', value: 'status' },
       { label: 'Deploy', value: 'deploy' },
       { label: 'Settings', value: 'settings' },
     );
@@ -133,8 +162,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     let chart = this.state.revisionPreview || currentChart;
     tabContents.push(
       {
-        value: 'logs', component: (
-          <LogSection selectors={this.state.podSelectors} />
+        value: 'status', component: (
+          <StatusSection currentChart={chart} selectors={this.state.podSelectors} />
         ),
       },
       {
@@ -271,8 +300,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
                   <StatusColor status={chart.info.status} />{chart.info.status}
                 </StatusIndicator>
                 <LastDeployed>
-                  <Dot>•</Dot>Last deployed
-                  {this.readableDate(chart.info.last_deployed)}
+                  <Dot>•</Dot>Last deployed 
+                  {' ' + this.readableDate(chart.info.last_deployed)}
                 </LastDeployed>
               </InfoWrapper>
 

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

@@ -1,9 +1,8 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
-import api from '../../../../shared/api';
 
 import { Context } from '../../../../shared/Context';
-import { ResourceType, StorageType, ChartType } from '../../../../shared/types';
+import { ResourceType, ChartType } from '../../../../shared/types';
 
 import ResourceItem from './ResourceItem';
 import Loading from '../../../../components/Loading';

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

@@ -59,7 +59,7 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
       service_account_id: currentCluster.service_account_id,
     }, (err: any, res: any) => {
       if (err) {
-        setCurrentError(err.response.data);
+        console.log(err)
         this.setState({ saveValuesStatus: 'error' });
       } else {
         this.setState({ saveValuesStatus: 'successful' });

+ 0 - 109
dashboard/src/main/home/cluster-dashboard/expanded-chart/log/LogSection.tsx

@@ -1,109 +0,0 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
-import api from '../../../../../shared/api';
-import { ResourceType, ChartType } from '../../../../../shared/types';
-import Logs from './Logs';
-import { Context } from '../../../../../shared/Context';
-
-type PropsType = {
-  selectors: string[],
-};
-
-type StateType = {
-  logs: string[]
-  pods: string[],
-  selectedPod: string,
-};
-
-export default class LogSection extends Component<PropsType, StateType> {
-  state = {
-    logs: [] as string[],
-    pods: [] as string[],
-    selectedPod: null as string,
-    matchingPods: [] as any[]
-  }
-
-  renderLogs = () => {
-    return <Logs key={this.state.selectedPod} selectedPod={this.state.selectedPod} />
-  }
-
-  renderPodTabs = () => {
-    return this.state.pods.map((pod, i) => {
-      return (
-        <Tab
-          key={i}
-          selected={(this.state.selectedPod == pod)}
-          onClick={() => {
-            this.setState({ selectedPod: pod })
-          }}
-        >
-          {pod}
-        </Tab>
-      );
-    });
-  }
-
-  componentDidMount() {
-    const { selectors } = this.props;
-    let { currentCluster, currentProject } = this.context;
-
-    api.getMatchingPods('<token>', { 
-      cluster_id: currentCluster.id,
-      service_account_id: currentCluster.service_account_id,
-      selectors,
-    }, {
-      id: currentProject.id
-    }, (err: any, res: any) => {
-      // console.log("SELECTORS", selectors)
-      this.setState({ pods: res.data, selectedPod: res.data[0] })
-    });
-  }
-
-  render() {
-    return (
-      <StyledLogSection>
-        <TabWrapper>
-          {this.renderPodTabs()}
-        </TabWrapper>
-        {this.renderLogs()}
-      </StyledLogSection>
-    );
-  }
-}
-
-LogSection.contextType = Context;
-
-const TabWrapper = styled.div`
-  display: flex;
-  flex-direction: column;
-  overflow: hidden;
-  width: 30%;
-  float: left;
-`
-
-const Tab = styled.div`
-  align-items: center;
-  color: ${(props: {selected: boolean}) => props.selected ? 'white' : '#ffffff66'};
-  background: ${(props: {selected: boolean}) => props.selected ? '#ffffff18' : '##ffffff11'};
-  height: 100%;
-  justify-content: center;
-  font-size: 13px;
-  padding: 15px 13px;
-  margin-right: 10px;
-  border-radius: 5px;
-  text-shadow: 0px 0px 8px none;
-  cursor: pointer;
-  :hover {
-    color: white;
-    background: #ffffff18;
-  }
-`;
-
-const StyledLogSection = styled.span`
-  width: 100%;
-  height: 100%;
-  position: relative;
-  font-size: 13px;
-  padding: 0px;
-  user-select: text;
-`;

+ 0 - 66
dashboard/src/main/home/cluster-dashboard/expanded-chart/log/Logs.tsx

@@ -1,66 +0,0 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
-import { Context } from '../../../../../shared/Context';
-
-type PropsType = {
-  selectedPod: string,
-};
-
-type StateType = {
-  logs: string[]
-};
-
-export default class Logs extends Component<PropsType, StateType> {
-  
-  state = {
-    logs: [] as string[],
-  }
-
-  scrollRef = React.createRef<HTMLDivElement>()
-
-  scrollToBottom = () => {
-    this.scrollRef.current.scrollTop = this.scrollRef.current.scrollHeight
-  }
-
-  renderLogs = () => {
-    return this.state.logs.map((log, i) => {
-        return <div key={i}>{log}</div>
-    })
-  }
-
-  componentDidMount() {
-    let { currentCluster, currentProject } = this.context;
-    let ws = new WebSocket(`ws://localhost:8080/api/projects/${currentProject.id}/k8s/default/pod/${this.props.selectedPod}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
-
-    ws.onopen = () => {
-      console.log('connected to websocket')
-    }
-
-    ws.onmessage = evt => {
-      this.setState({ logs: [...this.state.logs, evt.data] }, () => {
-        this.scrollToBottom()
-      })
-    }
-  }
-
-  render() {
-    return (
-      <LogStream ref={this.scrollRef}>
-        {this.renderLogs()}
-      </LogStream>
-    );
-  }
-}
-
-Logs.contextType = Context;
-
-const LogStream = styled.div`
-  width: 70%;
-  height: 100%;
-  background: #202227;
-  position: relative;
-  padding: 25px;
-  user-select: text;
-  overflow: auto;
-  border-radius: 5px;
-`;

+ 313 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -0,0 +1,313 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import { kindToIcon } from '../../../../../shared/rosettaStone';
+import api from '../../../../../shared/api';
+import { Context } from '../../../../../shared/Context';
+
+type PropsType = {
+  controller: any,
+  selectedPod: any,
+  selectPod: Function,
+};
+
+type StateType = {
+  expanded: boolean,
+  pods: any[],
+  raw: any[],
+};
+
+// Controller tab in log section that displays list of pods on click.
+export default class ControllerTab extends Component<PropsType, StateType> {
+  state = {
+    expanded: false,
+    pods: [] as any[],
+    raw: [] as any[],
+  }
+
+  getAvailability = (kind: string, c: any) => {
+    switch (kind?.toLowerCase()) {
+      case "deployment":
+      case "replicaset":
+        return [
+          c.status?.availableReplicas || c.status?.replicas - c.status?.unavailableReplicas, 
+          c.status?.replicas
+        ]
+      case "statefulset":
+       return [c.status?.readyReplicas, c.status?.replicas]
+      case "daemonset":
+        return [c.status?.numberAvailable, c.status?.desiredNumberScheduled]
+      }
+  }
+
+  renderIcon = (kind: string) => {
+
+    let icon = 'tonality';
+    if (Object.keys(kindToIcon).includes(kind)) {
+      icon = kindToIcon[kind]; 
+    }
+    
+    return (
+      <IconWrapper>
+        <i className="material-icons">{icon}</i>
+      </IconWrapper>
+    );
+  }
+
+  getPodStatus = (status: any) => {
+    if (status?.phase == 'Pending') {
+      return 'waiting'
+    }
+
+    if (status?.phase == 'Failed') {
+      return 'failed'
+    }
+
+    if (status?.phase == 'Running') {
+      let collatedStatus = 'running';
+
+      status.containerStatuses.forEach((s: any) => {
+        if (s.state?.waiting) {
+          collatedStatus = 'waiting'
+        } else if (s.state?.terminated) {
+          collatedStatus = 'failed'
+          throw {};
+        }
+      })
+      return collatedStatus;
+    }
+  }
+
+  renderExpanded = () => {
+    if (this.state.expanded) {
+      return (
+        <ExpandWrapper>
+            {
+              this.state.raw.map((pod) => {
+                let status = this.getPodStatus(pod.status)
+                return (
+                  <Tab 
+                    key={pod.metadata?.name}
+                    selected={(this.props.selectedPod?.metadata?.name === pod?.metadata?.name)}
+                    onClick={() => {this.props.selectPod(pod)}}
+                  > 
+                    {pod.metadata?.name}
+                    <Status>
+                      <StatusColor status={status} />
+                      {status}
+                    </Status>
+                  </Tab>)
+              })
+            }
+        </ExpandWrapper>
+      );
+    }
+  }
+
+  componentDidMount() {
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+    let { controller } = this.props;
+
+    let selectors = [] as string[]
+    let ml = controller?.spec?.selector?.matchLabels || controller?.spec?.selector
+    let i = 1;
+    let selector = ''
+    for (var key in ml) {
+      selector += key + '=' + ml[key]
+      if (i != Object.keys(ml).length) {
+        selector += ','
+      }
+      i += 1;
+    }
+    selectors.push(selector)
+    
+    api.getMatchingPods('<token>', { 
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id,
+      selectors,
+    }, {
+      id: currentProject.id
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+        setCurrentError(JSON.stringify(err))
+        return
+      }
+      let pods = res?.data?.map((pod: any) => {
+        return {
+          namespace: pod?.metadata?.namespace, 
+          name: pod?.metadata?.name,
+          phase: pod?.status?.phase,
+        }
+      })
+      console.log(res.data)
+      this.setState({ pods, raw: res.data })
+    })
+  }
+
+  render() {
+    let { controller } = this.props;
+    let [available, total] = this.getAvailability(controller.kind, controller);
+    let status = (available == total) ? 'running' : 'waiting'
+    return (
+      <StyledResourceItem>
+        <ResourceHeader
+          expanded={this.state.expanded}
+          onClick={() => this.setState({ expanded: !this.state.expanded })}
+        >
+          <DropdownIcon expanded={this.state.expanded}>
+            <i className="material-icons">arrow_right</i>
+          </DropdownIcon>
+          <Info>
+          <Metadata>
+            {this.renderIcon(controller.kind)}
+            {`${controller.kind}`}
+            <ResourceName
+              showKindLabels={true}
+            >
+              {controller.metadata.name}
+            </ResourceName>
+          </Metadata>
+          <Status>
+            <StatusColor status={status} />
+            {available}/{total}
+          </Status>
+          </Info>
+        </ResourceHeader>
+        {this.renderExpanded()}
+      </StyledResourceItem>
+    );
+  }
+}
+
+ControllerTab.contextType = Context;
+
+const StyledResourceItem = styled.div`
+  width: 100%;
+`;
+
+const ExpandWrapper = styled.div`
+  overflow: hidden;
+`;
+
+const ResourceHeader = styled.div`
+  width: 100%;
+  height: 60px;
+  display: flex;
+  align-items: center;
+  color: #ffffff66;
+  padding: 8px 13px;
+  text-transform: capitalize;
+  cursor: pointer;
+  background: ${(props: { expanded: boolean }) => props.expanded ? '#ffffff11' : ''};
+  :hover {
+    background: #ffffff18;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+`;
+
+const Info = styled.div`
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  width: 100%;
+  height: 100%;
+`;
+
+const Metadata = styled.div`
+  display: flex;
+  align-items: center;
+  width: 85%;
+`;
+
+const Status = styled.div`
+  display: flex;
+  font-size: 13px;
+  flex-direction: row;
+  text-transform: capitalize;
+  align-items: center;
+  font-family: 'Hind Siliguri', sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const StatusColor = styled.div`
+  margin-bottom: 1px;
+  margin-right: 5px;
+  width: 8px;
+  height: 8px;
+  background: ${(props: { status: string }) => (props.status === 'running' ? '#4797ff' : props.status === 'failed' ? "#ed5f85" : "#f5cb42")};
+  border-radius: 20px;
+`;
+
+const Tab = styled.div`
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  color: ${(props: {selected: boolean}) => props.selected ? 'white' : '#ffffff66'};
+  background: ${(props: {selected: boolean}) => props.selected ? '#ffffff18' : '##ffffff11'};
+  font-size: 13px;
+  padding: 20px 12px 20px 45px;
+  text-shadow: 0px 0px 8px none;
+  cursor: pointer;
+  :hover {
+    color: white;
+    background: #ffffff18;
+  }
+`;
+
+const ResourceName = styled.div`
+  color: #ffffff;
+  margin-left: ${(props: { showKindLabels: boolean }) => props.showKindLabels ? '10px' : ''};
+  text-transform: none;
+  max-width: 60%;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+
+  :hover {
+    overflow: visible;
+  }
+`;
+
+const IconWrapper = styled.div`
+  width: 25px;
+  height: 25px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 16px;
+    color: #ffffff;
+    margin-right: 14px;
+  }
+`;
+
+const DropdownIcon = styled.div`
+  > i {
+    margin-right: 13px;
+    font-size: 20px;
+    color: #ffffff66;
+    cursor: pointer;
+    border-radius: 20px;
+    background: ${(props: { expanded: boolean }) => props.expanded ? '#ffffff18' : ''};
+    transform: ${(props: { expanded: boolean }) => props.expanded ? 'rotate(180deg)' : ''};
+    animation: ${(props: { expanded: boolean }) => props.expanded ? 'quarterTurn 0.3s' : ''};
+    animation-fill-mode: forwards;
+
+    @keyframes quarterTurn {
+      from { transform: rotate(0deg) }
+      to { transform: rotate(90deg) }
+    }
+  }
+`;

+ 103 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -0,0 +1,103 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import { Context } from '../../../../../shared/Context';
+
+type PropsType = {
+  selectedPod: any,
+};
+
+type StateType = {
+  logs: string[],
+  ws: any
+};
+
+export default class Logs extends Component<PropsType, StateType> {
+  
+  state = {
+    logs: [] as string[],
+    ws : null as any
+  }
+
+  scrollRef = React.createRef<HTMLDivElement>()
+
+  scrollToBottom = () => {
+    this.scrollRef.current.scrollTop = this.scrollRef.current.scrollHeight
+  }
+
+  renderLogs = () => {
+    let { selectedPod } = this.props;
+    if (!selectedPod?.metadata?.name) {
+      return <Message>Please select a pod to view its logs.</Message>
+    }
+    if (this.state.logs.length == 0) {
+      return <Message>No logs to display from this pod.</Message>
+    }
+    return this.state.logs.map((log, i) => {
+        return <div key={i}>{log}</div>
+    })
+  }
+
+  componentDidMount() {
+    let { currentCluster, currentProject } = this.context;
+    let { selectedPod } = this.props;
+    if (!selectedPod.metadata?.name) return
+
+    let ws = new WebSocket(`ws://localhost:8080/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
+
+    this.setState({ ws }, () => {
+      if (!this.state.ws) return;
+  
+      this.state.ws.onopen = () => {
+        console.log('connected to websocket')
+      }
+  
+      this.state.ws.onmessage = (evt: MessageEvent) => {
+        this.setState({ logs: [...this.state.logs, evt.data] }, () => {
+          this.scrollToBottom()
+        })
+      }
+  
+      this.state.ws.onerror = (err: ErrorEvent) => {
+        console.log(err)
+      }
+    })
+  }
+
+  componentWillUnmount() {
+    if (this.state.ws) {
+      this.state.ws.close()
+    }
+  }
+
+  render() {
+    return (
+      <LogStream ref={this.scrollRef}>
+        {this.renderLogs()}
+      </LogStream>
+    );
+  }
+}
+
+Logs.contextType = Context;
+
+const LogStream = styled.div`
+  overflow: auto;
+  width: 65%;
+  float: right;
+  height: 100%;
+  background: #202227;
+  padding: 25px;
+  user-select: text;
+  overflow: auto;
+  border-radius: 5px;
+`;
+
+const Message = styled.div`
+  display: flex;
+  height: 100%;
+  width: 100%;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 14px;
+`

+ 156 - 0
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx

@@ -0,0 +1,156 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import api from '../../../../../shared/api';
+import Logs from './Logs';
+import ControllerTab from './ControllerTab';
+import { Context } from '../../../../../shared/Context';
+import { ChartType, StorageType } from '../../../../../shared/types';
+import Loading from '../../../../../components/Loading';
+
+type PropsType = {
+  selectors: string[],
+  currentChart: ChartType,
+};
+
+type StateType = {
+  logs: string[]
+  pods: any[],
+  selectedPod: any,
+  controllers: any[],
+  loading: boolean,
+};
+
+export default class StatusSection extends Component<PropsType, StateType> {
+  state = {
+    logs: [] as string[],
+    pods: [] as any[],
+    selectedPod: {} as any,
+    controllers: [] as any[],
+    loading: true,
+  }
+
+  renderLogs = () => {
+    return <Logs 
+      key={this.state.selectedPod?.metadata?.name} 
+      selectedPod={this.state.selectedPod} 
+    />
+  }
+
+  selectPod = (pod: any) => {
+    this.setState({
+      selectedPod: pod
+    })
+  }
+
+  renderTabs = () => {
+    return this.state.controllers.map((c) => {
+      return (
+        <ControllerTab 
+          key={c.metadata.uid} 
+          selectedPod={this.state.selectedPod} 
+          selectPod={this.selectPod.bind(this)}
+          controller={c}
+        />
+      )
+    })
+  }
+
+  renderStatusSection = () => {
+    if (this.state.loading) {
+      return (
+        <NoControllers> 
+          <Loading />
+        </NoControllers>
+      )
+    }
+    if (this.state.controllers.length > 0) {
+      return (
+        <Wrapper>
+          <TabWrapper>
+            {this.renderTabs()}
+          </TabWrapper>
+          {this.renderLogs()}
+        </Wrapper>
+      )
+    } else {
+      return (
+        <NoControllers> 
+          <i className="material-icons">category</i> 
+          No objects to display. This might happen while your app is still deploying.
+        </NoControllers>
+      )
+    }
+  }
+
+  componentDidMount() {
+    const { selectors, currentChart } = this.props;
+    let { currentCluster, currentProject, setCurrentError } = this.context;
+
+    api.getChartControllers('<token>', {
+      namespace: currentChart.namespace,
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id,
+      storage: StorageType.Secret
+    }, {
+      id: currentProject.id,
+      name: currentChart.name,
+      revision: currentChart.version
+    }, (err: any, res: any) => {
+      if (err) {
+        setCurrentError(JSON.stringify(err));
+        return
+      }
+      this.setState({ controllers: res.data, loading: false })
+    });
+  }
+
+  render() {
+    return (
+      <StyledStatusSection>
+        {this.renderStatusSection()}
+      </StyledStatusSection>
+    );
+  }
+}
+
+StatusSection.contextType = Context;
+
+const TabWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  overflow: auto;
+  width: 35%;
+  float: left;
+  max-height: 100%;
+  background: #ffffff11;
+`
+
+const StyledStatusSection = styled.div`
+  width: 100%;
+  height: 100%;
+  position: relative;
+  font-size: 13px;
+  padding: 0px;
+  user-select: text;
+`;
+
+const Wrapper = styled.div`
+  width: 100%;
+  height: 100%;
+`;
+
+const NoControllers = styled.div`
+  padding-top: 20%;
+  position: relative;
+  width: 100%;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  color: #ffffff44;
+  font-size: 14px;
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+  }
+`;

+ 6 - 7
dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx

@@ -4,7 +4,7 @@ import styled from 'styled-components';
 import { Context } from '../../../../shared/Context';
 import api from '../../../../shared/api';
 
-import { PorterChart, ChoiceType, Cluster } from '../../../../shared/types';
+import { PorterChart, ChoiceType, Cluster, StorageType } from '../../../../shared/types';
 import Selector from '../../../../components/Selector';
 import ImageSelector from '../../../../components/image-selector/ImageSelector';
 import TabRegion from '../../../../components/TabRegion';
@@ -35,22 +35,21 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
   };
 
   onSubmit = (formValues: any) => {
-    console.log(formValues);
-
     let { currentCluster, currentProject } = this.context;
-    console.log(formValues);
     api.deployTemplate('<token>', {
       templateName: this.props.currentTemplate.name,
-      clusterID: currentCluster.id,
       imageURL: "index.docker.io/bitnami/redis",
+      storage: StorageType.Secret,
       formValues,
     }, {
       id: currentProject.id,
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id,
     }, (err: any, res: any) => {
       if (err) {
-        // console.log(err)
+        console.log(err)
       } else {
-        // console.log(res.data)
+        console.log(res.data)
       }
     });
   }

+ 15 - 4
dashboard/src/shared/api.tsx

@@ -71,6 +71,15 @@ const getChartComponents = baseApi<{
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/components`;
 });
 
+const getChartControllers = baseApi<{
+  namespace: string,
+  cluster_id: number,
+  service_account_id: number,
+  storage: StorageType
+}, { id: number, name: string, revision: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/controllers`;
+});
+
 const getNamespaces = baseApi<{
   cluster_id: number,
   service_account_id: number,
@@ -155,11 +164,12 @@ const deleteProject = baseApi<{}, { id: number }>('DELETE', pathParams => {
 
 const deployTemplate = baseApi<{
   templateName: string,
-  clusterID: number,
   imageURL: string,
-  formValues: any
-}, { id: number }>('POST', pathParams => {
-  return `/api/projects/${pathParams.id}/deploy`;
+  formValues: any,
+  storage: StorageType,
+}, { id: number, cluster_id: number, service_account_id: number }>('POST', pathParams => {
+  let {id, cluster_id, service_account_id} = pathParams;
+  return `/api/projects/${id}/deploy?cluster_id=${cluster_id}&service_account_id=${service_account_id}`;
 });
 
 // Bundle export to allow default api import (api.<method> is more readable)
@@ -175,6 +185,7 @@ export default {
   getCharts,
   getChart,
   getChartComponents,
+  getChartControllers,
   getNamespaces,
   getMatchingPods,
   getRevisions,

+ 13 - 0
go.sum

@@ -60,22 +60,27 @@ github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX
 github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
 github.com/Azure/go-autorest v11.7.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
 github.com/Azure/go-autorest v12.0.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
+github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
 github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
 github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI=
 github.com/Azure/go-autorest/autorest v0.9.3/go.mod h1:GsRuLYvwzLjjjRoWEIyMUaYq8GNUx2nRB378IPt/1p0=
 github.com/Azure/go-autorest/autorest v0.10.0/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630=
 github.com/Azure/go-autorest/autorest v0.10.2/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630=
+github.com/Azure/go-autorest/autorest v0.11.1 h1:eVvIXUKiTgv++6YnWb42DUA1YL7qDugnKP0HljexdnQ=
 github.com/Azure/go-autorest/autorest v0.11.1/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw=
 github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0=
 github.com/Azure/go-autorest/autorest/adal v0.8.0/go.mod h1:Z6vX6WXXuyieHAXwMj0S6HY6e6wcHn37qQMBQlvY3lc=
 github.com/Azure/go-autorest/autorest/adal v0.8.1/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q=
 github.com/Azure/go-autorest/autorest/adal v0.8.2/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q=
+github.com/Azure/go-autorest/autorest/adal v0.9.0 h1:SigMbuFNuKgc1xcGhaeapbh+8fgsu+GxgDRFyg7f5lM=
 github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg=
+github.com/Azure/go-autorest/autorest/adal v0.9.5 h1:Y3bBUV4rTuxenJJs41HU3qmqsb+auo+a3Lz+PlJPpL0=
 github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A=
 github.com/Azure/go-autorest/autorest/azure/auth v0.4.2/go.mod h1:90gmfKdlmKgfjUpnCEpOJzsUEjrWDSLwHIG73tSXddM=
 github.com/Azure/go-autorest/autorest/azure/cli v0.3.1/go.mod h1:ZG5p860J94/0kI9mNJVoIoLgXcirM2gF5i2kWloofxw=
 github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA=
 github.com/Azure/go-autorest/autorest/date v0.2.0/go.mod h1:vcORJHLJEh643/Ioh9+vPmf1Ij9AEBM5FuBIXLmIy0g=
+github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw=
 github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74=
 github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=
 github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=
@@ -87,8 +92,10 @@ github.com/Azure/go-autorest/autorest/to v0.3.0/go.mod h1:MgwOyqaIuKdG4TL/2ywSsI
 github.com/Azure/go-autorest/autorest/validation v0.1.0/go.mod h1:Ha3z/SqBeaalWQvokg3NZAlQTalVMtOIAs1aGK7G6u8=
 github.com/Azure/go-autorest/autorest/validation v0.2.0/go.mod h1:3EEqHnBxQGHXRYq3HT1WyXAvT7LLY3tl70hw6tQIbjI=
 github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc=
+github.com/Azure/go-autorest/logger v0.2.0 h1:e4RVHVZKC5p6UANLJHkM4OfR1UKZPj8Wt8Pcx+3oqrE=
 github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
 github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk=
+github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo=
 github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
 github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -317,6 +324,7 @@ github.com/denverdino/aliyungo v0.0.0-20180316152028-2581e433b270/go.mod h1:dV8l
 github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0=
 github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY=
 github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
 github.com/digitalocean/godo v1.19.0/go.mod h1:AAPQ+tiM4st79QHlEBTg8LM7JQNre4SAQCbn56wEyKY=
@@ -389,6 +397,7 @@ github.com/fluxcd/flux/pkg/install v0.0.0-20201001122558-cb08da1b356a/go.mod h1:
 github.com/fluxcd/go-git-providers v0.0.3/go.mod h1:iaXf3nEq8MB/LzxfbNcCl48sAtIReUU7jqjJ7CEnfFQ=
 github.com/fluxcd/helm-operator/pkg/install v0.0.0-20200729150005-1467489f7ee4/go.mod h1:ijsiZLK3c4Qu4sFqHu5pJdwjmMEjvKpwivq3uAdffBk=
 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
+github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 github.com/fortytw2/leaktest v1.2.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
@@ -649,6 +658,7 @@ github.com/googleapis/gnostic v0.2.2/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTV
 github.com/gookit/color v1.2.4/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg=
 github.com/gophercloud/gophercloud v0.0.0-20180807015416-4ea085781bae/go.mod h1:3WdhXV3rUYy9p6AUW8d94kr+HS62Y4VL9mBnFxsD8q4=
 github.com/gophercloud/gophercloud v0.0.0-20190216224116-dcc6e84aef1b/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8=
+github.com/gophercloud/gophercloud v0.1.0 h1:P/nh25+rzXouhytV2pUHBb65fnds26Ghl8/391+sT5o=
 github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
 github.com/goreleaser/goreleaser v0.136.0/go.mod h1:wiKrPUeSNh6Wu8nUHxZydSOVQ/OZvOaO7DTtFqie904=
@@ -1875,8 +1885,10 @@ gotest.tools/gotestsum v0.3.5/go.mod h1:Mnf3e5FUzXbkCfynWBGOwLssY7gTQgCHObK9tMpA
 grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o=
 helm.sh/helm v2.16.12+incompatible h1:nQfifk10KcpAGD1RJaNZVW/fWiqluV0JMuuDwdba4rw=
 helm.sh/helm v2.16.12+incompatible/go.mod h1:0Xbc6ErzwWH9qC55X1+hE3ZwhM3atbhCm/NbFZw5i+4=
+helm.sh/helm v2.17.0+incompatible h1:cSe3FaQOpRWLDXvTObQNj0P7WI98IG5yloU6tQVls2k=
 helm.sh/helm/v3 v3.3.4 h1:tbad6WQVMxEw1HlVBvI2rQqOblmI5lgXOrWAMwJ198M=
 helm.sh/helm/v3 v3.3.4/go.mod h1:CyCGQa53/k1JFxXvXveGwtfJ4cuB9zkaBSGa5rnAiHU=
+helm.sh/helm/v3 v3.4.1 h1:NIdlBGKFRTAkhz0ooYKw1VBbmTldxNAZRY1nH6Glk6I=
 honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1894,6 +1906,7 @@ k8s.io/apiextensions-apiserver v0.18.8/go.mod h1:7f4ySEkkvifIr4+BRrRWriKKIJjPyg9
 k8s.io/apimachinery v0.16.8/go.mod h1:Xk2vD2TRRpuWYLQNM6lT9R7DSFZUYG03SarNkbGrnKE=
 k8s.io/apimachinery v0.18.8 h1:jimPrycCqgx2QPearX3to1JePz7wSbVLq+7PdBTTwQ0=
 k8s.io/apimachinery v0.18.8/go.mod h1:6sQd+iHEqmOtALqOFjSWp2KZ9F0wlU/nWm0ZgsYWMig=
+k8s.io/apimachinery v0.19.4 h1:+ZoddM7nbzrDCp0T3SWnyxqf8cbWPT2fkZImoyvHUG0=
 k8s.io/apiserver v0.18.8/go.mod h1:12u5FuGql8Cc497ORNj79rhPdiXQC4bf53X/skR/1YM=
 k8s.io/cli-runtime v0.18.8 h1:ycmbN3hs7CfkJIYxJAOB10iW7BVPmXGXkfEyiV9NJ+k=
 k8s.io/cli-runtime v0.18.8/go.mod h1:7EzWiDbS9PFd0hamHHVoCY4GrokSTPSL32MA4rzIu0M=

+ 2 - 0
internal/config/config.go

@@ -26,6 +26,8 @@ type ServerConf struct {
 	TimeoutWrite   time.Duration `env:"SERVER_TIMEOUT_WRITE,default=10s"`
 	TimeoutIdle    time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
 
+	IsLocal bool `env:"IS_LOCAL,default=false"`
+
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 }

+ 49 - 0
internal/forms/action.go

@@ -2,6 +2,7 @@ package forms
 
 import (
 	"encoding/base64"
+	"net/url"
 	"strings"
 
 	"github.com/porter-dev/porter/internal/kubernetes"
@@ -91,6 +92,13 @@ func (sar *ServiceAccountActionResolver) PopulateServiceAccount(
 		}
 	}
 
+	// if auth mechanism is local, just write the kubeconfig and return: rest of config is
+	// unnecessary
+	if sar.SACandidate.AuthMechanism == models.Local && len(sar.SACandidate.Kubeconfig) > 0 {
+		sar.SA.Kubeconfig = sar.SACandidate.Kubeconfig
+		return nil
+	}
+
 	if len(authInfo.ClientCertificateData) > 0 {
 		sar.SA.ClientCertificateData = authInfo.ClientCertificateData
 	}
@@ -179,6 +187,47 @@ func (cda *ClusterCADataAction) PopulateServiceAccount(
 	return nil
 }
 
+// ClusterLocalhostAction contains the non-localhost server
+type ClusterLocalhostAction struct {
+	*ServiceAccountActionResolver
+	ClusterHostname string `json:"cluster_hostname" form:"required"`
+}
+
+// PopulateServiceAccount will add cluster ca data to a cluster in the ServiceAccount's
+// list of clusters
+func (cla *ClusterLocalhostAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := cla.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	saCandidate := cla.ServiceAccountActionResolver.SACandidate
+
+	for i, cluster := range cla.ServiceAccountActionResolver.SA.Clusters {
+		if cluster.Name == saCandidate.ClusterName && cluster.Server == saCandidate.ClusterEndpoint {
+			serverURL, err := url.Parse(cluster.Server)
+
+			if err != nil {
+				continue
+			}
+
+			if serverURL.Port() == "" {
+				serverURL.Host = cla.ClusterHostname
+			} else {
+				serverURL.Host = cla.ClusterHostname + ":" + serverURL.Port()
+			}
+
+			(&cluster).Server = serverURL.String()
+			cla.ServiceAccountActionResolver.SA.Clusters[i] = cluster
+		}
+	}
+
+	return nil
+}
+
 // ClientCertDataAction contains the base64 encoded cluster cert data
 type ClientCertDataAction struct {
 	*ServiceAccountActionResolver

+ 93 - 8
internal/forms/action_test.go

@@ -20,7 +20,7 @@ func TestPopulateServiceAccountBasic(t *testing.T) {
 	})
 
 	// create a ServiceAccountCandidate from a kubeconfig
-	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClusterCAWithData))
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClusterCAWithData), false)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -82,7 +82,7 @@ func TestPopulateServiceAccountClusterDataAction(t *testing.T) {
 	})
 
 	// create a ServiceAccountCandidate from a kubeconfig
-	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClusterCAWithoutData))
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClusterCAWithoutData), false)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -137,6 +137,71 @@ func TestPopulateServiceAccountClusterDataAction(t *testing.T) {
 	}
 }
 
+func TestPopulateServiceAccountClusterLocalhostAction(t *testing.T) {
+	// create the in-memory repository
+	repo := test.NewRepository(true)
+
+	// create a new project
+	repo.Project.CreateProject(&models.Project{
+		Name: "test-project",
+	})
+
+	// create a ServiceAccountCandidate from a kubeconfig
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClusterLocalhost), false)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.ClusterLocalhostAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		ClusterHostname: "host.docker.internal",
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(form.ServiceAccountActionResolver.SA)
+	decodedStr, _ := base64.StdEncoding.DecodeString("LS0tLS1CRUdJTiBDRVJ=")
+
+	if len(sa.Clusters) != 1 {
+		t.Fatalf("cluster not written\n")
+	}
+
+	if sa.Clusters[0].ServiceAccountID != 1 {
+		t.Errorf("service account ID of joined cluster is not 1")
+	}
+
+	if sa.Clusters[0].Server != "https://host.docker.internal:30000" {
+		t.Errorf("service account cluster server is incorrect: expected %s, got %s\n",
+			"https://host.docker.internal:30000", sa.Clusters[0].Server)
+	}
+
+	if sa.AuthMechanism != "x509" {
+		t.Errorf("service account auth mechanism is not x509")
+	}
+
+	if string(sa.ClientCertificateData) != string(decodedStr) {
+		t.Errorf("service account cert data and input do not match: expected %s, got %s\n",
+			string(sa.ClientCertificateData), string(decodedStr))
+	}
+
+	if string(sa.ClientKeyData) != string(decodedStr) {
+		t.Errorf("service account key data and input do not match: expected %s, got %s\n",
+			string(sa.ClientKeyData), string(decodedStr))
+	}
+}
+
 func TestPopulateServiceAccountClientCertAction(t *testing.T) {
 	// create the in-memory repository
 	repo := test.NewRepository(true)
@@ -147,7 +212,7 @@ func TestPopulateServiceAccountClientCertAction(t *testing.T) {
 	})
 
 	// create a ServiceAccountCandidate from a kubeconfig
-	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClientWithoutCertData))
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClientWithoutCertData), false)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -212,7 +277,7 @@ func TestPopulateServiceAccountClientCertAndKeyActions(t *testing.T) {
 	})
 
 	// create a ServiceAccountCandidate from a kubeconfig
-	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClientWithoutCertAndKeyData))
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClientWithoutCertAndKeyData), false)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -289,7 +354,7 @@ func TestPopulateServiceAccountTokenDataAction(t *testing.T) {
 	})
 
 	// create a ServiceAccountCandidate from a kubeconfig
-	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(BearerTokenWithoutData))
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(BearerTokenWithoutData), false)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -344,7 +409,7 @@ func TestPopulateServiceAccountGCPKeyDataAction(t *testing.T) {
 	})
 
 	// create a ServiceAccountCandidate from a kubeconfig
-	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(GCPPlugin))
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(GCPPlugin), false)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -398,7 +463,7 @@ func TestPopulateServiceAccountAWSKeyDataAction(t *testing.T) {
 	})
 
 	// create a ServiceAccountCandidate from a kubeconfig
-	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(AWSEKSGetTokenExec))
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(AWSEKSGetTokenExec), false)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -464,7 +529,7 @@ func TestPopulateServiceAccountOIDCAction(t *testing.T) {
 	})
 
 	// create a ServiceAccountCandidate from a kubeconfig
-	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(OIDCAuthWithoutData))
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(OIDCAuthWithoutData), false)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -550,6 +615,26 @@ users:
 current-context: context-test
 `
 
+const ClusterLocalhost string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost:30000
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate-data: LS0tLS1CRUdJTiBDRVJ=
+    client-key-data: LS0tLS1CRUdJTiBDRVJ=
+current-context: context-test
+`
+
 const ClientWithoutCertData string = `
 apiVersion: v1
 kind: Config

+ 10 - 2
internal/forms/candidate.go

@@ -10,12 +10,20 @@ import (
 type CreateServiceAccountCandidatesForm struct {
 	ProjectID  uint   `json:"project_id"`
 	Kubeconfig string `json:"kubeconfig"`
+
+	// Represents whether the auth mechanism should be designated as
+	// "local": if so, the auth mechanism uses local plugins/mechanisms purely from the
+	// kubeconfig.
+	IsLocal bool `json:"is_local"`
 }
 
 // ToServiceAccountCandidates creates a ServiceAccountCandidate from the kubeconfig and
 // project id
-func (csa *CreateServiceAccountCandidatesForm) ToServiceAccountCandidates() ([]*models.ServiceAccountCandidate, error) {
-	candidates, err := kubernetes.GetServiceAccountCandidates([]byte(csa.Kubeconfig))
+func (csa *CreateServiceAccountCandidatesForm) ToServiceAccountCandidates(
+	isServerLocal bool,
+) ([]*models.ServiceAccountCandidate, error) {
+	// can only use "local" auth mechanism if the server is running locally
+	candidates, err := kubernetes.GetServiceAccountCandidates([]byte(csa.Kubeconfig), isServerLocal && csa.IsLocal)
 
 	if err != nil {
 		return nil, err

+ 13 - 0
internal/forms/release.go

@@ -123,3 +123,16 @@ type UpgradeReleaseForm struct {
 	Name   string `json:"name" form:"required"`
 	Values string `json:"values" form:"required"`
 }
+
+// ChartTemplateForm represents the accepted values for installing a new chart from a template.
+type ChartTemplateForm struct {
+	TemplateName string                 `json:"templateName" form:"required"`
+	ImageURL     string                 `json:"imageURL" form:"required"`
+	FormValues   map[string]interface{} `json:"formValues"`
+}
+
+// InstallChartTemplateForm represents the accepted values for installing a new chart from a template.
+type InstallChartTemplateForm struct {
+	*ReleaseForm
+	*ChartTemplateForm
+}

+ 56 - 0
internal/helm/agent.go

@@ -3,7 +3,10 @@ package helm
 import (
 	"fmt"
 
+	"github.com/pkg/errors"
 	"helm.sh/helm/v3/pkg/action"
+	"helm.sh/helm/v3/pkg/chart"
+	"helm.sh/helm/v3/pkg/chart/loader"
 	"helm.sh/helm/v3/pkg/release"
 	"k8s.io/helm/pkg/chartutil"
 )
@@ -77,6 +80,47 @@ func (a *Agent) UpgradeRelease(
 	return res, nil
 }
 
+// InstallChart installs a new chart by URL, absolute or relative filepaths.
+// Equivalent to `helm install [CHART_NAME] [cp]` where cp is one of the following:
+//  1) Absolute URL: https://example.com/charts/nginx-1.2.3.tgz
+//  2) path to packaged chart ./nginx-1.2.3.tgz
+//  3) path to unpacked chart ./nginx
+func (a *Agent) InstallChart(
+	cp string,
+	values []byte,
+) (*release.Release, error) {
+	cmd := action.NewInstall(a.ActionConfig)
+	valuesYaml, err := chartutil.ReadValues(values)
+
+	if err != nil {
+		return nil, fmt.Errorf("Values could not be parsed: %v", err)
+	}
+
+	// Only supports filepaths for now, URL option WIP.
+	// Check chart dependencies to make sure all are present in /charts
+	chartRequested, err := loader.Load(cp)
+	if err != nil {
+		return nil, err
+	}
+
+	if err := checkIfInstallable(chartRequested); err != nil {
+		return nil, err
+	}
+
+	if chartRequested.Metadata.Deprecated {
+		return nil, fmt.Errorf("This chart is deprecated")
+	}
+
+	if req := chartRequested.Metadata.Dependencies; req != nil {
+		if err := action.CheckDependencies(chartRequested, req); err != nil {
+			// TODO: Handle dependency updates.
+			return nil, err
+		}
+	}
+
+	return cmd.Run(chartRequested, valuesYaml)
+}
+
 // RollbackRelease rolls a release back to a specified revision/version
 func (a *Agent) RollbackRelease(
 	name string,
@@ -86,3 +130,15 @@ func (a *Agent) RollbackRelease(
 	cmd.Version = version
 	return cmd.Run(name)
 }
+
+// ------------------------ Helm agent helper functions ------------------------ //
+
+// checkIfInstallable validates if a chart can be installed
+// Application chart type is only installable
+func checkIfInstallable(ch *chart.Chart) error {
+	switch ch.Metadata.Type {
+	case "", "application":
+		return nil
+	}
+	return errors.Errorf("%s charts are not installable", ch.Metadata.Type)
+}

+ 41 - 0
internal/helm/grapher/object.go

@@ -51,3 +51,44 @@ func ParseObjs(objs []map[string]interface{}) []Object {
 	}
 	return objArr
 }
+
+// ParseControllers parses a k8s object from a single-document yaml
+// and returns an array of controllers.
+func ParseControllers(objs []map[string]interface{}) []Object {
+	objArr := []Object{}
+
+	for i, obj := range objs {
+		kind := getField(obj, "kind")
+
+		// ignore block comments
+		if kind == nil {
+			continue
+		}
+
+		switch kind.(string) {
+		// Parse for all possible controller types
+		case "Deployment", "StatefulSet", "ReplicaSet", "DaemonSet", "Job":
+			name := getField(obj, "metadata", "name")
+			namespace := getField(obj, "metadata", "namespace")
+
+			if namespace == nil {
+				namespace = "default"
+			}
+
+			if name == nil {
+				name = ""
+			}
+
+			// First add the object that appears on the YAML
+			parsedObj := Object{
+				ID:        i,
+				Kind:      kind.(string),
+				Name:      name.(string),
+				Namespace: namespace.(string),
+			}
+			objArr = append(objArr, parsedObj)
+		}
+
+	}
+	return objArr
+}

+ 151 - 8
internal/kubernetes/agent.go

@@ -5,12 +5,17 @@ import (
 	"context"
 	"fmt"
 	"io"
+	"strings"
 
 	"github.com/gorilla/websocket"
+	"github.com/porter-dev/porter/internal/helm/grapher"
+	appsv1 "k8s.io/api/apps/v1"
 	v1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
+	"k8s.io/client-go/informers"
 	"k8s.io/client-go/kubernetes"
+	"k8s.io/client-go/tools/cache"
 )
 
 // Agent is a Kubernetes agent for performing operations that interact with the
@@ -20,6 +25,16 @@ type Agent struct {
 	Clientset        kubernetes.Interface
 }
 
+type Message struct {
+	EventType string
+	Object    interface{}
+	Kind      string
+}
+
+type ListOptions struct {
+	FieldSelector string
+}
+
 // ListNamespaces simply lists namespaces
 func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	return a.Clientset.CoreV1().Namespaces().List(
@@ -28,6 +43,42 @@ func (a *Agent) ListNamespaces() (*v1.NamespaceList, error) {
 	)
 }
 
+// GetDeployment gets the depployment given the name and namespace
+func (a *Agent) GetDeployment(c grapher.Object) (*appsv1.Deployment, error) {
+	return a.Clientset.AppsV1().Deployments(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetStatefulSet gets the statefulset given the name and namespace
+func (a *Agent) GetStatefulSet(c grapher.Object) (*appsv1.StatefulSet, error) {
+	return a.Clientset.AppsV1().StatefulSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetReplicaSet gets the replicaset given the name and namespace
+func (a *Agent) GetReplicaSet(c grapher.Object) (*appsv1.ReplicaSet, error) {
+	return a.Clientset.AppsV1().ReplicaSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
+// GetDaemonSet gets the daemonset by name and namespace
+func (a *Agent) GetDaemonSet(c grapher.Object) (*appsv1.DaemonSet, error) {
+	return a.Clientset.AppsV1().DaemonSets(c.Namespace).Get(
+		context.TODO(),
+		c.Name,
+		metav1.GetOptions{},
+	)
+}
+
 // GetPodsByLabel retrieves pods with matching labels
 func (a *Agent) GetPodsByLabel(selector string) (*v1.PodList, error) {
 	// Search in all namespaces for matching pods
@@ -55,18 +106,110 @@ func (a *Agent) GetPodLogs(namespace string, name string, conn *websocket.Conn)
 	defer podLogs.Close()
 
 	r := bufio.NewReader(podLogs)
-	for {
-		bytes, err := r.ReadBytes('\n')
+	errorchan := make(chan error)
 
-		if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
-			return writeErr
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			if _, _, err := conn.ReadMessage(); err != nil {
+				conn.Close()
+				errorchan <- nil
+				fmt.Println("Successfully closed log stream")
+				return
+			}
 		}
+	}()
 
-		if err != nil {
-			if err != io.EOF {
-				return err
+	go func() {
+		for {
+			select {
+			case <-errorchan:
+				defer close(errorchan)
+				return
+			default:
+			}
+			bytes, err := r.ReadBytes('\n')
+			if writeErr := conn.WriteMessage(websocket.TextMessage, bytes); writeErr != nil {
+				errorchan <- writeErr
+				return
 			}
-			return nil
+			if err != nil {
+				if err != io.EOF {
+					errorchan <- err
+					return
+				}
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	for {
+		select {
+		case err = <-errorchan:
+			return err
+		}
+	}
+}
+
+// StreamControllerStatus streams controller status. Supports Deployment, StatefulSet, ReplicaSet, and DaemonSet
+// TODO: Support Jobs
+func (a *Agent) StreamControllerStatus(conn *websocket.Conn, kind string) error {
+	factory := informers.NewSharedInformerFactory(
+		a.Clientset,
+		10,
+	)
+	var informer cache.SharedInformer
+
+	// Spins up an informer depending on kind. Convert to lowercase for robustness
+	switch strings.ToLower(kind) {
+	case "deployment":
+		informer = factory.Apps().V1().Deployments().Informer()
+	case "statefulset":
+		informer = factory.Apps().V1().StatefulSets().Informer()
+	case "replicaset":
+		informer = factory.Apps().V1().ReplicaSets().Informer()
+	case "daemonset":
+		informer = factory.Apps().V1().DaemonSets().Informer()
+	}
+
+	stopper := make(chan struct{})
+	errorchan := make(chan error)
+	defer close(errorchan)
+
+	informer.AddEventHandler(cache.ResourceEventHandlerFuncs{
+		UpdateFunc: func(oldObj, newObj interface{}) {
+			msg := Message{
+				EventType: "UPDATE",
+				Object:    newObj,
+				Kind:      strings.ToLower(kind),
+			}
+			if writeErr := conn.WriteJSON(msg); writeErr != nil {
+				errorchan <- writeErr
+				return
+			}
+		},
+	})
+
+	go func() {
+		// listens for websocket closing handshake
+		for {
+			if _, _, err := conn.ReadMessage(); err != nil {
+				defer conn.Close()
+				defer close(stopper)
+				defer fmt.Println("Successfully closed controller status stream")
+				errorchan <- nil
+				return
+			}
+		}
+	}()
+
+	go informer.Run(stopper)
+
+	for {
+		select {
+		case err := <-errorchan:
+			return err
 		}
 	}
 }

+ 1 - 1
internal/kubernetes/config.go

@@ -20,7 +20,7 @@ import (
 	"k8s.io/client-go/util/homedir"
 
 	// add oidc provider here
-	_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
+	_ "k8s.io/client-go/plugin/pkg/client/auth"
 )
 
 // GetAgentOutOfClusterConfig creates a new Agent using the OutOfClusterConfig

+ 42 - 18
internal/kubernetes/kubeconfig.go

@@ -3,6 +3,7 @@ package kubernetes
 import (
 	"context"
 	"errors"
+	"net/url"
 	"strings"
 
 	"github.com/porter-dev/porter/internal/models"
@@ -19,7 +20,11 @@ import (
 
 // GetServiceAccountCandidates parses a kubeconfig for a list of service account
 // candidates.
-func GetServiceAccountCandidates(kubeconfig []byte) ([]*models.ServiceAccountCandidate, error) {
+//
+// The local boolean represents whether the auth mechanism should be designated as
+// "local": if so, the auth mechanism uses local plugins/mechanisms purely from the
+// kubeconfig.
+func GetServiceAccountCandidates(kubeconfig []byte, local bool) ([]*models.ServiceAccountCandidate, error) {
 	config, err := clientcmd.NewClientConfigFromBytes(kubeconfig)
 
 	if err != nil {
@@ -39,19 +44,25 @@ func GetServiceAccountCandidates(kubeconfig []byte) ([]*models.ServiceAccountCan
 		awsClusterID := ""
 		authInfoName := context.AuthInfo
 
-		// get the auth mechanism and actions
-		authMechanism, authInfoActions := parseAuthInfoForActions(rawConf.AuthInfos[authInfoName])
-		clusterActions := parseClusterForActions(rawConf.Clusters[clusterName])
-
-		actions := append(authInfoActions, clusterActions...)
+		actions := make([]models.ServiceAccountAction, 0)
+		var authMechanism string
 
-		// if auth mechanism is unsupported, we'll skip it
-		if authMechanism == models.NotAvailable {
-			continue
-		} else if authMechanism == models.AWS {
-			// if the auth mechanism is AWS, we need to parse more explicitly
-			// for the cluster id
-			awsClusterID = parseAuthInfoForAWSClusterID(rawConf.AuthInfos[authInfoName], clusterName)
+		if local {
+			authMechanism = models.Local
+		} else {
+			// get the auth mechanism and actions
+			authMechanism, actions = parseAuthInfoForActions(rawConf.AuthInfos[authInfoName])
+			clusterActions := parseClusterForActions(rawConf.Clusters[clusterName])
+			actions = append(actions, clusterActions...)
+
+			// if auth mechanism is unsupported, we'll skip it
+			if authMechanism == models.NotAvailable {
+				continue
+			} else if authMechanism == models.AWS {
+				// if the auth mechanism is AWS, we need to parse more explicitly
+				// for the cluster id
+				awsClusterID = parseAuthInfoForAWSClusterID(rawConf.AuthInfos[authInfoName], clusterName)
+			}
 		}
 
 		// construct the raw kubeconfig that's relevant for that context
@@ -196,12 +207,21 @@ func parseClusterForActions(cluster *api.Cluster) (actions []models.ServiceAccou
 	actions = make([]models.ServiceAccountAction, 0)
 
 	if cluster.CertificateAuthority != "" && len(cluster.CertificateAuthorityData) == 0 {
-		return []models.ServiceAccountAction{
-			models.ServiceAccountAction{
-				Name:     models.ClusterCADataAction,
+		actions = append(actions, models.ServiceAccountAction{
+			Name:     models.ClusterCADataAction,
+			Resolved: false,
+			Filename: cluster.CertificateAuthority,
+		})
+	}
+
+	serverURL, err := url.Parse(cluster.Server)
+
+	if err == nil {
+		if hostname := serverURL.Hostname(); hostname == "127.0.0.1" || hostname == "localhost" {
+			actions = append(actions, models.ServiceAccountAction{
+				Name:     models.ClusterLocalhostAction,
 				Resolved: false,
-				Filename: cluster.CertificateAuthority,
-			},
+			})
 		}
 	}
 
@@ -272,6 +292,10 @@ func GetClientConfigFromServiceAccount(
 	clusterID uint,
 	updateTokenCache UpdateTokenCacheFunc,
 ) (clientcmd.ClientConfig, error) {
+	if sa.AuthMechanism == models.Local {
+		return clientcmd.NewClientConfigFromBytes(sa.Kubeconfig)
+	}
+
 	apiConfig, err := createRawConfigFromServiceAccount(sa, clusterID, updateTokenCache)
 
 	if err != nil {

+ 79 - 40
internal/kubernetes/kubeconfig_test.go

@@ -79,7 +79,7 @@ var BasicContextAllowedTests = []kubeConfigTest{
 		expected: []models.Context{
 			models.Context{
 				Name:     "context-test",
-				Server:   "https://localhost",
+				Server:   "https://10.10.10.10",
 				Cluster:  "cluster-test",
 				User:     "test-admin",
 				Selected: true,
@@ -112,7 +112,7 @@ var BasicContextAllTests = []kubeConfigTest{
 		expected: []models.Context{
 			models.Context{
 				Name:     "context-test",
-				Server:   "https://localhost",
+				Server:   "https://10.10.10.10",
 				Cluster:  "cluster-test",
 				User:     "test-admin",
 				Selected: false,
@@ -153,7 +153,7 @@ func TestGetRestrictedClientConfig(t *testing.T) {
 		t.Fatalf("Fatal error: %s\n", err.Error())
 	}
 
-	if cluster, clusterFound := rawConf.Clusters["cluster-test"]; !clusterFound || cluster.Server != "https://localhost" {
+	if cluster, clusterFound := rawConf.Clusters["cluster-test"]; !clusterFound || cluster.Server != "https://10.10.10.10" {
 		t.Errorf("invalid cluster returned")
 	}
 
@@ -187,12 +187,31 @@ var SACandidatesTests = []saCandidatesTest{
 				},
 				Kind:            "connector",
 				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
+				ClusterEndpoint: "https://10.10.10.10",
 				AuthMechanism:   models.X509,
 				Kubeconfig:      []byte(ClusterCAWithoutData),
 			},
 		},
 	},
+	saCandidatesTest{
+		name: "test cluster localhost",
+		raw:  []byte(ClusterLocalhost),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "fix-cluster-localhost",
+						Resolved: false,
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.X509,
+				Kubeconfig:      []byte(ClusterLocalhost),
+			},
+		},
+	},
 	saCandidatesTest{
 		name: "x509 test with cert and key data",
 		raw:  []byte(x509WithData),
@@ -201,7 +220,7 @@ var SACandidatesTests = []saCandidatesTest{
 				Actions:         []models.ServiceAccountAction{},
 				Kind:            "connector",
 				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
+				ClusterEndpoint: "https://10.10.10.10",
 				AuthMechanism:   models.X509,
 				Kubeconfig:      []byte(x509WithData),
 			},
@@ -221,7 +240,7 @@ var SACandidatesTests = []saCandidatesTest{
 				},
 				Kind:            "connector",
 				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
+				ClusterEndpoint: "https://10.10.10.10",
 				AuthMechanism:   models.X509,
 				Kubeconfig:      []byte(x509WithoutCertData),
 			},
@@ -241,7 +260,7 @@ var SACandidatesTests = []saCandidatesTest{
 				},
 				Kind:            "connector",
 				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
+				ClusterEndpoint: "https://10.10.10.10",
 				AuthMechanism:   models.X509,
 				Kubeconfig:      []byte(x509WithoutKeyData),
 			},
@@ -266,7 +285,7 @@ var SACandidatesTests = []saCandidatesTest{
 				},
 				Kind:            "connector",
 				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
+				ClusterEndpoint: "https://10.10.10.10",
 				AuthMechanism:   models.X509,
 				Kubeconfig:      []byte(x509WithoutCertAndKeyData),
 			},
@@ -280,7 +299,7 @@ var SACandidatesTests = []saCandidatesTest{
 				Actions:         []models.ServiceAccountAction{},
 				Kind:            "connector",
 				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
+				ClusterEndpoint: "https://10.10.10.10",
 				AuthMechanism:   models.Bearer,
 				Kubeconfig:      []byte(BearerTokenWithData),
 			},
@@ -300,7 +319,7 @@ var SACandidatesTests = []saCandidatesTest{
 				},
 				Kind:            "connector",
 				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
+				ClusterEndpoint: "https://10.10.10.10",
 				AuthMechanism:   models.Bearer,
 				Kubeconfig:      []byte(BearerTokenWithoutData),
 			},
@@ -319,7 +338,7 @@ var SACandidatesTests = []saCandidatesTest{
 				},
 				Kind:            "connector",
 				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
+				ClusterEndpoint: "https://10.10.10.10",
 				AuthMechanism:   models.GCP,
 				Kubeconfig:      []byte(GCPPlugin),
 			},
@@ -338,7 +357,7 @@ var SACandidatesTests = []saCandidatesTest{
 				},
 				Kind:            "connector",
 				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
+				ClusterEndpoint: "https://10.10.10.10",
 				AuthMechanism:   models.AWS,
 				Kubeconfig:      []byte(AWSIamAuthenticatorExec),
 			},
@@ -357,7 +376,7 @@ var SACandidatesTests = []saCandidatesTest{
 				},
 				Kind:            "connector",
 				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
+				ClusterEndpoint: "https://10.10.10.10",
 				AuthMechanism:   models.AWS,
 				Kubeconfig:      []byte(AWSEKSGetTokenExec),
 			},
@@ -377,7 +396,7 @@ var SACandidatesTests = []saCandidatesTest{
 				},
 				Kind:            "connector",
 				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
+				ClusterEndpoint: "https://10.10.10.10",
 				AuthMechanism:   models.OIDC,
 				Kubeconfig:      []byte(OIDCAuthWithoutData),
 			},
@@ -391,7 +410,7 @@ var SACandidatesTests = []saCandidatesTest{
 				Actions:         []models.ServiceAccountAction{},
 				Kind:            "connector",
 				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
+				ClusterEndpoint: "https://10.10.10.10",
 				AuthMechanism:   models.OIDC,
 				Kubeconfig:      []byte(OIDCAuthWithData),
 			},
@@ -405,7 +424,7 @@ var SACandidatesTests = []saCandidatesTest{
 				Actions:         []models.ServiceAccountAction{},
 				Kind:            "connector",
 				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
+				ClusterEndpoint: "https://10.10.10.10",
 				AuthMechanism:   models.Basic,
 				Kubeconfig:      []byte(BasicAuth),
 			},
@@ -413,9 +432,9 @@ var SACandidatesTests = []saCandidatesTest{
 	},
 }
 
-func TestGetServiceAccountCandidates(t *testing.T) {
+func TestGetServiceAccountCandidatesNonLocal(t *testing.T) {
 	for _, c := range SACandidatesTests {
-		result, err := kubernetes.GetServiceAccountCandidates(c.raw)
+		result, err := kubernetes.GetServiceAccountCandidates(c.raw, false)
 
 		if err != nil {
 			t.Fatalf("error occurred %v\n", err)
@@ -492,7 +511,7 @@ func TestGetServiceAccountCandidates(t *testing.T) {
 }
 
 func TestAWSClusterIDGuess(t *testing.T) {
-	result, err := kubernetes.GetServiceAccountCandidates([]byte(AWSIamAuthenticatorExec))
+	result, err := kubernetes.GetServiceAccountCandidates([]byte(AWSIamAuthenticatorExec), false)
 
 	if err != nil {
 		t.Fatalf("error occurred %v\n", err)
@@ -506,7 +525,7 @@ func TestAWSClusterIDGuess(t *testing.T) {
 		t.Errorf("Guess AWS cluster id failed: expected %s, got %s\n", "cluster-test-aws-id-guess", result[0].AWSClusterIDGuess)
 	}
 
-	result, err = kubernetes.GetServiceAccountCandidates([]byte(AWSEKSGetTokenExec))
+	result, err = kubernetes.GetServiceAccountCandidates([]byte(AWSEKSGetTokenExec), false)
 
 	if err != nil {
 		t.Fatalf("error occurred %v\n", err)
@@ -527,7 +546,7 @@ kind: Config
 preferences: {}
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
   name: porter-test-1
 current-context: context-test
 users:
@@ -557,7 +576,7 @@ preferences: {}
 current-context: default
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
   name: porter-test-1
 contexts:
 - context:
@@ -573,7 +592,7 @@ preferences: {}
 current-context: default
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
   name: porter-test-1
 contexts:
 - context:
@@ -592,7 +611,7 @@ preferences: {}
 current-context: default
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
   name: porter-test-1
 contexts:
 - context:
@@ -611,7 +630,7 @@ preferences: {}
 current-context: context-test
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
   name: cluster-test
 contexts:
 - context:
@@ -628,7 +647,7 @@ kind: Config
 clusters:
 - name: cluster-test
   cluster:
-    server: https://localhost
+    server: https://10.10.10.10
     certificate-authority: /fake/path/to/ca.pem
 contexts:
 - context:
@@ -643,6 +662,26 @@ users:
 current-context: context-test
 `
 
+const ClusterLocalhost string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate-data: LS0tLS1CRUdJTiBDRVJ=
+    client-key-data: LS0tLS1CRUdJTiBDRVJ=
+current-context: context-test
+`
+
 const x509WithData string = `
 apiVersion: v1
 kind: Config
@@ -650,7 +689,7 @@ preferences: {}
 current-context: context-test
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
   name: cluster-test
 contexts:
 - context:
@@ -671,7 +710,7 @@ preferences: {}
 current-context: context-test
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
   name: cluster-test
 contexts:
 - context:
@@ -692,7 +731,7 @@ preferences: {}
 current-context: context-test
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
   name: cluster-test
 contexts:
 - context:
@@ -713,7 +752,7 @@ preferences: {}
 current-context: context-test
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
   name: cluster-test
 contexts:
 - context:
@@ -734,7 +773,7 @@ preferences: {}
 current-context: context-test
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
   name: cluster-test
 contexts:
 - context:
@@ -754,7 +793,7 @@ preferences: {}
 current-context: context-test
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
   name: cluster-test
 contexts:
 - context:
@@ -772,7 +811,7 @@ kind: Config
 clusters:
 - name: cluster-test
   cluster:
-    server: https://localhost
+    server: https://10.10.10.10
     certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
 users:
 - name: test-admin
@@ -791,7 +830,7 @@ const AWSIamAuthenticatorExec = `
 apiVersion: v1
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
     certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
   name: cluster-test
 contexts:
@@ -818,7 +857,7 @@ const AWSEKSGetTokenExec = `
 apiVersion: v1
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
     certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
   name: cluster-test
 contexts:
@@ -846,7 +885,7 @@ const OIDCAuthWithoutData = `
 apiVersion: v1
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
     certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
   name: cluster-test
 contexts:
@@ -864,7 +903,7 @@ users:
       config:
         client-id: porter-api
         id-token: token
-        idp-issuer-url: https://localhost
+        idp-issuer-url: https://10.10.10.10
         idp-certificate-authority: /fake/path/to/ca.pem
       name: oidc
 `
@@ -873,7 +912,7 @@ const OIDCAuthWithData = `
 apiVersion: v1
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
     certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
   name: cluster-test
 contexts:
@@ -891,7 +930,7 @@ users:
       config:
         client-id: porter-api
         id-token: token
-        idp-issuer-url: https://localhost
+        idp-issuer-url: https://10.10.10.10
         idp-certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
       name: oidc
 `
@@ -900,7 +939,7 @@ const BasicAuth = `
 apiVersion: v1
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
     certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
   name: cluster-test
 contexts:

+ 14 - 7
internal/models/action.go

@@ -4,13 +4,14 @@ import "gorm.io/gorm"
 
 // Action names
 const (
-	ClusterCADataAction  string = "upload-cluster-ca-data"
-	ClientCertDataAction        = "upload-client-cert-data"
-	ClientKeyDataAction         = "upload-client-key-data"
-	OIDCIssuerDataAction        = "upload-oidc-idp-issuer-ca-data"
-	TokenDataAction             = "upload-token-data"
-	GCPKeyDataAction            = "upload-gcp-key-data"
-	AWSDataAction               = "upload-aws-data"
+	ClusterCADataAction    string = "upload-cluster-ca-data"
+	ClusterLocalhostAction        = "fix-cluster-localhost"
+	ClientCertDataAction          = "upload-client-cert-data"
+	ClientKeyDataAction           = "upload-client-key-data"
+	OIDCIssuerDataAction          = "upload-oidc-idp-issuer-ca-data"
+	TokenDataAction               = "upload-token-data"
+	GCPKeyDataAction              = "upload-gcp-key-data"
+	AWSDataAction                 = "upload-aws-data"
 )
 
 // ServiceAccountAction is an action that must be resolved to set up
@@ -59,6 +60,7 @@ type ServiceAccountAllActions struct {
 	Name string `json:"name"`
 
 	ClusterCAData      string `json:"cluster_ca_data,omitempty"`
+	ClusterHostname    string `json:"cluster_hostname,omitempty"`
 	ClientCertData     string `json:"client_cert_data,omitempty"`
 	ClientKeyData      string `json:"client_key_data,omitempty"`
 	OIDCIssuerCAData   string `json:"oidc_idp_issuer_ca_data,omitempty"`
@@ -87,6 +89,11 @@ var ServiceAccountActionInfos = map[string]ServiceAccountActionInfo{
 		Docs:   "https://github.com/porter-dev/porter",
 		Fields: "cluster_ca_data",
 	},
+	"fix-cluster-localhost": ServiceAccountActionInfo{
+		Name:   ClusterLocalhostAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "cluster_hostname",
+	},
 	"upload-client-cert-data": ServiceAccountActionInfo{
 		Name:   ClientCertDataAction,
 		Docs:   "https://github.com/porter-dev/porter",

+ 4 - 0
internal/models/serviceaccount.go

@@ -12,6 +12,7 @@ const (
 	OIDC                = "oidc"
 	GCP                 = "gcp-sa"
 	AWS                 = "aws-sa"
+	Local               = "local"
 	NotAvailable        = "n/a"
 )
 
@@ -139,6 +140,9 @@ type ServiceAccount struct {
 	OIDCCertificateAuthorityData []byte `json:"idp-certificate-authority-data"`
 	OIDCIDToken                  []byte `json:"id-token"`
 	OIDCRefreshToken             []byte `json:"refresh-token"`
+
+	// The raw kubeconfig, used by local auth mechanisms
+	Kubeconfig []byte `json:"kubeconfig"`
 }
 
 // ServiceAccountExternal is an external ServiceAccount to be shared over REST

+ 21 - 0
node_modules/@types/js-base64/LICENSE

@@ -0,0 +1,21 @@
+    MIT License
+
+    Copyright (c) Microsoft Corporation.
+
+    Permission is hereby granted, free of charge, to any person obtaining a copy
+    of this software and associated documentation files (the "Software"), to deal
+    in the Software without restriction, including without limitation the rights
+    to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+    copies of the Software, and to permit persons to whom the Software is
+    furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice shall be included in all
+    copies or substantial portions of the Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+    OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+    SOFTWARE

+ 16 - 0
node_modules/@types/js-base64/README.md

@@ -0,0 +1,16 @@
+# Installation
+> `npm install --save @types/js-base64`
+
+# Summary
+This package contains type definitions for js-base64 (https://github.com/dankogai/js-base64).
+
+# Details
+Files were exported from https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/js-base64.
+
+### Additional Details
+ * Last updated: Sat, 18 Jul 2020 15:47:12 GMT
+ * Dependencies: none
+ * Global values: `Base64`
+
+# Credits
+These definitions were written by [Denis Carriere](https://github.com/DenisCarriere), [Tommy Lent](https://github.com/tlent), and [JounQin](https://github.com/JounQin).

+ 82 - 0
node_modules/@types/js-base64/index.d.ts

@@ -0,0 +1,82 @@
+// Type definitions for js-base64 3.0
+// Project: https://github.com/dankogai/js-base64
+// Definitions by: Denis Carriere <https://github.com/DenisCarriere>
+//                 Tommy Lent <https://github.com/tlent>
+//                 JounQin <https://github.com/JounQin>
+// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
+
+export interface Base64 {
+    VERSION: string;
+    encode(s: string, uriSafe?: boolean): string;
+    encodeURI(s: string): string;
+    encodeURL: Base64['encodeURI'];
+    decode(base64: string): string;
+    atob(base64: string): string;
+    btoa(s: string): string;
+    fromBase64(base64: string): string;
+    toBase64(s: string, uriSafe?: boolean): string;
+    btou(s: string): string;
+    utob(s: string): string;
+    fromUint8Array(uint8Array: Uint8Array, uriSafe?: boolean): string;
+    toUint8Array(s: string): Uint8Array;
+    extendString(): void;
+    extendUint8Array(): void;
+    extendBuiltins(): void;
+}
+
+export const Base64: Base64;
+
+export const VERSION: string;
+
+export const encode: Base64['encode'];
+
+export const encodeURI: Base64['encodeURI'];
+
+export const encodeURL: Base64['encodeURL'];
+
+export const decode: Base64['decode'];
+
+export const atob: Base64['atob'];
+
+export const btoa: Base64['btoa'];
+
+export const fromBase64: Base64['fromBase64'];
+
+export const toBase64: Base64['toBase64'];
+
+export const btou: Base64['btou'];
+
+export const utob: Base64['utob'];
+
+export const fromUint8Array: Base64['fromUint8Array'];
+
+export const toUint8Array: Base64['toUint8Array'];
+
+export const extendString: Base64['extendString'];
+
+export const extendUint8Array: Base64['extendUint8Array'];
+
+export const extendBuiltins: Base64['extendBuiltins'];
+
+/**
+ * only for global usage, not available in esm actually
+ */
+export function noConflict(): Base64;
+
+export as namespace Base64;
+
+declare global {
+    interface String {
+        fromBase64(): string;
+        toBase64(uriSafe?: boolean): string;
+        toBase64URI(): string;
+        toBase64URL(): string;
+        toUint8Array(): Uint8Array;
+    }
+
+    interface Uint8Array {
+        toBase64(uriSafe?: boolean): string;
+        toBase64URI(): string;
+        toBase64URL(): string;
+    }
+}

+ 62 - 0
node_modules/@types/js-base64/package.json

@@ -0,0 +1,62 @@
+{
+  "_from": "@types/js-base64",
+  "_id": "@types/js-base64@3.0.0",
+  "_inBundle": false,
+  "_integrity": "sha512-BnEyOcDE4H6bkg8m84xhdbkYoAoCg8sYERmAvE4Ff50U8jTfbmOinRdJpauBn1P9XsCCQgCLuSiyz3PM4WHYOA==",
+  "_location": "/@types/js-base64",
+  "_phantomChildren": {},
+  "_requested": {
+    "type": "tag",
+    "registry": true,
+    "raw": "@types/js-base64",
+    "name": "@types/js-base64",
+    "escapedName": "@types%2fjs-base64",
+    "scope": "@types",
+    "rawSpec": "",
+    "saveSpec": null,
+    "fetchSpec": "latest"
+  },
+  "_requiredBy": [
+    "#USER",
+    "/"
+  ],
+  "_resolved": "https://registry.npmjs.org/@types/js-base64/-/js-base64-3.0.0.tgz",
+  "_shasum": "b7b4c130facefefd5c57ba82664c41e2995f91be",
+  "_spec": "@types/js-base64",
+  "_where": "/Users/trevorshim/Development/porter-dev/porter",
+  "bugs": {
+    "url": "https://github.com/DefinitelyTyped/DefinitelyTyped/issues"
+  },
+  "bundleDependencies": false,
+  "contributors": [
+    {
+      "name": "Denis Carriere",
+      "url": "https://github.com/DenisCarriere"
+    },
+    {
+      "name": "Tommy Lent",
+      "url": "https://github.com/tlent"
+    },
+    {
+      "name": "JounQin",
+      "url": "https://github.com/JounQin"
+    }
+  ],
+  "dependencies": {},
+  "deprecated": false,
+  "description": "TypeScript definitions for js-base64",
+  "homepage": "https://github.com/DefinitelyTyped/DefinitelyTyped#readme",
+  "license": "MIT",
+  "main": "",
+  "name": "@types/js-base64",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/DefinitelyTyped/DefinitelyTyped.git",
+    "directory": "types/js-base64"
+  },
+  "scripts": {},
+  "typeScriptVersion": "3.0",
+  "types": "index.d.ts",
+  "typesPublisherContentHash": "4b5afb34917caed330bdb1d07cae9ec4f28c8f27affcb5551a4412b3f9d082eb",
+  "version": "3.0.0"
+}

+ 4 - 0
server/api/api.go

@@ -34,11 +34,13 @@ type App struct {
 	translator   *ut.Translator
 	cookieName   string
 	testing      bool
+	isLocal      bool
 	TestAgents   *TestAgents
 	GithubConfig *oauth2.Config
 }
 
 // New returns a new App instance
+// TODO -- this should accept an app/server config
 func New(
 	logger *lr.Logger,
 	db *gorm.DB,
@@ -47,6 +49,7 @@ func New(
 	store sessions.Store,
 	cookieName string,
 	testing bool,
+	isLocal bool,
 	githubConfig *oauth.Config,
 ) *App {
 	// for now, will just support the english translator from the
@@ -82,6 +85,7 @@ func New(
 		translator:   &trans,
 		cookieName:   cookieName,
 		testing:      testing,
+		isLocal:      isLocal,
 		TestAgents:   testAgents,
 		GithubConfig: oauthGithubConf,
 	}

+ 53 - 26
server/api/deploy_handler.go

@@ -10,63 +10,76 @@ import (
 	"io"
 	"io/ioutil"
 	"net/http"
+	"net/url"
 	"strings"
 
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/models"
 	"gopkg.in/yaml.v2"
 )
 
-// DeployTemplateForm describes the parameters of a deploy template request
-type DeployTemplateForm struct {
-	TemplateName string
-	ClusterID    int
-	ImageURL     string
-	FormValues   map[string]interface{}
-}
-
 // HandleDeployTemplate triggers a chart deployment from a template
 func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
 
-	// TODO: use create form
-	requestForm := make(map[string]interface{})
+	form := &forms.InstallChartTemplateForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{
+				UpdateTokenCache: app.updateTokenCache,
+			},
+		},
+		ChartTemplateForm: &forms.ChartTemplateForm{},
+	}
+
+	form.ReleaseForm.PopulateHelmOptionsFromQueryParams(
+		vals,
+		app.repo.ServiceAccount,
+	)
 
-	// decode from JSON to form value
-	if err := json.NewDecoder(r.Body).Decode(&requestForm); err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
 
-	// TODO: use create form
-	params := DeployTemplateForm{}
-	params.TemplateName = requestForm["templateName"].(string)
-	params.ClusterID = int(requestForm["clusterID"].(float64))
-	params.ImageURL = requestForm["imageURL"].(string)
-	params.FormValues = requestForm["formValues"].(map[string]interface{})
+	agent, err := app.getAgentFromReleaseForm(
+		w,
+		r,
+		form.ReleaseForm,
+	)
+
+	if err != nil {
+		return
+	}
 
 	baseURL := "https://porter-dev.github.io/chart-repo/"
-	defaultValues, err := getDefaultValues(params.TemplateName, baseURL)
+	values, err := getDefaultValues(form.ChartTemplateForm.TemplateName, baseURL)
 	if err != nil {
 		return
 	}
 
 	// Set image URL
-	(*defaultValues)["image"].(map[interface{}]interface{})["repository"] = params.ImageURL
+	(*values)["image"].(map[interface{}]interface{})["repository"] = form.ChartTemplateForm.ImageURL
 
 	// Loop through form params to override
-	for k := range params.FormValues {
+	for k := range form.ChartTemplateForm.FormValues {
 		switch v := interface{}(k).(type) {
 		case string:
 			splits := strings.Split(v, ".")
 
 			// Validate that the field to override exists
-			currentLoc := *defaultValues
+			currentLoc := *values
 			for s := range splits {
 				key := splits[s]
 				val := currentLoc[key]
 				if val == nil {
 					fmt.Printf("No such field: %v\n", key)
 				} else if s == len(splits)-1 {
-					newValue := params.FormValues[v]
+					newValue := form.ChartTemplateForm.FormValues[v]
 					fmt.Printf("Overriding default %v with %v\n", val, newValue)
 					currentLoc[key] = newValue
 				} else {
@@ -79,15 +92,29 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 		}
 	}
 
-	d, err := yaml.Marshal(defaultValues)
+	v, err := yaml.Marshal(values)
+
 	if err != nil {
 		return
 	}
 
 	// Output values.yaml string
-	fmt.Println(string(d))
+	_, err = agent.InstallChart(baseURL+"react-0.1.5.tgz", v)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseDeploy,
+			Errors: []string{"error installing a new chart" + err.Error()},
+		}, w)
+
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
 }
 
+// ------------------------ Deploy handler helper functions ------------------------ //
+
 func getDefaultValues(templateName string, baseURL string) (*map[interface{}]interface{}, error) {
 	resp, err := http.Get(baseURL + "index.yaml")
 	if err != nil {

+ 1 - 1
server/api/helpers_test.go

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

+ 65 - 2
server/api/k8s_handler.go

@@ -9,6 +9,7 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
+	v1 "k8s.io/api/core/v1"
 
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/forms"
@@ -189,7 +190,7 @@ func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
 	}
 
-	pods := []string{}
+	pods := []v1.Pod{}
 	for _, selector := range vals["selectors"] {
 		podsList, err := agent.GetPodsByLabel(selector)
 
@@ -199,7 +200,7 @@ func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 		}
 
 		for _, pod := range podsList.Items {
-			pods = append(pods, pod.ObjectMeta.Name)
+			pods = append(pods, pod)
 		}
 	}
 
@@ -208,3 +209,65 @@ func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 }
+
+// HandleStreamControllerStatus test calls
+// TODO: Refactor repeated calls.
+func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Request) {
+
+	// get session to retrieve correct kubeconfig
+	_, err := app.store.Get(r, app.cookieName)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	form := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			UpdateTokenCache: app.updateTokenCache,
+		},
+	}
+
+	form.PopulateK8sOptionsFromQueryParams(vals, app.repo.ServiceAccount)
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new agent
+	var agent *kubernetes.Agent
+
+	if app.testing {
+		agent = app.TestAgents.K8sAgent
+	} else {
+		agent, err = kubernetes.GetAgentOutOfClusterConfig(form.OutOfClusterConfig)
+	}
+
+	upgrader.CheckOrigin = func(r *http.Request) bool { return true }
+
+	// upgrade to websocket.
+	conn, err := upgrader.Upgrade(w, r, nil)
+
+	if err != nil {
+		app.handleErrorUpgradeWebsocket(err, w)
+	}
+
+	// get path parameters
+	kind := chi.URLParam(r, "kind")
+	err = agent.StreamControllerStatus(conn, kind)
+
+	if err != nil {
+		app.handleErrorWebsocketWrite(err, w)
+		return
+	}
+}

+ 8 - 1
server/api/project_handler.go

@@ -210,7 +210,7 @@ func (app *App) HandleCreateProjectSACandidates(w http.ResponseWriter, r *http.R
 	}
 
 	// convert the form to a ServiceAccountCandidate
-	saCandidates, err := form.ToServiceAccountCandidates()
+	saCandidates, err := form.ToServiceAccountCandidates(app.isLocal)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -355,6 +355,13 @@ func (app *App) HandleResolveSACandidateActions(w http.ResponseWriter, r *http.R
 				ClusterCAData:                action.ClusterCAData,
 			}
 
+			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
+		case models.ClusterLocalhostAction:
+			form := &forms.ClusterLocalhostAction{
+				ServiceAccountActionResolver: saResolverBase,
+				ClusterHostname:              action.ClusterHostname,
+			}
+
 			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
 		case models.ClientCertDataAction:
 			form := &forms.ClientCertDataAction{

+ 14 - 14
server/api/project_handler_test.go

@@ -129,7 +129,7 @@ var readProjectSATest = []*projTest{
 		endpoint:  "/api/projects/1/serviceAccounts/1",
 		body:      ``,
 		expStatus: http.StatusOK,
-		expBody:   `{"id":1,"project_id":1,"kind":"connector","clusters":[{"id":1,"service_account_id":1,"name":"cluster-test","server":"https://localhost"}],"auth_mechanism":"oidc"}`,
+		expBody:   `{"id":1,"project_id":1,"kind":"connector","clusters":[{"id":1,"service_account_id":1,"name":"cluster-test","server":"https://10.10.10.10"}],"auth_mechanism":"oidc"}`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
 			projectSABodyValidator,
@@ -153,7 +153,7 @@ var listProjectClustersTest = []*projTest{
 		endpoint:  "/api/projects/1/clusters",
 		body:      ``,
 		expStatus: http.StatusOK,
-		expBody:   `[{"id":1,"service_account_id":1,"name":"cluster-test","server":"https://localhost"}]`,
+		expBody:   `[{"id":1,"service_account_id":1,"name":"cluster-test","server":"https://10.10.10.10"}]`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
 			projectClustersValidator,
@@ -176,7 +176,7 @@ var createProjectSACandidatesTests = []*projTest{
 		endpoint:  "/api/projects/1/candidates",
 		body:      `{"kubeconfig":"` + OIDCAuthWithDataForJSON + `"}`,
 		expStatus: http.StatusCreated,
-		expBody:   `[{"id":1,"actions":[],"created_sa_id":1,"project_id":1,"kind":"connector","context_name":"context-test","cluster_name":"cluster-test","cluster_endpoint":"https://localhost","auth_mechanism":"oidc"}]`,
+		expBody:   `[{"id":1,"actions":[],"created_sa_id":1,"project_id":1,"kind":"connector","context_name":"context-test","cluster_name":"cluster-test","cluster_endpoint":"https://10.10.10.10","auth_mechanism":"oidc"}]`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
 			projectSACandidateBodyValidator,
@@ -231,7 +231,7 @@ var createProjectSACandidatesTests = []*projTest{
 		endpoint:  "/api/projects/1/candidates",
 		body:      `{"kubeconfig":"` + OIDCAuthWithoutDataForJSON + `"}`,
 		expStatus: http.StatusCreated,
-		expBody:   `[{"id":1,"actions":[{"name":"upload-oidc-idp-issuer-ca-data","filename":"/fake/path/to/ca.pem","docs":"https://github.com/porter-dev/porter","resolved":false,"fields":"oidc_idp_issuer_ca_data"}],"project_id":1,"kind":"connector","context_name":"context-test","cluster_name":"cluster-test","cluster_endpoint":"https://localhost","auth_mechanism":"oidc"}]`,
+		expBody:   `[{"id":1,"actions":[{"name":"upload-oidc-idp-issuer-ca-data","filename":"/fake/path/to/ca.pem","docs":"https://github.com/porter-dev/porter","resolved":false,"fields":"oidc_idp_issuer_ca_data"}],"project_id":1,"kind":"connector","context_name":"context-test","cluster_name":"cluster-test","cluster_endpoint":"https://10.10.10.10","auth_mechanism":"oidc"}]`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
 			projectSACandidateBodyValidator,
@@ -255,7 +255,7 @@ var listProjectSACandidatesTests = []*projTest{
 		endpoint:  "/api/projects/1/candidates",
 		body:      ``,
 		expStatus: http.StatusOK,
-		expBody:   `[{"id":1,"actions":[{"name":"upload-oidc-idp-issuer-ca-data","filename":"/fake/path/to/ca.pem","docs":"https://github.com/porter-dev/porter","resolved":false,"fields":"oidc_idp_issuer_ca_data"}],"project_id":1,"kind":"connector","context_name":"context-test","cluster_name":"cluster-test","cluster_endpoint":"https://localhost","auth_mechanism":"oidc"}]`,
+		expBody:   `[{"id":1,"actions":[{"name":"upload-oidc-idp-issuer-ca-data","filename":"/fake/path/to/ca.pem","docs":"https://github.com/porter-dev/porter","resolved":false,"fields":"oidc_idp_issuer_ca_data"}],"project_id":1,"kind":"connector","context_name":"context-test","cluster_name":"cluster-test","cluster_endpoint":"https://10.10.10.10","auth_mechanism":"oidc"}]`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
 			projectSACandidateBodyValidator,
@@ -279,7 +279,7 @@ var resolveProjectSACandidatesTests = []*projTest{
 		endpoint:  "/api/projects/1/candidates/1/resolve",
 		body:      `[{"name": "upload-oidc-idp-issuer-ca-data", "oidc_idp_issuer_ca_data": "LS0tLS1CRUdJTiBDRVJ="}]`,
 		expStatus: http.StatusCreated,
-		expBody:   `{"id":1,"project_id":1,"kind":"connector","clusters":[{"id":1,"service_account_id":1,"name":"cluster-test","server":"https://localhost"}],"auth_mechanism":"oidc"}`,
+		expBody:   `{"id":1,"project_id":1,"kind":"connector","clusters":[{"id":1,"service_account_id":1,"name":"cluster-test","server":"https://10.10.10.10"}],"auth_mechanism":"oidc"}`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
 			projectSABodyValidator,
@@ -341,7 +341,7 @@ func initProjectSACandidate(tester *tester) {
 	}
 
 	// convert the form to a ServiceAccountCandidate
-	saCandidates, _ := form.ToServiceAccountCandidates()
+	saCandidates, _ := form.ToServiceAccountCandidates(false)
 
 	for _, saCandidate := range saCandidates {
 		tester.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
@@ -357,7 +357,7 @@ func initProjectSADefault(tester *tester) {
 	}
 
 	// convert the form to a ServiceAccountCandidate
-	saCandidates, _ := form.ToServiceAccountCandidates()
+	saCandidates, _ := form.ToServiceAccountCandidates(false)
 
 	for _, saCandidate := range saCandidates {
 		tester.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
@@ -430,15 +430,15 @@ func projectClustersValidator(c *projTest, tester *tester, t *testing.T) {
 	}
 }
 
-const OIDCAuthWithDataForJSON string = `apiVersion: v1\nclusters:\n- cluster:\n    server: https://localhost\n    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\ncurrent-context: context-test\nkind: Config\npreferences: {}\nusers:\n- name: test-admin\n  user:\n    auth-provider:\n      config:\n        client-id: porter-api\n        id-token: token\n        idp-issuer-url: https://localhost\n        idp-certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n      name: oidc`
+const OIDCAuthWithDataForJSON string = `apiVersion: v1\nclusters:\n- cluster:\n    server: https://10.10.10.10\n    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\ncurrent-context: context-test\nkind: Config\npreferences: {}\nusers:\n- name: test-admin\n  user:\n    auth-provider:\n      config:\n        client-id: porter-api\n        id-token: token\n        idp-issuer-url: https://10.10.10.10\n        idp-certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n      name: oidc`
 
-const OIDCAuthWithoutDataForJSON string = `apiVersion: v1\nclusters:\n- cluster:\n    server: https://localhost\n    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\ncurrent-context: context-test\nkind: Config\npreferences: {}\nusers:\n- name: test-admin\n  user:\n    auth-provider:\n      config:\n        client-id: porter-api\n        id-token: token\n        idp-issuer-url: https://localhost\n        idp-certificate-authority: /fake/path/to/ca.pem\n      name: oidc`
+const OIDCAuthWithoutDataForJSON string = `apiVersion: v1\nclusters:\n- cluster:\n    server: https://10.10.10.10\n    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\ncurrent-context: context-test\nkind: Config\npreferences: {}\nusers:\n- name: test-admin\n  user:\n    auth-provider:\n      config:\n        client-id: porter-api\n        id-token: token\n        idp-issuer-url: https://10.10.10.10\n        idp-certificate-authority: /fake/path/to/ca.pem\n      name: oidc`
 
 const OIDCAuthWithoutData string = `
 apiVersion: v1
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
     certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
   name: cluster-test
 contexts:
@@ -456,7 +456,7 @@ users:
       config:
         client-id: porter-api
         id-token: token
-        idp-issuer-url: https://localhost
+        idp-issuer-url: https://10.10.10.10
         idp-certificate-authority: /fake/path/to/ca.pem
       name: oidc
 `
@@ -465,7 +465,7 @@ const OIDCAuthWithData string = `
 apiVersion: v1
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
     certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
   name: cluster-test
 contexts:
@@ -483,7 +483,7 @@ users:
       config:
         client-id: porter-api
         id-token: token
-        idp-issuer-url: https://localhost
+        idp-issuer-url: https://10.10.10.10
         idp-certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
       name: oidc
 `

+ 129 - 1
server/api/release_handler.go

@@ -10,6 +10,7 @@ import (
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/helm/grapher"
+	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/repository"
 )
 
@@ -103,7 +104,7 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// HandleGetReleaseComponents retrieves a single release based on a name and revision
+// HandleGetReleaseComponents retrieves kubernetes objects listed in a release identified by name and revision
 func (app *App) HandleGetReleaseComponents(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")
 	revision, err := strconv.ParseUint(chi.URLParam(r, "revision"), 0, 64)
@@ -158,6 +159,133 @@ func (app *App) HandleGetReleaseComponents(w http.ResponseWriter, r *http.Reques
 	}
 }
 
+// HandleGetReleaseControllers retrieves controllers that belong to a release.
+// Used to display status of charts.
+func (app *App) HandleGetReleaseControllers(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+	revision, err := strconv.ParseUint(chi.URLParam(r, "revision"), 0, 64)
+
+	form := &forms.GetReleaseForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{
+				UpdateTokenCache: app.updateTokenCache,
+			},
+		},
+		Name:     name,
+		Revision: int(revision),
+	}
+
+	agent, err := app.getAgentFromQueryParams(
+		w,
+		r,
+		form.ReleaseForm,
+		form.ReleaseForm.PopulateHelmOptionsFromQueryParams,
+	)
+
+	// errors are handled in app.getAgentFromQueryParams
+	if err != nil {
+		return
+	}
+
+	release, err := agent.GetRelease(form.Name, form.Revision)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusNotFound, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+
+		return
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+
+	// get the filter options
+	k8sForm := &forms.K8sForm{
+		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
+			UpdateTokenCache: app.updateTokenCache,
+		},
+	}
+
+	k8sForm.PopulateK8sOptionsFromQueryParams(vals, app.repo.ServiceAccount)
+
+	// validate the form
+	if err := app.validator.Struct(k8sForm); err != nil {
+		app.handleErrorFormValidation(err, ErrK8sValidate, w)
+		return
+	}
+
+	// create a new kubernetes agent
+	var k8sAgent *kubernetes.Agent
+
+	if app.testing {
+		k8sAgent = app.TestAgents.K8sAgent
+	} else {
+		k8sAgent, err = kubernetes.GetAgentOutOfClusterConfig(k8sForm.OutOfClusterConfig)
+	}
+
+	yamlArr := grapher.ImportMultiDocYAML([]byte(release.Manifest))
+	controllers := grapher.ParseControllers(yamlArr)
+	retrievedControllers := []interface{}{}
+
+	// get current status of each controller
+	// TODO: refactor with type assertion
+	for _, c := range controllers {
+		switch c.Kind {
+		case "Deployment":
+			rc, err := k8sAgent.GetDeployment(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			rc.Kind = c.Kind
+			retrievedControllers = append(retrievedControllers, rc)
+		case "StatefulSet":
+			rc, err := k8sAgent.GetStatefulSet(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			rc.Kind = c.Kind
+			retrievedControllers = append(retrievedControllers, rc)
+		case "DaemonSet":
+			rc, err := k8sAgent.GetDaemonSet(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			rc.Kind = c.Kind
+			retrievedControllers = append(retrievedControllers, rc)
+		case "ReplicaSet":
+			rc, err := k8sAgent.GetReplicaSet(c)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			rc.Kind = c.Kind
+			retrievedControllers = append(retrievedControllers, rc)
+		}
+	}
+
+	if err := json.NewEncoder(w).Encode(retrievedControllers); err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+}
+
 // HandleListReleaseHistory retrieves a history of releases based on a release name
 func (app *App) HandleListReleaseHistory(w http.ResponseWriter, r *http.Request) {
 	name := chi.URLParam(r, "name")

+ 37 - 1
server/router/router.go

@@ -163,6 +163,20 @@ func New(
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/releases/{name}/{revision}/controllers",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleGetReleaseControllers, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/releases/{name}/history",
@@ -261,7 +275,15 @@ func New(
 		r.Method(
 			"POST",
 			"/projects/{project_id}/deploy",
-			auth.BasicAuthenticate(requestlog.NewHandler(a.HandleDeployTemplate, l)),
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleDeployTemplate, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
 		)
 
 		// /api/templates routes
@@ -302,6 +324,20 @@ func New(
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/k8s/{kind}/status",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveServiceAccountAccess(
+					requestlog.NewHandler(a.HandleStreamControllerStatus, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 			"GET",
 			"/projects/{project_id}/k8s/pods",

+ 1 - 0
v.yml

@@ -0,0 +1 @@
+ok: true