Kaynağa Gözat

Merge branch 'frontend-paas'

jusrhee 5 yıl önce
ebeveyn
işleme
b20afd71cf
100 değiştirilmiş dosya ile 6803 ekleme ve 2442 silme
  1. 1 1
      .air.toml
  2. 1 0
      .gitignore
  3. 14 3
      README.md
  4. 124 0
      cli/cmd/api/api.go
  5. 74 0
      cli/cmd/api/helper_test.go
  6. 51 0
      cli/cmd/api/k8s.go
  7. 271 0
      cli/cmd/api/project.go
  8. 419 0
      cli/cmd/api/project_test.go
  9. 255 0
      cli/cmd/api/user.go
  10. 164 0
      cli/cmd/api/user_test.go
  11. 155 0
      cli/cmd/auth.go
  12. 92 0
      cli/cmd/config.go
  13. 75 0
      cli/cmd/connect.go
  14. 314 0
      cli/cmd/connect/kubeconfig.go
  15. 0 36
      cli/cmd/credstore/credstore.go
  16. 0 5
      cli/cmd/credstore/credstore_darwin.go
  17. 0 5
      cli/cmd/credstore/credstore_linux.go
  18. 0 34
      cli/cmd/credstore/credstore_test.go
  19. 0 5
      cli/cmd/credstore/credstore_windows.go
  20. 10 6
      cli/cmd/docker/agent.go
  21. 203 8
      cli/cmd/docker/porter.go
  22. 53 0
      cli/cmd/errors.go
  23. 0 98
      cli/cmd/generate.go
  24. 0 63
      cli/cmd/helpers.go
  25. 38 0
      cli/cmd/open.go
  26. 185 0
      cli/cmd/project.go
  27. 30 2
      cli/cmd/root.go
  28. 146 0
      cli/cmd/server.go
  29. 0 337
      cli/cmd/start.go
  30. 24 0
      cli/cmd/utils/browser.go
  31. 26 0
      cli/cmd/utils/close.go
  32. 57 0
      cli/cmd/utils/prompt.go
  33. 27 0
      cli/cmd/utils/random_string.go
  34. 25 81
      cmd/app/main.go
  35. 7 0
      cmd/migrate/main.go
  36. 25 0
      cmd/ready/main.go
  37. 6 0
      dashboard/src/assets/filter.svg
  38. 3 0
      dashboard/src/assets/home.svg
  39. 21 10
      dashboard/src/components/Selector.tsx
  40. 1 0
      dashboard/src/components/repo-selector/BranchList.tsx
  41. 2 0
      dashboard/src/components/repo-selector/ContentsList.tsx
  42. 7 1
      dashboard/src/components/repo-selector/RepoSelector.tsx
  43. 9 3
      dashboard/src/components/values-form/InputRow.tsx
  44. 1 1
      dashboard/src/components/values-form/ValuesForm.tsx
  45. 87 14
      dashboard/src/main/home/Home.tsx
  46. 0 92
      dashboard/src/main/home/Toolbar.tsx
  47. 327 0
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  48. 16 9
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  49. 0 0
      dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx
  50. 10 6
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  51. 18 6
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  52. 0 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/GraphSection.tsx
  53. 0 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ListSection.tsx
  54. 29 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/LogSection.tsx
  55. 0 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ResourceItem.tsx
  56. 12 7
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  57. 0 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  58. 9 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  59. 0 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/Edge.tsx
  60. 0 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx
  61. 0 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/InfoPanel.tsx
  62. 0 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/Node.tsx
  63. 0 0
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/SelectRegion.tsx
  64. 31 93
      dashboard/src/main/home/dashboard/Dashboard.tsx
  65. 0 379
      dashboard/src/main/home/modals/ClusterConfigModal.tsx
  66. 165 0
      dashboard/src/main/home/modals/ClusterInstructionsModal.tsx
  67. 202 0
      dashboard/src/main/home/modals/CreateProjectModal.tsx
  68. 10 13
      dashboard/src/main/home/modals/LaunchTemplateModal.tsx
  69. 40 24
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  70. 12 13
      dashboard/src/main/home/sidebar/Drawer.tsx
  71. 284 0
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  72. 67 34
      dashboard/src/main/home/sidebar/Sidebar.tsx
  73. 1 0
      dashboard/src/main/home/templates/expanded-template/ExpandedTemplate.tsx
  74. 13 7
      dashboard/src/shared/Context.tsx
  75. 55 24
      dashboard/src/shared/api.tsx
  76. 14 4
      dashboard/src/shared/types.tsx
  77. 0 17
      docker/.env
  78. 3 1
      docker/Dockerfile
  79. 0 849
      docs/API.md
  80. 28 18
      docs/GETTING_STARTED.md
  81. 7 3
      go.mod
  82. 13 0
      go.sum
  83. 6 2
      internal/adapter/gorm.go
  84. 7 4
      internal/config/config.go
  85. 323 0
      internal/forms/action.go
  86. 677 0
      internal/forms/action_test.go
  87. 29 0
      internal/forms/candidate.go
  88. 26 12
      internal/forms/k8s.go
  89. 43 0
      internal/forms/project.go
  90. 34 16
      internal/forms/release.go
  91. 0 61
      internal/forms/user.go
  92. 7 8
      internal/helm/config.go
  93. 25 9
      internal/kubernetes/config.go
  94. 344 0
      internal/kubernetes/kubeconfig.go
  95. 602 9
      internal/kubernetes/kubeconfig_test.go
  96. 52 4
      internal/kubernetes/local/kubeconfig.go
  97. 118 0
      internal/models/action.go
  98. 43 0
      internal/models/cluster.go
  99. 47 0
      internal/models/project.go
  100. 51 0
      internal/models/repoclient.go

+ 1 - 1
.air.toml

@@ -7,7 +7,7 @@ tmp_dir = "tmp"
 
 [build]
 # Just plain old shell command. You could use `make` as well.
-cmd = "go build -o ./tmp/migrate ./cmd/migrate; go build -o ./tmp/app ./cmd/app"
+cmd = "go build -o ./tmp/ready ./cmd/ready; go build -o ./tmp/migrate ./cmd/migrate; go build -o ./tmp/app ./cmd/app"
 # Binary file yields from `cmd`.
 bin = "tmp/migrate; tmp/app"
 # Customize binary.

+ 1 - 0
.gitignore

@@ -1,5 +1,6 @@
 .DS_Store
 .env
+docker/.env
 app
 *.db
 test.yaml

+ 14 - 3
README.md

@@ -12,12 +12,23 @@ Porter is a **dashboard for Helm** with support for the following features:
 To view the dashboard locally, follow the instructions to install the latest CLI release for [Mac](#mac-installation), [Linux](#linux-installation), or [Windows](#windows-installation). While the Docker engine is running, run:
 
 ```sh
-porter start
+porter server start
 ```
 
-When prompted, enter the admin email/password you would like to use. After the server has started, go to `localhost:8080/login` and **log in with the credentials you just set**. 
+Wait for the Porter server to start, and then run the following commands:
 
-To view more detailed setup instructions, please consult the [getting started](docs/GETTING_STARTED.md) docs.
+```sh
+{
+porter auth register
+porter project create porter-test
+porter connect kubeconfig
+porter open
+}
+```
+
+The last command should open up the Porter dashboard in your browser: log in with the credentials you just set. To view more detailed setup instructions, please consult the [getting started](docs/GETTING_STARTED.md) docs.
+
+To shut down the Porter instance, run `porter server stop` (or kill the containers manually using the docker CLI). 
 
 ### Mac Installation
 

+ 124 - 0
cli/cmd/api/api.go

@@ -0,0 +1,124 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"path/filepath"
+	"time"
+
+	"k8s.io/client-go/util/homedir"
+)
+
+// Client represents the client for the Porter API
+type Client struct {
+	BaseURL        string
+	HTTPClient     *http.Client
+	Cookie         *http.Cookie
+	CookieFilePath string
+}
+
+// HTTPError is the Porter error response returned if a request fails
+type HTTPError struct {
+	Code   uint     `json:"code"`
+	Errors []string `json:"errors"`
+}
+
+// NewClient constructs a new client based on a set of options
+func NewClient(baseURL string, cookieFileName string) *Client {
+	home := homedir.HomeDir()
+	cookieFilePath := filepath.Join(home, ".porter", cookieFileName)
+
+	client := &Client{
+		BaseURL:        baseURL,
+		CookieFilePath: cookieFilePath,
+		HTTPClient: &http.Client{
+			Timeout: time.Minute,
+		},
+	}
+
+	cookie, _ := client.getCookie()
+
+	if cookie != nil {
+		client.Cookie = cookie
+	}
+
+	return client
+}
+
+func (c *Client) sendRequest(req *http.Request, v interface{}, useCookie bool) (*HTTPError, error) {
+	req.Header.Set("Content-Type", "application/json; charset=utf-8")
+	req.Header.Set("Accept", "application/json; charset=utf-8")
+
+	if cookie, _ := c.getCookie(); useCookie && cookie != nil {
+		c.Cookie = cookie
+		req.AddCookie(c.Cookie)
+	}
+
+	res, err := c.HTTPClient.Do(req)
+
+	if err != nil {
+		return nil, err
+	}
+
+	defer res.Body.Close()
+
+	if cookies := res.Cookies(); len(cookies) == 1 {
+		c.saveCookie(cookies[0])
+	}
+
+	if res.StatusCode < http.StatusOK || res.StatusCode >= http.StatusBadRequest {
+		var errRes HTTPError
+		if err = json.NewDecoder(res.Body).Decode(&errRes); err == nil {
+			return &errRes, nil
+		}
+
+		return nil, fmt.Errorf("unknown error, status code: %d", res.StatusCode)
+	}
+
+	if v != nil {
+		if err = json.NewDecoder(res.Body).Decode(v); err != nil {
+			return nil, err
+		}
+	}
+
+	return nil, nil
+}
+
+// CookieStorage for temporary fs-based cookie storage before jwt tokens
+type CookieStorage struct {
+	Cookie *http.Cookie `json:"cookie"`
+}
+
+// saves single cookie to file
+func (c *Client) saveCookie(cookie *http.Cookie) error {
+	data, err := json.Marshal(&CookieStorage{
+		Cookie: cookie,
+	})
+
+	if err != nil {
+		return err
+	}
+
+	return ioutil.WriteFile(c.CookieFilePath, data, 0644)
+}
+
+// retrieves single cookie from file
+func (c *Client) getCookie() (*http.Cookie, error) {
+	data, err := ioutil.ReadFile(c.CookieFilePath)
+
+	if err != nil {
+		return nil, err
+	}
+
+	cookie := &CookieStorage{}
+
+	err = json.Unmarshal(data, cookie)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return cookie.Cookie, nil
+}

+ 74 - 0
cli/cmd/api/helper_test.go

@@ -0,0 +1,74 @@
+package api_test
+
+import (
+	"fmt"
+	"os"
+	"testing"
+
+	"github.com/porter-dev/porter/cli/cmd/docker"
+)
+
+const baseURL string = "http://localhost:10000/api"
+
+func TestMain(m *testing.M) {
+	err := startPorterServerWithDocker("user", 10000, docker.SQLite)
+
+	if err != nil {
+		fmt.Printf("%v\n", err)
+		os.Exit(1)
+	}
+
+	code := m.Run()
+	stopPorterServerWithDocker("user")
+
+	os.Exit(code)
+}
+
+type db int
+
+const (
+	pg db = iota
+	sqlite
+)
+
+// Spins up and shuts down the Docker api server with the given options
+func startPorterServerWithDocker(processID string, port int, db docker.PorterDB) error {
+	env := []string{
+		"ADMIN_INIT=false",
+	}
+
+	startOpts := &docker.PorterStartOpts{
+		ProcessID:      processID,
+		ServerImageTag: "testing",
+		ServerPort:     port,
+		DB:             db,
+		Env:            env,
+	}
+
+	_, _, err := docker.StartPorter(startOpts)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func stopPorterServerWithDocker(processID string) error {
+	agent, err := docker.NewAgentFromEnv()
+
+	if err != nil {
+		return err
+	}
+
+	err = agent.StopPorterContainersWithProcessID(processID, true)
+
+	if err != nil {
+		return err
+	}
+
+	// remove volumes
+	err = agent.RemoveLocalVolume("porter_sqlite_" + processID)
+
+	return nil
+}

+ 51 - 0
cli/cmd/api/k8s.go

@@ -0,0 +1,51 @@
+package api
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"net/url"
+
+	v1 "k8s.io/api/core/v1"
+)
+
+// GetK8sNamespacesResponse is the list of namespaces returned when a
+// user has successfully authenticated
+type GetK8sNamespacesResponse v1.NamespaceList
+
+// GetK8sNamespaces gets a namespaces list in a k8s cluster
+func (c *Client) GetK8sNamespaces(
+	ctx context.Context,
+	projectID uint,
+	serviceAccountID uint,
+	clusterID uint,
+) (*GetK8sNamespacesResponse, error) {
+	sa := fmt.Sprintf("%d", serviceAccountID)
+	cl := fmt.Sprintf("%d", clusterID)
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/k8s/namespaces?"+url.Values{
+			"service_account_id": []string{sa},
+			"cluster_id":         []string{cl},
+		}.Encode(), c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &GetK8sNamespacesResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}

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

@@ -0,0 +1,271 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// GetProjectResponse is the response returned after querying for a
+// given project
+type GetProjectResponse models.ProjectExternal
+
+// GetProject retrieves a project by id
+func (c *Client) GetProject(ctx context.Context, projectID uint) (*GetProjectResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d", c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &GetProjectResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// ListProjectClustersResponse lists the linked clusters for a project
+type ListProjectClustersResponse []models.ClusterExternal
+
+// ListProjectClusters creates a list of clusters for a given project
+func (c *Client) ListProjectClusters(
+	ctx context.Context,
+	projectID uint,
+) (ListProjectClustersResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/clusters", c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := ListProjectClustersResponse{}
+
+	if httpErr, err := c.sendRequest(req, &bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// CreateProjectRequest represents the accepted fields for creating a project
+type CreateProjectRequest struct {
+	Name string `json:"name" form:"required"`
+}
+
+// CreateProjectResponse is the resulting project after creation
+type CreateProjectResponse models.ProjectExternal
+
+// CreateProject creates a project with the given request options
+func (c *Client) CreateProject(
+	ctx context.Context,
+	createProjectRequest *CreateProjectRequest,
+) (*CreateProjectResponse, error) {
+	data, err := json.Marshal(createProjectRequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects", c.BaseURL),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateProjectResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// CreateProjectCandidatesRequest creates a project service account candidate,
+// which can be resolved to create a service account
+type CreateProjectCandidatesRequest struct {
+	Kubeconfig string `json:"kubeconfig"`
+}
+
+// CreateProjectCandidatesResponse is the list of candidates returned after
+// creating the candidates
+type CreateProjectCandidatesResponse []*models.ServiceAccountCandidateExternal
+
+// CreateProjectCandidates creates a service account candidate for a given project,
+// accepting a kubeconfig that gets parsed into a candidate
+func (c *Client) CreateProjectCandidates(
+	ctx context.Context,
+	projectID uint,
+	createCandidatesRequest *CreateProjectCandidatesRequest,
+) (CreateProjectCandidatesResponse, error) {
+	data, err := json.Marshal(createCandidatesRequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/candidates", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := CreateProjectCandidatesResponse{}
+
+	if httpErr, err := c.sendRequest(req, &bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// GetProjectCandidatesResponse is the list of service account candidates
+type GetProjectCandidatesResponse []*models.ServiceAccountCandidateExternal
+
+// GetProjectCandidates returns the service account candidates for a given
+// project id
+func (c *Client) GetProjectCandidates(
+	ctx context.Context,
+	projectID uint,
+) (GetProjectCandidatesResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/candidates", c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := GetProjectCandidatesResponse{}
+
+	if httpErr, err := c.sendRequest(req, &bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// CreateProjectServiceAccountRequest is a list of service account actions,
+// which resolve a given service account
+type CreateProjectServiceAccountRequest []*models.ServiceAccountAllActions
+
+// CreateProjectServiceAccountResponse is the service account that gets
+// returned after the actions have been resolved
+type CreateProjectServiceAccountResponse models.ServiceAccountExternal
+
+// CreateProjectServiceAccount creates a service account given a project id
+// and a candidate id, which gets resolved using the list of actions
+func (c *Client) CreateProjectServiceAccount(
+	ctx context.Context,
+	projectID uint,
+	candidateID uint,
+	createSARequest CreateProjectServiceAccountRequest,
+) (*CreateProjectServiceAccountResponse, error) {
+	data, err := json.Marshal(&createSARequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/candidates/%d/resolve", c.BaseURL, projectID, candidateID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateProjectServiceAccountResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// DeleteProjectResponse is the object returned after project deletion
+type DeleteProjectResponse models.ProjectExternal
+
+// DeleteProject deletes a project by id
+func (c *Client) DeleteProject(ctx context.Context, projectID uint) (*DeleteProjectResponse, error) {
+	req, err := http.NewRequest(
+		"DELETE",
+		fmt.Sprintf("%s/projects/%d", c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &DeleteProjectResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}

+ 419 - 0
cli/cmd/api/project_test.go

@@ -0,0 +1,419 @@
+package api_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/models"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+)
+
+func initProject(name string, client *api.Client, t *testing.T) *api.CreateProjectResponse {
+	t.Helper()
+
+	resp, err := client.CreateProject(context.Background(), &api.CreateProjectRequest{
+		Name: name,
+	})
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	return resp
+}
+
+func initProjectCandidate(
+	projectID uint,
+	kubeconfig string,
+	client *api.Client,
+	t *testing.T,
+) *models.ServiceAccountCandidateExternal {
+	t.Helper()
+
+	resp, err := client.CreateProjectCandidates(
+		context.Background(),
+		projectID,
+		&api.CreateProjectCandidatesRequest{
+			Kubeconfig: kubeconfig,
+		},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	return resp[0]
+}
+
+func initProjectSA(
+	projectID uint,
+	candidateID uint,
+	client *api.Client,
+	t *testing.T,
+) *api.CreateProjectServiceAccountResponse {
+	t.Helper()
+
+	resp, err := client.CreateProjectServiceAccount(
+		context.Background(),
+		projectID,
+		candidateID,
+		api.CreateProjectServiceAccountRequest{
+			&models.ServiceAccountAllActions{
+				Name:             models.OIDCIssuerDataAction,
+				OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
+			},
+		},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	return resp
+}
+
+func TestCreateProject(t *testing.T) {
+	email := "create_project_test@example.com"
+	client := api.NewClient(baseURL, "cookie_create_project_test.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+
+	resp, err := client.CreateProject(context.Background(), &api.CreateProjectRequest{
+		Name: "project-test",
+	})
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure user is admin and project name is correct
+	if resp.Name != "project-test" {
+		t.Errorf("project name incorrect: expected %s, got %s\n", "project-test", resp.Name)
+	}
+
+	if len(resp.Roles) != 1 {
+		t.Fatalf("project role length is not 1")
+	}
+
+	if resp.Roles[0].Kind != models.RoleAdmin {
+		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
+	}
+
+	if resp.Roles[0].UserID != user.ID {
+		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
+	}
+}
+
+func TestGetProject(t *testing.T) {
+	email := "get_project_test@example.com"
+	client := api.NewClient(baseURL, "cookie_get_project_test.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+	project := initProject("project-test", client, t)
+
+	resp, err := client.GetProject(context.Background(), project.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure user is admin and project name is correct
+	if resp.Name != "project-test" {
+		t.Errorf("project name incorrect: expected %s, got %s\n", "project-test", resp.Name)
+	}
+
+	if len(resp.Roles) != 1 {
+		t.Fatalf("project role length is not 1")
+	}
+
+	if resp.Roles[0].Kind != models.RoleAdmin {
+		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
+	}
+
+	if resp.Roles[0].UserID != user.ID {
+		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
+	}
+}
+
+func TestCreateProjectCandidates(t *testing.T) {
+	email := "create_project_candidates_test@example.com"
+	client := api.NewClient(baseURL, "cookie_create_project_candidates_test.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+	project := initProject("project-test", client, t)
+
+	resp, err := client.CreateProjectCandidates(
+		context.Background(),
+		project.ID,
+		&api.CreateProjectCandidatesRequest{
+			Kubeconfig: OIDCAuthWithoutData,
+		},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure length is 1
+	if len(resp) != 1 {
+		t.Fatalf("candidates length is not 1\n")
+	}
+
+	// make sure auth mechanism is OIDC, project id is correct, and cluster info is correct
+	if resp[0].AuthMechanism != models.OIDC {
+		t.Errorf("oidc auth mechanism incorrect: expected %s, got %s\n", models.OIDC, resp[0].AuthMechanism)
+	}
+
+	if resp[0].ProjectID != project.ID {
+		t.Errorf("project id incorrect: expected %d, got %d\n", project.ID, resp[0].ProjectID)
+	}
+
+	if resp[0].ClusterName != "cluster-test" {
+		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)
+	}
+
+	// make sure correct actions need to be performed
+	if len(resp[0].Actions) != 1 {
+		t.Fatalf("actions length is not 1\n")
+	}
+
+	if resp[0].Actions[0].Name != models.OIDCIssuerDataAction {
+		t.Errorf("action name incorrect: expected %s, got %s\n", models.OIDCIssuerDataAction, resp[0].Actions[0].Name)
+	}
+
+	if resp[0].Actions[0].Filename != "/fake/path/to/ca.pem" {
+		t.Errorf("action filename incorrect: expected %s, got %s\n", "/fake/path/to/ca.pem", resp[0].Actions[0].Filename)
+	}
+}
+
+func TestGetProjectCandidates(t *testing.T) {
+	email := "get_project_candidates_test@example.com"
+	client := api.NewClient(baseURL, "cookie_get_project_candidates_test.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+	project := initProject("project-test", client, t)
+	initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
+
+	resp, err := client.GetProjectCandidates(context.Background(), project.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure length is 1
+	if len(resp) != 1 {
+		t.Fatalf("candidates length is not 1\n")
+	}
+
+	// make sure auth mechanism is OIDC, project id is correct, and cluster info is correct
+	if resp[0].AuthMechanism != models.OIDC {
+		t.Errorf("oidc auth mechanism incorrect: expected %s, got %s\n", models.OIDC, resp[0].AuthMechanism)
+	}
+
+	if resp[0].ProjectID != project.ID {
+		t.Errorf("project id incorrect: expected %d, got %d\n", project.ID, resp[0].ProjectID)
+	}
+
+	if resp[0].ClusterName != "cluster-test" {
+		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)
+	}
+
+	// make sure correct actions need to be performed
+	if len(resp[0].Actions) != 1 {
+		t.Fatalf("actions length is not 1\n")
+	}
+
+	if resp[0].Actions[0].Name != models.OIDCIssuerDataAction {
+		t.Errorf("action name incorrect: expected %s, got %s\n", models.OIDCIssuerDataAction, resp[0].Actions[0].Name)
+	}
+
+	if resp[0].Actions[0].Filename != "/fake/path/to/ca.pem" {
+		t.Errorf("action filename incorrect: expected %s, got %s\n", "/fake/path/to/ca.pem", resp[0].Actions[0].Filename)
+	}
+}
+
+func TestCreateProjectServiceAccount(t *testing.T) {
+	email := "create_project_sa_test@example.com"
+	client := api.NewClient(baseURL, "cookie_create_project_sa_test.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+	project := initProject("project-test", client, t)
+	saCandidate := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
+
+	resp, err := client.CreateProjectServiceAccount(
+		context.Background(),
+		project.ID,
+		saCandidate.ID,
+		api.CreateProjectServiceAccountRequest{
+			&models.ServiceAccountAllActions{
+				Name:             models.OIDCIssuerDataAction,
+				OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
+			},
+		},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// ensure project id and metadata is correct
+	if resp.ProjectID != project.ID {
+		t.Errorf("project id incorrect: expected %d, got %d\n", project.ID, resp.ProjectID)
+	}
+
+	if resp.Kind != "connector" {
+		t.Errorf("service account kind incorrect: expected %s, got %s\n", "connector", resp.Kind)
+	}
+
+	if resp.AuthMechanism != models.OIDC {
+		t.Errorf("service account auth mechanism incorrect: expected %s, got %s\n", models.OIDC, resp.AuthMechanism)
+	}
+
+	// verify clusters
+	if len(resp.Clusters) != 1 {
+		t.Fatalf("length of clusters is not 1")
+	}
+
+	if resp.Clusters[0].ServiceAccountID != resp.ID {
+		t.Errorf("cluster's sa id is incorrect: expected %d, got %d\n", resp.ID, resp.Clusters[0].ServiceAccountID)
+	}
+
+	if resp.Clusters[0].Name != "cluster-test" {
+		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)
+	}
+}
+
+func TestListProjectClusters(t *testing.T) {
+	email := "list_project_clusters_test@example.com"
+	client := api.NewClient(baseURL, "cookie_list_project_clusters_test.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+	project := initProject("project-test", client, t)
+	saCandidate := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
+	sa := initProjectSA(project.ID, saCandidate.ID, client, t)
+
+	resp, err := client.ListProjectClusters(
+		context.Background(),
+		project.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// verify clusters
+	if len(resp) != 1 {
+		t.Fatalf("length of clusters is not 1")
+	}
+
+	if resp[0].ServiceAccountID != sa.ID {
+		t.Errorf("cluster's sa id is incorrect: expected %d, got %d\n", sa.ID, resp[0].ServiceAccountID)
+	}
+
+	if resp[0].Name != "cluster-test" {
+		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)
+	}
+}
+
+func TestDeleteProject(t *testing.T) {
+	email := "delete_project_test@example.com"
+	client := api.NewClient(baseURL, "cookie_delete_project_test.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+	project := initProject("project-test", client, t)
+
+	resp, err := client.DeleteProject(context.Background(), project.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure user is admin and project name is correct
+	if resp.Name != "project-test" {
+		t.Errorf("project name incorrect: expected %s, got %s\n", "project-test", resp.Name)
+	}
+
+	if len(resp.Roles) != 1 {
+		t.Fatalf("project role length is not 1")
+	}
+
+	if resp.Roles[0].Kind != models.RoleAdmin {
+		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
+	}
+
+	if resp.Roles[0].UserID != user.ID {
+		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
+	}
+
+	// make sure that project can no longer be found
+	_, err = client.GetProject(context.Background(), project.ID)
+
+	if err == nil {
+		t.Fatalf("no error returned\n")
+	}
+}
+
+const OIDCAuthWithoutData string = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    auth-provider:
+      config:
+        client-id: porter-api
+        id-token: token
+        idp-issuer-url: https://localhost
+        idp-certificate-authority: /fake/path/to/ca.pem
+      name: oidc
+`

+ 255 - 0
cli/cmd/api/user.go

@@ -0,0 +1,255 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// AuthCheckResponse is the user model response that is returned if the
+// user is logged in
+type AuthCheckResponse models.UserExternal
+
+// AuthCheck performs a check to ensure that the user is logged in
+func (c *Client) AuthCheck(ctx context.Context) (*AuthCheckResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/auth/check", c.BaseURL),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+
+	bodyResp := &AuthCheckResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// LoginRequest is the email/password associated with a login request
+type LoginRequest struct {
+	Email    string `json:"email"`
+	Password string `json:"password"`
+}
+
+// LoginResponse is the user model response that is returned after successfully
+// logging in
+type LoginResponse models.UserExternal
+
+// Login authorizes the user and grants them a cookie-based session
+func (c *Client) Login(ctx context.Context, loginRequest *LoginRequest) (*LoginResponse, error) {
+	data, err := json.Marshal(loginRequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/login", c.BaseURL),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &LoginResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, false); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// Logout logs the user out and deauthorizes the cookie-based session
+func (c *Client) Logout(ctx context.Context) error {
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/logout", c.BaseURL),
+		nil,
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(ctx)
+
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return err
+	}
+
+	return nil
+}
+
+// CreateUserRequest is the email/password associated with creating a user
+type CreateUserRequest struct {
+	Email    string `json:"email"`
+	Password string `json:"password"`
+}
+
+// CreateUserResponse is the user model response that is returned after successfully
+// creating a user
+type CreateUserResponse models.UserExternal
+
+// CreateUser will create the user, authorize the user and grant them a cookie-based session
+func (c *Client) CreateUser(
+	ctx context.Context,
+	createUserRequest *CreateUserRequest,
+) (*CreateUserResponse, error) {
+	data, err := json.Marshal(createUserRequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/users", c.BaseURL),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateUserResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, false); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// GetUserResponse is the user model response that is returned after successfully
+// getting a user
+type GetUserResponse models.UserExternal
+
+// GetUser retrieves a user given a user id
+func (c *Client) GetUser(ctx context.Context, userID uint) (*GetUserResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/users/%d", c.BaseURL, userID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+
+	bodyResp := &GetUserResponse{}
+
+	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// ListUserProjectsResponse is the list of projects returned
+type ListUserProjectsResponse []*models.ProjectExternal
+
+// ListUserProjects returns a list of projects associated with a user
+func (c *Client) ListUserProjects(ctx context.Context, userID uint) (ListUserProjectsResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/users/%d/projects", c.BaseURL, userID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+
+	bodyResp := ListUserProjectsResponse{}
+
+	if httpErr, err := c.sendRequest(req, &bodyResp, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return nil, err
+	}
+
+	return bodyResp, nil
+}
+
+// DeleteUserRequest is the password needed to verify a user should be deleted
+type DeleteUserRequest struct {
+	Password string `json:"password"`
+}
+
+// DeleteUser deletes a user of a given user id
+func (c *Client) DeleteUser(
+	ctx context.Context,
+	userID uint,
+	deleteUserRequest *DeleteUserRequest,
+) error {
+	data, err := json.Marshal(deleteUserRequest)
+
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(
+		"DELETE",
+		fmt.Sprintf("%s/users/%d", c.BaseURL, userID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(ctx)
+
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
+		if httpErr != nil {
+			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
+		}
+
+		return err
+	}
+
+	return nil
+}

+ 164 - 0
cli/cmd/api/user_test.go

@@ -0,0 +1,164 @@
+package api_test
+
+import (
+	"context"
+	"strings"
+	"testing"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+func initUser(email string, client *api.Client, t *testing.T) *api.CreateUserResponse {
+	t.Helper()
+
+	resp, err := client.CreateUser(context.Background(), &api.CreateUserRequest{
+		Email:    email,
+		Password: "hello1234",
+	})
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	return resp
+}
+
+func TestLogin(t *testing.T) {
+	email := "login_test@example.com"
+	client := api.NewClient(baseURL, "cookie_login_test.json")
+	user := initUser(email, client, t)
+
+	resp, err := client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if resp.Email != user.Email {
+		t.Errorf("incorrect email: expected %s, got %s\n", user.Email, resp.Email)
+	}
+}
+
+func TestLogout(t *testing.T) {
+	email := "logout_test@example.com"
+	client := api.NewClient(baseURL, "cookie_logout_test.json")
+	user := initUser(email, client, t)
+
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+
+	err := client.Logout(context.Background())
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// try to get the user and ensure 403
+	_, err = client.AuthCheck(context.Background())
+
+	if err != nil && !strings.Contains(err.Error(), "403") {
+		t.Fatalf("%v\n", err)
+	}
+}
+
+func TestAuthCheck(t *testing.T) {
+	email := "auth_check_test@example.com"
+	client := api.NewClient(baseURL, "cookie_auth_check_test.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+
+	resp, err := client.AuthCheck(context.Background())
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if resp.Email != user.Email {
+		t.Errorf("incorrect email: expected %s, got %s\n", user.Email, resp.Email)
+	}
+}
+
+func TestGetUser(t *testing.T) {
+	email := "get_user_test@example.com"
+	client := api.NewClient(baseURL, "cookie_get_user_test.json")
+	user := initUser(email, client, t)
+
+	resp, err := client.GetUser(context.Background(), user.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if resp.Email != user.Email {
+		t.Errorf("incorrect email: expected %s, got %s\n", user.Email, resp.Email)
+	}
+}
+
+func TestListUserProjects(t *testing.T) {
+	email := "list_user_projects@example.com"
+	client := api.NewClient(baseURL, "cookie_list_user_projects.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+	project := initProject("project-test", client, t)
+
+	projects, err := client.ListUserProjects(context.Background(), user.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(projects) != 1 {
+		t.Fatalf("length of projects is not 1")
+	}
+
+	resp := projects[0]
+
+	// make sure user is admin and project name is correct
+	if resp.Name != project.Name {
+		t.Errorf("project name incorrect: expected %s, got %s\n", project.Name, resp.Name)
+	}
+
+	if len(resp.Roles) != 1 {
+		t.Fatalf("project role length is not 1")
+	}
+
+	if resp.Roles[0].Kind != models.RoleAdmin {
+		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
+	}
+
+	if resp.Roles[0].UserID != user.ID {
+		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
+	}
+}
+
+func TestDeleteUser(t *testing.T) {
+	email := "delete_user_test@example.com"
+	client := api.NewClient(baseURL, "cookie_delete_user_test.json")
+	user := initUser(email, client, t)
+
+	err := client.DeleteUser(context.Background(), user.ID, &api.DeleteUserRequest{
+		Password: "hello1234",
+	})
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	_, err = client.GetUser(context.Background(), user.ID)
+
+	if err != nil && !strings.Contains(err.Error(), "could not find requested object") {
+		t.Fatalf("%v\n", err)
+	}
+}

+ 155 - 0
cli/cmd/auth.go

@@ -0,0 +1,155 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/fatih/color"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+	"github.com/spf13/cobra"
+)
+
+var authCmd = &cobra.Command{
+	Use:   "auth",
+	Short: "Commands for authenticating to a Porter server",
+}
+
+var loginCmd = &cobra.Command{
+	Use:   "login",
+	Short: "Authorizes a user for a given Porter server",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := login()
+
+		if err != nil {
+			color.Red("Error logging in:", err.Error())
+			os.Exit(1)
+		}
+	},
+}
+
+var registerCmd = &cobra.Command{
+	Use:   "register",
+	Short: "Creates a user for a given Porter server",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := register()
+
+		if err != nil {
+			color.Red("Error registering:", err.Error())
+			os.Exit(1)
+		}
+	},
+}
+
+var logoutCmd = &cobra.Command{
+	Use:   "logout",
+	Short: "Logs a user out of a given Porter server",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, logout)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(authCmd)
+
+	authCmd.AddCommand(loginCmd)
+	authCmd.AddCommand(registerCmd)
+	authCmd.AddCommand(logoutCmd)
+
+	authCmd.PersistentFlags().StringVar(
+		&host,
+		"host",
+		getHost(),
+		"host url of Porter instance",
+	)
+}
+
+func login() error {
+	client := api.NewClient(getHost()+"/api", "cookie.json")
+	user, _ := client.AuthCheck(context.Background())
+
+	if user != nil {
+		color.Yellow("You are already logged in. If you'd like to log out, run \"porter auth logout\".")
+		return nil
+	}
+
+	var username, pw string
+
+	fmt.Println("Please log in with an email and password:")
+
+	username, err := utils.PromptPlaintext("Email: ")
+
+	if err != nil {
+		return err
+	}
+
+	pw, err = utils.PromptPassword("Password: ")
+
+	if err != nil {
+		return err
+	}
+
+	_, err = client.Login(context.Background(), &api.LoginRequest{
+		Email:    username,
+		Password: pw,
+	})
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Println("Successfully logged in!")
+
+	return nil
+}
+
+func register() error {
+	host := getHost()
+
+	fmt.Println("Please register your admin account with an email and password:")
+
+	username, err := utils.PromptPlaintext("Email: ")
+
+	if err != nil {
+		return err
+	}
+
+	pw, err := utils.PromptPasswordWithConfirmation()
+
+	if err != nil {
+		return err
+	}
+
+	client := api.NewClient(host+"/api", "cookie.json")
+
+	resp, err := client.CreateUser(context.Background(), &api.CreateUserRequest{
+		Email:    username,
+		Password: pw,
+	})
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Printf("Created user with email %s and id %d\n", username, resp.ID)
+
+	return nil
+}
+
+func logout(user *api.AuthCheckResponse, client *api.Client, args []string) error {
+	err := client.Logout(context.Background())
+
+	if err != nil {
+		return err
+	}
+
+	color.Green("Successfully logged out")
+
+	return nil
+}

+ 92 - 0
cli/cmd/config.go

@@ -0,0 +1,92 @@
+package cmd
+
+import (
+	"os"
+	"strconv"
+
+	"github.com/fatih/color"
+	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+)
+
+// a set of shared flags
+var (
+	host      string
+	projectID uint
+)
+
+var configCmd = &cobra.Command{
+	Use:   "config",
+	Short: "Commands that control local configuration settings",
+}
+
+var setProjectCmd = &cobra.Command{
+	Use:   "set-project [id]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Saves the project id in the default configuration",
+	Run: func(cmd *cobra.Command, args []string) {
+		projID, err := strconv.ParseUint(args[0], 10, 64)
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+
+		err = setProject(uint(projID))
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
+var setHostCmd = &cobra.Command{
+	Use:   "set-host [host]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Saves the host in the default configuration",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := setHost(args[0])
+
+		if err != nil {
+			color.New(color.FgRed).Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(configCmd)
+
+	configCmd.AddCommand(setProjectCmd)
+	configCmd.AddCommand(setHostCmd)
+}
+
+func setProject(id uint) error {
+	viper.Set("project", id)
+	color.New(color.FgGreen).Printf("Set the current project id as %d\n", id)
+	return viper.WriteConfig()
+}
+
+func setHost(host string) error {
+	viper.Set("host", host)
+	err := viper.WriteConfig()
+	color.New(color.FgGreen).Printf("Set the current host as %s\n", host)
+	return err
+}
+
+func getHost() string {
+	if host != "" {
+		return host
+	}
+
+	return viper.GetString("host")
+}
+
+func getProjectID() uint {
+	if projectID != 0 {
+		return projectID
+	}
+
+	return viper.GetUint("project")
+}

+ 75 - 0
cli/cmd/connect.go

@@ -0,0 +1,75 @@
+package cmd
+
+import (
+	"os"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/connect"
+	"github.com/spf13/cobra"
+)
+
+var (
+	kubeconfigPath string
+	print          *bool
+	contexts       *[]string
+)
+
+var connectCmd = &cobra.Command{
+	Use:   "connect",
+	Short: "Commands that connect to external clusters and providers",
+}
+
+var connectKubeconfigCmd = &cobra.Command{
+	Use:   "kubeconfig",
+	Short: "Uses the local kubeconfig to connect to a cluster",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnect)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(connectCmd)
+
+	connectCmd.AddCommand(connectKubeconfigCmd)
+
+	connectCmd.PersistentFlags().StringVar(
+		&host,
+		"host",
+		getHost(),
+		"host url of Porter instance",
+	)
+
+	projectID = *connectCmd.PersistentFlags().UintP(
+		"project-id",
+		"p",
+		getProjectID(),
+		"project id to use",
+	)
+
+	connectKubeconfigCmd.PersistentFlags().StringVarP(
+		&kubeconfigPath,
+		"kubeconfig",
+		"k",
+		"",
+		"path to kubeconfig",
+	)
+
+	contexts = connectKubeconfigCmd.PersistentFlags().StringArray(
+		"contexts",
+		nil,
+		"the list of contexts to connect (defaults to the current context)",
+	)
+}
+
+func runConnect(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	return connect.Kubeconfig(
+		client,
+		kubeconfigPath,
+		*contexts,
+		getProjectID(),
+	)
+}

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

@@ -0,0 +1,314 @@
+package connect
+
+import (
+	"context"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"strings"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+	"github.com/porter-dev/porter/internal/kubernetes/local"
+	gcpLocal "github.com/porter-dev/porter/internal/providers/gcp/local"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// Kubeconfig creates a service account for a project by parsing the local
+// kubeconfig and resolving actions that must be performed.
+func Kubeconfig(
+	client *api.Client,
+	kubeconfigPath string,
+	contexts []string,
+	projectID uint,
+) error {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	// get the kubeconfig
+	rawBytes, err := local.GetKubeconfigFromHost(kubeconfigPath, contexts)
+
+	if err != nil {
+		return err
+	}
+
+	// send kubeconfig to client
+	saCandidates, err := client.CreateProjectCandidates(
+		context.Background(),
+		projectID,
+		&api.CreateProjectCandidatesRequest{
+			Kubeconfig: string(rawBytes),
+		},
+	)
+
+	if err != nil {
+		return err
+	}
+
+	for _, saCandidate := range saCandidates {
+		resolvers := make(api.CreateProjectServiceAccountRequest, 0)
+
+		for _, action := range saCandidate.Actions {
+			switch action.Name {
+			case models.ClusterCADataAction:
+				resolveAction, err := resolveClusterCAAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.ClientCertDataAction:
+				resolveAction, err := resolveClientCertAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.ClientKeyDataAction:
+				resolveAction, err := resolveClientKeyAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.OIDCIssuerDataAction:
+				resolveAction, err := resolveOIDCIssuerAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.TokenDataAction:
+				resolveAction, err := resolveTokenDataAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.GCPKeyDataAction:
+				resolveAction, err := resolveGCPKeyAction(
+					saCandidate.ClusterEndpoint,
+					saCandidate.ClusterName,
+				)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.AWSKeyDataAction:
+			}
+		}
+
+		sa, err := client.CreateProjectServiceAccount(
+			context.Background(),
+			projectID,
+			saCandidate.ID,
+			resolvers,
+		)
+
+		if err != nil {
+			return err
+		}
+
+		for _, cluster := range sa.Clusters {
+			color.New(color.FgGreen).Printf("created service account for cluster %s with id %d\n", cluster.Name, sa.ID)
+
+			// sanity check to ensure it's working
+			// namespaces, err := client.GetK8sNamespaces(
+			// 	context.Background(),
+			// 	projectID,
+			// 	sa.ID,
+			// 	cluster.ID,
+			// )
+
+			// if err != nil {
+			// 	return err
+			// }
+
+			// for _, ns := range namespaces.Items {
+			// 	fmt.Println(ns.ObjectMeta.GetName())
+			// }
+		}
+	}
+
+	return nil
+}
+
+// resolves a cluster ca data action
+func resolveClusterCAAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:          models.ClusterCADataAction,
+		ClusterCAData: base64.StdEncoding.EncodeToString(fileBytes),
+	}, nil
+}
+
+// resolves a client cert data action
+func resolveClientCertAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:           models.ClientCertDataAction,
+		ClientCertData: base64.StdEncoding.EncodeToString(fileBytes),
+	}, nil
+}
+
+// resolves a client key data action
+func resolveClientKeyAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:          models.ClientKeyDataAction,
+		ClientKeyData: base64.StdEncoding.EncodeToString(fileBytes),
+	}, nil
+}
+
+// resolves an oidc issuer data action
+func resolveOIDCIssuerAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:             models.OIDCIssuerDataAction,
+		OIDCIssuerCAData: base64.StdEncoding.EncodeToString(fileBytes),
+	}, nil
+}
+
+// resolves a token data action
+func resolveTokenDataAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:      models.TokenDataAction,
+		TokenData: string(fileBytes),
+	}, nil
+}
+
+// resolves a gcp key data action
+func resolveGCPKeyAction(endpoint string, clusterName string) (*models.ServiceAccountAllActions, error) {
+	userResp, err := utils.PromptPlaintext(
+		fmt.Sprintf(
+			`Detected GKE cluster in kubeconfig for the endpoint %s (%s). 
+Porter can set up a service account in your GCP project to connect to this cluster automatically.
+Would you like to proceed? %s `,
+			color.New(color.FgCyan).Sprintf("%s", endpoint),
+			clusterName,
+			color.New(color.FgCyan).Sprintf("[y/n]"),
+		),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if userResp := strings.ToLower(userResp); userResp == "y" || userResp == "yes" {
+		agent, _ := gcpLocal.NewDefaultAgent()
+		projID, err := agent.GetProjectIDForGKECluster(endpoint)
+
+		if err != nil {
+			return nil, err
+		}
+
+		agent.ProjectID = projID
+
+		name := "porter-dashboard-" + utils.StringWithCharset(6, "abcdefghijklmnopqrstuvwxyz1234567890")
+
+		// create the service account and give it the correct iam permissions
+		resp, err := agent.CreateServiceAccount(name)
+
+		if err != nil {
+			color.New(color.FgRed).Println("Automatic creation failed, manual input required.")
+			return resolveGCPKeyActionManual(endpoint, clusterName)
+		}
+
+		err = agent.SetServiceAccountIAMPolicy(resp)
+
+		if err != nil {
+			return nil, err
+		}
+
+		// get the service account key data to send to the server
+		bytes, err := agent.CreateServiceAccountKey(resp)
+
+		if err != nil {
+			return nil, err
+		}
+
+		return &models.ServiceAccountAllActions{
+			Name:       models.GCPKeyDataAction,
+			GCPKeyData: string(bytes),
+		}, nil
+	}
+
+	return resolveGCPKeyActionManual(endpoint, clusterName)
+}
+
+func resolveGCPKeyActionManual(endpoint string, clusterName string) (*models.ServiceAccountAllActions, error) {
+	keyFileLocation, err := utils.PromptPlaintext(fmt.Sprintf(`Please provide the full path to a service account key file.
+Key file location: `))
+
+	if err != nil {
+		return nil, err
+	}
+
+	// attempt to read the key file location
+	if info, err := os.Stat(keyFileLocation); !os.IsNotExist(err) && !info.IsDir() {
+		// read the file
+		bytes, err := ioutil.ReadFile(keyFileLocation)
+
+		if err != nil {
+			return nil, err
+		}
+
+		return &models.ServiceAccountAllActions{
+			Name:       models.GCPKeyDataAction,
+			GCPKeyData: string(bytes),
+		}, nil
+	}
+
+	return nil, errors.New("Key file not found")
+}
+
+// resolves an aws key data action

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

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

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

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

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

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

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

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

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

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

+ 10 - 6
cli/cmd/docker/agent.go

@@ -3,6 +3,7 @@ package docker
 import (
 	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"strings"
@@ -68,6 +69,11 @@ func (a *Agent) CreateLocalVolume(name string) (*types.Volume, error) {
 	return &vol, nil
 }
 
+// RemoveLocalVolume removes a volume by name
+func (a *Agent) RemoveLocalVolume(name string) error {
+	return a.client.VolumeRemove(a.ctx, name, true)
+}
+
 // CreateBridgeNetworkIfNotExist creates a volume using driver type "local" with the
 // given name if it does not exist. If the volume does exist but does not contain
 // the required label (a.label), an error is thrown.
@@ -141,8 +147,6 @@ type PullImageEvent struct {
 
 // PullImage pulls an image specified by the image string
 func (a *Agent) PullImage(image string) error {
-	fmt.Println("Pulling image:", image)
-
 	// pull the specified image
 	out, err := a.client.ImagePull(a.ctx, image, types.ImagePullOptions{})
 
@@ -164,8 +168,6 @@ func (a *Agent) PullImage(image string) error {
 		}
 	}
 
-	fmt.Println("Finished pulling image:", image)
-
 	return nil
 }
 
@@ -198,14 +200,16 @@ func (a *Agent) WaitForContainerHealthy(id string, streak int) error {
 
 		health := cont.State.Health
 
-		if health == nil || health.Status == "healthy" || health.FailingStreak >= streak {
+		if health == nil || health.Status == "healthy" {
+			return nil
+		} else if health.FailingStreak >= streak {
 			break
 		}
 
 		time.Sleep(time.Second)
 	}
 
-	return nil
+	return errors.New("container not healthy")
 }
 
 // ------------------------- AGENT HELPER FUNCTIONS ------------------------- //

+ 203 - 8
cli/cmd/docker/porter.go

@@ -2,6 +2,7 @@ package docker
 
 import (
 	"fmt"
+	"strings"
 	"time"
 
 	"github.com/docker/docker/api/types"
@@ -10,8 +11,156 @@ import (
 	"github.com/docker/go-connections/nat"
 )
 
-// PorterStartOpts are the options for starting the Porter container
+// PorterDB is used for enumerating DB types
+type PorterDB int
+
+// The supported DB types
+const (
+	Postgres PorterDB = iota
+	SQLite
+)
+
+// PorterStartOpts are the options for starting the Porter stack
 type PorterStartOpts struct {
+	ProcessID      string
+	ServerImageTag string
+	ServerPort     int
+	DB             PorterDB
+	Env            []string
+}
+
+// StartPorter creates a new Docker agent using the host environment, and creates a
+// new Porter instance
+func StartPorter(opts *PorterStartOpts) (agent *Agent, id string, err error) {
+	agent, err = NewAgentFromEnv()
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	// the volume mounts to use
+	mounts := make([]mount.Mount, 0)
+
+	// the volumes passed to the Porter container
+	volumesMap := make(map[string]struct{})
+
+	netID, err := agent.CreateBridgeNetworkIfNotExist("porter_network_" + opts.ProcessID)
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	switch opts.DB {
+	case SQLite:
+		// check if sqlite volume exists, create it if not
+		vol, err := agent.CreateLocalVolumeIfNotExist("porter_sqlite_" + opts.ProcessID)
+
+		if err != nil {
+			return nil, "", err
+		}
+
+		// create mount
+		mount := mount.Mount{
+			Type:        mount.TypeVolume,
+			Source:      vol.Name,
+			Target:      "/sqlite",
+			ReadOnly:    false,
+			Consistency: mount.ConsistencyFull,
+		}
+
+		mounts = append(mounts, mount)
+		volumesMap[vol.Name] = struct{}{}
+
+		opts.Env = append(opts.Env, []string{
+			"SQL_LITE=true",
+			"SQL_LITE_PATH=/sqlite/porter.db",
+		}...)
+	case Postgres:
+		// check if postgres volume exists, create it if not
+		vol, err := agent.CreateLocalVolumeIfNotExist("porter_postgres_" + opts.ProcessID)
+
+		if err != nil {
+			return nil, "", err
+		}
+
+		// pgMount is mount for postgres container
+		pgMount := []mount.Mount{
+			mount.Mount{
+				Type:        mount.TypeVolume,
+				Source:      vol.Name,
+				Target:      "/var/lib/postgresql/data",
+				ReadOnly:    false,
+				Consistency: mount.ConsistencyFull,
+			},
+		}
+
+		// create postgres container with mount
+		startOpts := PostgresOpts{
+			Name:   "porter_postgres_" + opts.ProcessID,
+			Image:  "postgres:latest",
+			Mounts: pgMount,
+			VolumeMap: map[string]struct{}{
+				"porter_postgres": struct{}{},
+			},
+			NetworkID: netID,
+			Env: []string{
+				"POSTGRES_USER=porter",
+				"POSTGRES_PASSWORD=porter",
+				"POSTGRES_DB=porter",
+			},
+		}
+
+		pgID, err := agent.StartPostgresContainer(startOpts)
+
+		if err != nil {
+			return nil, "", err
+		}
+
+		err = agent.WaitForContainerHealthy(pgID, 10)
+
+		if err != nil {
+			return nil, "", err
+		}
+
+		opts.Env = append(opts.Env, []string{
+			"SQL_LITE=false",
+			"DB_USER=porter",
+			"DB_PASS=porter",
+			"DB_NAME=porter",
+			"DB_HOST=porter_postgres_" + opts.ProcessID,
+			"DB_PORT=5432",
+		}...)
+	}
+
+	// create Porter container
+	startOpts := PorterServerStartOpts{
+		Name:          "porter_server_" + opts.ProcessID,
+		Image:         "porter1/porter:" + opts.ServerImageTag,
+		HostPort:      uint(opts.ServerPort),
+		ContainerPort: 8080,
+		Mounts:        mounts,
+		VolumeMap:     volumesMap,
+		NetworkID:     netID,
+		Env:           opts.Env,
+	}
+
+	id, err = agent.StartPorterContainer(startOpts)
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	err = agent.WaitForContainerHealthy(id, 10)
+
+	if err != nil {
+		return nil, "", err
+	}
+
+	return agent, id, nil
+}
+
+// PorterServerStartOpts are the options for starting the Porter server
+type PorterServerStartOpts struct {
 	Name          string
 	Image         string
 	StartCmd      []string
@@ -25,7 +174,7 @@ type PorterStartOpts struct {
 
 // StartPorterContainer pulls a specific Porter image and starts a container
 // using the Docker engine. It returns the container ID
-func (a *Agent) StartPorterContainer(opts PorterStartOpts) (string, error) {
+func (a *Agent) StartPorterContainer(opts PorterServerStartOpts) (string, error) {
 	id, err := a.upsertPorterContainer(opts)
 
 	if err != nil {
@@ -52,7 +201,7 @@ func (a *Agent) StartPorterContainer(opts PorterStartOpts) (string, error) {
 // if spec has changed, remove and recreate container
 // if container does not exist, create the container
 // otherwise, return stopped container
-func (a *Agent) upsertPorterContainer(opts PorterStartOpts) (id string, err error) {
+func (a *Agent) upsertPorterContainer(opts PorterServerStartOpts) (id string, err error) {
 	containers, err := a.getContainersCreatedByStart()
 
 	// remove the matching container
@@ -78,7 +227,7 @@ func (a *Agent) upsertPorterContainer(opts PorterStartOpts) (id string, err erro
 }
 
 // create the container and return its id
-func (a *Agent) pullAndCreatePorterContainer(opts PorterStartOpts) (id string, err error) {
+func (a *Agent) pullAndCreatePorterContainer(opts PorterServerStartOpts) (id string, err error) {
 	a.PullImage(opts.Image)
 
 	// format the port array for binding to host machine
@@ -101,6 +250,12 @@ func (a *Agent) pullAndCreatePorterContainer(opts PorterStartOpts) (id string, e
 		Labels:  labels,
 		Volumes: opts.VolumeMap,
 		Env:     opts.Env,
+		Healthcheck: &container.HealthConfig{
+			Test:     []string{"CMD-SHELL", "/porter/ready"},
+			Interval: 10 * time.Second,
+			Timeout:  5 * time.Second,
+			Retries:  3,
+		},
 	}, &container.HostConfig{
 		PortBindings: portBindings,
 		Mounts:       opts.Mounts,
@@ -226,10 +381,8 @@ func (a *Agent) startPostgresContainer(id string) error {
 }
 
 // StopPorterContainers finds all containers that were started via the CLI and stops them
-// without removal.
-func (a *Agent) StopPorterContainers() error {
-	fmt.Println("Stopping containers...")
-
+// -- removes the container if remove is set to true
+func (a *Agent) StopPorterContainers(remove bool) error {
 	containers, err := a.getContainersCreatedByStart()
 
 	if err != nil {
@@ -245,6 +398,48 @@ func (a *Agent) StopPorterContainers() error {
 		if err != nil {
 			return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
 		}
+
+		if remove {
+			err = a.client.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
+
+			if err != nil {
+				return a.handleDockerClientErr(err, "Could not remove container "+container.ID)
+			}
+		}
+	}
+
+	return nil
+}
+
+// StopPorterContainersWithProcessID finds all containers that were started via the CLI
+// and have a given process id and stops them -- removes the container if remove is set
+// to true
+func (a *Agent) StopPorterContainersWithProcessID(processID string, remove bool) error {
+	containers, err := a.getContainersCreatedByStart()
+
+	if err != nil {
+		return err
+	}
+
+	// remove all Porter containers
+	for _, container := range containers {
+		if strings.Contains(container.Names[0], "_"+processID) {
+			timeout, _ := time.ParseDuration("15s")
+
+			err := a.client.ContainerStop(a.ctx, container.ID, &timeout)
+
+			if err != nil {
+				return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
+			}
+
+			if remove {
+				err = a.client.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
+
+				if err != nil {
+					return a.handleDockerClientErr(err, "Could not remove container "+container.ID)
+				}
+			}
+		}
 	}
 
 	return nil

+ 53 - 0
cli/cmd/errors.go

@@ -0,0 +1,53 @@
+package cmd
+
+import (
+	"context"
+	"strings"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+)
+
+func checkLoginAndRun(args []string, runner func(user *api.AuthCheckResponse, client *api.Client, args []string) error) error {
+	client := api.NewClient(getHost()+"/api", "cookie.json")
+
+	user, err := client.AuthCheck(context.Background())
+
+	if err != nil {
+		red := color.New(color.FgRed)
+
+		if strings.Contains(err.Error(), "403") {
+			red.Print("You are not logged in. Log in using \"porter auth login\"\n")
+			return nil
+		} else if strings.Contains(err.Error(), "connection refused") {
+			red.Printf("Unable to connect to the Porter server at %s\n", getHost())
+			red.Print("To set a different host, run \"porter config set-host [HOST]\"\n")
+			red.Print("To start a local server, run \"porter server start\"\n")
+			return nil
+		}
+
+		red.Printf("Error: %v\n", err.Error())
+		return err
+	}
+
+	err = runner(user, client, args)
+
+	if err != nil {
+		red := color.New(color.FgRed)
+
+		if strings.Contains(err.Error(), "403") {
+			red.Print("You do not have the necessary permissions to view this resource")
+			return nil
+		} else if strings.Contains(err.Error(), "connection refused") {
+			red.Printf("Unable to connect to the Porter server at %s\n", getHost())
+			red.Print("To set a different host, run \"porter config set-host [HOST]\"")
+			red.Print("To start a local server, run \"porter server start\"")
+			return nil
+		}
+
+		red.Printf("Error: %v\n", err.Error())
+		return err
+	}
+
+	return nil
+}

+ 0 - 98
cli/cmd/generate.go

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

+ 0 - 63
cli/cmd/helpers.go

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

+ 38 - 0
cli/cmd/open.go

@@ -0,0 +1,38 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+
+	"github.com/spf13/cobra"
+)
+
+var openCmd = &cobra.Command{
+	Use:   "open",
+	Short: "Opens the browser at the currently set Porter instance",
+	Run: func(cmd *cobra.Command, args []string) {
+		client := api.NewClient(getHost()+"/api", "cookie.json")
+
+		user, err := client.AuthCheck(context.Background())
+
+		if err == nil {
+			utils.OpenBrowser(fmt.Sprintf("%s/login?email=%s", getHost(), user.Email))
+		} else {
+			utils.OpenBrowser(fmt.Sprintf("%s/register", getHost()))
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(openCmd)
+
+	rootCmd.PersistentFlags().StringVar(
+		&host,
+		"host",
+		getHost(),
+		"host url of Porter instance",
+	)
+}

+ 185 - 0
cli/cmd/project.go

@@ -0,0 +1,185 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strconv"
+	"strings"
+	"text/tabwriter"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+	"github.com/spf13/cobra"
+)
+
+// projectCmd represents the "porter project" base command when called
+// without any subcommands
+var projectCmd = &cobra.Command{
+	Use:   "project",
+	Short: "Commands that control Porter project settings",
+}
+
+var createProjectCmd = &cobra.Command{
+	Use:   "create [name]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Creates a project with the authorized user as admin",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, createProject)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var deleteProjectCmd = &cobra.Command{
+	Use:   "delete [id]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Deletes the project with the given id",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, deleteProject)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var listProjectCmd = &cobra.Command{
+	Use:   "list",
+	Short: "Lists the projects for the logged in user",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, listProjects)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var listProjectClustersCmd = &cobra.Command{
+	Use:   "clusters list",
+	Short: "Lists the linked clusters for a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, listProjectClusters)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(projectCmd)
+
+	projectCmd.PersistentFlags().StringVar(
+		&host,
+		"host",
+		getHost(),
+		"host url of Porter instance",
+	)
+
+	projectCmd.AddCommand(createProjectCmd)
+
+	projectCmd.AddCommand(deleteProjectCmd)
+
+	projectCmd.AddCommand(listProjectCmd)
+
+	projectCmd.AddCommand(listProjectClustersCmd)
+}
+
+func createProject(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
+	resp, err := client.CreateProject(context.Background(), &api.CreateProjectRequest{
+		Name: args[0],
+	})
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Printf("Created project with name %s and id %d\n", args[0], resp.ID)
+
+	return setProject(resp.ID)
+}
+
+func listProjects(user *api.AuthCheckResponse, client *api.Client, args []string) error {
+	projects, err := client.ListUserProjects(context.Background(), user.ID)
+
+	if err != nil {
+		return err
+	}
+
+	w := new(tabwriter.Writer)
+	w.Init(os.Stdout, 3, 8, 0, '\t', tabwriter.AlignRight)
+
+	fmt.Fprintf(w, "%s\t%s\n", "ID", "NAME")
+
+	currProjectID := getProjectID()
+
+	for _, project := range projects {
+		if currProjectID == project.ID {
+			color.New(color.FgGreen).Fprintf(w, "%d\t%s (current project)\n", project.ID, project.Name)
+		} else {
+			fmt.Fprintf(w, "%d\t%s\n", project.ID, project.Name)
+		}
+	}
+
+	w.Flush()
+
+	return nil
+}
+
+func deleteProject(_ *api.AuthCheckResponse, client *api.Client, args []string) error {
+	userResp, err := utils.PromptPlaintext(
+		fmt.Sprintf(
+			`Are you sure you'd like to delete the project with id %s? %s `,
+			args[0],
+			color.New(color.FgCyan).Sprintf("[y/n]"),
+		),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	if userResp := strings.ToLower(userResp); userResp == "y" || userResp == "yes" {
+		id, err := strconv.ParseUint(args[0], 10, 64)
+
+		if err != nil {
+			return err
+		}
+
+		resp, err := client.DeleteProject(context.Background(), uint(id))
+
+		if err != nil {
+			return err
+		}
+
+		color.New(color.FgGreen).Printf("Deleted project with name %s and id %d\n", resp.Name, resp.ID)
+	}
+
+	return nil
+}
+
+func listProjectClusters(user *api.AuthCheckResponse, client *api.Client, args []string) error {
+	clusters, err := client.ListProjectClusters(context.Background(), getProjectID())
+
+	if err != nil {
+		return err
+	}
+
+	w := new(tabwriter.Writer)
+	w.Init(os.Stdout, 3, 8, 0, '\t', tabwriter.AlignRight)
+
+	fmt.Fprintf(w, "%s\t%s\t%s\n", "ID", "NAME", "SERVER")
+
+	for _, cluster := range clusters {
+		fmt.Fprintf(w, "%d\t%s\t%s\n", cluster.ID, cluster.Name, cluster.Server)
+	}
+
+	w.Flush()
+
+	return nil
+}

+ 30 - 2
cli/cmd/root.go

@@ -1,10 +1,14 @@
 package cmd
 
 import (
-	"fmt"
+	"io/ioutil"
 	"os"
+	"path/filepath"
 
+	"github.com/fatih/color"
 	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+	"k8s.io/client-go/util/homedir"
 )
 
 // rootCmd represents the base command when called without any subcommands
@@ -14,11 +18,35 @@ var rootCmd = &cobra.Command{
 	Long:  `Porter is a tool for creating, versioning, and updating Kubernetes deployments using a visual dashboard. For more information, visit github.com/porter-dev/porter`,
 }
 
+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() {
+	viper.SetConfigName("porter")
+	viper.SetConfigType("yaml")
+	viper.AddConfigPath(filepath.Join(home, ".porter"))
+
+	err := viper.ReadInConfig()
+
+	if err != nil {
+		if _, ok := err.(viper.ConfigFileNotFoundError); ok {
+			// create blank config file
+			err := ioutil.WriteFile(filepath.Join(home, ".porter", "porter.yaml"), []byte{}, 0644)
+
+			if err != nil {
+				color.New(color.FgRed).Printf("%v\n", err)
+				os.Exit(1)
+			}
+		} else {
+			// Config file was found but another error was produced
+			color.New(color.FgRed).Printf("%v\n", err)
+			os.Exit(1)
+		}
+	}
+
 	if err := rootCmd.Execute(); err != nil {
-		fmt.Println(err)
+		color.New(color.FgRed).Println(err)
 		os.Exit(1)
 	}
 }

+ 146 - 0
cli/cmd/server.go

@@ -0,0 +1,146 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/docker"
+
+	"github.com/spf13/cobra"
+)
+
+type startOps struct {
+	imageTag string `form:"required"`
+	db       string `form:"oneof=sqlite postgres"`
+	port     *int   `form:"required"`
+}
+
+var opts = &startOps{}
+
+var serverCmd = &cobra.Command{
+	Use:   "server",
+	Short: "Commands to control a local Porter server",
+}
+
+// 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 err != nil {
+			red := color.New(color.FgRed)
+			red.Println("Error running start:", err.Error())
+			red.Println("Shutting down...")
+
+			err = stop()
+
+			if err != nil {
+				red.Println("Shutdown unsuccessful:", err.Error())
+			}
+
+			os.Exit(1)
+		}
+	},
+}
+
+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)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(serverCmd)
+
+	serverCmd.AddCommand(startCmd)
+	serverCmd.AddCommand(stopCmd)
+
+	startCmd.PersistentFlags().StringVar(
+		&opts.db,
+		"db",
+		"sqlite",
+		"the db to use, one of sqlite or postgres",
+	)
+
+	startCmd.PersistentFlags().StringVar(
+		&opts.imageTag,
+		"image-tag",
+		"latest",
+		"the Porter image tag to use",
+	)
+
+	opts.port = startCmd.PersistentFlags().IntP(
+		"port",
+		"p",
+		8080,
+		"the host port to run the server on",
+	)
+}
+
+func start(
+	imageTag string,
+	db string,
+	port int,
+) error {
+	env := make([]string, 0)
+	var porterDB docker.PorterDB
+
+	switch db {
+	case "postgres":
+		porterDB = docker.Postgres
+	case "sqlite":
+		porterDB = docker.SQLite
+	}
+
+	startOpts := &docker.PorterStartOpts{
+		ProcessID:      "main",
+		ServerImageTag: imageTag,
+		ServerPort:     port,
+		DB:             porterDB,
+		Env:            env,
+	}
+
+	_, _, err := docker.StartPorter(startOpts)
+
+	if err != nil {
+		return err
+	}
+
+	green := color.New(color.FgGreen)
+
+	green.Printf("Server ready: listening on localhost:%d\n", port)
+
+	return setHost(fmt.Sprintf("http://localhost:%d", port))
+}
+
+func stop() error {
+	agent, err := docker.NewAgentFromEnv()
+
+	if err != nil {
+		return err
+	}
+
+	err = agent.StopPorterContainersWithProcessID("main", false)
+
+	if err != nil {
+		return err
+	}
+
+	green := color.New(color.FgGreen)
+
+	green.Println("Successfully stopped the Porter server.")
+
+	return nil
+}

+ 0 - 337
cli/cmd/start.go

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

+ 24 - 0
cli/cmd/utils/browser.go

@@ -0,0 +1,24 @@
+package utils
+
+import (
+	"os/exec"
+	"runtime"
+)
+
+// OpenBrowser opens the specified URL in the default browser of the user.
+func OpenBrowser(url string) error {
+	var cmd string
+	var args []string
+
+	switch runtime.GOOS {
+	case "windows":
+		cmd = "cmd"
+		args = []string{"/c", "start"}
+	case "darwin":
+		cmd = "open"
+	default: // "linux", "freebsd", "openbsd", "netbsd"
+		cmd = "xdg-open"
+	}
+	args = append(args, url)
+	return exec.Command(cmd, args...).Start()
+}

+ 26 - 0
cli/cmd/utils/close.go

@@ -0,0 +1,26 @@
+package utils
+
+import (
+	"os"
+	"os/signal"
+	"syscall"
+
+	"github.com/fatih/color"
+)
+
+func closeHandler(closer func() error) {
+	sig := make(chan os.Signal)
+	signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
+	go func() {
+		<-sig
+		err := closer()
+
+		if err == nil {
+			color.New(color.FgRed).Println("shutdown successful")
+			os.Exit(0)
+		}
+
+		color.New(color.FgRed).Printf("shutdown unsuccessful: %s\n", err.Error())
+		os.Exit(1)
+	}()
+}

+ 57 - 0
cli/cmd/utils/prompt.go

@@ -0,0 +1,57 @@
+package utils
+
+import (
+	"bufio"
+	"errors"
+	"fmt"
+	"os"
+	"strings"
+
+	"golang.org/x/crypto/ssh/terminal"
+)
+
+// PromptPlaintext prompts a user to input plain text
+func PromptPlaintext(prompt string) (string, error) {
+	reader := bufio.NewReader(os.Stdin)
+
+	fmt.Print(prompt)
+	text, err := reader.ReadString('\n')
+
+	if err != nil {
+		return "", err
+	}
+
+	return strings.TrimSpace(text), nil
+}
+
+// PromptPassword prompts a user to input a hidden field
+func PromptPassword(prompt string) (string, error) {
+	fmt.Print(prompt)
+	pw, err := terminal.ReadPassword(0)
+	fmt.Print("\r")
+
+	if err != nil {
+		return "", err
+	}
+
+	return strings.TrimSpace(string(pw)), nil
+}
+
+// PromptPasswordWithConfirmation is a helper function to prompt
+// for a password twice
+func PromptPasswordWithConfirmation() (string, error) {
+	pw, err := PromptPassword("Password: ")
+	if err != nil {
+		return "", err
+	}
+	confirmPw, err := PromptPassword("Confirm password: ")
+	if err != nil {
+		return "", err
+	}
+
+	if pw != confirmPw {
+		return "", errors.New("Passwords do not match")
+	}
+
+	return pw, nil
+}

+ 27 - 0
cli/cmd/utils/random_string.go

@@ -0,0 +1,27 @@
+package utils
+
+import (
+	"math/rand"
+	"time"
+)
+
+const charset = "abcdefghijklmnopqrstuvwxyz" +
+	"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+
+var seededRand *rand.Rand = rand.New(
+	rand.NewSource(time.Now().UnixNano()))
+
+// StringWithCharset returns a random string by pulling from a given charset
+func StringWithCharset(length int, charset string) string {
+	b := make([]byte, length)
+	for i := range b {
+		b[i] = charset[seededRand.Intn(len(charset))]
+	}
+	return string(b)
+}
+
+// String returns a random string, pulling from a standard alphanumeric charset
+// [a-zA-Z0-9]
+func String(length int) string {
+	return StringWithCharset(length, charset)
+}

+ 25 - 81
cmd/app/main.go

@@ -2,21 +2,16 @@ package main
 
 import (
 	"fmt"
-	"io/ioutil"
 	"log"
 	"net/http"
-	"os"
-
-	"github.com/porter-dev/porter/internal/kubernetes"
 
 	"github.com/gorilla/sessions"
-	"github.com/porter-dev/porter/internal/forms"
-	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
 	"github.com/porter-dev/porter/server/api"
 
-	adapter "github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/adapter"
 	sessionstore "github.com/porter-dev/porter/internal/auth"
 	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
@@ -35,26 +30,37 @@ func main() {
 		return
 	}
 
-	repo := gorm.NewRepository(db)
-
-	// upsert admin if config requires
-	if appConf.Db.AdminInit {
-		err := upsertAdmin(repo.User, appConf.Db.AdminEmail, appConf.Db.AdminPassword)
+	var key [32]byte
 
-		if err != nil {
-			fmt.Println("Error while upserting admin: " + err.Error())
-		}
+	for i, b := range []byte(appConf.Db.EncryptionKey) {
+		key[i] = b
 	}
 
+	repo := gorm.NewRepository(db, &key)
+
 	// declare as Store interface (methods Get, New, Save)
 	var store sessions.Store
 	store, _ = sessionstore.NewStore(repo, appConf.Server)
 
 	validator := vr.New()
 
-	a := api.New(logger, repo, validator, store, appConf.Server.CookieName, false)
-
-	appRouter := router.New(a, store, appConf.Server.CookieName, appConf.Server.StaticFilePath)
+	a := api.New(
+		logger,
+		nil,
+		repo,
+		validator,
+		store,
+		appConf.Server.CookieName,
+		false,
+		&oauth.Config{
+			ClientID:     appConf.Server.GithubClientID,
+			ClientSecret: appConf.Server.GithubClientSecret,
+			Scopes:       []string{"repo", "user", "read:user"},
+			BaseURL:      appConf.Server.ServerURL,
+		},
+	)
+
+	appRouter := router.New(a, store, appConf.Server.CookieName, appConf.Server.StaticFilePath, repo)
 
 	address := fmt.Sprintf(":%d", appConf.Server.Port)
 
@@ -69,68 +75,6 @@ func main() {
 	}
 
 	if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
-		log.Fatal("Server startup failed")
-	}
-}
-
-func upsertAdmin(repo repository.UserRepository, email, pw string) error {
-	admUser, err := repo.ReadUserByEmail(email)
-
-	// create the user in this case
-	if err != nil {
-		form := forms.CreateUserForm{
-			Email:    email,
-			Password: pw,
-		}
-
-		admUser, err = form.ToUser(repo)
-
-		if err != nil {
-			return err
-		}
-
-		admUser, err = repo.CreateUser(admUser)
-
-		if err != nil {
-			return err
-		}
+		log.Fatal("Server startup failed", err)
 	}
-
-	filename := "/porter/porter.kubeconfig"
-
-	// read if kubeconfig file exists, if it does update the user
-	if _, err := os.Stat(filename); !os.IsNotExist(err) {
-		fileBytes, err := ioutil.ReadFile(filename)
-
-		contexts := make([]string, 0)
-		allContexts, err := kubernetes.GetContextsFromBytes(fileBytes, []string{})
-
-		if err != nil {
-			return err
-		}
-
-		for _, context := range allContexts {
-			contexts = append(contexts, context.Name)
-		}
-
-		form := forms.UpdateUserForm{
-			ID:              admUser.ID,
-			RawKubeConfig:   string(fileBytes),
-			AllowedContexts: contexts,
-		}
-
-		admUser, err = form.ToUser(repo)
-
-		if err != nil {
-			return err
-		}
-
-		admUser, err = repo.UpdateUser(admUser)
-
-		if err != nil {
-			return err
-		}
-	}
-
-	return nil
 }

+ 7 - 0
cmd/migrate/main.go

@@ -23,8 +23,15 @@ func main() {
 	}
 
 	err = db.AutoMigrate(
+		&models.Project{},
+		&models.Role{},
+		&models.ServiceAccount{},
+		&models.ServiceAccountAction{},
+		&models.ServiceAccountCandidate{},
+		&models.Cluster{},
 		&models.User{},
 		&models.Session{},
+		&models.RepoClient{},
 	)
 
 	if err != nil {

+ 25 - 0
cmd/ready/main.go

@@ -0,0 +1,25 @@
+package main
+
+import (
+	"fmt"
+	"net/http"
+	"os"
+
+	"github.com/porter-dev/porter/internal/config"
+)
+
+func main() {
+	appConf := config.FromEnv()
+
+	resp, err := http.Get(fmt.Sprintf("http://localhost:%d/api/livez", appConf.Server.Port))
+
+	if err != nil || resp.StatusCode >= http.StatusBadRequest {
+		os.Exit(1)
+	}
+
+	resp, err = http.Get(fmt.Sprintf("http://localhost:%d/api/readyz", appConf.Server.Port))
+
+	if err != nil || resp.StatusCode >= http.StatusBadRequest {
+		os.Exit(1)
+	}
+}

+ 6 - 0
dashboard/src/assets/filter.svg

@@ -0,0 +1,6 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M10.0833 15.958H3.50777C2.67555 15.958 2 16.6217 2 17.4393C2 18.2558 2.67555 18.9206 3.50777 18.9206H10.0833C10.9155 18.9206 11.5911 18.2558 11.5911 17.4393C11.5911 16.6217 10.9155 15.958 10.0833 15.958Z" fill="white"/>
+<path opacity="0.4" d="M22 6.37856C22 5.56203 21.3244 4.89832 20.4933 4.89832H13.9178C13.0856 4.89832 12.4101 5.56203 12.4101 6.37856C12.4101 7.19618 13.0856 7.85989 13.9178 7.85989H20.4933C21.3244 7.85989 22 7.19618 22 6.37856Z" fill="white"/>
+<path d="M8.87774 6.37856C8.87774 8.24523 7.33886 9.75821 5.43887 9.75821C3.53999 9.75821 2 8.24523 2 6.37856C2 4.51298 3.53999 3 5.43887 3C7.33886 3 8.87774 4.51298 8.87774 6.37856Z" fill="white"/>
+<path d="M22 17.3992C22 19.2648 20.4611 20.7778 18.5611 20.7778C16.6623 20.7778 15.1223 19.2648 15.1223 17.3992C15.1223 15.5325 16.6623 14.0196 18.5611 14.0196C20.4611 14.0196 22 15.5325 22 17.3992Z" fill="white"/>
+</svg>

+ 3 - 0
dashboard/src/assets/home.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.14373 20.7821V17.7152C9.14372 16.9381 9.77567 16.3067 10.5584 16.3018H13.4326C14.2189 16.3018 14.8563 16.9346 14.8563 17.7152V20.7732C14.8562 21.4473 15.404 21.9951 16.0829 22H18.0438C18.9596 22.0023 19.8388 21.6428 20.4872 21.0007C21.1356 20.3586 21.5 19.4868 21.5 18.5775V9.86585C21.5 9.13139 21.1721 8.43471 20.6046 7.9635L13.943 2.67427C12.7785 1.74912 11.1154 1.77901 9.98539 2.74538L3.46701 7.9635C2.87274 8.42082 2.51755 9.11956 2.5 9.86585V18.5686C2.5 20.4637 4.04738 22 5.95617 22H7.87229C8.19917 22.0023 8.51349 21.8751 8.74547 21.6464C8.97746 21.4178 9.10793 21.1067 9.10792 20.7821H9.14373Z" fill="white"/>
+</svg>

+ 21 - 10
dashboard/src/components/Selector.tsx

@@ -6,6 +6,7 @@ type PropsType = {
   options: { value: string, label: string }[],
   setActiveValue: (x: string) => void,
   width: string,
+  height?: string,
   dropdownLabel?: string,
   dropdownWidth?: string,
   dropdownMaxHeight?: string,
@@ -41,6 +42,16 @@ export default class Selector extends Component<PropsType, StateType> {
     });
   }
 
+  renderDropdownLabel = () => {
+    if (this.props.dropdownLabel && this.props.dropdownLabel !== '') {
+      return (
+        <DropdownLabel>
+          {this.props.dropdownLabel}
+        </DropdownLabel>
+      );
+    }
+  }
+
   renderDropdown = () => {
     if (this.state.expanded) {
       return (
@@ -50,9 +61,7 @@ export default class Selector extends Component<PropsType, StateType> {
             dropdownWidth={this.props.dropdownWidth ? this.props.dropdownWidth : this.props.width}
             dropdownMaxHeight={this.props.dropdownMaxHeight}
           >
-            <DropdownLabel>
-              {this.props.dropdownLabel}
-            </DropdownLabel>
+            {this.renderDropdownLabel()}
             {this.renderOptionList()}
           </Dropdown>
         </div>
@@ -75,6 +84,7 @@ export default class Selector extends Component<PropsType, StateType> {
           onClick={() => this.setState({ expanded: !this.state.expanded })}
           expanded={this.state.expanded}
           width={this.props.width}
+          height={this.props.height}
         >
           <TextWrap>
             {activeValue === '' ? 'All' : this.getLabel(activeValue)}
@@ -91,6 +101,7 @@ const TextWrap = styled.div`
   white-space: nowrap;
   overflow: hidden;
   text-overflow: ellipsis;
+  z-index: 999;
 `;
 
 const DropdownLabel = styled.div`
@@ -102,6 +113,7 @@ const DropdownLabel = styled.div`
 
 const Option = styled.div` 
   width: 100%;
+  border-top: 1px solid #00000000;
   border-bottom: 1px solid ${(props: { selected: boolean, lastItem: boolean }) => props.lastItem ? '#ffffff00' : '#ffffff15'};
   height: 35px;
   font-size: 13px;
@@ -136,12 +148,11 @@ const Dropdown = styled.div`
   background: #26282f;
   width: ${(props: { dropdownWidth: string, dropdownMaxHeight: string }) => props.dropdownWidth};
   max-height: ${(props: { dropdownWidth: string, dropdownMaxHeight: string }) => props.dropdownMaxHeight ? props.dropdownMaxHeight : '300px'};
-  padding-bottom: 10px;
   border-radius: 3px;
   z-index: 999;
   overflow-y: auto;
   margin-bottom: 20px;
-  box-shadow: 0 8px 20px 0px #00000055;
+  box-shadow: 0 8px 20px 0px #00000088;
 `;
 
 const StyledSelector = styled.div`
@@ -149,8 +160,8 @@ const StyledSelector = styled.div`
 `;
 
 const MainSelector = styled.div`
-  width: ${(props: { expanded: boolean, width: string }) => props.width};
-  height: 30px;
+  width: ${(props: { expanded: boolean, width: string, height?: string }) => props.width};
+  height: ${(props: { expanded: boolean, width: string, height?: string }) => props.height ? props.height : '30px'};
   border: 1px solid #ffffff55;
   font-size: 13px;
   padding: 5px 10px;
@@ -160,13 +171,13 @@ const MainSelector = styled.div`
   align-items: center;
   justify-content: space-between;
   cursor: pointer;
-  background: ${(props: { expanded: boolean, width: string }) => props.expanded ? '#ffffff33' : '#ffffff11'};
+  background: ${(props: { expanded: boolean, width: string, height?: string }) => props.expanded ? '#ffffff33' : '#ffffff11'};
   :hover {
-    background: ${(props: { expanded: boolean, width: string }) => props.expanded ? '#ffffff33' : '#ffffff22'};
+    background: ${(props: { expanded: boolean, width: string, height?: string }) => props.expanded ? '#ffffff33' : '#ffffff22'};
   }
 
   > i {
     font-size: 20px;
-    transform: ${(props: { expanded: boolean, width: string }) => props.expanded ? 'rotate(180deg)' : ''};
+    transform: ${(props: { expanded: boolean, width: string, height?: string }) => props.expanded ? 'rotate(180deg)' : ''};
   }
 `;

+ 1 - 0
dashboard/src/components/repo-selector/BranchList.tsx

@@ -107,5 +107,6 @@ const LoadingWrapper = styled.div`
   display: flex;
   align-items: center;
   justify-content: center;
+  font-size: 13px;
   color: #ffffff44;
 `;

+ 2 - 0
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -149,6 +149,7 @@ const BackLabel = styled.div`
   font-size: 16px;
   padding-left: 16px;
   margin-top: -4px;
+  padding-bottom: 4px;
 `;
 
 const Item = styled.div`
@@ -189,6 +190,7 @@ const FileItem = styled(Item)`
 const LoadingWrapper = styled.div`
   padding: 30px 0px;
   background: #ffffff11;
+  font-size: 13px;
   display: flex;
   align-items: center;
   justify-content: center;

+ 7 - 1
dashboard/src/components/repo-selector/RepoSelector.tsx

@@ -5,6 +5,7 @@ import info from '../../assets/info.svg';
 
 import api from '../../shared/api';
 import { RepoType } from '../../shared/types';
+import { Context } from '../../shared/Context';
 
 import Loading from '../../components/Loading';
 import BranchList from './BranchList';
@@ -36,9 +37,11 @@ export default class RepoSelector extends Component<PropsType, StateType> {
   }
 
   componentDidMount() {
+    let { currentProject, currentCluster } = this.context;
 
     // Get repos
-    api.getRepos('<token>', {}, {}, (err: any, res: any) => {
+    api.getRepos('<token>', {
+    }, { id: currentProject.id }, (err: any, res: any) => {
       if (err) {
         this.setState({ loading: false, error: true });
       } else {
@@ -172,6 +175,8 @@ export default class RepoSelector extends Component<PropsType, StateType> {
   }
 }
 
+RepoSelector.contextType = Context;
+
 const SelectedBranch = styled.div`
   color: #ffffff55;
   margin-left: 10px;
@@ -233,6 +238,7 @@ const LoadingWrapper = styled.div`
   background: #ffffff11;
   display: flex;
   align-items: center;
+  font-size: 13px;
   justify-content: center;
   color: #ffffff44;
 `;

+ 9 - 3
dashboard/src/components/values-form/InputRow.tsx

@@ -2,11 +2,13 @@ import React, { ChangeEvent, Component } from 'react';
 import styled from 'styled-components';
 
 type PropsType = {
-  label: string,
+  label?: string,
   type: string,
   value: string | number,
   setValue: (x: string) => void,
   unit?: string
+  placeholder?: string
+  width?: string
 };
 
 type StateType = {
@@ -14,12 +16,14 @@ type StateType = {
 
 export default class InputRow extends Component<PropsType, StateType> {
   render() {
-    let { label, value, type, unit } = this.props;
+    let { label, value, type, unit, placeholder, width } = this.props;
     return (
       <StyledInputRow>
         <Label>{label}</Label>
         <InputWrapper>
           <Input
+            placeholder={placeholder}
+            width={width}
             type={type}
             value={value}
             onChange={(e: ChangeEvent<HTMLInputElement>) =>
@@ -49,7 +53,7 @@ const Input = styled.input`
   background: #ffffff11;
   border: 1px solid #ffffff55;
   border-radius: 3px;
-  width: 270px;
+  width: ${(props: { width: string }) => props.width ? props.width : '270px'};
   color: white;
   padding: 5px 8px;
   margin-right: 8px;
@@ -59,6 +63,8 @@ const Input = styled.input`
 const Label = styled.div`
   color: #ffffff;
   margin-bottom: 10px;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
 `;
 
 const StyledInputRow = styled.div`

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

@@ -169,7 +169,7 @@ const StyledValuesForm = styled.div`
   height: 100%;
   background: #ffffff11;
   color: #ffffff;
-  padding: 0px 35px 30px;
+  padding: 0px 35px 25px;
   position: relative;
   border-radius: 5px;
   font-size: 13px;

+ 87 - 14
dashboard/src/main/home/Home.tsx

@@ -3,13 +3,16 @@ import styled from 'styled-components';
 import ReactModal from 'react-modal';
 
 import { Context } from '../../shared/Context';
+import api from '../../shared/api';
 
 import Sidebar from './sidebar/Sidebar';
 import Dashboard from './dashboard/Dashboard';
-import ClusterConfigModal from './modals/ClusterConfigModal';
-import LaunchTemplateModal from './modals/LaunchTemplateModal';
+import ClusterDashboard from './cluster-dashboard/ClusterDashboard';
 import Loading from '../../components/Loading';
 import Templates from './templates/Templates';
+import LaunchTemplateModal from './modals/LaunchTemplateModal';
+import CreateProjectModal from './modals/CreateProjectModal';
+import ClusterInstructionsModal from './modals/ClusterInstructionsModal';
 
 type PropsType = {
   logOut: () => void
@@ -25,13 +28,27 @@ export default class Home extends Component<PropsType, StateType> {
   state = {
     forceSidebar: true,
     showWelcome: false,
-    currentView: 'dashboard'
+    currentView: 'cluster-dashboard'
+  }
+
+  componentDidMount() {
+    let { user } = this.context;
+    api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
+      if (err) {
+        // console.log(err)
+      } else if (res.data) {
+        if (res.data.length === 0) {
+          this.context.setCurrentModal('CreateProjectModal', { keepOpen: true });
+        }
+      }
+    });
   }
 
+  // TODO: move into ClusterDashboard
   renderDashboard = () => {
     let { currentCluster, setCurrentModal } = this.context;
 
-    if (currentCluster === '' || this.state.showWelcome) {
+    if (currentCluster === {} || this.state.showWelcome) {
       return (
         <DashboardWrapper>
           <Placeholder>
@@ -52,7 +69,7 @@ export default class Home extends Component<PropsType, StateType> {
 
     return (
       <DashboardWrapper>
-        <Dashboard
+        <ClusterDashboard
           currentCluster={currentCluster}
           setSidebar={(x: boolean) => this.setState({ forceSidebar: x })}
         />
@@ -61,37 +78,55 @@ export default class Home extends Component<PropsType, StateType> {
   }
 
   renderContents = () => {
-    if (this.state.currentView === 'dashboard') {
+    if (this.state.currentView === 'cluster-dashboard') {
       return (
         <StyledDashboard>
           {this.renderDashboard()}
         </StyledDashboard>
       );
+    } else if (this.state.currentView === 'dashboard') {
+      return (
+        <StyledDashboard>
+          <DashboardWrapper>
+            <Dashboard />
+          </DashboardWrapper>
+        </StyledDashboard>
+      );
     }
 
-    return <Templates />
+    return <Templates />;
   }
 
   render() {
+    let { currentModal, setCurrentModal, currentProject } = this.context;
     return (
       <StyledHome>
         <ReactModal
-          isOpen={this.context.currentModal === 'ClusterConfigModal'}
-          onRequestClose={() => this.context.setCurrentModal(null, null)}
+          isOpen={currentModal === 'LaunchTemplateModal'}
+          onRequestClose={() => setCurrentModal(null, null)}
           style={MediumModalStyles}
           ariaHideApp={false}
         >
-          <ClusterConfigModal />
+          <LaunchTemplateModal />
         </ReactModal>
         <ReactModal
-          isOpen={this.context.currentModal === 'LaunchTemplateModal'}
-          onRequestClose={() => this.context.setCurrentModal(null, null)}
-          style={MediumModalStyles}
+          isOpen={currentModal === 'CreateProjectModal'}
+          onRequestClose={() => currentProject ? setCurrentModal(null, null) : null }
+          style={ProjectModalStyles}
           ariaHideApp={false}
         >
-          <LaunchTemplateModal />
+          <CreateProjectModal />
+        </ReactModal>
+        <ReactModal
+          isOpen={currentModal === 'ClusterInstructionsModal'}
+          onRequestClose={() => setCurrentModal(null, null)}
+          style={TallModalStyles}
+          ariaHideApp={false}
+        >
+          <ClusterInstructionsModal />
         </ReactModal>
 
+
         <Sidebar
           logOut={this.props.logOut}
           forceSidebar={this.state.forceSidebar}
@@ -127,6 +162,44 @@ const MediumModalStyles = {
   },
 };
 
+const ProjectModalStyles = {
+  overlay: {
+    backgroundColor: 'rgba(0,0,0,0.6)',
+    zIndex: 2,
+  },
+  content: {
+    borderRadius: '7px',
+    border: 0,
+    width: '565px',
+    maxWidth: '80vw',
+    margin: '0 auto',
+    height: '225px',
+    top: 'calc(50% - 120px)',
+    backgroundColor: '#202227',
+    animation: 'floatInModal 0.5s 0s',
+    overflow: 'visible',
+  },
+};
+
+const TallModalStyles = {
+  overlay: {
+    backgroundColor: 'rgba(0,0,0,0.6)',
+    zIndex: 2,
+  },
+  content: {
+    borderRadius: '7px',
+    border: 0,
+    width: '760px',
+    maxWidth: '80vw',
+    margin: '0 auto',
+    height: '650px',
+    top: 'calc(50% - 325px)',
+    backgroundColor: '#202227',
+    animation: 'floatInModal 0.5s 0s',
+    overflow: 'visible',
+  },
+};
+
 const StyledDashboard = styled.div`
   height: 100%;
   width: 100vw;

+ 0 - 92
dashboard/src/main/home/Toolbar.tsx

@@ -1,92 +0,0 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
-import ReactModal from 'react-modal';
-
-import { Context } from '../../shared/Context';
-
-import ClusterConfigModal from './modals/ClusterConfigModal';
-
-type PropsType = {
-  logOut: () => void
-};
-
-type StateType = {
-};
-
-export default class Home extends Component<PropsType, StateType> {
-  render() {
-    return (
-      <StyledHome>
-        <ReactModal
-          isOpen={this.context.currentModal === 'ClusterConfigModal'}
-          onRequestClose={() => this.context.setCurrentModal(null)}
-          style={MediumModalStyles}
-          ariaHideApp={false}
-        >
-          <ClusterConfigModal />
-        </ReactModal>
-
-        <DummyDashboard>
-          🏗️🏗️🏗️🏗️🏗️
-        </DummyDashboard>
-      </StyledHome>
-    );
-  }
-}
-
-Home.contextType = Context;
-
-const MediumModalStyles = {
-  overlay: {
-    backgroundColor: 'rgba(0,0,0,0.6)',
-    zIndex: 2,
-  },
-  content: {
-    borderRadius: '7px',
-    border: 0,
-    width: '760px',
-    maxWidth: '80vw',
-    margin: '0 auto',
-    height: '575px',
-    top: 'calc(50% - 289px)',
-    backgroundColor: '#24272a',
-    animation: 'floatInModal 0.5s 0s',
-    overflow: 'visible',
-  },
-};
-
-const DummyDashboard = styled.div`
-  height: 100%;
-  width: 100vw;
-  font-family: 'Work Sans', sans-serif;
-  overflow-y: auto;
-  display: flex;
-  letter-spacing: 10px;
-  flex: 1;
-  justify-content: center;
-  padding-bottom: 30px;
-  align-items: center;
-  background: ${props => props.theme.bg};
-  position: relative;
-`;
-
-const StyledHome = styled.div`
-  width: 100vw;
-  height: 100vh;
-  position: fixed;
-  top: 0;
-  left: 0;
-  margin: 0;
-  user-select: none;
-  display: flex;
-  justify-content: center;
-
-  @keyframes floatInModal {
-    from {
-      opacity: 0; transform: translateY(30px);
-    }
-    to {
-      opacity: 1; transform: translateY(0px);
-    }
-  }
-`;

+ 327 - 0
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -0,0 +1,327 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import gradient from '../../../assets/gradient.jpg';
+
+import { Context } from '../../../shared/Context';
+import { ChartType, StorageType, Cluster } from '../../../shared/types';
+import api from '../../../shared/api';
+
+import ChartList from './chart/ChartList';
+import NamespaceSelector from './NamespaceSelector';
+import ExpandedChart from './expanded-chart/ExpandedChart';
+
+type PropsType = {
+  currentCluster: Cluster,
+  setSidebar: (x: boolean) => void
+};
+
+type StateType = {
+  namespace: string,
+  currentChart: ChartType | null
+};
+
+export default class ClusterDashboard extends Component<PropsType, StateType> {
+  state = {
+    namespace: '',
+    currentChart: null as (ChartType | null)
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+
+    // Reset namespace filter and close expanded chart on cluster change
+    if (prevProps.currentCluster !== this.props.currentCluster) {
+      this.setState({ namespace: '', currentChart: null });
+    }
+  }
+
+  // Allows rollback to update the top-level chart
+  refreshChart = () => {
+    let { currentProject } = this.context;
+    let { currentCluster } = this.props;
+    api.getChart('<token>', {
+      namespace: this.state.namespace,
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id,
+      storage: StorageType.Secret
+    }, {
+      name: this.state.currentChart.name,
+      revision: 0,
+      id: currentProject.id
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        this.setState({ currentChart: res.data });
+      }
+    });
+  }
+
+  renderDashboardIcon = () => {
+    if (false) {
+      let { currentCluster } = this.props;
+      return (
+        <DashboardIcon>
+          <DashboardImage src={gradient} />
+          <Overlay>{currentCluster && currentCluster.name[0].toUpperCase()}</Overlay>
+        </DashboardIcon>
+      );
+    }
+
+    return (
+      <DashboardIcon>
+        <i className="material-icons">device_hub</i>
+      </DashboardIcon>
+    );
+  }
+
+  renderContents = () => {
+    let { currentCluster, setSidebar } = this.props;
+
+    if (this.state.currentChart) {
+      return (
+        <ExpandedChart
+          currentChart={this.state.currentChart}
+          refreshChart={this.refreshChart}
+          setCurrentChart={(x: ChartType | null) => this.setState({ currentChart: x })}
+          setSidebar={setSidebar}
+        />
+      );
+    }
+
+    return (
+      <div>
+        <TitleSection>
+          {this.renderDashboardIcon()}
+          <Title>{currentCluster.name}</Title>
+          <i className="material-icons">more_vert</i>
+        </TitleSection>
+
+        <InfoSection>
+          <TopRow>
+            <InfoLabel>
+              <i className="material-icons">info</i> Info
+            </InfoLabel>
+          </TopRow>
+          <Description>Porter dashboard for {currentCluster.name}.</Description>
+        </InfoSection>
+
+        <LineBreak />
+        
+        <ControlRow>
+          <Button disabled={true}>
+            <i className="material-icons">add</i> Deploy a Chart
+          </Button>
+          <NamespaceSelector
+            setNamespace={(namespace) => this.setState({ namespace })}
+            namespace={this.state.namespace}
+          />
+        </ControlRow>
+
+        <ChartList
+          currentCluster={currentCluster}
+          namespace={this.state.namespace}
+          setCurrentChart={(x: ChartType) => this.setState({ currentChart: x })}
+        />
+      </div>
+    );
+  }
+
+  render() {
+    return (
+      <div>
+        {this.renderContents()}
+      </div>
+    );
+  }
+}
+
+ClusterDashboard.contextType = Context;
+
+const ControlRow = styled.div`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 35px;
+  padding-left: 0px;
+`;
+
+const TopRow = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Description = styled.div`
+  color: #ffffff;
+  margin-top: 13px;
+  margin-left: 2px;
+  font-size: 13px;
+`;
+
+const InfoLabel = styled.div`
+  width: 72px;
+  height: 20px;
+  display: flex;
+  align-items: center;
+  color: #7A838F;
+  font-size: 13px;
+  > i {
+    color: #8B949F;
+    font-size: 18px;
+    margin-right: 5px;
+  }
+`;
+
+const InfoSection = styled.div`
+  margin-top: 20px;
+  font-family: 'Work Sans', sans-serif;
+  margin-left: 0px;
+  margin-bottom: 35px;
+`;
+
+const Button = styled.div`
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  font-size: 13px;
+  cursor: pointer;
+  font-family: 'Work Sans', sans-serif;
+  border-radius: 20px;
+  color: white;
+  height: 30px;
+  padding: 0px 8px;
+  padding-bottom: 1px;
+  margin-right: 10px;
+  font-weight: 500;
+  padding-right: 15px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  box-shadow: 0 5px 8px 0px #00000010;
+  cursor: not-allowed;
+
+  background: ${(props: { disabled: boolean }) => props.disabled ? '#aaaabbee' :'#616FEEcc'};
+  :hover {
+    background: ${(props: { disabled: boolean }) => props.disabled ? '' : '#505edddd'};
+  }
+
+  > i {
+    color: white;
+    width: 18px;
+    height: 18px;
+    font-size: 12px;
+    border-radius: 20px;
+    display: flex;
+    align-items: center;
+    margin-right: 5px;
+    justify-content: center;
+  }
+`;
+
+const ButtonStack = styled(Button)`
+  min-width: 119px;
+  max-width: 119px;
+  background: #616FEEcc;
+  :hover {
+    background: #505edddd;
+  }
+`;
+
+const ButtonAlt = styled(Button)`
+  min-width: 150px;
+  max-width: 150px;
+  background: #7A838Fdd;
+
+  :hover {
+    background: #69727eee;
+  }
+`;
+
+const ConfigButtonAlt = styled(ButtonAlt)`
+  min-width: 166px;
+  max-width: 166px;
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 180px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 10px 80px 35px;
+`;
+
+const ServiceSection = styled.div`
+  padding-bottom: 150px;
+`;
+
+const Overlay = styled.div`
+  height: 100%;
+  width: 100%;
+  position: absolute;
+  background: #00000028;
+  top: 0;
+  left: 0;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 24px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+  color: white;
+`;
+
+const DashboardImage = styled.img`
+  height: 45px;
+  width: 45px;
+  border-radius: 5px;
+`;
+
+const DashboardIcon = styled.div`
+  position: relative;
+  height: 45px;
+  width: 45px;
+  border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #676C7C;
+  border: 2px solid #8e94aa;
+
+  > i {
+    font-size: 22px;
+  }
+`;
+
+const Title = styled.div`
+  font-size: 20px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+  margin-left: 18px;
+  color: #ffffff;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const TitleSection = styled.div`
+  height: 80px;
+  margin-top: 10px;
+  margin-bottom: 10px;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  padding-left: 0px;
+
+  > i {
+    margin-left: 10px;
+    cursor: not-allowed;
+    font-size 18px;
+    color: #858FAAaa;
+    padding: 5px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+    margin-bottom: -3px;
+  }
+`;

+ 16 - 9
dashboard/src/main/home/dashboard/NamespaceSelector.tsx → dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx

@@ -8,7 +8,6 @@ import Selector from '../../../components/Selector';
 
 type PropsType = {
   setNamespace: (x: string) => void,
-  currentCluster: string,
   namespace: string
 };
 
@@ -16,22 +15,25 @@ type StateType = {
   namespaceOptions: { label: string, value: string }[]
 };
 
-
-// TODO: display selected in option dropdown and actually filter!
-
+// TODO: fix update to unmounted component 
 export default class NamespaceSelector extends Component<PropsType, StateType> {
+  _isMounted = false;
+
   state = {
     namespaceOptions: [] as { label: string, value: string }[]
   }
 
   updateOptions = () => {
-    let { currentCluster } = this.context;
+    let { currentCluster, currentProject } = this.context;
 
-    api.getNamespaces('<token>', { context: currentCluster }, {}, (err: any, res: any) => {
-      if (err) {
+    api.getNamespaces('<token>', {
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id
+    }, { id: currentProject.id }, (err: any, res: any) => {
+      if (err && this._isMounted) {
         // setCurrentError('Could not read clusters: ' + JSON.stringify(err));
         this.setState({ namespaceOptions: [{ label: 'All', value: '' }] });
-      } else {
+      } else if (this._isMounted) {
         let namespaceOptions: { label: string, value: string }[] = [{ label: 'All', value: '' }];
         res.data.items.forEach((x: { metadata: { name: string }}, i: number) => {
           namespaceOptions.push({ label: x.metadata.name, value: x.metadata.name });
@@ -42,6 +44,7 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
   }
 
   componentDidMount() {
+    this._isMounted = true;
     this.updateOptions();
   }
 
@@ -51,6 +54,10 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
     }
   }
 
+  componentWillUnmount() {
+    this._isMounted = false;
+  }
+
   render() {
     return ( 
       <StyledNamespaceSelector>
@@ -61,7 +68,7 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
           activeValue={this.props.namespace}
           setActiveValue={(namespace) => this.props.setNamespace(namespace)}
           options={this.state.namespaceOptions}
-          dropdownLabel='Namespace:'
+          dropdownLabel='Namespace'
           width='150px'
           dropdownWidth='230px'
           closeOverlay={true}

+ 0 - 0
dashboard/src/main/home/dashboard/chart/Chart.tsx → dashboard/src/main/home/cluster-dashboard/chart/Chart.tsx


+ 10 - 6
dashboard/src/main/home/dashboard/chart/ChartList.tsx → dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -3,13 +3,13 @@ import styled from 'styled-components';
 
 import { Context } from '../../../../shared/Context';
 import api from '../../../../shared/api';
-import { ChartType, StorageType } from '../../../../shared/types';
+import { ChartType, StorageType, Cluster } from '../../../../shared/types';
 
 import Chart from './Chart';
 import Loading from '../../../../components/Loading';
 
 type PropsType = {
-  currentCluster: string,
+  currentCluster: Cluster,
   namespace: string,
   setCurrentChart: (c: ChartType) => void
 };
@@ -28,7 +28,7 @@ export default class ChartList extends Component<PropsType, StateType> {
   }
 
   updateCharts = () => {
-    let { currentCluster } = this.context;
+    let { currentCluster, currentProject } = this.context;
 
     this.setState({ loading: true });
     setTimeout(() => {
@@ -37,16 +37,20 @@ export default class ChartList extends Component<PropsType, StateType> {
       }
     }, 3000);
 
+    console.log(currentCluster.id, currentCluster.service_account_id);
+
     api.getCharts('<token>', {
       namespace: this.props.namespace,
-      context: currentCluster,
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id,
       storage: StorageType.Secret,
       limit: 20,
       skip: 0,
       byDate: false,
       statusFilter: ['deployed']
-    }, {}, (err: any, res: any) => {
-      if (err) {
+    }, { id: currentProject.id }, (err: any, res: any) => {
+        if (err) {
+        console.log(err)
         // setCurrentError(JSON.stringify(err));
         this.setState({ loading: false, error: true });
       } else {

+ 18 - 6
dashboard/src/main/home/dashboard/expanded-chart/ExpandedChart.tsx → dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -152,14 +152,19 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   }
 
   updateResources = () => {
-    let { currentCluster } = this.context;
+    let { currentCluster, currentProject } = this.context;
     let { currentChart } = this.props;
 
     api.getChartComponents('<token>', {
       namespace: currentChart.namespace,
-      context: currentCluster,
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id,
       storage: StorageType.Secret
-    }, { name: currentChart.name, revision: currentChart.version }, (err: any, res: any) => {
+    }, {
+      id: currentProject.id,
+      name: currentChart.name,
+      revision: currentChart.version
+    }, (err: any, res: any) => {
       if (err) {
         console.log(err)
       } else {
@@ -180,15 +185,20 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   }
 
   setRevisionPreview = (oldChart: ChartType) => {
-    let { currentCluster } = this.context;
+    let { currentCluster, currentProject } = this.context;
     this.setState({ revisionPreview: oldChart });
 
     if (oldChart) {
       api.getChartComponents('<token>', {
         namespace: oldChart.namespace,
-        context: currentCluster,
+        cluster_id: currentCluster.id,
+        service_account_id: currentCluster.service_account_id,
         storage: StorageType.Secret
-      }, { name: oldChart.name, revision: oldChart.version }, (err: any, res: any) => {
+      }, {
+        id: currentProject.id,
+        name: oldChart.name,
+        revision: oldChart.version
+      }, (err: any, res: any) => {
         if (err) {
           console.log(err)
         } else {
@@ -202,6 +212,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       } else if (this.state.currentTab === 'detailed-logs') {
         this.setState({ currentTab: 'graph' });
       }
+    } else {
+      this.updateResources();
     }
   }
 

+ 0 - 0
dashboard/src/main/home/dashboard/expanded-chart/GraphSection.tsx → dashboard/src/main/home/cluster-dashboard/expanded-chart/GraphSection.tsx


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


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

@@ -0,0 +1,29 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+};
+
+type StateType = {
+};
+
+export default class LogSection extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  render() {
+    return (
+      <StyledLogSection>
+        (Logs unimplemented)
+      </StyledLogSection>
+    );
+  }
+}
+
+const StyledLogSection = styled.div`
+  width: 100%;
+  height: 100%;
+  background: #202227;
+  position: relative;
+  padding: 20px;
+`;

+ 0 - 0
dashboard/src/main/home/dashboard/expanded-chart/ResourceItem.tsx → dashboard/src/main/home/cluster-dashboard/expanded-chart/ResourceItem.tsx


+ 12 - 7
dashboard/src/main/home/dashboard/expanded-chart/RevisionSection.tsx → dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx

@@ -32,12 +32,13 @@ export default class RevisionSection extends Component<PropsType, StateType> {
 
   refreshHistory = () => {
     let { chart } = this.props;
-
+    let { currentCluster, currentProject } = this.context;
     api.getRevisions('<token>', {
       namespace: chart.namespace,
-      context: this.context.currentCluster,
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id,
       storage: StorageType.Secret
-    }, { name: chart.name }, (err: any, res: any) => {
+    }, { id: currentProject.id, name: chart.name }, (err: any, res: any) => {
       if (err) {
         console.log(err)
       } else {
@@ -66,26 +67,30 @@ export default class RevisionSection extends Component<PropsType, StateType> {
   }
 
   handleRollback = () => {
-    let { setCurrentError, currentCluster } = this.context;
+    let { setCurrentError, currentCluster, currentProject } = this.context;
 
     let revisionNumber = this.state.rollbackRevision;
     this.setState({ loading: true, rollbackRevision: null });
 
     api.rollbackChart('<token>', {
       namespace: this.props.chart.namespace,
-      context: currentCluster,
       storage: StorageType.Secret,
       revision: revisionNumber
     }, {
-      name: this.props.chart.name
+      id: currentProject.id,
+      name: this.props.chart.name,
+      cluster_id: currentCluster.id,
+      service_account_id: currentCluster.service_account_id,
     }, (err: any, res: any) => {
       if (err) {
-        setCurrentError(err.response.data.errors[0]);
+        console.log(err);
+        setCurrentError(err.response.data);
         this.setState({ loading: false });
       } else {
         this.setState({ loading: false });
         this.props.refreshChart();
         this.refreshHistory();
+        this.props.setRevisionPreview(null);
       }
     });
   }

+ 0 - 0
dashboard/src/main/home/dashboard/expanded-chart/SettingsSection.tsx → dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx


+ 9 - 5
dashboard/src/main/home/dashboard/expanded-chart/ValuesYaml.tsx → dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx

@@ -45,18 +45,22 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
   }
 
   handleSaveValues = () => {
-    let { currentCluster, setCurrentError } = this.context;
+    let { currentCluster, setCurrentError, currentProject } = this.context;
     this.setState({ saveValuesStatus: 'loading' });
 
     api.upgradeChartValues('<token>', {
       namespace: this.props.currentChart.namespace,
-      context: currentCluster,
       storage: StorageType.Secret,
       values: this.state.values
-    }, { name: this.props.currentChart.name }, (err: any, res: any) => {
+    }, {
+      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.response.data.errors[0]);
-        this.setState({ saveValuesStatus: 'error ' });
+        setCurrentError(err.response.data);
+        this.setState({ saveValuesStatus: 'error' });
       } else {
         this.setState({ saveValuesStatus: 'successful' });
         this.props.refreshChart();

+ 0 - 0
dashboard/src/main/home/dashboard/expanded-chart/graph/Edge.tsx → dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/Edge.tsx


+ 0 - 0
dashboard/src/main/home/dashboard/expanded-chart/graph/GraphDisplay.tsx → dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx


+ 0 - 0
dashboard/src/main/home/dashboard/expanded-chart/graph/InfoPanel.tsx → dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/InfoPanel.tsx


+ 0 - 0
dashboard/src/main/home/dashboard/expanded-chart/graph/Node.tsx → dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/Node.tsx


+ 0 - 0
dashboard/src/main/home/dashboard/expanded-chart/graph/SelectRegion.tsx → dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/SelectRegion.tsx


+ 31 - 93
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -6,72 +6,33 @@ import { Context } from '../../../shared/Context';
 import { ChartType, StorageType } from '../../../shared/types';
 import api from '../../../shared/api';
 
-import ChartList from './chart/ChartList';
-import NamespaceSelector from './NamespaceSelector';
-import ExpandedChart from './expanded-chart/ExpandedChart';
-
 type PropsType = {
-  currentCluster: string,
-  setSidebar: (x: boolean) => void
 };
 
 type StateType = {
-  namespace: string,
-  currentChart: ChartType | null
 };
 
 export default class Dashboard extends Component<PropsType, StateType> {
   state = {
-    namespace: '',
-    currentChart: null as (ChartType | null)
-  }
-
-  componentDidUpdate(prevProps: PropsType) {
-
-    // Reset namespace filter and close expanded chart on cluster change
-    if (prevProps.currentCluster !== this.props.currentCluster) {
-      this.setState({ namespace: '', currentChart: null });
-    }
   }
 
-  // Allows rollback to update the top-level chart
-  refreshChart = () => {
-    let { currentCluster } = this.props;
-    api.getChart('<token>', {
-      namespace: this.state.namespace,
-      context: currentCluster,
-      storage: StorageType.Secret
-    }, { name: this.state.currentChart.name, revision: 0 }, (err: any, res: any) => {
-      if (err) {
-        console.log(err);
-      } else {
-        this.setState({ currentChart: res.data });
-      }
-    });
+  renderDashboardIcon = () => {
+    let { currentProject } = this.context;
+    return (
+      <DashboardIcon>
+        <DashboardImage src={gradient} />
+        <Overlay>{currentProject && currentProject.name[0].toUpperCase()}</Overlay>
+      </DashboardIcon>
+    );
   }
 
   renderContents = () => {
-    let { currentCluster, setSidebar } = this.props;
-
-    if (this.state.currentChart) {
-      return (
-        <ExpandedChart
-          currentChart={this.state.currentChart}
-          refreshChart={this.refreshChart}
-          setCurrentChart={(x: ChartType | null) => this.setState({ currentChart: x })}
-          setSidebar={setSidebar}
-        />
-      );
-    }
-
+    let { currentProject } = this.context;
     return (
       <div>
         <TitleSection>
-          <ProjectIcon>
-            <ProjectImage src={gradient} />
-            <Overlay>{currentCluster && currentCluster[0].toUpperCase()}</Overlay>
-          </ProjectIcon>
-          <Title>{currentCluster}</Title>
+          {this.renderDashboardIcon()}
+          <Title>{currentProject && currentProject.name}</Title>
           <i className="material-icons">more_vert</i>
         </TitleSection>
 
@@ -81,27 +42,14 @@ export default class Dashboard extends Component<PropsType, StateType> {
               <i className="material-icons">info</i> Info
             </InfoLabel>
           </TopRow>
-          <Description>Porter dashboard for {currentCluster}.</Description>
+          <Description>Porter dashboard for {currentProject && currentProject.name}.</Description>
         </InfoSection>
 
         <LineBreak />
-        
-        <ControlRow>
-          <Button disabled={true}>
-            <i className="material-icons">add</i> Deploy a Chart
-          </Button>
-          <NamespaceSelector
-            setNamespace={(namespace) => this.setState({ namespace })}
-            namespace={this.state.namespace}
-            currentCluster={currentCluster}
-          />
-        </ControlRow>
 
-        <ChartList
-          currentCluster={currentCluster}
-          namespace={this.state.namespace}
-          setCurrentChart={(x: ChartType) => this.setState({ currentChart: x })}
-        />
+        <Placeholder>
+          🚧 Pipelines under construction.
+        </Placeholder>
       </div>
     );
   }
@@ -117,12 +65,13 @@ export default class Dashboard extends Component<PropsType, StateType> {
 
 Dashboard.contextType = Context;
 
-const ControlRow = styled.div`
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 35px;
-  padding-left: 0px;
+const Placeholder = styled.div`
+  width: 100%;
+  margin-top: 200px;
+  color: #aaaabb;
+  text-align: center;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
 `;
 
 const TopRow = styled.div`
@@ -198,15 +147,6 @@ const Button = styled.div`
   }
 `;
 
-const ButtonStack = styled(Button)`
-  min-width: 119px;
-  max-width: 119px;
-  background: #616FEEcc;
-  :hover {
-    background: #505edddd;
-  }
-`;
-
 const ButtonAlt = styled(Button)`
   min-width: 150px;
   max-width: 150px;
@@ -217,11 +157,6 @@ const ButtonAlt = styled(Button)`
   }
 `;
 
-const ConfigButtonAlt = styled(ButtonAlt)`
-  min-width: 166px;
-  max-width: 166px;
-`;
-
 const LineBreak = styled.div`
   width: calc(100% - 180px);
   height: 2px;
@@ -229,10 +164,6 @@ const LineBreak = styled.div`
   margin: 10px 80px 35px;
 `;
 
-const ServiceSection = styled.div`
-  padding-bottom: 150px;
-`;
-
 const Overlay = styled.div`
   height: 100%;
   width: 100%;
@@ -250,17 +181,24 @@ const Overlay = styled.div`
   color: white;
 `;
 
-const ProjectImage = styled.img`
+const DashboardImage = styled.img`
   height: 45px;
   width: 45px;
   border-radius: 5px;
 `;
 
-const ProjectIcon = styled.div`
+const DashboardIcon = styled.div`
   position: relative;
   height: 45px;
   width: 45px;
   border-radius: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 22px;
+  }
 `;
 
 const Title = styled.div`

+ 0 - 379
dashboard/src/main/home/modals/ClusterConfigModal.tsx

@@ -1,379 +0,0 @@
-import React, { Component } from 'react';
-import styled from 'styled-components';
-import close from '../../../assets/close.png';
-
-import api from '../../../shared/api';
-import { Context } from '../../../shared/Context';
-import { KubeContextConfig } from '../../../shared/types';
-
-import YamlEditor from '../../../components/YamlEditor';
-import SaveButton from '../../../components/SaveButton';
-import TabSelector from '../../../components/TabSelector';
-
-type PropsType = {
-};
-
-type StateType = {
-  currentTab: string,
-  kubeContexts: KubeContextConfig[],
-  rawKubeconfig: string,
-  saveKubeconfigStatus: string | null,
-  saveSelectedStatus: string | null
-};
-
-const tabOptions = [
-  { label: 'Raw Kubeconfig', value: 'kubeconfig' },
-  { label: 'Select Clusters', value: 'select' }
-];
-
-export default class ClusterConfigModal extends Component<PropsType, StateType> {
-  state = {
-    currentTab: 'kubeconfig',
-    kubeContexts: [] as KubeContextConfig[],
-    rawKubeconfig: '# If you are using certificate files, include those explicitly',
-    saveKubeconfigStatus: null as (string | null),
-    saveSelectedStatus: null as (string | null),
-  };
-  
-  updateChecklist = () => {
-    let { setCurrentError, user } = this.context;
-
-    // Parse kubeconfig to retrieve all possible clusters
-    api.getContexts('<token>', {}, { id: user.userId }, (err: any, res: any) => {
-      if (err) {
-        // setCurrentError(JSON.stringify(err));
-      } else {
-        this.setState({ kubeContexts: res.data });
-      }
-    });
-  }
-
-  componentDidMount() {
-    let { setCurrentError, user, currentModalData } = this.context;
-
-    if (currentModalData && currentModalData.currentTab) {
-      this.setState({ currentTab: 'select' });
-    }
-
-    api.getUser('<token>', {}, { id: user.userId }, (err: any, res: any) => {
-      if (err) {
-        // setCurrentError(JSON.stringify(err));
-      } else if (res.data.rawKubeConfig !== '') {
-        this.setState({ rawKubeconfig: res.data.rawKubeConfig });
-      }
-    });
-
-    this.updateChecklist();
-  }
-
-  toggleCluster = (i: number): void => {
-    let newKubeContexts = this.state.kubeContexts;
-    newKubeContexts[i].selected = !newKubeContexts[i].selected;
-    this.setState({ kubeContexts: newKubeContexts });
-  };
-
-  renderClusterList = (): JSX.Element[] | JSX.Element => {
-    let { kubeContexts } = this.state;
-
-    if (kubeContexts && kubeContexts.length > 0) {
-      return kubeContexts.map((kubeContext: KubeContextConfig, i) => {
-        return (
-          <Row key={i} onClick={() => this.toggleCluster(i)}>
-            <Checkbox checked={kubeContext.selected}>
-              <i className="material-icons">done</i>
-            </Checkbox>
-            {kubeContext.name}
-          </Row>
-        );
-      })
-    }
-
-    return (
-      <Placeholder>
-        You need to 
-        <LinkText onClick={() => this.setState({ currentTab: 'kubeconfig'})}>
-          supply a kubeconfig
-        </LinkText>
-        first
-      </Placeholder>
-    );
-  };
-
-  handleSaveKubeconfig = () => {
-    let { rawKubeconfig } = this.state;
-    let { user } = this.context;
-
-    this.setState({ saveKubeconfigStatus: 'loading' });
-    api.updateUser(
-      '<token>',
-      { rawKubeConfig: rawKubeconfig },
-      { id: user.userId },
-      (err: any, res: any) => {
-        if (err) {
-          this.setState({ saveKubeconfigStatus: 'error' });
-        } else {
-          this.setState({ 
-            rawKubeconfig: res.data.rawKubeConfig,
-            saveKubeconfigStatus: 'successful'
-          });
-
-          this.updateChecklist();
-          this.context.currentModalData.updateClusters();
-        }
-      }
-    );
-  }
-
-  handleSaveSelected = () => {
-    let { kubeContexts } = this.state;
-    let { user } = this.context;
-
-    this.setState({ saveSelectedStatus: 'loading' });
-    let allowedContexts: string[] = [];
-    kubeContexts.forEach((x, i) => {
-      if (x.selected) {
-        allowedContexts.push(x.name);
-      }
-    });
-    
-    api.updateUser(
-      '<token>',
-      { allowedContexts },
-      { id: user.userId },
-      (err: any, res: any) => {
-        if (err) {
-          this.setState({ saveSelectedStatus: 'error' });
-        } else {
-          this.setState({ saveSelectedStatus: 'successful' });
-          this.updateChecklist();
-          this.context.currentModalData.updateClusters();
-        }
-      }
-    );
-  }
-  
-  renderTabContents = (): JSX.Element => {
-    if (this.state.currentTab === 'kubeconfig') {
-      return (
-        <div>
-          <Subtitle>Copy and paste your kubeconfig below</Subtitle>
-          <YamlEditor 
-            value={this.state.rawKubeconfig}
-            onChange={(e: any) => this.setState({ rawKubeconfig: e })}
-            height='327px'
-            border={true}
-          />
-          <UploadButton>
-            <i className="material-icons">cloud_upload</i> Upload Kubeconfig
-          </UploadButton>
-          <SaveButton
-            text='Save Kubeconfig'
-            onClick={this.handleSaveKubeconfig}
-            status={this.state.saveKubeconfigStatus}
-          />
-        </div>
-      )
-    }
-
-    return (
-      <div>
-        <Subtitle>Select the contexts you want Porter to use</Subtitle>
-        <ClusterList>
-          {this.renderClusterList()}
-        </ClusterList>
-        <SaveButton
-          text='Save Selected'
-          disabled={this.state.kubeContexts.length === 0}
-          onClick={this.handleSaveSelected}
-          status={this.state.saveSelectedStatus}
-        />
-      </div>
-    )
-  };
-
-  render() {
-    return (
-      <StyledClusterConfigModal>
-        <CloseButton onClick={() => {
-          this.context.setCurrentModal(null, null);
-        }}>
-          <CloseButtonImg src={close} />
-        </CloseButton>
-
-        <ModalTitle>Manage Clusters</ModalTitle>
-        <TabSelector
-          currentTab={this.state.currentTab}
-          options={tabOptions}
-          setCurrentTab={(value: string) => this.setState({ currentTab: value })}
-        />
-        {this.renderTabContents()}
-      </StyledClusterConfigModal>
-    );
-  }
-}
-
-ClusterConfigModal.contextType = Context;
-
-const UploadButton = styled.button`
-  display: flex;
-  align-items: center;
-  position: absolute;
-  bottom: 25px;
-  left: 30px;
-  height: 40px;
-  font-size: 13px;
-  font-weight: 500;
-  font-family: 'Work Sans', sans-serif;
-  color: white;
-  padding: 6px 20px 7px 20px;
-  text-align: left;
-  border: 0;
-  border-radius: 5px;
-  background: #ffffff11;
-  box-shadow: 0 2px 5px 0 #00000030;
-  cursor: not-allowed;
-  user-select: none;
-  :focus { outline: 0 }
-  :hover {
-    background: #ffffff22;
-  }
-
-  > i {
-    font-size: 18px;
-    margin-right: 12px;
-  }
-`;
-
-const Checkbox = styled.div`
-  width: 16px;
-  height: 16px;
-  border: 1px solid #ffffff44;
-  margin: 1px 15px 0px 12px;
-  border-radius: 3px;
-  background: ${(props: { checked: boolean }) => props.checked ? '#ffffff22' : ''};
-  display: flex;
-  align-items: center;
-  justify-content: center;
-
-  > i {
-    font-size: 12px;
-    padding-left: 0px;
-    display: ${(props: { checked: boolean }) => props.checked ? '' : 'none'};
-  }
-`;
-
-const Row = styled.div`
-  width: 100%;
-  height: 40px;
-  border-bottom: 1px solid #ffffff22;
-  display: flex;
-  align-items: center;
-  color: #ffffff;
-  font-size: 13px;
-  font-family: 'Work Sans', sans-serif;
-  cursor: pointer;
-  
-  :hover {
-    background: #ffffff11;
-  }
-`;
-
-const LinkText = styled.span`
-  font-weight: 500;
-  text-decoration: underline;
-  color: #949effcc;
-  cursor: pointer;
-  margin: 0px 5px;
-`;
-
-const Placeholder = styled.div`
-  color: #ffffff44;
-  font-family: 'Work Sans', sans-serif;
-  font-size: 13px;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  margin-top: -13px;
-  align-items: center;
-  justify-content: center;
-  user-select: none;
-`;
-
-const ClusterList = styled.div`
-  width: 100%;
-  height: 327px;
-  border-radius: 5px;
-  background: #ffffff06;
-  border: 1px solid #ffffff22;
-`;
-
-const Subtitle = styled.div`
-  padding: 17px 2px 25px;
-  font-family: 'Work Sans', sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-  margin-top: 8px;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-const ModalTitle = styled.div`
-  margin: 0px 2px 13px;
-  display: flex;
-  flex: 1;
-  font-family: 'Assistant';
-  font-size: 18px;
-  color: #ffffff;
-  font-weight: 700;
-  align-items: center;
-  position: relative;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-`;
-
-const Header = styled.div`
-  display: inline-block;
-  width: 100%;
-  font-size: 14px;
-  color: #7A838Faa;
-  font-family: 'Work Sans', sans-serif;
-`;
-
-const Plus = styled.span`
-  margin-right: 10px;
-`;
-
-const CloseButton = styled.div`
-  position: absolute;
-  z-index: 1;
-  display: block;
-  width: 40px;
-  height: 40px;
-  padding: 13px 0 12px 0;
-  text-align: center;
-  border-radius: 50%;
-  right: 15px;
-  top: 12px;
-  cursor: pointer;
-  :hover {
-    background-color: #ffffff11;
-  }
-`;
-
-const CloseButtonImg = styled.img`
-  width: 14px;
-  margin: 0 auto;
-`;
-
-const StyledClusterConfigModal= styled.div`
-  width: 100%;
-  position: absolute;
-  left: 0;
-  top: 0;
-  height: 100%;
-  padding: 25px 30px;
-  overflow: hidden;
-  border-radius: 6px;
-  background: #202227;
-`;

+ 165 - 0
dashboard/src/main/home/modals/ClusterInstructionsModal.tsx

@@ -0,0 +1,165 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import close from '../../../assets/close.png';
+import TabSelector from '../../../components/TabSelector';
+
+import { Context } from '../../../shared/Context';
+
+type PropsType = {
+};
+
+type StateType = {
+  currentTab: string,
+};
+
+const tabOptions = [
+  { label: 'MacOS', value: 'mac' }
+];
+
+export default class ClusterInstructionsModal extends Component<PropsType, StateType> {
+  state = {
+    currentTab: 'mac'
+  }
+ 
+  render() {
+    return (
+      <StyledClusterInstructionsModal>
+        <CloseButton onClick={() => {
+          this.context.setCurrentModal(null, null);
+        }}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+
+        <ModalTitle>Connecting to an Existing Cluster</ModalTitle>
+
+        <TabSelector
+          options={tabOptions}
+          currentTab={this.state.currentTab}
+          setCurrentTab={(value: string) => this.setState({ currentTab: value })}
+        />
+
+        <Placeholder>
+          1. Run the following command to retrieve the latest binary:
+          <Code>
+            &#123;<br />
+            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 \")<br />
+            name=$(basename $name)<br />
+            curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $name<br />
+            unzip -a $name<br />
+            rm $name<br />
+            &#125;
+          </Code>
+          2. Move the file into your bin:
+          <Code>
+            chmod +x ./porter<br />
+            sudo mv ./porter /usr/local/bin/porter
+          </Code>
+          3. Log in to the Porter CLI:
+          <Code>
+            porter auth login
+          </Code>
+          4. Configure the Porter CLI and link your current context:
+          <Code>
+            porter config set-project {this.context.currentProject.id}<br/>
+            porter config set-host {location.protocol + '//' + location.host}<br/>
+            porter connect kubeconfig
+          </Code>
+        </Placeholder>
+        
+      </StyledClusterInstructionsModal>
+    );
+  }
+}
+
+ClusterInstructionsModal.contextType = Context;
+
+const Code = styled.div`
+  background: #181B21;
+  padding: 10px 15px;
+  border: 1px solid #ffffff44;
+  border-radius: 5px;
+  margin: 10px 0px 15px;
+  color: #ffffff;
+  font-size: 13px;
+  user-select: text;
+  font-family: monospace;
+`;
+
+const A = styled.a`
+  color: #ffffff;
+  text-decoration: underline;
+  cursor: ${(props: { disabled?: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
+`;
+
+const Placeholder = styled.div`
+  color: #aaaabb;
+  font-size: 13px;
+  margin-left: 0px;
+  margin-top: 25px;
+  user-select: none;
+`;
+
+const Bold = styled.div`
+  font-weight: bold;
+  font-size: 20px;
+`;
+
+const Subtitle = styled.div`
+  padding: 17px 0px 25px;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  margin-top: 3px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: 'Assistant';
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledClusterInstructionsModal= styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 32px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

+ 202 - 0
dashboard/src/main/home/modals/CreateProjectModal.tsx

@@ -0,0 +1,202 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import close from '../../../assets/close.png';
+import gradient from '../../../assets/gradient.jpg';
+
+import api from '../../../shared/api';
+import { Context } from '../../../shared/Context';
+
+import SaveButton from '../../../components/SaveButton';
+import InputRow from '../../../components/values-form/InputRow';
+
+type PropsType = {
+};
+
+type StateType = {
+  projectName: string,
+  status: string | null
+};
+
+export default class CreateProjectModal extends Component<PropsType, StateType> {
+  state = {
+    projectName: '',
+    status: null as string | null,
+  };
+  
+  componentDidMount() {
+
+  }
+
+  createProject = () => {
+    this.setState({ status: 'loading' });
+    api.createProject('<token>', {
+      name: this.state.projectName
+    }, {}, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        this.context.currentModalData.updateProjects();
+        this.context.setCurrentModal(null, null);
+      }
+    });
+  }
+
+  renderCloseButton = () => {
+    if (this.context.currentModalData && !this.context.currentModalData.keepOpen) {
+      return (
+        <CloseButton onClick={() => {
+          this.context.setCurrentModal(null, null);
+        }}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+      );
+    }
+  }
+
+  isAlphanumeric = (x: string) => {
+    let re = /^[a-z0-9-]+$/;
+    if (x.length == 0 || x.search(re) === -1) {
+      return false;
+    }
+    return true;
+  }
+
+  render() {
+    return (
+      <StyledCreateProjectModal>
+        {this.renderCloseButton()}
+
+        <ModalTitle>New Project</ModalTitle>
+        <Subtitle>
+          Project name
+          <Warning highlight={!this.isAlphanumeric(this.state.projectName) && this.state.projectName !== ''}>
+            (lowercase letters, numbers, and "-" only)
+          </Warning>
+        </Subtitle>
+
+        <InputWrapper>
+          <ProjectIcon>
+            <ProjectImage src={gradient} />
+            <Letter>{this.state.projectName ? this.state.projectName[0].toUpperCase() : '-'}</Letter>
+          </ProjectIcon>
+          <InputRow
+            type='string'
+            value={this.state.projectName}
+            setValue={(x: string) => this.setState({ projectName: x })}
+            placeholder='ex: perspective-vortex'
+            width='470px'
+          />
+        </InputWrapper>
+
+        <SaveButton
+          text='Create'
+          disabled={!this.isAlphanumeric(this.state.projectName) || this.state.projectName === ''}
+          onClick={this.createProject}
+          status={this.state.status}
+        />
+      </StyledCreateProjectModal>
+      );
+  }
+}
+
+CreateProjectModal.contextType = Context;
+
+const Warning = styled.span`
+  color: ${(props: { highlight: boolean }) => props.highlight ? '#f5cb42' : ''};
+  margin-left: 5px;
+`;
+
+const Letter = styled.div`
+  height: 100%;
+  width: 100%;
+  position: absolute;
+  background: #00000028;
+  top: 0;
+  left: 0;
+  display: flex;
+  color: white;
+  align-items: center;
+  justify-content: center;
+`;
+
+const ProjectImage = styled.img`
+  width: 100%;
+  height: 100%;
+`;
+
+const ProjectIcon = styled.div`
+  width: 25px;
+  min-width: 25px;
+  height: 25px;
+  border-radius: 3px;
+  overflow: hidden;
+  position: relative;
+  margin-right: 10px;
+  font-weight: 400;
+  margin-top: 14px;
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  align-items: center;
+`;
+
+const Subtitle = styled.div`
+  margin-top: 23px;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 13px;
+  color: #aaaabb;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  margin-bottom: -10px;
+`;
+
+const ModalTitle = styled.div`
+  margin: 0px 0px 13px;
+  display: flex;
+  flex: 1;
+  font-family: 'Assistant';
+  font-size: 18px;
+  color: #ffffff;
+  user-select: none;
+  font-weight: 700;
+  align-items: center;
+  position: relative;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const CloseButton = styled.div`
+  position: absolute;
+  display: block;
+  width: 40px;
+  height: 40px;
+  padding: 13px 0 12px 0;
+  z-index: 1;
+  text-align: center;
+  border-radius: 50%;
+  right: 15px;
+  top: 12px;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+`;
+
+const CloseButtonImg = styled.img`
+  width: 14px;
+  margin: 0 auto;
+`;
+
+const StyledCreateProjectModal= styled.div`
+  width: 100%;
+  position: absolute;
+  left: 0;
+  top: 0;
+  height: 100%;
+  padding: 25px 32px;
+  overflow: hidden;
+  border-radius: 6px;
+  background: #202227;
+`;

+ 10 - 13
dashboard/src/main/home/modals/LaunchTemplateModal.tsx

@@ -4,7 +4,7 @@ import close from '../../../assets/close.png';
 
 import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
-import { KubeContextConfig, RepoType } from '../../../shared/types';
+import { Cluster, RepoType } from '../../../shared/types';
 
 import SaveButton from '../../../components/SaveButton';
 import Selector from '../../../components/Selector';
@@ -16,7 +16,7 @@ type PropsType = {
 
 type StateType = {
   currentView: string,
-  contextOptions: { label: string, value: string }[],
+  clusterOptions: { label: string, value: string }[],
   selectedCluster: string,
   selectedRepo: RepoType | null,
   selectedBranch: string,
@@ -26,27 +26,24 @@ type StateType = {
 export default class LaunchTemplateModal extends Component<PropsType, StateType> {
   state = {
     currentView: 'repo',
-    contextOptions: [] as { label: string, value: string }[],
-    selectedCluster: this.context.currentCluster,
+    clusterOptions: [] as { label: string, value: string }[],
+    selectedCluster: this.context.currentCluster.name,
     selectedRepo: null as RepoType | null,
     selectedBranch: '',
     subdirectory: '',
   };
   
   componentDidMount() {
-    let { setCurrentError, user } = this.context;
+    let { currentProject } = this.context;
 
     // TODO: query with selected filter once implemented
-    api.getContexts('<token>', {}, { id: user.userId }, (err: any, res: any) => {
+    api.getClusters('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
       if (err) {
         // console.log(err)
       } else if (res.data) {
-
-        // Filter selected (temporary)
-        let kubeContexts = res.data.filter((x: KubeContextConfig) => x.selected);
-        let contextOptions = kubeContexts.map((x: KubeContextConfig) => { return { label: x.name, value: x.name } });
-        if (kubeContexts.length > 0) {
-          this.setState({ contextOptions });
+        let clusterOptions = res.data.map((x: Cluster) => { return { label: x.name, value: x.name } });
+        if (res.data.length > 0) {
+          this.setState({ clusterOptions });
         }
       }
     });
@@ -129,7 +126,7 @@ export default class LaunchTemplateModal extends Component<PropsType, StateType>
             <Selector
               activeValue={this.state.selectedCluster}
               setActiveValue={(cluster: string) => this.setState({ selectedCluster: cluster })}
-              options={this.state.contextOptions}
+              options={this.state.clusterOptions}
               width='250px'
               dropdownWidth='335px'
               closeOverlay={true}

+ 40 - 24
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -4,7 +4,7 @@ import drawerBg from '../../../assets/drawer-bg.png';
 
 import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
-import { KubeContextConfig } from '../../../shared/types';
+import { Cluster } from '../../../shared/types';
 
 import Drawer from './Drawer';
 
@@ -19,7 +19,10 @@ type PropsType = {
 type StateType = {
   showDrawer: boolean,
   initializedDrawer: boolean,
-  kubeContexts: KubeContextConfig[]
+  clusters: Cluster[],
+
+  // Track last project id for refreshing clusters on project change
+  prevProjectId: number
 };
 
 export default class ClusterSection extends Component<PropsType, StateType> {
@@ -28,14 +31,15 @@ export default class ClusterSection extends Component<PropsType, StateType> {
   state = {
     showDrawer: false,
     initializedDrawer: false,
-    kubeContexts: [] as KubeContextConfig[]
+    clusters: [] as Cluster[],
+    prevProjectId: this.context.currentProject.id
   };
 
   updateClusters = () => {
-    let { setCurrentError, user, setCurrentCluster } = this.context;
+    let { currentProject, setCurrentCluster } = this.context;
 
     // TODO: query with selected filter once implemented
-    api.getContexts('<token>', {}, { id: user.userId }, (err: any, res: any) => {
+    api.getClusters('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
       if (err) {
 
         // Assume intializing if no contexts
@@ -45,15 +49,13 @@ export default class ClusterSection extends Component<PropsType, StateType> {
         
         // TODO: handle uninitialized kubeconfig
         if (res.data) {
-
-          // Filter selected (temporary)
-          let kubeContexts = res.data.filter((x: KubeContextConfig) => x.selected);
-          if (kubeContexts.length > 0) {
-            this.setState({ kubeContexts });
-            setCurrentCluster(kubeContexts[0].name);
+          let clusters = res.data;
+          if (clusters.length > 0) {
+            this.setState({ clusters });
+            setCurrentCluster(clusters[0]);
           } else {
-            this.setState({ kubeContexts: [] });
-            setCurrentCluster('');
+            this.setState({ clusters: [] });
+            setCurrentCluster({});
           }
         }
       }
@@ -67,6 +69,13 @@ export default class ClusterSection extends Component<PropsType, StateType> {
   // Need to override showDrawer when the sidebar is closed
   componentDidUpdate(prevProps: PropsType) {
     if (prevProps !== this.props) {
+
+      // Refresh clusters on project change 
+      if (this.state.prevProjectId !== this.context.currentProject.id) {
+        this.updateClusters();
+        this.setState({ prevProjectId: this.context.currentProject.id });
+      }
+
       if (this.props.forceCloseDrawer && this.state.showDrawer) {
         this.setState({ showDrawer: false });
         this.props.releaseDrawer();
@@ -85,10 +94,9 @@ export default class ClusterSection extends Component<PropsType, StateType> {
     if (this.state.initializedDrawer) {
       return (
         <Drawer
-          updateClusters={this.updateClusters}
           toggleDrawer={this.toggleDrawer}
           showDrawer={this.state.showDrawer}
-          kubeContexts={this.state.kubeContexts}
+          clusters={this.state.clusters}
         />
       );
     }
@@ -99,15 +107,15 @@ export default class ClusterSection extends Component<PropsType, StateType> {
   }
 
   renderContents = (): JSX.Element => {
-    let { kubeContexts, showDrawer } = this.state;
+    let { clusters, showDrawer } = this.state;
     let { currentCluster } = this.context;
 
-    if (kubeContexts.length > 0) {
+    if (clusters.length > 0) {
       return (
         <ClusterSelector isSelected={this.props.isSelected}>
-          <LinkWrapper onClick={() => this.props.setCurrentView('dashboard')}>
+          <LinkWrapper onClick={() => this.props.setCurrentView('cluster-dashboard')}>
             <ClusterIcon><i className="material-icons">device_hub</i></ClusterIcon>
-            <ClusterName>{currentCluster}</ClusterName>
+            <ClusterName>{currentCluster && currentCluster.name}</ClusterName>
           </LinkWrapper>
           <DrawerButton onClick={this.toggleDrawer}>
             <BgAccent src={drawerBg} />
@@ -116,11 +124,19 @@ export default class ClusterSection extends Component<PropsType, StateType> {
             </DropdownIcon>
           </DrawerButton>
         </ClusterSelector>
-      )
+      );
+    } else if (false) {
+      return (
+        <InitializeButton onClick={this.showClusterConfigModal}>
+          <Plus>+</Plus> Add a Cluster
+        </InitializeButton>
+      );
     }
 
     return (
-      <InitializeButton onClick={this.showClusterConfigModal}>
+      <InitializeButton
+        onClick={() => this.context.setCurrentModal('ClusterInstructionsModal', {})}
+      >
         <Plus>+</Plus> Add a Cluster
       </InitializeButton>
     )
@@ -198,7 +214,7 @@ const ClusterName = styled.div`
   display: inline-block;
   width: 130px;
   margin-left: 3px;
-  font-weight: 600;
+  font-weight: 400;
 `;
 
 const DropdownIcon = styled.span`
@@ -233,11 +249,11 @@ const DropdownIcon = styled.span`
 
 const ClusterIcon = styled.div`
   > i {
-    font-size: 18px;
+    font-size: 16px;
     display: flex;
     align-items: center;
     margin-bottom: 0px;
-    margin-left: 15px;
+    margin-left: 17px;
     margin-right: 10px;
   }
 `;

+ 12 - 13
dashboard/src/main/home/sidebar/Drawer.tsx

@@ -3,13 +3,12 @@ import styled from 'styled-components';
 import close from '../../../assets/close.png';
 
 import { Context } from '../../../shared/Context';
-import { KubeContextConfig } from '../../../shared/types';
+import { Cluster } from '../../../shared/types';
 
 type PropsType = {
   toggleDrawer: () => void,
   showDrawer: boolean,
-  kubeContexts: KubeContextConfig[],
-  updateClusters: () => void
+  clusters: Cluster[]
 };
 
 type StateType = {
@@ -18,11 +17,11 @@ type StateType = {
 export default class Drawer extends Component<PropsType, StateType> {
 
   renderClusterList = (): JSX.Element[] | JSX.Element => {
-    let { kubeContexts } = this.props;
+    let { clusters } = this.props;
     let { currentCluster, setCurrentCluster } = this.context;
 
-    if (kubeContexts.length > 0) {
-      return kubeContexts.map((kubeContext: KubeContextConfig, i: number) => {
+    if (clusters.length > 0) {
+      return clusters.map((cluster: Cluster, i: number) => {
         /*
         let active = this.context.activeProject &&
           this.context.activeProject.namespace == val.namespace; 
@@ -31,11 +30,11 @@ export default class Drawer extends Component<PropsType, StateType> {
         return (
           <ClusterOption
             key={i}
-            active={kubeContext.name === currentCluster}
-            onClick={() => setCurrentCluster(kubeContext.name)}
+            active={cluster.name === currentCluster.name}
+            onClick={() => setCurrentCluster(cluster)}
           >
             <ClusterIcon><i className="material-icons">device_hub</i></ClusterIcon>
-            <ClusterName>{kubeContext.name}</ClusterName>
+            <ClusterName>{cluster.name}</ClusterName>
           </ClusterOption>
         );
       });
@@ -64,9 +63,9 @@ export default class Drawer extends Component<PropsType, StateType> {
           {this.renderClusterList()}
 
           <InitializeButton onClick={() => {
-            this.context.setCurrentModal('ClusterConfigModal', { updateClusters: this.props.updateClusters });
+            this.context.setCurrentModal('ClusterInstructionsModal', {});
           }}>
-            <Plus>+</Plus> Manage Clusters
+            <Plus>+</Plus> Add a Cluster
           </InitializeButton>
         </StyledDrawer>
       </div>
@@ -173,11 +172,11 @@ const CloseButtonImg = styled.img`
 
 const ClusterIcon = styled.div`
   > i {
-    font-size: 18px;
+    font-size: 16px;
     display: flex;
     align-items: center;
     margin-bottom: 0px;
-    margin-left: 15px;
+    margin-left: 17px;
     margin-right: 10px;
   }
 `;

+ 284 - 0
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -0,0 +1,284 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import gradient from '../../../assets/gradient.jpg';
+
+import api from '../../../shared/api';
+import { Context } from '../../../shared/Context';
+import { ProjectType } from '../../../shared/types';
+
+type PropsType = {
+  setWelcome?: (x: boolean) => void,
+  setCurrentView?: (x: string) => void,
+};
+
+type StateType = {
+  projects: ProjectType[],
+  expanded: boolean
+};
+
+const options = [
+  { label: 'Thunder', value: 'z' },
+  { label: 'Lightning', value: 'x' },
+  { label: 'Storm', value: 'qq' },
+  { label: 'Backlog', value: 'd' },
+]
+
+export default class ProjectSection extends Component<PropsType, StateType> {
+  state = {
+    projects: [] as ProjectType[],
+    expanded: false,
+  };
+
+  updateProjects = () => {
+    let { user } = this.context;
+    api.getProjects('<token>', {}, { id: user.userId }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else if (res.data) {
+        this.setState({ projects: res.data });
+        if (res.data.length > 0) {
+          this.context.setCurrentProject(res.data[0]);
+        }
+      }
+    });
+  }
+
+  componentDidMount() {
+    this.updateProjects();
+  }
+  
+  showProjectCreateModal = () => {
+    this.context.setCurrentModal('CreateProjectModal', {
+      keepOpen: false,
+      updateProjects: this.updateProjects
+    });
+  }
+
+  renderOptionList = () => {
+    return this.state.projects.map((project: ProjectType, i: number) => {
+      return (
+        <Option
+          key={i}
+          selected={project.name === this.context.currentProject}
+          onClick={() => this.context.setCurrentProject(project)}
+        >
+          <ProjectIcon>
+            <ProjectImage src={gradient} />
+            <Letter>{project.name[0].toUpperCase()}</Letter>
+          </ProjectIcon>
+          <ProjectLabel>{project.name}</ProjectLabel>
+        </Option>
+      );
+    });
+  }
+
+  renderDropdown = () => {
+    if (this.state.expanded) {
+      return (
+        <div>
+          <CloseOverlay onClick={() => this.setState({ expanded: false })} />
+          <Dropdown>
+            {this.renderOptionList()}
+            <Option
+              selected={false}
+              lastItem={true}
+              onClick={this.showProjectCreateModal}
+            >
+              <ProjectIconAlt>+</ProjectIconAlt>
+              <ProjectLabel>Add a project</ProjectLabel>
+            </Option>
+          </Dropdown>
+        </div>
+      );
+    }
+  }
+
+  render() {
+    let { currentProject } = this.context;
+    if (currentProject) {
+      return (
+        <StyledProjectSection>
+          <MainSelector
+            onClick={() => this.setState({ expanded: !this.state.expanded })}
+            expanded={this.state.expanded}
+          >
+            <ProjectIcon>
+              <ProjectImage src={gradient} />
+              <Letter>{currentProject.name[0].toUpperCase()}</Letter>
+            </ProjectIcon>
+            <ProjectName>{currentProject.name}</ProjectName>
+            <i className="material-icons">arrow_drop_down</i>
+          </MainSelector>
+          {this.renderDropdown()}
+        </StyledProjectSection>
+      );
+    }
+    return (
+      <InitializeButton onClick={this.showProjectCreateModal}>
+        <Plus>+</Plus> Create a Project
+      </InitializeButton>
+    );
+  }
+}
+
+ProjectSection.contextType = Context;
+
+const ProjectLabel = styled.div`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const AddButton = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  padding: 12px 15px;
+`;
+
+const Plus = styled.div`
+  margin-right: 10px;
+  font-size: 15px;
+`;
+
+const InitializeButton = styled.div`
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: calc(100% - 10px);
+  height: 38px;
+  margin: 8px 5px;
+  font-size: 13px;
+  font-weight: 500;
+  border-radius: 3px;
+  color: #ffffff;
+  padding-bottom: 1px;
+  cursor: pointer;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+`;
+
+const Option = styled.div` 
+  width: 100%;
+  border-top: 1px solid #00000000;
+  border-bottom: 1px solid ${(props: { selected: boolean, lastItem?: boolean }) => props.lastItem ? '#ffffff00' : '#ffffff15'};
+  height: 45px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  align-items: center;
+  padding-left: 10px;
+  cursor: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '' : 'pointer'};;
+  padding-right: 10px;
+  background: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '#ffffff11' : ''};
+  :hover {
+    background: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '' : '#ffffff22'};
+  }
+
+  > i {
+    font-size: 18px;
+    margin-right: 12px;
+    margin-left: 5px;
+    color: #ffffff44;
+  }
+`;
+
+const CloseOverlay = styled.div`
+  position: fixed;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  z-index: 999;
+`;
+
+const Dropdown = styled.div`
+  position: absolute;
+  right: 10px;
+  top: calc(100% + 5px);
+  background: #26282f;
+  width: 180px;
+  max-height: 500px;
+  border-radius: 3px;
+  z-index: 999;
+  overflow-y: auto;
+  margin-bottom: 20px;
+  box-shadow: 0 5px 15px 5px #00000077;
+`;
+
+const ProjectName = styled.div`
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`;
+
+const Letter = styled.div`
+  height: 100%;
+  width: 100%;
+  position: absolute;
+  background: #00000028;
+  top: 0;
+  left: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const ProjectImage = styled.img`
+  width: 100%;
+  height: 100%;
+`;
+
+const ProjectIcon = styled.div`
+  width: 25px;
+  min-width: 25px;
+  height: 25px;
+  border-radius: 3px;
+  overflow: hidden;
+  position: relative;
+  margin-right: 10px;
+  font-weight: 400;
+`;
+
+const ProjectIconAlt = styled(ProjectIcon)`
+  border: 1px solid #ffffff44;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+`;
+
+const StyledProjectSection = styled.div`
+  position: relative;
+`;
+
+const MainSelector = styled.div`
+  display: flex;
+  align-items: center;
+  margin: 10px 0 0;
+  font-size: 14px;
+  font-family: 'Work Sans', sans-serif;
+  font-weight: 600;
+  cursor: pointer;
+  padding: 10px 0;
+  padding-left: 20px;
+  :hover {
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > i {
+    margin-left: 7px;
+    margin-right: 12px;
+    font-size: 20px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border-radius: 20px;
+    background: ${(props: { expanded: boolean }) => props.expanded ? '#ffffff22' : ''};
+  }
+`;

+ 67 - 34
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -4,11 +4,13 @@ import gradient from '../../../assets/gradient.jpg';
 import category from '../../../assets/category.svg';
 import pipelines from '../../../assets/pipelines.svg';
 import integrations from '../../../assets/integrations.svg';
+import filter from '../../../assets/filter.svg';
 
 import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
 
 import ClusterSection from './ClusterSection';
+import ProjectSection from './ProjectSection';
 
 type PropsType = {
   logOut: () => void,
@@ -102,41 +104,25 @@ export default class Sidebar extends Component<PropsType, StateType> {
     }); 
   }
 
-  // SidebarBg is separate to cover retracted drawer
-  render() {
-    return (
-      <div>
-        {this.renderPullTab()}
-        <StyledSidebar showSidebar={this.state.showSidebar}>
-          <SidebarBg />
-          <CollapseButton 
-            onClick={this.toggleSidebar} 
-            onMouseOver={() => { this.setState({ showTooltip: true }) }}
-            onMouseOut={() => { this.setState({ showTooltip: false }) }}
-          >
-            {this.renderTooltip()}
-            <i className="material-icons">double_arrow</i>
-          </CollapseButton>
-
-          <UserSection>
-            <RingWrapper>
-              <UserIcon src={gradient} />
-            </RingWrapper>
-            <UserName>{this.context.user.email}</UserName>
-          </UserSection>
-
+  renderProjectContents = () => {
+    if (this.context.currentProject) {
+      return (
+        <div>
           <SidebarLabel>Home</SidebarLabel>
+          <NavButton
+            onClick={() => this.props.setCurrentView('dashboard')}
+            selected={this.props.currentView === 'dashboard'}
+          >
+            <img src={category} />
+            Dashboard
+          </NavButton>
           <NavButton
             onClick={() => this.props.setCurrentView('templates')}
             selected={this.props.currentView === 'templates'}
           >
-            <img src={category} />
+            <img src={filter} />
             Templates
           </NavButton>
-          <NavButton disabled={true}>
-            <img src={pipelines} />
-            Pipelines
-          </NavButton>
           <NavButton disabled={true}>
             <img src={integrations} />
             Integrations
@@ -150,12 +136,45 @@ export default class Sidebar extends Component<PropsType, StateType> {
             releaseDrawer={() => this.setState({ forceCloseDrawer: false })}
             setWelcome={this.props.setWelcome}
             setCurrentView={this.props.setCurrentView}
-            isSelected={this.props.currentView === 'dashboard'}
+            isSelected={this.props.currentView === 'cluster-dashboard'}
           />
+        </div>
+      );
+    }
+
+    // Render placeholder if no project exists
+    return (
+      <ProjectPlaceholder>
+        No projects found.
+      </ProjectPlaceholder>
+    );
+  }
+
+  // SidebarBg is separate to cover retracted drawer
+  render() {
+    return (
+      <div>
+        {this.renderPullTab()}
+        <StyledSidebar showSidebar={this.state.showSidebar}>
+          <SidebarBg />
+          <CollapseButton 
+            onClick={this.toggleSidebar} 
+            onMouseOver={() => { this.setState({ showTooltip: true }) }}
+            onMouseOut={() => { this.setState({ showTooltip: false }) }}
+          >
+            {this.renderTooltip()}
+            <i className="material-icons">double_arrow</i>
+          </CollapseButton>
+
+          <ProjectSection />
+
+          <br />
+
+          {this.renderProjectContents()}
 
           <BottomSection>
             <LogOutButton onClick={this.handleLogout}>
-              Log Out <i className="material-icons">keyboard_return</i>
+            Log Out <i className="material-icons">keyboard_return</i>
             </LogOutButton>
           </BottomSection>
         </StyledSidebar>
@@ -166,14 +185,28 @@ export default class Sidebar extends Component<PropsType, StateType> {
 
 Sidebar.contextType = Context;
 
+const ProjectPlaceholder = styled.div`
+  background: #ffffff11;
+  border-radius: 5px;
+  margin: 0 15px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: calc(100% - 180px);
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+  color: #ffffff44;
+  margin-top: 20px;
+`;
+
 const NavButton = styled.div`
   display: block;
   position: relative;
   text-decoration: none;
   height: 42px;
-  padding: 10px 35px 12px 53px;
+  padding: 12px 35px 1px 53px;
   font-size: 14px;
-  font-family: 'Hind Siliguri', sans-serif;
+  font-family: 'Work Sans', sans-serif;
   color: #ffffff;
   overflow: hidden;
   white-space: nowrap;
@@ -256,7 +289,7 @@ const SidebarLabel = styled.div`
 const UserSection = styled.div`
   width: 100%;
   height: 40px;
-  margin: 6px 0px 25px;
+  margin: 6px 0px 17px;
   display: flex;
   flex: 1;
   flex-direction: row;
@@ -349,7 +382,7 @@ const CollapseButton = styled.div`
   top: 8px;
   height: 23px;
   width: 23px;
-  background: #525563;
+  background: #525563aa;
   border-top-left-radius: 3px;
   border-bottom-left-radius: 3px;
   cursor: pointer;

+ 1 - 0
dashboard/src/main/home/templates/expanded-template/ExpandedTemplate.tsx

@@ -89,6 +89,7 @@ const ContentSection = styled.div`
   font-size: 14px;
   line-height: 1.8em;
   padding-bottom: 100px;
+  overflow: hidden;
 `;
 
 const Tag = styled.div`

+ 13 - 7
dashboard/src/shared/Context.tsx

@@ -1,5 +1,7 @@
 import React, { Component } from 'react';
 
+import { ProjectType, Cluster } from '../shared/types';
+
 type PropsType = {
 }
 
@@ -25,24 +27,28 @@ const ContextConsumer = Context.Consumer;
 class ContextProvider extends Component {
   state = {
     currentModal: null as string | null,
-    setCurrentModal: (currentModal: string, currentModalData?: any): void => {
+    currentModalData: null as any,
+    setCurrentModal: (currentModal: string, currentModalData?: any) => {
       this.setState({ currentModal, currentModalData });
     },
-    currentModalData: null as any,
     currentError: null as string | null,
-    setCurrentError: (currentError: string): void => {
+    setCurrentError: (currentError: string) => {
       this.setState({ currentError });
     },
-    currentCluster: null as string | null,
-    setCurrentCluster: (currentCluster: string): void => {
+    currentCluster: null as Cluster | null,
+    setCurrentCluster: (currentCluster: Cluster) => {
       this.setState({ currentCluster });
     },
+    currentProject: null as ProjectType | null,
+    setCurrentProject: (currentProject: ProjectType) => {
+      this.setState({ currentProject });
+    },
     user: null as any,
-    setUser: (userId: number, email: string): void => {
+    setUser: (userId: number, email: string) => {
       this.setState({ user: {userId, email} });
     },
     devOpsMode: true,
-    setDevOpsMode: (devOpsMode: boolean): void => {
+    setDevOpsMode: (devOpsMode: boolean) => {
       this.setState({ devOpsMode });
     }
   };

+ 55 - 24
dashboard/src/shared/api.tsx

@@ -36,39 +36,47 @@ const updateUser = baseApi<{
   return `/api/users/${pathParams.id}`;
 });
 
-const getContexts = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/users/${pathParams.id}/contexts`;
+const getClusters = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/clusters`;
 });
 
 const getCharts = baseApi<{
   namespace: string,
-  context: string,
+  cluster_id: number,
+  service_account_id: number,
   storage: StorageType,
   limit: number,
   skip: number,
   byDate: boolean,
   statusFilter: string[]
-}>('GET', '/api/releases');
+}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases`;
+});
 
 const getChart = baseApi<{
   namespace: string,
-  context: string,
+  cluster_id: number,
+  service_account_id: number,
   storage: StorageType
-}, { name: string, revision: number }>('GET', pathParams => {
-  return `/api/releases/${pathParams.name}/${pathParams.revision}`;
+}, { id: number, name: string, revision: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}`;
 });
 
 const getChartComponents = baseApi<{
   namespace: string,
-  context: string,
+  cluster_id: number,
+  service_account_id: number,
   storage: StorageType
-}, { name: string, revision: number }>('GET', pathParams => {
-  return `/api/releases/${pathParams.name}/${pathParams.revision}/components`;
+}, { id: number, name: string, revision: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/components`;
 });
 
 const getNamespaces = baseApi<{
-  context: string
-}>('GET', '/api/k8s/namespaces');
+  cluster_id: number,
+  service_account_id: number,
+}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/k8s/namespaces`;
+});
 
 const getMatchingPods = baseApi<{
   context: string,
@@ -77,33 +85,46 @@ const getMatchingPods = baseApi<{
 
 const getRevisions = baseApi<{
   namespace: string,
-  context: string,
+  cluster_id: number,
+  service_account_id: number,
   storage: StorageType
-}, { name: string }>('GET', pathParams => {
-  return `/api/releases/${pathParams.name}/history`;
+}, { id: number, name: string }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/history`;
 });
 
 const rollbackChart = baseApi<{
   namespace: string,
-  context: string,
   storage: StorageType,
   revision: number
-}, { name: string }>('POST', pathParams => {
-  return `/api/releases/${pathParams.name}/rollback`;
+}, {
+  id: number,
+  name: string,
+  cluster_id: number,
+  service_account_id: number,
+  }>('POST', pathParams => {
+  let { id, name, cluster_id, service_account_id } = pathParams;
+  return `/api/projects/${id}/releases/${name}/rollback?cluster_id=${cluster_id}&service_account_id=${service_account_id}`;
 });
 
 const upgradeChartValues = baseApi<{
   namespace: string,
-  context: string,
   storage: StorageType,
   values: string
-}, { name: string }>('POST', pathParams => {
-  return `/api/releases/${pathParams.name}/upgrade`;
+}, {
+  id: number,
+  name: string,
+  cluster_id: number,
+  service_account_id: number,
+  }>('POST', pathParams => {
+  let { id, name, cluster_id, service_account_id } = pathParams;
+  return `/api/projects/${id}/releases/${name}/upgrade?cluster_id=${cluster_id}&service_account_id=${service_account_id}`;
 });
 
 const getTemplates = baseApi('GET', '/api/templates');
 
-const getRepos = baseApi('GET', '/api/repos');
+const getRepos = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/repos`;
+});
 
 const getBranches = baseApi<{}, { kind: string, repo: string }>('GET', pathParams => {
   return `/api/repos/${pathParams.kind}/${pathParams.repo}/branches`;
@@ -117,6 +138,14 @@ const getBranchContents = baseApi<{ dir: string }, {
   return `/api/repos/github/${pathParams.repo}/${pathParams.branch}/contents`;
 });
 
+const getProjects = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/users/${pathParams.id}/projects`;
+});
+
+const createProject = baseApi<{ name: string }, {}>('POST', pathParams => {
+  return `/api/projects`;
+});
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -126,7 +155,7 @@ export default {
   getRepos,
   getUser,
   updateUser,
-  getContexts,
+  getClusters,
   getCharts,
   getChart,
   getChartComponents,
@@ -137,5 +166,7 @@ export default {
   upgradeChartValues,
   getTemplates,
   getBranches,
-  getBranchContents
+  getBranchContents,
+  getProjects,
+  createProject
 }

+ 14 - 4
dashboard/src/shared/types.tsx

@@ -1,9 +1,8 @@
-export interface KubeContextConfig {
-  cluster: string,
+export interface Cluster {
+  id: number,
   name: string,
-  selected?: boolean,
   server: string,
-  user: string
+  service_account_id: number
 }
 
 export interface ChartType {
@@ -108,4 +107,15 @@ export interface RepoType {
 export interface FileType {
   Path: string,
   Type: string
+}
+
+export interface ProjectType {
+  id: number,
+  name: string,
+  roles: {
+    id: number,
+    kind: string,
+    user_id: number,
+    project_id: number
+  }[]
 }

+ 0 - 17
docker/.env

@@ -1,17 +0,0 @@
-DEBUG=true
-
-STATIC_FILE_PATH=/porter/static
-
-SERVER_PORT=8080
-SERVER_TIMEOUT_READ=5s
-SERVER_TIMEOUT_WRITE=10s
-SERVER_TIMEOUT_IDLE=15s
-
-DB_HOST=postgres
-DB_PORT=5432
-DB_USER=porter
-DB_PASS=porter
-DB_NAME=porter
-COOKIE_SECRETS=secret
-
-SQL_LITE=false

+ 3 - 1
docker/Dockerfile

@@ -22,7 +22,8 @@ FROM base AS build-go
 RUN --mount=type=cache,target=/root/.cache/go-build \
     --mount=type=cache,target=$GOPATH/pkg/mod \
     go build -ldflags '-w -s' -a -o ./bin/app ./cmd/app && \
-    go build -ldflags '-w -s' -a -o ./bin/migrate ./cmd/migrate
+    go build -ldflags '-w -s' -a -o ./bin/migrate ./cmd/migrate && \
+    go build -ldflags '-w -s' -a -o ./bin/ready ./cmd/ready
 
 # Go test environment
 # -------------------
@@ -52,6 +53,7 @@ RUN apk update
 
 COPY --from=build-go /porter/bin/app /porter/
 COPY --from=build-go /porter/bin/migrate /porter/
+COPY --from=build-go /porter/bin/ready /porter/
 COPY --from=build-webpack /webpack/build /porter/static
 
 ENV DEBUG=false

+ 0 - 849
docs/API.md

@@ -1,849 +0,0 @@
-**Table of Contents:**
-
-- [Overview](#overview)
-- [Global Errors](#global-errors)
-  - [`ErrorDataWrite`](#errordatawrite)
-  - [`ErrorDataRead`](#errordataread)
-  - [`ErrorInternal`](#errorinternal)
-- [`/api/users`](#apiusers)
-  - [`GET /api/users/{id}`](#get-apiusersid)
-  - [`GET /api/users/{id}/contexts`](#get-apiusersidcontexts)
-  - [`POST /api/users`](#post-apiusers)
-  - [`POST /api/login`](#post-apilogin)
-  - [`POST /api/logout`](#post-apilogout)
-  - [`PUT /api/users/{id}`](#put-apiusersid)
-  - [`DELETE /api/users/{id}`](#delete-apiusersid)
-- [`/api/releases`](#apireleases)
-  - [`GET /api/releases`](#get-apireleases)
-  - [`GET /api/releases/{name}/history`](#get-apireleasesnamehistory)
-  - [`GET /api/releases/{name}/{revision}`](#get-apireleasesnamerevision)
-  - [`POST /api/releases/{name}/rollback`](#post-apireleasesnamerollback)
-  - [`POST /api/releases/{name}/upgrade`](#post-apireleasesnameupgrade)
-- [`/api/k8s`](#apik8s)
-  - [`GET /api/k8s/namespaces`](#get-apik8snamespaces)
-
-
-### Overview
-
-This is the API specification that the Go server is implementing. 
-
-**Error handling:**
-
-Errors are passed via both a non-`2xx` status code and an HTTPError response body:
-
-```js
-HTTPError{
-    // The Porter custom error code
-    Code: Number,
-    // A descriptive error message
-    Errors: []String,
-}
-```
-
-Internal server errors are shared across all endpoints and are listed in the [Global Errors](#global-errors) section. 
-
-**Authentication:** The current authentication method is cookie-based sessions--most endpoints require a cookie-based session. 
-
-### Global Errors
-
-#### `ErrorDataWrite`
-
-**Description:** occurs when a write is attempted against the database and fails. 
-
-**Status Code:** `500`
-
-**Response Body:**
-
-```json
-{
-	"Code": 500,
-	"Errors": [{
-		"could not write to database"
-	}],
-}
-```
-
-#### `ErrorDataRead`
-
-**Description:** occurs when a read is attempted against the database and fails. 
-
-**Status Code:** `500`
-
-**Response Body:**
-
-```json
-{
-	"Code": 500,
-	"Errors": [{
-		"could not read from database"
-	}],
-}
-```
-
-#### `ErrorInternal`
-
-**Description:** occurs with a generic internal server error
-
-**Status Code:** `500`
-
-**Response Body:**
-
-```json
-{
-	"Code": 500,
-	"Errors": [{
-		"internal server error"
-	}],
-}
-```
-
-### `/api/users`
-
-#### `GET /api/users/{id}`
-
-**Description:** Gets a user object matching a specific `id`. 
-
-**URL parameters:** 
-
-- `id` The user's ID. 
-
-**Query parameters:** N/A
-
-**Request Body**: N/A
-
-**Successful Response Body**: 
-
-```js
-User{
-    "id": Number,
-    "email": String,
-    "contexts": []String,
-    "rawKubeConfig": String,
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:**
-
-- User not found
-  - Status Code: `404`
-  - Request Body:
-    ```json
-    {
-        "code":602,
-        "errors":["could not find requested object"]
-    }
-    ```
-- Invalid `id` URL parameter
-  - Status Code: `400`
-  - Request Body:
-    ```json
-    {
-        "code":600,
-        "errors":["could not process request"]
-    }
-    ```
-
-#### `GET /api/users/{id}/contexts`
-
-**Description:** Retrieves a list of contexts parsed from the provided kubeconfig. 
-
-**URL parameters:** 
-
-- `id` The user's ID. 
-
-**Query parameters:** N/A
-
-**Request Body**: N/A
-
-**Successful Response Body**: 
-
-```js
-[]Context{
-  "name": String,
-  "server": String,
-  "cluster": String,
-  "user": String,
-  "selected": Boolean,
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:** 
-- User not found
-  - Status Code: `404`
-  - Request Body:
-    ```json
-    {
-        "code":602,
-        "errors":["could not find requested object"]
-    }
-    ```
-- Invalid `id` URL parameter
-  - Status Code: `400`
-  - Request Body:
-    ```json
-    {
-        "code":600,
-        "errors":["could not process request"]
-    }
-    ```
-
-#### `POST /api/users`
-
-**Description:** Creates a new user with a given email and password.
-
-**URL parameters:** 
-
-- `id` The user's ID. 
-
-**Query parameters:** N/A
-
-**Request Body**: 
-
-```js
-{
-    "email": String,
-    "password": String,
-}
-```
-
-**Successful Response Body**:
-User object with only the id field. Other fields are empty - with values in parantheses.
-```js
-{
-  "id": Int,
-  "email": String ("")
-  "contexts": []String (NULL)
-  "rawKubeConfig": String ("")
-}
-```
-
-**Successful Status Code**: `201`
-
-**Errors:**
-
-- Invalid email (example: `{"email": "notanemail"}`)
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-        "code":601,
-        "errors":["email validation failed"]
-    }
-    ```
-
-- Missing field
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-        "code":601,
-        "errors":["required validation failed"]
-    }`
-    ```
-
-- Email already taken 
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-        "code":601,
-        "errors":["email already taken"]
-    }
-    ```
-
-#### `POST /api/login`
-
-**Description:** Logs a user in via email and password.
-
-**URL parameters:** N/A
-
-**Query parameters:** N/A
-
-**Request Body**: 
-
-```js
-{
-    "email": String,
-    "password": String,
-}
-```
-
-**Successful Response Body**:
-User object with only the id field. Other fields are empty - with values in parantheses.
-```js
-{
-  "id": Int,
-  "email": String ("")
-  "contexts": []String (NULL)
-  "rawKubeConfig": String ("")
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:**
-
-- Email not registered
-  - Status Code: `401`
-  - Request Body:
-    ```json
-    {
-        "code": 401,
-        "errors": ["email not registered"]
-    }
-    ```
-
-- Incorrect password
-  - Status Code: `401`
-  - Request Body:
-    ```json
-    {
-        "code":401,
-        "errors":["incorrect password"]
-    }
-    ```
-
-#### `POST /api/logout`
-
-**Description:** Logs a user out by detaching a user from the cookie-based session. 
-
-**URL parameters:** N/A
-
-**Query parameters:** N/A
-
-**Request Body**: N/A
-
-**Successful Response Body**: N/A
-
-**Successful Status Code**: `200`
-
-**Errors:**
-
-- Not logged in
-  - Status Code: `403`
-  - Request Body:
-    ```sh
-    "Forbidden"
-    ```
-
-#### `PUT /api/users/{id}`
-
-**Description:** Updates an existing user.
-
-**URL parameters:** 
-
-- `id` The user's ID. 
-
-**Query parameters:** N/A
-
-**Request body:**
-
-```js
-{
-  "rawKubeConfig": String,
-  "allowedContexts": []String,
-}
-```
-
-**Successful Response Body**: N/A
-
-**Successful Status Code**: `204`
-
-**Errors:** 
-
-- Invalid `id` URL parameter
-  - Status Code: `400`
-  - Request Body:
-    ```json
-    {
-        "code":600,
-        "errors":["could not process request"]
-    }
-    ```
-
-#### `DELETE /api/users/{id}`
-
-**Description:** Deletes an existing user, requires the password to be sent before deletion. 
-
-**URL parameters:** 
-
-- `id` The user's ID. 
-
-**Query parameters:** N/A
-
-**Request body:**
-
-```js
-{
-    "password": String,
-}
-```
-
-**Successful Response Body**: N/A
-
-**Successful Status Code**: `204`
-
-**Errors:** 
-
-- Invalid `password`
-  - Status Code: `400`
-  - Request Body:
-    ```json
-    {
-        "code":601,
-        "errors":["invalid password"]
-    }
-    ```
-    
-- Missing field
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-        "code":601,
-        "errors":["required validation failed"]
-    }`
-    ```
-
-- Invalid `id` URL parameter
-  - Status Code: `400`
-  - Request Body:
-    ```json
-    {
-        "code":600,
-        "errors":["could not process request"]
-    }
-    ```
-
-### `/api/releases`
-
-#### `GET /api/releases`
-
-**Description:** Gets a list of releases for a current context and a kubeconfig retrieved from the user's ID. 
-
-**URL parameters:** N/A
-
-**Query parameters:** 
-
-```js
-{
-  "namespace": String,
-  "context": String,
-  "storage": String("secret"|"configmap"|"memory"),
-  "limit": Number,
-  "skip": Number,
-  "byDate": Boolean,
-  "statusFilter": []String("unknown"|"deployed"|"uninstalled"|"superseded"|"failed"|"uninstalling"|"pending-install"|"pending-upgrade"|"pending-rollback")
-}
-```
-
-**Request Body**: N/A
-
-**Successful Response Body**: the full body is determined by the [release specification](https://pkg.go.dev/helm.sh/helm/v3@v3.3.4/pkg/release#Release): listed here is a subset of fields deemed to be most relevant. Note that all of the top-level fields are optional.
-
-```js
-[]Release{
-  // Name is the name of the release
-  "name": String,
-  "info": Info{
-    // LastDeployed is when the release was last deployed.
-    "last_deployed": String,
-    // Deleted tracks when this object was deleted.
-    "deleted": String,
-    // Description is human-friendly "log entry" about this release.
-    "description": String,
-    // Status is the current state of the release
-    "status": String("unknown"|"deployed"|"uninstalled"|"superseded"|"failed"|"uninstalling"|"pending-install"|"pending-upgrade"|"pending-rollback")
-  },
-  "chart": Chart{
-    "metadata": Metadata{
-      // The name of the chart
-      "name": String,
-      // The URL to a relevant project page, git repo, or contact person
-      "home": String,
-      // Sources is a list of URLs to the source code of this chart
-      "sources": []String,
-      // A SemVer 2 conformant version string of the chart
-      "version": String,
-      // A one-sentence description of the chart
-      "description": String,
-      // The URL to an icon file.
-      "icon": String,
-      // The API Version of this chart.
-      "apiVersion": String,
-    },
-    "templates": []File{
-      // Name is the path-like name of the template.
-      "name": String,
-      // Data is the template as byte data.
-      "data": String
-    },
-    // Values are default config for this chart.
-    "values": Map[String]{}
-  },
-  // The set of extra Values added to the release, which override the 
-  // default values inside of the chart
-  "config": Map[String]{},
-  // Manifest is the string representation of the rendered template
-  "manifest": String,
-  // Version is an int which represents the revision of the release.
-  "version": Number,
-  // Namespace is the kubernetes namespace of the release.
-  "namespace": String
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:** 
-
-- Missing required field
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-      "code":601,
-      "errors":["required validation failed"]
-    }
-    ```
-
-#### `GET /api/releases/{name}/history`
-
-**Description:** Gets a history of revisions for a given deployed release based on the release `name`.
-
-**URL parameters:** 
-
-- `name` The name of the release.
-
-**Query parameters:** 
-
-```js
-{
-  // The namespace of the cluster to be used
-  "namespace": String,
-  // The name of the context in the kubeconfig being used
-  "context": String,
-  // The Helm storage option to use
-  "storage": String("secret"|"configmap"|"memory")
-}
-```
-
-**Request Body**: N/A
-
-**Successful Response Body**: the full body is determined by the [release specification](https://pkg.go.dev/helm.sh/helm/v3@v3.3.4/pkg/release#Release): listed here is a subset of fields deemed to be most relevant. Note that all of the top-level fields are optional.
-
-```js
-[]Release{
-  // Name is the name of the release
-  "name": String,
-  "info": Info{
-    // LastDeployed is when the release was last deployed.
-    "last_deployed": String,
-    // Deleted tracks when this object was deleted.
-    "deleted": String,
-    // Description is human-friendly "log entry" about this release.
-    "description": String,
-    // Status is the current state of the release
-    "status": String("unknown"|"deployed"|"uninstalled"|"superseded"|"failed"|"uninstalling"|"pending-install"|"pending-upgrade"|"pending-rollback")
-  },
-  "chart": Chart{
-    "metadata": Metadata{
-      // The name of the chart
-      "name": String,
-      // The URL to a relevant project page, git repo, or contact person
-      "home": String,
-      // Sources is a list of URLs to the source code of this chart
-      "sources": []String,
-      // A SemVer 2 conformant version string of the chart
-      "version": String,
-      // A one-sentence description of the chart
-      "description": String,
-      // The URL to an icon file.
-      "icon": String,
-      // The API Version of this chart.
-      "apiVersion": String,
-    },
-    "templates": []File{
-      // Name is the path-like name of the template.
-      "name": String,
-      // Data is the template as byte data.
-      "data": String
-    },
-    // Values are default config for this chart.
-    "values": Map[String]{}
-  },
-  // The set of extra Values added to the release, which override the 
-  // default values inside of the chart
-  "config": Map[String]{},
-  // Manifest is the string representation of the rendered template
-  "manifest": String,
-  // Version is an int which represents the revision of the release.
-  "version": Number,
-  // Namespace is the kubernetes namespace of the release.
-  "namespace": String
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:** 
-
-- Release not found
-  - Status Code: `404`
-  - Request Body:
-    ```json
-    {
-      "code":602,
-      "errors":["release not found"]
-    }
-    ```
-- Missing required field
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-      "code":601,
-      "errors":["required validation failed"]
-    }
-    ```
-
-
-#### `GET /api/releases/{name}/{revision}`
-
-**Description:** Gets a single release for a current context and a kubeconfig retrieved from the user's ID based on a **name** and **revision**. To retrieve the latest deployed release, set **revision** to 0. 
-
-**URL parameters:** 
-
-- `name` The name of the release.
-- `revision` The number of the release (set to `0` for the latest deployed release).
-
-**Query parameters:** 
-
-```js
-{
-  // The namespace of the cluster to be used
-  "namespace": String,
-  // The name of the context in the kubeconfig being used
-  "context": String,
-  // The Helm storage option to use
-  "storage": String("secret"|"configmap"|"memory")
-}
-```
-
-**Request Body**: N/A
-
-**Successful Response Body**: the full body is determined by the [release specification](https://pkg.go.dev/helm.sh/helm/v3@v3.3.4/pkg/release#Release): listed here is a subset of fields deemed to be most relevant. Note that all of the top-level fields are optional.
-
-```js
-[]Release{
-  // Name is the name of the release
-  "name": String,
-  "info": Info{
-    // LastDeployed is when the release was last deployed.
-    "last_deployed": String,
-    // Deleted tracks when this object was deleted.
-    "deleted": String,
-    // Description is human-friendly "log entry" about this release.
-    "description": String,
-    // Status is the current state of the release
-    "status": String("unknown"|"deployed"|"uninstalled"|"superseded"|"failed"|"uninstalling"|"pending-install"|"pending-upgrade"|"pending-rollback")
-  },
-  "chart": Chart{
-    "metadata": Metadata{
-      // The name of the chart
-      "name": String,
-      // The URL to a relevant project page, git repo, or contact person
-      "home": String,
-      // Sources is a list of URLs to the source code of this chart
-      "sources": []String,
-      // A SemVer 2 conformant version string of the chart
-      "version": String,
-      // A one-sentence description of the chart
-      "description": String,
-      // The URL to an icon file.
-      "icon": String,
-      // The API Version of this chart.
-      "apiVersion": String,
-    },
-    "templates": []File{
-      // Name is the path-like name of the template.
-      "name": String,
-      // Data is the template as byte data.
-      "data": String
-    },
-    // Values are default config for this chart.
-    "values": Map[String]{}
-  },
-  // The set of extra Values added to the release, which override the 
-  // default values inside of the chart
-  "config": Map[String]{},
-  // Manifest is the string representation of the rendered template
-  "manifest": String,
-  // Version is an int which represents the revision of the release.
-  "version": Number,
-  // Namespace is the kubernetes namespace of the release.
-  "namespace": String
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:** 
-
-- Release not found
-  - Status Code: `404`
-  - Request Body:
-    ```json
-    {
-      "code":602,
-      "errors":["release not found"]
-    }
-    ```
-- Missing required field
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-      "code":601,
-      "errors":["required validation failed"]
-    }
-    ```
-
-#### `POST /api/releases/{name}/rollback`
-
-**Description:** Rolls a release back to a specified revision. 
-
-**URL parameters:** 
-
-- `name` The name of the release.
-
-**Query parameters:** N/A
-
-**Request Body**:
-
-```js
-{
-  // The namespace of the cluster to be used
-  "namespace": String,
-  // The name of the context in the kubeconfig being used
-  "context": String,
-  // The Helm storage option to use
-  "storage": String("secret"|"configmap"|"memory"),
-  // The revision number of the desired rollback target
-  "revision": Number
-}
-```
-
-
-**Successful Response Body**: N/A
-
-**Successful Status Code**: `200`
-
-**Errors:**
-
-- Rollback failed
-  - Status Code: `500`
-  - Request Body:
-    ```json
-    {
-      "code":603,
-      "errors":["rollback failed: <error>"]
-    }
-    ```
-- Missing required field
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-      "code":601,
-      "errors":["required validation failed"]
-    }
-    ```
-
-#### `POST /api/releases/{name}/upgrade`
-
-**Description:** Upgrades a release with new `values.yaml`. 
-
-**URL parameters:** 
-
-- `name` The name of the release.
-
-**Query parameters:** N/A
-
-**Request Body**:
-
-```js
-{
-  // The namespace of the cluster to be used
-  "namespace": String,
-  // The name of the context in the kubeconfig being used
-  "context": String,
-  // The Helm storage option to use
-  "storage": String("secret"|"configmap"|"memory"),
-  // The string of values to use
-  "values": String
-}
-```
-
-**Successful Response Body**: N/A
-
-**Successful Status Code**: `200`
-
-**Errors:** 
-
-- Upgrade failed
-  - Status Code: `500`
-  - Request Body:
-    ```json
-    {
-      "code":603,
-      "errors":["upgrade failed: <error>"]
-    }
-    ```
-- Missing required field
-  - Status Code: `422`
-  - Request Body:
-    ```json
-    {
-      "code":601,
-      "errors":["required validation failed"]
-    }
-    ```
-
-### `/api/k8s`
-
-#### `GET /api/k8s/namespaces`
-
-**Description:** 
-
-**URL parameters:** N/A
-
-**Query parameters:** N/A
-
-```js
-// The name of the context in the kubeconfig being used
-"context": String,
-```
-
-**Request Body**: N/A
-
-**Successful Response Body**: the full body is determined by the [namespace specification](https://pkg.go.dev/k8s.io/api/core/v1#NamespaceList), but we're primarily only interested in namespace `name`:
-
-```js
-{
-  "metadata": {},
-  "items": []Namespace{
-    "metadata": {
-      "name": String
-    }
-  }
-}
-```
-
-**Successful Status Code**: `200`
-
-**Errors:** TBD

+ 28 - 18
docs/GETTING_STARTED.md

@@ -6,13 +6,11 @@
     - [Linux Installation](#linux-installation)
     - [Windows Installation](#windows-installation)
 - [Local Setup](#local-setup)
-    - [Passing `kubeconfig` explicitly](#passing-kubeconfig-explicitly)
-    - [Passing a context list](#passing-a-context-list)
-    - [Skipping initialization steps](#skipping-initialization-steps)
+    - [Connecting to a Cluster](#connecting-to-a-cluster)
 
 ## Prerequisites
 
-You must have access to a Kubernetes cluster with Helm charts installed and the Docker engine must be running on your machine. 
+You must have access to a Kubernetes cluster with Helm charts installed and the Docker engine must be running on your machine. To quickly get a local Kubernetes cluster set up, following the instructions for [installing minikube](https://minikube.sigs.k8s.io/docs/start/), and make sure that minikube is set as the current context by ensuring the output of `kubectl config current-context` is `minikube`. 
 
 ## Installing 
 
@@ -67,28 +65,40 @@ Go [here](https://github.com/porter-dev/porter/releases/latest/download/porter_0
 
 > **Note:** the local setup process is tracked in [issue #60](https://github.com/porter-dev/porter/issues/60), while the overall onboarding flow is tracked in [issue #50](https://github.com/porter-dev/porter/issues/50). 
 
-To view Porter locally, you must have access to a Kubernetes cluster with Helm charts installed. The simplest way to run Porter is via `porter start`. This command will read the `current-context` that's set in your default `kubeconfig` (either by reading the `$KUBECONFIG` env variable or reading from `$HOME/.kube/config`). To view all options for `start`, type `porter start --help`. By default, the command performs the following steps:
+To view Porter locally, you must have access to a Kubernetes cluster with Helm charts installed. The simplest way to run Porter is via `porter server start`. After doing this, you can go to `http://localhost:8080` to register an account and create a project manually. Alternatively, you can run the following commands:
 
-1. Requests an admin account is created and writes the result to the local keychain (Mac), wincred (Windows), or pass (Linux). 
-2. Reads the default `kubeconfig` and populates certificates required by the current context. 
-3. Starts Porter as a Docker container with a persistent storage volume attached (by default, the volume will be called `porter_sqlite`).
+```sh
+porter auth register
+porter project create porter-test
+```
 
-### Passing `kubeconfig` explicitly
+### Connecting to a Cluster
 
-You can pass a path to a `kubeconfig` file explicitly via:
+In the case of local setup, you will have to connect to a cluster using the CLI command `porter connect kubeconfig`. By default, this command will read the `current-context` that's set in your default `kubeconfig` (either by reading the `$KUBECONFIG` env variable or reading from `$HOME/.kube/config`). You can also pass a path to a kubeconfig file explicitly (see below). 
 
-```sh
-porter start --kubeconfig path/to/kubeconfig
-```
+The Porter CLI will attempt to generate a working kubeconfig for many types of cluster configurations and auth mechanisms, even though the necessary commands and/or certificates will not be present in the Porter container. The CLI will attempt the following resolutions:
 
-### Passing a context list
+1. If a kubeconfig requires cluster CA data via the `certificate-authority` field, the CA data will be automatically populated. 
+2. If a kubeconfig requires client cert data via the `client-certificate` field, the certificate data will be automatically populated. 
+3. If a kubeconfig requires client key data via the `client-key` field, the key data will be automatically populated. 
+4. If a kubeconfig requires a custom `oidc` auth mechanism, and this mechanism requires OIDC issuer CA data via the `idp-certificate-authority` field, the CA data will be automatically populated. 
+5. If a kubeconfig requires a bearer token to be read from a `token-file` field, the token data will be automatically populated. 
+6. If a kubeconfig requires a custom `gcp` auth mechanism (for connecting with GKE clusters), the CLI will require a GCP `service-account` that has permissions to read from the GKE cluster. The CLI will ask the user if it can set this up automatically: if so, it will automatically detect the correct GCP project ID and will create a service account and download a key file. If the user does not wish the CLI to set this up automatically, the user will need to provide a file path to a service account key file that was downloaded from GCloud. 
 
-You can initialize Porter with a set of contexts by passing a context list to start. The contexts that Porter will be able to access are the same as `kubectl config get-contexts`. For example, if I had two contexts named `minikube` and `staging`, I would be able to visualize both of them via:
+> **Note:** AWS EKS support coming soon. 
+
+#### Passing `kubeconfig` explicitly
+
+You can pass a path to a `kubeconfig` file explicitly via:
 
 ```sh
-porter start --contexts minikube --contexts staging
+porter connect kubeconfig --kubeconfig path/to/kubeconfig
 ```
 
-### Skipping Initialization Steps
+#### Passing a context list
 
-To skip setting the admin account and/or the kubeconfig, `porter start` provides the `--insecure` and `--skip-kubeconfig` options.
+You can initialize Porter with a set of contexts by passing a context list to start. The contexts that Porter will be able to access are the same as `kubectl config get-contexts`. For example, if there are two contexts named `minikube` and `staging`, you could connect both of them via:
+
+```sh
+porter connect kubeconfig --contexts minikube --contexts staging
+```

+ 7 - 3
go.mod

@@ -3,6 +3,7 @@ module github.com/porter-dev/porter
 go 1.14
 
 require (
+	cloud.google.com/go v0.65.0
 	github.com/Azure/go-autorest/autorest v0.11.1 // indirect
 	github.com/DATA-DOG/go-sqlmock v1.5.0
 	github.com/Masterminds/semver v1.5.0 // indirect
@@ -15,7 +16,7 @@ require (
 	github.com/docker/go-connections v0.4.0
 	github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 // indirect
 	github.com/evanphx/json-patch v4.9.0+incompatible // indirect
-	github.com/fatih/color v1.9.0 // indirect
+	github.com/fatih/color v1.9.0
 	github.com/go-chi/chi v4.1.2+incompatible
 	github.com/go-chi/cors v1.1.1
 	github.com/go-playground/locales v0.13.0
@@ -23,6 +24,7 @@ require (
 	github.com/go-playground/validator/v10 v10.3.0
 	github.com/go-test/deep v1.0.7
 	github.com/google/go-cmp v0.5.1
+	github.com/google/go-github v17.0.0+incompatible
 	github.com/google/go-github/v32 v32.1.0
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/sessions v1.2.1
@@ -38,12 +40,14 @@ require (
 	github.com/rs/zerolog v1.20.0
 	github.com/sirupsen/logrus v1.7.0
 	github.com/spf13/cobra v1.0.0
+	github.com/spf13/viper v1.4.0
 	github.com/stretchr/testify v1.6.1
 	golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
-	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 // indirect
+	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43
 	golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6
 	golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e // indirect
-	google.golang.org/genproto v0.0.0-20201014134559-03b6142f0dc9 // indirect
+	google.golang.org/api v0.30.0
+	google.golang.org/genproto v0.0.0-20201014134559-03b6142f0dc9
 	google.golang.org/grpc v1.33.0 // indirect
 	gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
 	gopkg.in/go-playground/validator.v9 v9.31.0

+ 13 - 0
go.sum

@@ -14,6 +14,7 @@ cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bP
 cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
 cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
 cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
+cloud.google.com/go v0.65.0 h1:Dg9iHVQfrhq82rUNu9ZxUDrJLaxFUe/HlCVaLyRruq8=
 cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
 cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
 cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
@@ -361,6 +362,7 @@ github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4er
 github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
+github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
 github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
 github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
@@ -400,6 +402,7 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 github.com/google/go-cmp v0.5.1 h1:JFrFEBb2xKufg6XkJsJr+WbKb4FQlURi5RUcBveYu9k=
 github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
+github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
 github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II=
 github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI=
 github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
@@ -423,6 +426,7 @@ github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
 github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
 github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
+github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gnostic v0.0.0-20170729233727-0c5108395e2d/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTVRp3pOg5EKY=
 github.com/googleapis/gnostic v0.1.0 h1:rVsPeBmXbYv4If/cumu1AzZPwV58q433hvONV1UEZoI=
@@ -473,6 +477,7 @@ github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
 github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
 github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
@@ -609,6 +614,7 @@ github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-b
 github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
 github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
 github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
+github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
 github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
 github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -660,6 +666,7 @@ github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUb
 github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
 github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
 github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A=
 github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
@@ -725,6 +732,7 @@ github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FI
 github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys=
+github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM=
 github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
 github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
 github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
@@ -812,6 +820,7 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k
 github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
+github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
 github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
@@ -821,6 +830,7 @@ github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3
 github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
 github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
 github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
+github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
@@ -829,6 +839,7 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
+github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=
 github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
 github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
 github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
@@ -882,6 +893,7 @@ go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
 go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
 go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
+go.opencensus.io v0.22.4 h1:LYy1Hy3MJdrCdMwwzxA/dRok4ejH+RwNGbuoD9fCjto=
 go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
@@ -1164,6 +1176,7 @@ google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/
 google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
 google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
 google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
+google.golang.org/api v0.30.0 h1:yfrXXP61wVuLb0vBcG6qaOoIoqYEzOQS8jum51jkv2w=
 google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=

+ 6 - 2
internal/adapter/gorm.go

@@ -1,4 +1,4 @@
-package gorm
+package adapter
 
 import (
 	"fmt"
@@ -13,7 +13,11 @@ import (
 // New returns a new gorm database instance
 func New(conf *config.DBConf) (*gorm.DB, error) {
 	if conf.SQLLite {
-		return gorm.Open(sqlite.Open(conf.SQLLitePath), &gorm.Config{})
+		// we add DisableForeignKeyConstraintWhenMigrating since our sqlite does
+		// not support foreign key constraints
+		return gorm.Open(sqlite.Open(conf.SQLLitePath), &gorm.Config{
+			DisableForeignKeyConstraintWhenMigrating: true,
+		})
 	}
 
 	dsn := fmt.Sprintf(

+ 7 - 4
internal/config/config.go

@@ -17,6 +17,7 @@ type Conf struct {
 
 // ServerConf is the server configuration
 type ServerConf struct {
+	ServerURL      string        `env:"SERVER_URL,default=http://localhost:8080"`
 	Port           int           `env:"SERVER_PORT,default=8080"`
 	StaticFilePath string        `env:"STATIC_FILE_PATH,default=/porter/static"`
 	CookieName     string        `env:"COOKIE_NAME,default=porter"`
@@ -24,21 +25,23 @@ type ServerConf struct {
 	TimeoutRead    time.Duration `env:"SERVER_TIMEOUT_READ,default=5s"`
 	TimeoutWrite   time.Duration `env:"SERVER_TIMEOUT_WRITE,default=10s"`
 	TimeoutIdle    time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
+
+	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
+	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,
 // it assumes the default docker-compose configuration is used
 type DBConf struct {
+	// EncryptionKey is the key to use for sensitive values that are encrypted at rest
+	EncryptionKey string `env:"ENCRYPTION_KEY,default=__random_strong_encryption_key__"`
+
 	Host     string `env:"DB_HOST,default=postgres"`
 	Port     int    `env:"DB_PORT,default=5432"`
 	Username string `env:"DB_USER,default=porter"`
 	Password string `env:"DB_PASS,default=porter"`
 	DbName   string `env:"DB_NAME,default=porter"`
 
-	AdminInit     bool   `env:"ADMIN_INIT,default=true"`
-	AdminEmail    string `env:"ADMIN_EMAIL,default=admin@example.com"`
-	AdminPassword string `env:"ADMIN_PASSWORD,default=password"`
-
 	SQLLite     bool   `env:"SQL_LITE,default=false"`
 	SQLLitePath string `env:"SQL_LITE_PATH,default=/porter/porter.db"`
 }

+ 323 - 0
internal/forms/action.go

@@ -0,0 +1,323 @@
+package forms
+
+import (
+	"encoding/base64"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// ActionResolver exposes an interface for resolving an action as a ServiceAccount.
+// So that actions can be chained together, a pointer to a serviceAccount can be
+// used -- if this points to nil, a new service account is created
+type ActionResolver interface {
+	PopulateServiceAccount(repo repository.ServiceAccountRepository) error
+}
+
+// ServiceAccountActionResolver is the base type for resolving a ServiceAccountAction
+// that belongs to a given ServiceAccountCandidate
+type ServiceAccountActionResolver struct {
+	ServiceAccountCandidateID uint `json:"sa_candidate_id" form:"required"`
+	SA                        *models.ServiceAccount
+	SACandidate               *models.ServiceAccountCandidate
+}
+
+// PopulateServiceAccount will create a service account if it does not exist,
+// or will append a new cluster given by a ServiceAccountCandidate to the
+// ServiceAccount
+func (sar *ServiceAccountActionResolver) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	var err error
+	id := sar.ServiceAccountCandidateID
+
+	if sar.SACandidate == nil {
+		sar.SACandidate, err = repo.ReadServiceAccountCandidate(id)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	rawConf, err := kubernetes.GetRawConfigFromBytes(sar.SACandidate.Kubeconfig)
+
+	if err != nil {
+		return err
+	}
+
+	context := rawConf.Contexts[rawConf.CurrentContext]
+
+	authInfoName := context.AuthInfo
+	authInfo := rawConf.AuthInfos[authInfoName]
+
+	clusterName := context.Cluster
+	cluster := rawConf.Clusters[clusterName]
+
+	modelCluster := models.Cluster{
+		Name:                  clusterName,
+		LocationOfOrigin:      cluster.LocationOfOrigin,
+		Server:                cluster.Server,
+		TLSServerName:         cluster.TLSServerName,
+		InsecureSkipTLSVerify: cluster.InsecureSkipTLSVerify,
+	}
+
+	if len(cluster.CertificateAuthorityData) > 0 {
+		modelCluster.CertificateAuthorityData = cluster.CertificateAuthorityData
+	}
+
+	if sar.SA == nil {
+		sar.SA = &models.ServiceAccount{
+			ProjectID:         sar.SACandidate.ProjectID,
+			Kind:              sar.SACandidate.Kind,
+			Clusters:          []models.Cluster{modelCluster},
+			AuthMechanism:     sar.SACandidate.AuthMechanism,
+			LocationOfOrigin:  authInfo.LocationOfOrigin,
+			Impersonate:       authInfo.Impersonate,
+			ImpersonateGroups: strings.Join(authInfo.ImpersonateGroups, ","),
+		}
+	} else {
+		doesClusterExist := false
+
+		for _, cluster := range sar.SA.Clusters {
+			if cluster.Name == sar.SACandidate.ClusterName && cluster.Server == sar.SACandidate.ClusterEndpoint {
+				doesClusterExist = true
+			}
+		}
+
+		if !doesClusterExist {
+			sar.SA.Clusters = append(sar.SA.Clusters, modelCluster)
+		}
+	}
+
+	if len(authInfo.ClientCertificateData) > 0 {
+		sar.SA.ClientCertificateData = authInfo.ClientCertificateData
+	}
+
+	if len(authInfo.ClientKeyData) > 0 {
+		sar.SA.ClientKeyData = authInfo.ClientKeyData
+	}
+
+	if authInfo.Token != "" {
+		sar.SA.Token = authInfo.Token
+	}
+
+	if authInfo.Username != "" {
+		sar.SA.Username = authInfo.Username
+	}
+
+	if authInfo.Password != "" {
+		sar.SA.Password = authInfo.Password
+	}
+
+	if authInfo.AuthProvider != nil && authInfo.AuthProvider.Name == "oidc" {
+		if url, ok := authInfo.AuthProvider.Config["idp-issuer-url"]; ok {
+			sar.SA.OIDCIssuerURL = url
+		}
+
+		if clientID, ok := authInfo.AuthProvider.Config["client-id"]; ok {
+			sar.SA.OIDCClientID = clientID
+		}
+
+		if clientSecret, ok := authInfo.AuthProvider.Config["client-secret"]; ok {
+			sar.SA.OIDCClientSecret = clientSecret
+		}
+
+		if caData, ok := authInfo.AuthProvider.Config["idp-certificate-authority-data"]; ok {
+			// based on the implementation, the oidc plugin expects the data to be base64 encoded,
+			// which means we will not decode it here
+			// reference: https://github.com/kubernetes/kubernetes/blob/9dfb4c876bfca7a5ae84259fae2bc337ed90c2d7/staging/src/k8s.io/client-go/plugin/pkg/client/auth/oidc/oidc.go#L135
+			sar.SA.OIDCCertificateAuthorityData = caData
+		}
+
+		if idToken, ok := authInfo.AuthProvider.Config["id-token"]; ok {
+			sar.SA.OIDCIDToken = idToken
+		}
+
+		if refreshToken, ok := authInfo.AuthProvider.Config["refresh-token"]; ok {
+			sar.SA.OIDCRefreshToken = refreshToken
+		}
+	}
+
+	return nil
+}
+
+// ClusterCADataAction contains the base64 encoded cluster CA data
+type ClusterCADataAction struct {
+	*ServiceAccountActionResolver
+	ClusterCAData string `json:"cluster_ca_data" form:"required"`
+}
+
+// PopulateServiceAccount will add cluster ca data to a cluster in the ServiceAccount's
+// list of clusters
+func (cda *ClusterCADataAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := cda.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	saCandidate := cda.ServiceAccountActionResolver.SACandidate
+
+	for i, cluster := range cda.ServiceAccountActionResolver.SA.Clusters {
+		if cluster.Name == saCandidate.ClusterName && cluster.Server == saCandidate.ClusterEndpoint {
+			decoded, err := base64.StdEncoding.DecodeString(cda.ClusterCAData)
+
+			// skip if decoding error
+			if err != nil {
+				return err
+			}
+
+			(&cluster).CertificateAuthorityData = decoded
+			cda.ServiceAccountActionResolver.SA.Clusters[i] = cluster
+		}
+	}
+
+	return nil
+}
+
+// ClientCertDataAction contains the base64 encoded cluster cert data
+type ClientCertDataAction struct {
+	*ServiceAccountActionResolver
+	ClientCertData string `json:"client_cert_data" form:"required"`
+}
+
+// PopulateServiceAccount will add client CA data to a ServiceAccount
+func (ccda *ClientCertDataAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := ccda.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(ccda.ClientCertData)
+
+	// skip if decoding error
+	if err != nil {
+		return err
+	}
+
+	ccda.ServiceAccountActionResolver.SA.ClientCertificateData = decoded
+
+	return nil
+}
+
+// ClientKeyDataAction contains the base64 encoded cluster key data
+type ClientKeyDataAction struct {
+	*ServiceAccountActionResolver
+	ClientKeyData string `json:"client_key_data" form:"required"`
+}
+
+// PopulateServiceAccount will add client CA data to a ServiceAccount
+func (ckda *ClientKeyDataAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := ckda.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	decoded, err := base64.StdEncoding.DecodeString(ckda.ClientKeyData)
+
+	// skip if decoding error
+	if err != nil {
+		return err
+	}
+
+	ckda.ServiceAccountActionResolver.SA.ClientKeyData = decoded
+
+	return nil
+}
+
+// OIDCIssuerDataAction contains the base64 encoded IDP issuer CA data
+type OIDCIssuerDataAction struct {
+	*ServiceAccountActionResolver
+	OIDCIssuerCAData string `json:"oidc_idp_issuer_ca_data" form:"required"`
+}
+
+// PopulateServiceAccount will add OIDC issuer CA data to a ServiceAccount
+func (oida *OIDCIssuerDataAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := oida.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	// based on the implementation, the oidc plugin expects the data to be base64 encoded,
+	// which means we will not decode it here
+	// reference: https://github.com/kubernetes/kubernetes/blob/9dfb4c876bfca7a5ae84259fae2bc337ed90c2d7/staging/src/k8s.io/client-go/plugin/pkg/client/auth/oidc/oidc.go#L135
+	oida.ServiceAccountActionResolver.SA.OIDCCertificateAuthorityData = oida.OIDCIssuerCAData
+
+	return nil
+}
+
+// TokenDataAction contains the token data to use
+type TokenDataAction struct {
+	*ServiceAccountActionResolver
+	TokenData string `json:"token_data" form:"required"`
+}
+
+// PopulateServiceAccount will add bearer token data to a ServiceAccount
+func (tda *TokenDataAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := tda.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	tda.ServiceAccountActionResolver.SA.Token = tda.TokenData
+
+	return nil
+}
+
+// GCPKeyDataAction contains the GCP key data
+type GCPKeyDataAction struct {
+	*ServiceAccountActionResolver
+	GCPKeyData string `json:"gcp_key_data" form:"required"`
+}
+
+// PopulateServiceAccount will add GCP key data to a ServiceAccount
+func (gkda *GCPKeyDataAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := gkda.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	gkda.ServiceAccountActionResolver.SA.KeyData = []byte(gkda.GCPKeyData)
+
+	return nil
+}
+
+// AWSKeyDataAction contains the AWS key data
+type AWSKeyDataAction struct {
+	*ServiceAccountActionResolver
+	AWSKeyData string `json:"aws_key_data" form:"required"`
+}
+
+// PopulateServiceAccount will add GCP key data to a ServiceAccount
+func (akda *AWSKeyDataAction) PopulateServiceAccount(
+	repo repository.ServiceAccountRepository,
+) error {
+	err := akda.ServiceAccountActionResolver.PopulateServiceAccount(repo)
+
+	if err != nil {
+		return err
+	}
+
+	akda.ServiceAccountActionResolver.SA.KeyData = []byte(akda.AWSKeyData)
+
+	return nil
+}

+ 677 - 0
internal/forms/action_test.go

@@ -0,0 +1,677 @@
+package forms_test
+
+import (
+	"encoding/base64"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository/test"
+)
+
+func TestPopulateServiceAccountBasic(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(ClusterCAWithData))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.ServiceAccountActionResolver{
+		ServiceAccountCandidateID: 1,
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(form.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 string(sa.Clusters[0].CertificateAuthorityData) != string(decodedStr) {
+		t.Errorf("cluster ca data and input do not match: expected %s, got %s\n",
+			string(sa.Clusters[0].CertificateAuthorityData), string(decodedStr))
+	}
+
+	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 TestPopulateServiceAccountClusterDataAction(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(ClusterCAWithoutData))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.ClusterCADataAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		ClusterCAData: "LS0tLS1CRUdJTiBDRVJ=",
+	}
+
+	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 string(sa.Clusters[0].CertificateAuthorityData) != string(decodedStr) {
+		t.Errorf("cluster ca data and input do not match: expected %s, got %s\n",
+			string(sa.Clusters[0].CertificateAuthorityData), string(decodedStr))
+	}
+
+	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)
+
+	// create a new project
+	repo.Project.CreateProject(&models.Project{
+		Name: "test-project",
+	})
+
+	// create a ServiceAccountCandidate from a kubeconfig
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClientWithoutCertData))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.ClientCertDataAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		ClientCertData: "LS0tLS1CRUdJTiBDRVJ=",
+	}
+
+	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 string(sa.Clusters[0].CertificateAuthorityData) != string(decodedStr) {
+		t.Errorf("cluster ca data and input do not match: expected %s, got %s\n",
+			string(sa.Clusters[0].CertificateAuthorityData), string(decodedStr))
+	}
+
+	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 TestPopulateServiceAccountClientCertAndKeyActions(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(ClientWithoutCertAndKeyData))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.ClientCertDataAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		ClientCertData: "LS0tLS1CRUdJTiBDRVJ=",
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	keyForm := forms.ClientKeyDataAction{
+		ServiceAccountActionResolver: form.ServiceAccountActionResolver,
+		ClientKeyData:                "LS0tLS1CRUdJTiBDRVJ=",
+	}
+
+	err = keyForm.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(keyForm.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 string(sa.Clusters[0].CertificateAuthorityData) != string(decodedStr) {
+		t.Errorf("cluster ca data and input do not match: expected %s, got %s\n",
+			string(sa.Clusters[0].CertificateAuthorityData), string(decodedStr))
+	}
+
+	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 cert data and input do not match: expected %s, got %s\n",
+			string(sa.ClientKeyData), string(decodedStr))
+	}
+}
+
+func TestPopulateServiceAccountTokenDataAction(t *testing.T) {
+	// create the in-memory repository
+	repo := test.NewRepository(true)
+	tokenData := "abcdefghijklmnop"
+
+	// create a new project
+	repo.Project.CreateProject(&models.Project{
+		Name: "test-project",
+	})
+
+	// create a ServiceAccountCandidate from a kubeconfig
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(BearerTokenWithoutData))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.TokenDataAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		TokenData: tokenData,
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(form.ServiceAccountActionResolver.SA)
+
+	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.AuthMechanism != models.Bearer {
+		t.Errorf("service account auth mechanism is not %s\n", models.Bearer)
+	}
+
+	if sa.Token != tokenData {
+		t.Errorf("service account token data is wrong: expected %s, got %s\n",
+			tokenData, sa.Token)
+	}
+}
+
+func TestPopulateServiceAccountGCPKeyDataAction(t *testing.T) {
+	// create the in-memory repository
+	repo := test.NewRepository(true)
+	gcpKeyData := []byte(`{"key": "data"}`)
+
+	// create a new project
+	repo.Project.CreateProject(&models.Project{
+		Name: "test-project",
+	})
+
+	// create a ServiceAccountCandidate from a kubeconfig
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(GCPPlugin))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.GCPKeyDataAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		GCPKeyData: string(gcpKeyData),
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(form.ServiceAccountActionResolver.SA)
+
+	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.AuthMechanism != models.GCP {
+		t.Errorf("service account auth mechanism is not %s\n", models.GCP)
+	}
+
+	if string(sa.KeyData) != string(gcpKeyData) {
+		t.Errorf("service account token data is wrong: expected %s, got %s\n",
+			string(sa.KeyData), string(gcpKeyData))
+	}
+}
+
+func TestPopulateServiceAccountAWSKeyDataAction(t *testing.T) {
+	// create the in-memory repository
+	repo := test.NewRepository(true)
+	awsKeyData := []byte(`{"key": "data"}`)
+
+	// create a new project
+	repo.Project.CreateProject(&models.Project{
+		Name: "test-project",
+	})
+
+	// create a ServiceAccountCandidate from a kubeconfig
+	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(AWSEKSGetTokenExec))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.AWSKeyDataAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		AWSKeyData: string(awsKeyData),
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(form.ServiceAccountActionResolver.SA)
+
+	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.AuthMechanism != models.AWS {
+		t.Errorf("service account auth mechanism is not %s\n", models.AWS)
+	}
+
+	if string(sa.KeyData) != string(awsKeyData) {
+		t.Errorf("service account token data is wrong: expected %s, got %s\n",
+			string(sa.KeyData), string(awsKeyData))
+	}
+}
+
+func TestPopulateServiceAccountOIDCAction(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(OIDCAuthWithoutData))
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	for _, saCandidate := range saCandidates {
+		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	}
+
+	// create a new form
+	form := forms.OIDCIssuerDataAction{
+		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+			ServiceAccountCandidateID: 1,
+		},
+		OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
+	}
+
+	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	sa, err := repo.ServiceAccount.CreateServiceAccount(form.ServiceAccountActionResolver.SA)
+
+	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.AuthMechanism != models.OIDC {
+		t.Errorf("service account auth mechanism is not %s\n", models.OIDC)
+	}
+
+	if string(sa.OIDCCertificateAuthorityData) != "LS0tLS1CRUdJTiBDRVJ=" {
+		t.Errorf("service account key data and input do not match: expected %s, got %s\n",
+			string(sa.OIDCCertificateAuthorityData), "LS0tLS1CRUdJTiBDRVJ=")
+	}
+}
+
+const ClusterCAWithData string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+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 ClusterCAWithoutData string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+    certificate-authority: /fake/path/to/ca.pem
+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
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate: /fake/path/to/ca.pem
+    client-key-data: LS0tLS1CRUdJTiBDRVJ=
+current-context: context-test
+`
+
+const ClientWithoutCertAndKeyData string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate: /fake/path/to/ca.pem
+    client-key: /fake/path/to/ca.pem
+current-context: context-test
+`
+
+const BearerTokenWithoutData string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: context-test
+clusters:
+- cluster:
+    server: https://localhost
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    tokenFile: /path/to/token/file.txt
+`
+const GCPPlugin string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+users:
+- name: test-admin
+  user:
+    auth-provider:
+      name: gcp
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+`
+
+const AWSEKSGetTokenExec string = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    exec:
+      apiVersion: client.authentication.k8s.io/v1alpha1
+      command: aws
+      args:
+        - "eks"
+        - "get-token"
+        - "--cluster-name"
+        - "cluster-test"
+`
+
+const OIDCAuthWithoutData string = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    auth-provider:
+      config:
+        client-id: porter-api
+        id-token: token
+        idp-issuer-url: https://localhost
+        idp-certificate-authority: /fake/path/to/ca.pem
+      name: oidc
+`

+ 29 - 0
internal/forms/candidate.go

@@ -0,0 +1,29 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// CreateServiceAccountCandidatesForm represents the accepted values for
+// creating a list of ServiceAccountCandidates from a kubeconfig
+type CreateServiceAccountCandidatesForm struct {
+	ProjectID  uint   `json:"project_id"`
+	Kubeconfig string `json:"kubeconfig"`
+}
+
+// ToServiceAccountCandidates creates a ServiceAccountCandidate from the kubeconfig and
+// project id
+func (csa *CreateServiceAccountCandidatesForm) ToServiceAccountCandidates() ([]*models.ServiceAccountCandidate, error) {
+	candidates, err := kubernetes.GetServiceAccountCandidates([]byte(csa.Kubeconfig))
+
+	if err != nil {
+		return nil, err
+	}
+
+	for _, saCandidate := range candidates {
+		saCandidate.ProjectID = csa.ProjectID
+	}
+
+	return candidates, nil
+}

+ 26 - 12
internal/forms/k8s.go

@@ -2,6 +2,7 @@ package forms
 
 import (
 	"net/url"
+	"strconv"
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/repository"
@@ -14,22 +15,35 @@ type K8sForm struct {
 
 // PopulateK8sOptionsFromQueryParams populates fields in the ReleaseForm using the passed
 // url.Values (the parsed query params)
-func (kf *K8sForm) PopulateK8sOptionsFromQueryParams(vals url.Values) {
-	if context, ok := vals["context"]; ok && len(context) == 1 {
-		kf.Context = context[0]
+func (kf *K8sForm) PopulateK8sOptionsFromQueryParams(
+	vals url.Values,
+	repo repository.ServiceAccountRepository,
+) error {
+	if clusterID, ok := vals["cluster_id"]; ok && len(clusterID) == 1 {
+		id, err := strconv.ParseUint(clusterID[0], 10, 64)
+
+		if err != nil {
+			return err
+		}
+
+		kf.ClusterID = uint(id)
 	}
-}
 
-// PopulateK8sOptionsFromUserID uses the passed userID to populate the HelmOptions object
-func (kf *K8sForm) PopulateK8sOptionsFromUserID(userID uint, repo repository.UserRepository) error {
-	user, err := repo.ReadUser(userID)
+	if serviceAccountID, ok := vals["service_account_id"]; ok && len(serviceAccountID) == 1 {
+		id, err := strconv.ParseUint(serviceAccountID[0], 10, 64)
 
-	if err != nil {
-		return err
-	}
+		if err != nil {
+			return err
+		}
+
+		sa, err := repo.ReadServiceAccount(uint(id))
 
-	kf.AllowedContexts = user.ContextToSlice()
-	kf.KubeConfig = user.RawKubeConfig
+		if err != nil {
+			return err
+		}
+
+		kf.ServiceAccount = sa
+	}
 
 	return nil
 }

+ 43 - 0
internal/forms/project.go

@@ -0,0 +1,43 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// WriteProjectForm is a generic form for write operations to the Project model
+type WriteProjectForm interface {
+	ToProject(repo repository.ProjectRepository) (*models.Project, error)
+}
+
+// CreateProjectForm represents the accepted values for creating a project
+type CreateProjectForm struct {
+	WriteProjectForm
+	Name string `json:"name" form:"required"`
+}
+
+// ToProject converts the project to a gorm project model
+func (cpf *CreateProjectForm) ToProject(_ repository.ProjectRepository) (*models.Project, error) {
+	return &models.Project{
+		Name: cpf.Name,
+	}, nil
+}
+
+// CreateProjectRoleForm represents the accepted values for creating a project
+// role
+type CreateProjectRoleForm struct {
+	WriteProjectForm
+	ID    uint          `json:"project_id" form:"required"`
+	Roles []models.Role `json:"roles"`
+}
+
+// ToProject converts the form to a gorm project model
+func (cprf *CreateProjectRoleForm) ToProject(_ repository.ProjectRepository) (*models.Project, error) {
+	return &models.Project{
+		Model: gorm.Model{
+			ID: cprf.ID,
+		},
+		Roles: cprf.Roles,
+	}, nil
+}

+ 34 - 16
internal/forms/release.go

@@ -15,9 +15,34 @@ type ReleaseForm struct {
 
 // PopulateHelmOptionsFromQueryParams populates fields in the ReleaseForm using the passed
 // url.Values (the parsed query params)
-func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(vals url.Values) {
-	if context, ok := vals["context"]; ok && len(context) == 1 {
-		rf.Context = context[0]
+func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(
+	vals url.Values,
+	repo repository.ServiceAccountRepository,
+) error {
+	if clusterID, ok := vals["cluster_id"]; ok && len(clusterID) == 1 {
+		id, err := strconv.ParseUint(clusterID[0], 10, 64)
+
+		if err != nil {
+			return err
+		}
+
+		rf.ClusterID = uint(id)
+	}
+
+	if serviceAccountID, ok := vals["service_account_id"]; ok && len(serviceAccountID) == 1 {
+		id, err := strconv.ParseUint(serviceAccountID[0], 10, 64)
+
+		if err != nil {
+			return err
+		}
+
+		sa, err := repo.ReadServiceAccount(uint(id))
+
+		if err != nil {
+			return err
+		}
+
+		rf.ServiceAccount = sa
 	}
 
 	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
@@ -27,18 +52,6 @@ func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(vals url.Values) {
 	if storage, ok := vals["storage"]; ok && len(storage) == 1 {
 		rf.Storage = storage[0]
 	}
-}
-
-// PopulateHelmOptionsFromUserID uses the passed user ID to populate the HelmOptions object
-func (rf *ReleaseForm) PopulateHelmOptionsFromUserID(userID uint, repo repository.UserRepository) error {
-	user, err := repo.ReadUser(userID)
-
-	if err != nil {
-		return err
-	}
-
-	rf.AllowedContexts = user.ContextToSlice()
-	rf.KubeConfig = user.RawKubeConfig
 
 	return nil
 }
@@ -51,7 +64,10 @@ type ListReleaseForm struct {
 
 // PopulateListFromQueryParams populates fields in the ListReleaseForm using the passed
 // url.Values (the parsed query params)
-func (lrf *ListReleaseForm) PopulateListFromQueryParams(vals url.Values) {
+func (lrf *ListReleaseForm) PopulateListFromQueryParams(
+	vals url.Values,
+	_ repository.ServiceAccountRepository,
+) error {
 	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
 		lrf.ListFilter.Namespace = namespace[0]
 	}
@@ -77,6 +93,8 @@ func (lrf *ListReleaseForm) PopulateListFromQueryParams(vals url.Values) {
 	if statusFilter, ok := vals["statusFilter"]; ok {
 		lrf.ListFilter.StatusFilter = statusFilter
 	}
+
+	return nil
 }
 
 // GetReleaseForm represents the accepted values for getting a single Helm release

+ 0 - 61
internal/forms/user.go

@@ -1,9 +1,6 @@
 package forms
 
 import (
-	"strings"
-
-	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/crypto/bcrypt"
@@ -58,64 +55,6 @@ func (luf *LoginUserForm) ToUser(_ repository.UserRepository) (*models.User, err
 	}, nil
 }
 
-// UpdateUserForm represents the accepted values for updating a user
-//
-// ID is a query parameter, the other two are sent in JSON body
-type UpdateUserForm struct {
-	WriteUserForm
-	ID              uint     `form:"required"`
-	RawKubeConfig   string   `json:"rawKubeConfig,omitempty"`
-	AllowedContexts []string `json:"allowedContexts"`
-}
-
-// ToUser converts an UpdateUserForm to models.User by parsing the kubeconfig
-// and the allowed clusters to generate a list of ClusterConfigs.
-func (uuf *UpdateUserForm) ToUser(repo repository.UserRepository) (*models.User, error) {
-	rawBytes := []byte(uuf.RawKubeConfig)
-	contexts := uuf.AllowedContexts
-
-	savedUser, err := repo.ReadUser(uuf.ID)
-
-	if err != nil {
-		return nil, err
-	}
-
-	// if the rawKubeConfig is empty, query the DB for a non-empty one
-	if uuf.RawKubeConfig == "" {
-		rawBytes = savedUser.RawKubeConfig
-	}
-
-	// if the allowedContexts is nil, query the DB for a non-nil one
-	if uuf.AllowedContexts == nil {
-		contexts = savedUser.ContextToSlice()
-	}
-
-	if len(rawBytes) > 0 {
-		// validate the kubeconfig
-		_contexts, err := kubernetes.GetContextsFromBytes(rawBytes, contexts)
-
-		if err != nil {
-			return nil, err
-		}
-
-		contexts = make([]string, 0)
-
-		// ensure only joined contexts get written
-		for _, context := range _contexts {
-			if context.Selected {
-				contexts = append(contexts, context.Name)
-			}
-		}
-	}
-
-	contextsJoin := strings.Join(contexts, ",")
-
-	savedUser.Contexts = contextsJoin
-	savedUser.RawKubeConfig = rawBytes
-
-	return savedUser, nil
-}
-
 // DeleteUserForm represents the accepted values for deleting a user
 type DeleteUserForm struct {
 	WriteUserForm

+ 7 - 8
internal/helm/config.go

@@ -6,6 +6,7 @@ import (
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/logger"
+	"github.com/porter-dev/porter/internal/models"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chartutil"
 	"helm.sh/helm/v3/pkg/kube"
@@ -17,11 +18,10 @@ import (
 // Form represents the options for connecting to a cluster and
 // creating a Helm agent
 type Form struct {
-	KubeConfig      []byte
-	AllowedContexts []string
-	Context         string `json:"context" form:"required"`
-	Storage         string `json:"storage" form:"oneof=secret configmap memory"`
-	Namespace       string `json:"namespace"`
+	ServiceAccount *models.ServiceAccount `form:"required"`
+	ClusterID      uint                   `json:"cluster_id" form:"required"`
+	Storage        string                 `json:"storage" form:"oneof=secret configmap memory"`
+	Namespace      string                 `json:"namespace"`
 }
 
 // GetAgentOutOfClusterConfig creates a new Agent from outside the cluster using
@@ -29,9 +29,8 @@ type Form struct {
 func GetAgentOutOfClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 	// create a kubernetes agent
 	conf := &kubernetes.OutOfClusterConfig{
-		KubeConfig:      form.KubeConfig,
-		AllowedContexts: form.AllowedContexts,
-		Context:         form.Context,
+		ServiceAccount: form.ServiceAccount,
+		ClusterID:      form.ClusterID,
 	}
 
 	k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(conf)

+ 25 - 9
internal/kubernetes/config.go

@@ -6,6 +6,7 @@ import (
 	"strings"
 	"time"
 
+	"github.com/porter-dev/porter/internal/models"
 	"k8s.io/apimachinery/pkg/api/meta"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
@@ -59,13 +60,13 @@ func GetAgentTesting(objects ...runtime.Object) *Agent {
 // OutOfClusterConfig is the set of parameters required for an out-of-cluster connection.
 // This implements RESTClientGetter
 type OutOfClusterConfig struct {
-	KubeConfig      []byte
-	AllowedContexts []string
-	Context         string `json:"context" form:"required"`
+	ServiceAccount *models.ServiceAccount `form:"required"`
+	ClusterID      uint                   `json:"cluster_id" form:"required"`
 }
 
-// ToRESTConfig creates a kubernetes REST client factory -- it simply calls ClientConfig on
-// the result of ToRawKubeConfigLoader
+// ToRESTConfig creates a kubernetes REST client factory -- it calls ClientConfig on
+// the result of ToRawKubeConfigLoader, and also adds a custom http transport layer
+// if necessary (required for GCP auth)
 func (conf *OutOfClusterConfig) ToRESTConfig() (*rest.Config, error) {
 	restConf, err := conf.ToRawKubeConfigLoader().ClientConfig()
 
@@ -73,6 +74,22 @@ func (conf *OutOfClusterConfig) ToRESTConfig() (*rest.Config, error) {
 		return nil, err
 	}
 
+	// if conf.ServiceAccount.AuthMechanism == models.GCP {
+	// 	creds, err := google.CredentialsFromJSON(
+	// 		context.Background(),
+	// 		conf.ServiceAccount.KeyData,
+	// 		"https://www.googleapis.com/auth/cloud-platform",
+	// 	)
+
+	// 	if err != nil {
+	// 		return nil, err
+	// 	}
+
+	// 	restConf.Transport = &oauth2.Transport{
+	// 		Source: creds.TokenSource,
+	// 	}
+	// }
+
 	rest.SetKubernetesDefaults(restConf)
 	return restConf, nil
 }
@@ -80,10 +97,9 @@ func (conf *OutOfClusterConfig) ToRESTConfig() (*rest.Config, error) {
 // ToRawKubeConfigLoader creates a clientcmd.ClientConfig from the raw kubeconfig found in
 // the OutOfClusterConfig. It does not implement loading rules or overrides.
 func (conf *OutOfClusterConfig) ToRawKubeConfigLoader() clientcmd.ClientConfig {
-	cmdConf, _ := GetRestrictedClientConfigFromBytes(
-		conf.KubeConfig,
-		conf.Context,
-		conf.AllowedContexts,
+	cmdConf, _ := GetClientConfigFromServiceAccount(
+		conf.ServiceAccount,
+		conf.ClusterID,
 	)
 
 	return cmdConf

+ 344 - 0
internal/kubernetes/kubeconfig.go

@@ -1,11 +1,355 @@
 package kubernetes
 
 import (
+	"context"
+	"errors"
+	"fmt"
+	"strings"
+
 	"github.com/porter-dev/porter/internal/models"
+	"golang.org/x/oauth2/google"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/clientcmd/api"
 )
 
+// GetServiceAccountCandidates parses a kubeconfig for a list of service account
+// candidates.
+func GetServiceAccountCandidates(kubeconfig []byte) ([]*models.ServiceAccountCandidate, error) {
+	config, err := clientcmd.NewClientConfigFromBytes(kubeconfig)
+
+	if err != nil {
+		return nil, err
+	}
+
+	rawConf, err := config.RawConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	res := make([]*models.ServiceAccountCandidate, 0)
+
+	for contextName, context := range rawConf.Contexts {
+		clusterName := context.Cluster
+		authInfoName := context.AuthInfo
+
+		// get the auth mechanism and actions
+		authMechanism, authInfoActions := parseAuthInfoForActions(rawConf.AuthInfos[authInfoName])
+		clusterActions := parseClusterForActions(rawConf.Clusters[clusterName])
+
+		actions := append(authInfoActions, clusterActions...)
+
+		// if auth mechanism is unsupported, we'll skip it
+		if authMechanism == models.NotAvailable {
+			continue
+		}
+
+		// construct the raw kubeconfig that's relevant for that context
+		contextConf, err := getConfigForContext(&rawConf, contextName)
+
+		if err != nil {
+			continue
+		}
+
+		rawBytes, err := clientcmd.Write(*contextConf)
+
+		if err == nil {
+			// create the candidate service account
+			res = append(res, &models.ServiceAccountCandidate{
+				Actions:         actions,
+				Kind:            "connector",
+				ClusterName:     clusterName,
+				ClusterEndpoint: rawConf.Clusters[clusterName].Server,
+				AuthMechanism:   authMechanism,
+				Kubeconfig:      rawBytes,
+			})
+		}
+	}
+
+	return res, nil
+}
+
+// GetRawConfigFromBytes returns the clientcmdapi.Config from kubeconfig
+// bytes
+func GetRawConfigFromBytes(kubeconfig []byte) (*api.Config, error) {
+	config, err := clientcmd.NewClientConfigFromBytes(kubeconfig)
+
+	if err != nil {
+		fmt.Println("ERROR IS HERE")
+		return nil, err
+	}
+
+	rawConf, err := config.RawConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &rawConf, nil
+}
+
+// Parsing rules are:
+//
+// (1) If a client certificate + client key exist, uses x509 auth mechanism
+// (2) If an oidc/gcp/aws plugin exists, uses that auth mechanism
+// (3) If a bearer token exists, uses bearer token auth mechanism
+// (4) If a username/password exist, uses basic auth mechanism
+// (5) Otherwise, the config gets skipped
+//
+func parseAuthInfoForActions(authInfo *api.AuthInfo) (authMechanism string, actions []models.ServiceAccountAction) {
+	actions = make([]models.ServiceAccountAction, 0)
+
+	if (authInfo.ClientCertificate != "" || len(authInfo.ClientCertificateData) != 0) &&
+		(authInfo.ClientKey != "" || len(authInfo.ClientKeyData) != 0) {
+		if len(authInfo.ClientCertificateData) == 0 {
+			actions = append(actions, models.ServiceAccountAction{
+				Name:     models.ClientCertDataAction,
+				Resolved: false,
+				Filename: authInfo.ClientCertificate,
+			})
+		}
+
+		if len(authInfo.ClientKeyData) == 0 {
+			actions = append(actions, models.ServiceAccountAction{
+				Name:     models.ClientKeyDataAction,
+				Resolved: false,
+				Filename: authInfo.ClientKey,
+			})
+		}
+
+		return models.X509, actions
+	}
+
+	if authInfo.AuthProvider != nil {
+		switch authInfo.AuthProvider.Name {
+		case "oidc":
+			filename, isFile := authInfo.AuthProvider.Config["idp-certificate-authority"]
+			data, isData := authInfo.AuthProvider.Config["idp-certificate-authority-data"]
+
+			if isFile && (!isData || data == "") {
+				return models.OIDC, []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     models.OIDCIssuerDataAction,
+						Resolved: false,
+						Filename: filename,
+					},
+				}
+			}
+
+			return models.OIDC, actions
+		case "gcp":
+			return models.GCP, []models.ServiceAccountAction{
+				models.ServiceAccountAction{
+					Name:     models.GCPKeyDataAction,
+					Resolved: false,
+				},
+			}
+		}
+	}
+
+	if authInfo.Exec != nil {
+		if authInfo.Exec.Command == "aws" || authInfo.Exec.Command == "aws-iam-authenticator" {
+			return models.AWS, []models.ServiceAccountAction{
+				models.ServiceAccountAction{
+					Name:     models.AWSKeyDataAction,
+					Resolved: false,
+				},
+			}
+		}
+	}
+
+	if authInfo.Token != "" || authInfo.TokenFile != "" {
+		if authInfo.Token == "" {
+			return models.Bearer, []models.ServiceAccountAction{
+				models.ServiceAccountAction{
+					Name:     models.TokenDataAction,
+					Resolved: false,
+					Filename: authInfo.TokenFile,
+				},
+			}
+		}
+
+		return models.Bearer, actions
+	}
+
+	if authInfo.Username != "" && authInfo.Password != "" {
+		return models.Basic, actions
+	}
+
+	return models.NotAvailable, actions
+}
+
+// Parses the cluster object to determine actions -- only currently supported action is
+// population of the cluster certificate authority data
+func parseClusterForActions(cluster *api.Cluster) (actions []models.ServiceAccountAction) {
+	actions = make([]models.ServiceAccountAction, 0)
+
+	if cluster.CertificateAuthority != "" && len(cluster.CertificateAuthorityData) == 0 {
+		return []models.ServiceAccountAction{
+			models.ServiceAccountAction{
+				Name:     models.ClusterCADataAction,
+				Resolved: false,
+				Filename: cluster.CertificateAuthority,
+			},
+		}
+	}
+
+	return actions
+}
+
+// getKubeconfigForContext returns the raw kubeconfig associated with only a
+// single context of the raw config
+func getConfigForContext(
+	rawConf *api.Config,
+	contextName string,
+) (*api.Config, error) {
+	copyConf := rawConf.DeepCopy()
+
+	copyConf.Clusters = make(map[string]*api.Cluster)
+	copyConf.AuthInfos = make(map[string]*api.AuthInfo)
+	copyConf.Contexts = make(map[string]*api.Context)
+	copyConf.CurrentContext = contextName
+
+	context, ok := rawConf.Contexts[contextName]
+
+	if ok {
+		userName := context.AuthInfo
+		clusterName := context.Cluster
+		authInfo, userFound := rawConf.AuthInfos[userName]
+		cluster, clusterFound := rawConf.Clusters[clusterName]
+
+		if userFound && clusterFound {
+			copyConf.Clusters[clusterName] = cluster
+			copyConf.AuthInfos[userName] = authInfo
+			copyConf.Contexts[contextName] = context
+		} else {
+			return nil, errors.New("linked user and cluster not found")
+		}
+	} else {
+		return nil, errors.New("context not found")
+	}
+
+	return copyConf, nil
+}
+
+// GetClientConfigFromServiceAccount will construct new clientcmd.ClientConfig using
+// the configuration saved within a ServiceAccount model
+func GetClientConfigFromServiceAccount(
+	sa *models.ServiceAccount,
+	clusterID uint,
+) (clientcmd.ClientConfig, error) {
+	apiConfig, err := createRawConfigFromServiceAccount(sa, clusterID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	config := clientcmd.NewDefaultClientConfig(*apiConfig, &clientcmd.ConfigOverrides{})
+
+	return config, nil
+}
+
+func createRawConfigFromServiceAccount(
+	sa *models.ServiceAccount,
+	clusterID uint,
+) (*api.Config, error) {
+	apiConfig := &api.Config{}
+
+	var cluster *models.Cluster = nil
+
+	// find the cluster within the ServiceAccount configuration
+	for _, _cluster := range sa.Clusters {
+		if _cluster.ID == clusterID {
+			cluster = &_cluster
+		}
+	}
+
+	if cluster == nil {
+		return nil, errors.New("cluster not found")
+	}
+
+	clusterMap := make(map[string]*api.Cluster)
+
+	clusterMap[cluster.Name] = &api.Cluster{
+		LocationOfOrigin:         cluster.LocationOfOrigin,
+		Server:                   cluster.Server,
+		TLSServerName:            cluster.TLSServerName,
+		InsecureSkipTLSVerify:    cluster.InsecureSkipTLSVerify,
+		CertificateAuthorityData: cluster.CertificateAuthorityData,
+	}
+
+	// construct the auth infos
+	authInfoName := cluster.Name + "-" + sa.AuthMechanism
+
+	authInfoMap := make(map[string]*api.AuthInfo)
+
+	authInfoMap[authInfoName] = &api.AuthInfo{
+		LocationOfOrigin: sa.LocationOfOrigin,
+		Impersonate:      sa.Impersonate,
+	}
+
+	if groups := strings.Split(sa.ImpersonateGroups, ","); len(groups) > 0 && groups[0] != "" {
+		authInfoMap[authInfoName].ImpersonateGroups = groups
+	}
+
+	switch sa.AuthMechanism {
+	case models.X509:
+		authInfoMap[authInfoName].ClientCertificateData = sa.ClientCertificateData
+		authInfoMap[authInfoName].ClientKeyData = sa.ClientKeyData
+	case models.Basic:
+		authInfoMap[authInfoName].Username = sa.Username
+		authInfoMap[authInfoName].Password = sa.Password
+	case models.Bearer:
+		authInfoMap[authInfoName].Token = sa.Token
+	case models.OIDC:
+		authInfoMap[authInfoName].AuthProvider = &api.AuthProviderConfig{
+			Name: "oidc",
+			Config: map[string]string{
+				"idp-issuer-url":                 sa.OIDCIssuerURL,
+				"client-id":                      sa.OIDCClientID,
+				"client-secret":                  sa.OIDCClientSecret,
+				"idp-certificate-authority-data": sa.OIDCCertificateAuthorityData,
+				"id-token":                       sa.OIDCIDToken,
+				"refresh-token":                  sa.OIDCRefreshToken,
+			},
+		}
+	// we'll add a bearer token here for now
+	case models.GCP:
+		creds, err := google.CredentialsFromJSON(
+			context.Background(),
+			sa.KeyData,
+			"https://www.googleapis.com/auth/cloud-platform",
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		tok, err := creds.TokenSource.Token()
+
+		authInfoMap[authInfoName].Token = tok.AccessToken
+	case models.AWS:
+	default:
+		return nil, errors.New("not a supported auth mechanism")
+	}
+
+	// create a context of the cluster name
+	contextMap := make(map[string]*api.Context)
+
+	contextMap[cluster.Name] = &api.Context{
+		LocationOfOrigin: cluster.LocationOfOrigin,
+		Cluster:          cluster.Name,
+		AuthInfo:         authInfoName,
+	}
+
+	apiConfig.Clusters = clusterMap
+	apiConfig.AuthInfos = authInfoMap
+	apiConfig.Contexts = contextMap
+	apiConfig.CurrentContext = cluster.Name
+
+	return apiConfig, nil
+}
+
 // GetRestrictedClientConfigFromBytes returns a clientcmd.ClientConfig from a raw kubeconfig,
 // a context name, and the set of allowed contexts.
 func GetRestrictedClientConfigFromBytes(

+ 602 - 9
internal/kubernetes/kubeconfig_test.go

@@ -7,6 +7,7 @@ import (
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
+	"k8s.io/client-go/tools/clientcmd"
 )
 
 type kubeConfigTest struct {
@@ -165,6 +166,331 @@ func TestGetRestrictedClientConfig(t *testing.T) {
 	}
 }
 
+type saCandidatesTest struct {
+	name     string
+	raw      []byte
+	expected []*models.ServiceAccountCandidate
+}
+
+var SACandidatesTests = []saCandidatesTest{
+	saCandidatesTest{
+		name: "test without cluster ca data",
+		raw:  []byte(ClusterCAWithoutData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-cluster-ca-data",
+						Resolved: false,
+						Filename: "/fake/path/to/ca.pem",
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.X509,
+				Kubeconfig:      []byte(ClusterCAWithoutData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "x509 test with cert and key data",
+		raw:  []byte(x509WithData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions:         []models.ServiceAccountAction{},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.X509,
+				Kubeconfig:      []byte(x509WithData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "x509 test without cert data",
+		raw:  []byte(x509WithoutCertData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-client-cert-data",
+						Resolved: false,
+						Filename: "/fake/path/to/cert.pem",
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.X509,
+				Kubeconfig:      []byte(x509WithoutCertData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "x509 test without key data",
+		raw:  []byte(x509WithoutKeyData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-client-key-data",
+						Resolved: false,
+						Filename: "/fake/path/to/key.pem",
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.X509,
+				Kubeconfig:      []byte(x509WithoutKeyData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "x509 test without cert and key data",
+		raw:  []byte(x509WithoutCertAndKeyData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-client-cert-data",
+						Resolved: false,
+						Filename: "/fake/path/to/cert.pem",
+					},
+					models.ServiceAccountAction{
+						Name:     "upload-client-key-data",
+						Resolved: false,
+						Filename: "/fake/path/to/key.pem",
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.X509,
+				Kubeconfig:      []byte(x509WithoutCertAndKeyData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "bearer token test with data",
+		raw:  []byte(BearerTokenWithData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions:         []models.ServiceAccountAction{},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.Bearer,
+				Kubeconfig:      []byte(BearerTokenWithData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "bearer token test without data",
+		raw:  []byte(BearerTokenWithoutData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-token-data",
+						Resolved: false,
+						Filename: "/path/to/token/file.txt",
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.Bearer,
+				Kubeconfig:      []byte(BearerTokenWithoutData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "gcp test",
+		raw:  []byte(GCPPlugin),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-gcp-key-data",
+						Resolved: false,
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.GCP,
+				Kubeconfig:      []byte(GCPPlugin),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "aws iam authenticator test",
+		raw:  []byte(AWSIamAuthenticatorExec),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-aws-key-data",
+						Resolved: false,
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.AWS,
+				Kubeconfig:      []byte(AWSIamAuthenticatorExec),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "aws eks get-token test",
+		raw:  []byte(AWSEKSGetTokenExec),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-aws-key-data",
+						Resolved: false,
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.AWS,
+				Kubeconfig:      []byte(AWSEKSGetTokenExec),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "oidc without ca data",
+		raw:  []byte(OIDCAuthWithoutData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions: []models.ServiceAccountAction{
+					models.ServiceAccountAction{
+						Name:     "upload-oidc-idp-issuer-ca-data",
+						Resolved: false,
+						Filename: "/fake/path/to/ca.pem",
+					},
+				},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.OIDC,
+				Kubeconfig:      []byte(OIDCAuthWithoutData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "oidc with ca data",
+		raw:  []byte(OIDCAuthWithData),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions:         []models.ServiceAccountAction{},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.OIDC,
+				Kubeconfig:      []byte(OIDCAuthWithData),
+			},
+		},
+	},
+	saCandidatesTest{
+		name: "basic auth test",
+		raw:  []byte(BasicAuth),
+		expected: []*models.ServiceAccountCandidate{
+			&models.ServiceAccountCandidate{
+				Actions:         []models.ServiceAccountAction{},
+				Kind:            "connector",
+				ClusterName:     "cluster-test",
+				ClusterEndpoint: "https://localhost",
+				AuthMechanism:   models.Basic,
+				Kubeconfig:      []byte(BasicAuth),
+			},
+		},
+	},
+}
+
+func TestGetServiceAccountCandidates(t *testing.T) {
+	for _, c := range SACandidatesTests {
+		result, err := kubernetes.GetServiceAccountCandidates(c.raw)
+
+		if err != nil {
+			t.Fatalf("error occurred %v\n", err)
+		}
+
+		// make result into a map so it's easier to compare
+		resMap := make(map[string]*models.ServiceAccountCandidate)
+
+		for _, res := range result {
+			resMap[res.Kind+"-"+res.ClusterEndpoint+"-"+res.AuthMechanism] = res
+		}
+
+		for _, exp := range c.expected {
+			res, ok := resMap[exp.Kind+"-"+exp.ClusterEndpoint+"-"+exp.AuthMechanism]
+
+			if !ok {
+				t.Fatalf("%s failed: no matching result for %s\n", c.name,
+					exp.Kind+"-"+exp.ClusterEndpoint+"-"+exp.AuthMechanism)
+			}
+
+			// compare basic string fields
+			if exp.AuthMechanism != res.AuthMechanism {
+				t.Errorf("%s failed on auth mechanism: expected %s, got %s\n",
+					c.name, exp.AuthMechanism, res.AuthMechanism)
+			}
+
+			if exp.ClusterName != res.ClusterName {
+				t.Errorf("%s failed on cluster name: expected %s, got %s\n",
+					c.name, exp.ClusterName, res.ClusterName)
+			}
+
+			if exp.ClusterEndpoint != res.ClusterEndpoint {
+				t.Errorf("%s failed on cluster endpoint: expected %s, got %s\n",
+					c.name, exp.ClusterEndpoint, res.ClusterEndpoint)
+			}
+
+			if len(res.Actions) != len(exp.Actions) {
+				t.Errorf("%s failed on action names: expected length %d, got length %d\n",
+					c.name, len(res.Actions), len(exp.Actions))
+			} else {
+				for i, action := range exp.Actions {
+					if res.Actions[i].Name != action.Name {
+						t.Errorf("%s failed on action names: expected res to contain %s, got %s\n",
+							c.name, action.Name, res.Actions[i].Name)
+					}
+
+					if res.Actions[i].Filename != action.Filename {
+						t.Errorf("%s failed on action file names: expected res to contain %s, got %s\n",
+							c.name, action.Filename, res.Actions[i].Filename)
+					}
+				}
+			}
+
+			// compare kubeconfig by transforming into a client config
+			resConfig, _ := clientcmd.NewClientConfigFromBytes(res.Kubeconfig)
+			expConfig, err := clientcmd.NewClientConfigFromBytes(exp.Kubeconfig)
+
+			if err != nil {
+				t.Fatalf("config from bytes, error occurred %v\n", err)
+			}
+
+			resRawConf, _ := resConfig.RawConfig()
+			expRawConf, err := expConfig.RawConfig()
+
+			if err != nil {
+				t.Fatalf("raw config conversion, error occurred %v\n", err)
+			}
+
+			if !reflect.DeepEqual(resRawConf, expRawConf) {
+				t.Errorf("%s failed: expected %v, got %v\n", c.name, expRawConf, resRawConf)
+			}
+		}
+	}
+}
+
 const noContexts string = `
 apiVersion: v1
 kind: Config
@@ -266,7 +592,28 @@ users:
   - name: test-admin
 `
 
-const oidcPlugin string = `
+const ClusterCAWithoutData string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+    certificate-authority: /fake/path/to/ca.pem
+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
 preferences: {}
@@ -281,16 +628,262 @@ contexts:
     user: test-admin
   name: context-test
 users:
-  - name: test-admin
-  - name: test-admin
+- name: test-admin
+  user:
+    client-certificate-data: LS0tLS1CRUdJTiBDRVJ=
+    client-key-data: LS0tLS1CRUdJTiBDRVJ=
+`
+
+const x509WithoutCertData string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: context-test
+clusters:
+- cluster:
+    server: https://localhost
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate: /fake/path/to/cert.pem
+    client-key-data: LS0tLS1CRUdJTiBDRVJ=
+`
+
+const x509WithoutKeyData string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: context-test
+clusters:
+- cluster:
+    server: https://localhost
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate-data: LS0tLS1CRUdJTiBDRVJ=
+    client-key: /fake/path/to/key.pem
+`
+
+const x509WithoutCertAndKeyData string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: context-test
+clusters:
+- cluster:
+    server: https://localhost
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate: /fake/path/to/cert.pem
+    client-key: /fake/path/to/key.pem
+`
+
+const BearerTokenWithData string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: context-test
+clusters:
+- cluster:
+    server: https://localhost
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    token: LS0tLS1CRUdJTiBDRVJ=
+`
+
+const BearerTokenWithoutData string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: context-test
+clusters:
+- cluster:
+    server: https://localhost
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    tokenFile: /path/to/token/file.txt
+`
+const GCPPlugin string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+users:
+- name: test-admin
+  user:
+    auth-provider:
+      name: gcp
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+`
+
+const AWSIamAuthenticatorExec = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    exec:
+      apiVersion: client.authentication.k8s.io/v1alpha1
+      command: aws-iam-authenticator
+      args:
+        - "token"
+        - "-i"
+        - "cluster-test"
+`
+
+const AWSEKSGetTokenExec = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    exec:
+      apiVersion: client.authentication.k8s.io/v1alpha1
+      command: aws
+      args:
+        - "eks"
+        - "get-token"
+        - "--cluster-name"
+        - "cluster-test"
+`
+
+const OIDCAuthWithoutData = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
   user:
     auth-provider:
       config:
-        client-id: sampleclientid
-        client-secret: sampleclientsecret
-        id-token: IDTOKEN
-        idp-issuer-url: https://login.example.com/
-		refresh-token: REFRESHTOKEN
-		idp-certificate-authority: /example/file/on/system.pem
+        client-id: porter-api
+        id-token: token
+        idp-issuer-url: https://localhost
+        idp-certificate-authority: /fake/path/to/ca.pem
       name: oidc
 `
+
+const OIDCAuthWithData = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    auth-provider:
+      config:
+        client-id: porter-api
+        id-token: token
+        idp-issuer-url: https://localhost
+        idp-certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+      name: oidc
+`
+
+const BasicAuth = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://localhost
+    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+  name: cluster-test
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+current-context: context-test
+kind: Config
+preferences: {}
+users:
+- name: test-admin
+  user:
+    username: admin
+    password: changeme
+`

+ 52 - 4
internal/kubernetes/local/kubeconfig.go

@@ -1,5 +1,3 @@
-// +build cli
-
 package local
 
 import (
@@ -17,6 +15,58 @@ import (
 	"k8s.io/client-go/util/homedir"
 )
 
+// GetKubeconfigFromHost returns the kubeconfig for a list of contexts using default
+// options set on the host, or an explicit kubeconfig path. It then strips the kubeconfig
+// of contexts not specified in the contexts array, and returns generate kubeconfig.
+func GetKubeconfigFromHost(kubeconfigPath string, contexts []string) ([]byte, error) {
+	envVarName := clientcmd.RecommendedConfigPathEnvVar
+
+	if kubeconfigPath != "" {
+		if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) {
+			// the specified kubeconfig does not exist so fallback to other options
+			kubeconfigPath = ""
+		}
+	}
+
+	if kubeconfigPath == "" && os.Getenv(envVarName) == "" {
+		if home := homedir.HomeDir(); home != "" {
+			kubeconfigPath = filepath.Join(home, ".kube", "config")
+		}
+	}
+
+	loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
+	loadingRules.ExplicitPath = kubeconfigPath
+
+	clientConf := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{})
+	rawConf, err := clientConf.RawConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	if len(contexts) == 0 {
+		contexts = []string{rawConf.CurrentContext}
+
+		if contexts[0] == "" {
+			return nil, fmt.Errorf("at least one context must be specified")
+		}
+	}
+
+	conf, err := stripAndValidateClientContexts(&rawConf, contexts[0], contexts)
+
+	if err != nil {
+		return nil, err
+	}
+
+	strippedRawConf, err := conf.RawConfig()
+
+	if err != nil {
+		return nil, err
+	}
+
+	return clientcmd.Write(strippedRawConf)
+}
+
 // GetConfigFromHostWithCertData gets the kubeconfig using default options set on the host:
 // the kubeconfig can either be retrieved from a specified path or an environment variable.
 // This function only outputs a clientcmd that uses the allowedContexts.
@@ -68,8 +118,6 @@ func GetConfigFromHostWithCertData(kubeconfigPath string, allowedContexts []stri
 	return res, nil
 }
 
-// GetRestrictedClientConfigFromBytes returns a clientcmd.ClientConfig from a raw kubeconfig,
-// a context name, and the set of allowed contexts.
 func stripAndValidateClientContexts(
 	rawConf *clientcmdapi.Config,
 	currentContext string,

+ 118 - 0
internal/models/action.go

@@ -0,0 +1,118 @@
+package models
+
+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"
+	AWSKeyDataAction            = "upload-aws-key-data"
+)
+
+// ServiceAccountAction is an action that must be resolved to set up
+// a ServiceAccount
+type ServiceAccountAction struct {
+	gorm.Model
+
+	ServiceAccountCandidateID uint
+
+	// One of the constant action names
+	Name     string `json:"name"`
+	Resolved bool   `json:"resolved"`
+
+	// Filename is an optional filename, if the action requires
+	// data populated from a local file
+	Filename string `json:"filename,omitempty"`
+}
+
+// Externalize generates an external ServiceAccount to be shared over REST
+func (u *ServiceAccountAction) Externalize() *ServiceAccountActionExternal {
+	info := ServiceAccountActionInfos[u.Name]
+
+	return &ServiceAccountActionExternal{
+		Name:     u.Name,
+		Resolved: u.Resolved,
+		Filename: u.Filename,
+		Docs:     info.Docs,
+		Fields:   info.Fields,
+	}
+}
+
+// ServiceAccountActionExternal is an external ServiceAccountAction to be
+// sent over REST
+type ServiceAccountActionExternal struct {
+	Name     string `json:"name"`
+	Docs     string `json:"docs"`
+	Resolved bool   `json:"resolved"`
+	Fields   string `json:"fields"`
+	Filename string `json:"filename,omitempty"`
+}
+
+// ServiceAccountAllActions is a helper type that contains the fields for
+// all possible actions, so that raw bytes can be unmarshaled in a single
+// read
+type ServiceAccountAllActions struct {
+	Name string `json:"name"`
+
+	ClusterCAData    string `json:"cluster_ca_data,omitempty"`
+	ClientCertData   string `json:"client_cert_data,omitempty"`
+	ClientKeyData    string `json:"client_key_data,omitempty"`
+	OIDCIssuerCAData string `json:"oidc_idp_issuer_ca_data,omitempty"`
+	TokenData        string `json:"token_data,omitempty"`
+	GCPKeyData       string `json:"gcp_key_data,omitempty"`
+	AWSKeyData       string `json:"aws_key_data,omitempty"`
+}
+
+// ServiceAccountActionInfo contains the information for actions to be
+// performed in order to initialize a ServiceAccount
+type ServiceAccountActionInfo struct {
+	Name string `json:"name"`
+	Docs string `json:"docs"`
+
+	// a comma-separated list of required fields to send in an action request
+	Fields string `json:"fields"`
+}
+
+// ServiceAccountActionInfos contain the information for actions to be
+// performed in order to initialize a ServiceAccount
+var ServiceAccountActionInfos = map[string]ServiceAccountActionInfo{
+	"upload-cluster-ca-data": ServiceAccountActionInfo{
+		Name:   ClusterCADataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "cluster_ca_data",
+	},
+	"upload-client-cert-data": ServiceAccountActionInfo{
+		Name:   ClientCertDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "client_cert_data",
+	},
+	"upload-client-key-data": ServiceAccountActionInfo{
+		Name:   ClientKeyDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "client_key_data",
+	},
+	"upload-oidc-idp-issuer-ca-data": ServiceAccountActionInfo{
+		Name:   OIDCIssuerDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "oidc_idp_issuer_ca_data",
+	},
+	"upload-token-data": ServiceAccountActionInfo{
+		Name:   TokenDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "token_data",
+	},
+	"upload-gcp-key-data": ServiceAccountActionInfo{
+		Name:   GCPKeyDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "gcp_key_data",
+	},
+	"upload-aws-key-data": ServiceAccountActionInfo{
+		Name:   AWSKeyDataAction,
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "aws_key_data",
+	},
+}

+ 43 - 0
internal/models/cluster.go

@@ -0,0 +1,43 @@
+package models
+
+import "gorm.io/gorm"
+
+// Cluster type that extends gorm.Model
+type Cluster struct {
+	gorm.Model
+
+	Name                  string `json:"name"`
+	ServiceAccountID      uint   `json:"service_account_id"`
+	LocationOfOrigin      string `json:"location_of_origin"`
+	Server                string `json:"server"`
+	TLSServerName         string `json:"tls-server-name,omitempty"`
+	InsecureSkipTLSVerify bool   `json:"insecure-skip-tls-verify,omitempty"`
+	ProxyURL              string `json:"proxy-url,omitempty"`
+
+	// CertificateAuthorityData is encrypted at rest
+	CertificateAuthorityData []byte `json:"certificate-authority-data,omitempty"`
+}
+
+// ClusterExternal is the external cluster type to be sent over REST
+type ClusterExternal struct {
+	ID                    uint   `json:"id"`
+	ServiceAccountID      uint   `json:"service_account_id"`
+	Name                  string `json:"name"`
+	Server                string `json:"server"`
+	TLSServerName         string `json:"tls-server-name,omitempty"`
+	InsecureSkipTLSVerify bool   `json:"insecure-skip-tls-verify,omitempty"`
+	ProxyURL              string `json:"proxy-url,omitempty"`
+}
+
+// Externalize generates an external Cluster to be shared over REST
+func (c *Cluster) Externalize() *ClusterExternal {
+	return &ClusterExternal{
+		ID:                    c.Model.ID,
+		ServiceAccountID:      c.ServiceAccountID,
+		Name:                  c.Name,
+		Server:                c.Server,
+		TLSServerName:         c.TLSServerName,
+		InsecureSkipTLSVerify: c.InsecureSkipTLSVerify,
+		ProxyURL:              c.ProxyURL,
+	}
+}

+ 47 - 0
internal/models/project.go

@@ -0,0 +1,47 @@
+package models
+
+import (
+	"gorm.io/gorm"
+)
+
+// Project type that extends gorm.Model
+type Project struct {
+	gorm.Model
+
+	Name        string       `json:"name"`
+	Roles       []Role       `json:"roles"`
+	RepoClients []RepoClient `json:"repo_clients,omitempty"`
+
+	ServiceAccountCandidates []ServiceAccountCandidate `json:"sa_candidates"`
+	ServiceAccounts          []ServiceAccount          `json:"serviceaccounts"`
+}
+
+// ProjectExternal represents the Project type that is sent over REST
+type ProjectExternal struct {
+	ID          uint                 `json:"id"`
+	Name        string               `json:"name"`
+	Roles       []RoleExternal       `json:"roles"`
+	RepoClients []RepoClientExternal `json:"repo_clients,omitempty"`
+}
+
+// Externalize generates an external Project to be shared over REST
+func (p *Project) Externalize() *ProjectExternal {
+	roles := make([]RoleExternal, 0)
+
+	for _, role := range p.Roles {
+		roles = append(roles, *role.Externalize())
+	}
+
+	repoClients := make([]RepoClientExternal, 0)
+
+	for _, repoClient := range p.RepoClients {
+		repoClients = append(repoClients, *repoClient.Externalize())
+	}
+
+	return &ProjectExternal{
+		ID:          p.ID,
+		Name:        p.Name,
+		Roles:       roles,
+		RepoClients: repoClients,
+	}
+}

+ 51 - 0
internal/models/repoclient.go

@@ -0,0 +1,51 @@
+package models
+
+import (
+	"gorm.io/gorm"
+)
+
+// The allowed repository clients
+const (
+	RepoClientGithub = "github"
+)
+
+// RepoClient is a client for a set of repositories that has been added
+// via a project OAuth flow
+type RepoClient struct {
+	gorm.Model
+
+	ProjectID  uint `json:"project_id"`
+	UserID     uint `json:"user_id"`
+	RepoUserID uint `json:"repo_id"`
+
+	// the kind can be one of the predefined repo kinds
+	Kind string `json:"kind"`
+
+	// ------------------------------------------------------------------
+	// All fields below this line are encrypted before storage
+	// ------------------------------------------------------------------
+
+	AccessToken  string `json:"access_token"`
+	RefreshToken string `json:"refresh_token"`
+}
+
+// RepoClientExternal is a RepoClient scrubbed of sensitive information to be
+// shared over REST
+type RepoClientExternal struct {
+	ID         uint   `json:"id"`
+	ProjectID  uint   `json:"project_id"`
+	UserID     uint   `json:"user_id"`
+	RepoUserID uint   `json:"repo_id"`
+	Kind       string `json:"kind"`
+}
+
+// Externalize generates an external RepoClient to be shared over REST
+func (r *RepoClient) Externalize() *RepoClientExternal {
+	return &RepoClientExternal{
+		ID:         r.Model.ID,
+		ProjectID:  r.ProjectID,
+		UserID:     r.UserID,
+		RepoUserID: r.RepoUserID,
+		Kind:       r.Kind,
+	}
+}

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor