瀏覽代碼

Merge branch 'master' into beta.3.deploy-agent

sunguroku 5 年之前
父節點
當前提交
f4fa0e5d9f
共有 100 個文件被更改,包括 7360 次插入3258 次删除
  1. 13 1
      .darwin.goreleaser.yml
  2. 2 1
      .gitignore
  3. 9 2
      .goreleaser.yml
  4. 2 2
      README.md
  5. 103 0
      cli/cmd/api/integration.go
  6. 1 4
      cli/cmd/api/k8s.go
  7. 24 27
      cli/cmd/api/project.go
  8. 75 113
      cli/cmd/api/project_test.go
  9. 172 0
      cli/cmd/api/registry.go
  10. 0 17
      cli/cmd/cluster.go
  11. 60 3
      cli/cmd/config.go
  12. 62 2
      cli/cmd/connect.go
  13. 144 0
      cli/cmd/connect/ecr.go
  14. 82 0
      cli/cmd/connect/gcr.go
  15. 142 133
      cli/cmd/connect/kubeconfig.go
  16. 172 0
      cli/cmd/github/release.go
  17. 182 0
      cli/cmd/providers/aws/agent.go
  18. 0 0
      cli/cmd/providers/aws/authconfigmap.go
  19. 17 5
      cli/cmd/providers/aws/local/config.go
  20. 0 0
      cli/cmd/providers/gcp/agent.go
  21. 1 1
      cli/cmd/providers/gcp/local/config.go
  22. 117 0
      cli/cmd/registry.go
  23. 11 1
      cli/cmd/root.go
  24. 93 18
      cli/cmd/server.go
  25. 28 0
      cmd/app/main.go
  26. 14 6
      cmd/migrate/main.go
  27. 4 0
      dashboard/src/assets/edit.svg
  28. 二進制
      dashboard/src/assets/tag.png
  29. 262 0
      dashboard/src/components/ResourceTab.tsx
  30. 26 1
      dashboard/src/components/TabSelector.tsx
  31. 64 0
      dashboard/src/components/TooltipParent.tsx
  32. 176 47
      dashboard/src/components/image-selector/ImageSelector.tsx
  33. 141 0
      dashboard/src/components/image-selector/TagList.tsx
  34. 0 3
      dashboard/src/components/repo-selector/BranchList.tsx
  35. 1 1
      dashboard/src/components/repo-selector/RepoSelector.tsx
  36. 14 0
      dashboard/src/components/values-form/Heading.tsx
  37. 14 0
      dashboard/src/components/values-form/Helper.tsx
  38. 7 1
      dashboard/src/components/values-form/InputRow.tsx
  39. 64 0
      dashboard/src/components/values-form/TextArea.tsx
  40. 11 18
      dashboard/src/components/values-form/ValuesForm.tsx
  41. 1 1
      dashboard/src/main/Main.tsx
  42. 15 1
      dashboard/src/main/home/Home.tsx
  43. 10 7
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  44. 0 1
      dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx
  45. 17 18
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  46. 3 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  47. 63 18
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ListSection.tsx
  48. 1 3
      dashboard/src/main/home/cluster-dashboard/expanded-chart/RevisionSection.tsx
  49. 58 17
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  50. 1 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ValuesYaml.tsx
  51. 103 201
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  52. 16 10
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx
  53. 9 9
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/StatusSection.tsx
  54. 9 2
      dashboard/src/main/home/dashboard/Dashboard.tsx
  55. 27 38
      dashboard/src/main/home/integrations/IntegrationList.tsx
  56. 159 13
      dashboard/src/main/home/integrations/Integrations.tsx
  57. 20 6
      dashboard/src/main/home/integrations/integration-form/DockerHubForm.tsx
  58. 128 0
      dashboard/src/main/home/integrations/integration-form/ECRForm.tsx
  59. 116 0
      dashboard/src/main/home/integrations/integration-form/EKSForm.tsx
  60. 84 0
      dashboard/src/main/home/integrations/integration-form/GCRForm.tsx
  61. 104 0
      dashboard/src/main/home/integrations/integration-form/GKEForm.tsx
  62. 39 0
      dashboard/src/main/home/integrations/integration-form/IntegrationForm.tsx
  63. 106 32
      dashboard/src/main/home/modals/ClusterInstructionsModal.tsx
  64. 197 0
      dashboard/src/main/home/modals/IntegrationsInstructionsModal.tsx
  65. 56 31
      dashboard/src/main/home/modals/IntegrationsModal.tsx
  66. 1 1
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  67. 3 2
      dashboard/src/main/home/sidebar/Sidebar.tsx
  68. 21 0
      dashboard/src/main/home/templates/Templates.tsx
  69. 3 1
      dashboard/src/main/home/templates/expanded-template/ExpandedTemplate.tsx
  70. 19 5
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  71. 1 1
      dashboard/src/main/home/templates/expanded-template/TemplateInfo.tsx
  72. 70 20
      dashboard/src/shared/api.tsx
  73. 1 1
      dashboard/src/shared/baseApi.tsx
  74. 41 12
      dashboard/src/shared/common.tsx
  75. 6 0
      dashboard/src/shared/types.tsx
  76. 26 0
      docs/GCR.md
  77. 1 0
      go.mod
  78. 19 0
      go.sum
  79. 1 0
      internal/config/config.go
  80. 0 327
      internal/forms/action.go
  81. 0 688
      internal/forms/action_test.go
  82. 20 8
      internal/forms/candidate.go
  83. 495 0
      internal/forms/cluster.go
  84. 1053 0
      internal/forms/cluster_test.go
  85. 358 0
      internal/forms/helper_test.go
  86. 45 0
      internal/forms/integration.go
  87. 3 13
      internal/forms/k8s.go
  88. 24 0
      internal/forms/registry.go
  89. 4 14
      internal/forms/release.go
  90. 7 8
      internal/helm/config.go
  91. 191 14
      internal/kubernetes/config.go
  92. 344 0
      internal/kubernetes/fixtures/kubeconfig.go
  93. 186 386
      internal/kubernetes/kubeconfig.go
  94. 218 777
      internal/kubernetes/kubeconfig_test.go
  95. 0 120
      internal/models/action.go
  96. 316 25
      internal/models/cluster.go
  97. 34 0
      internal/models/cluster_test.go
  98. 0 16
      internal/models/context.go
  99. 45 0
      internal/models/gitrepo.go
  100. 152 0
      internal/models/integrations/aws.go

+ 13 - 1
.darwin.goreleaser.yml

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

+ 2 - 1
.gitignore

@@ -7,4 +7,5 @@ test.yaml
 dist
 gon.hcl
 internal/local_templates
-docker/prod.Dockerfile
+gon*.hcl
+*prod.Dockerfile

+ 9 - 2
.goreleaser.yml

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

+ 2 - 2
README.md

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

+ 103 - 0
cli/cmd/api/integration.go

@@ -0,0 +1,103 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// CreateAWSIntegrationRequest represents the accepted fields for creating
+// an aws integration
+type CreateAWSIntegrationRequest struct {
+	AWSRegion          string `json:"aws_region"`
+	AWSAccessKeyID     string `json:"aws_access_key_id"`
+	AWSSecretAccessKey string `json:"aws_secret_access_key"`
+}
+
+// CreateAWSIntegrationResponse is the resulting integration after creation
+type CreateAWSIntegrationResponse ints.AWSIntegrationExternal
+
+// CreateAWSIntegration creates an AWS integration with the given request options
+func (c *Client) CreateAWSIntegration(
+	ctx context.Context,
+	projectID uint,
+	createAWS *CreateAWSIntegrationRequest,
+) (*CreateAWSIntegrationResponse, error) {
+	data, err := json.Marshal(createAWS)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/integrations/aws", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateAWSIntegrationResponse{}
+
+	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
+}
+
+// CreateGCPIntegrationRequest represents the accepted fields for creating
+// a gcp integration
+type CreateGCPIntegrationRequest struct {
+	GCPKeyData string `json:"gcp_key_data"`
+}
+
+// CreateGCPIntegrationResponse is the resulting integration after creation
+type CreateGCPIntegrationResponse ints.GCPIntegrationExternal
+
+// CreateGCPIntegration creates a GCP integration with the given request options
+func (c *Client) CreateGCPIntegration(
+	ctx context.Context,
+	projectID uint,
+	createGCP *CreateGCPIntegrationRequest,
+) (*CreateGCPIntegrationResponse, error) {
+	data, err := json.Marshal(createGCP)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/integrations/gcp", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateGCPIntegrationResponse{}
+
+	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
+}

+ 1 - 4
cli/cmd/api/k8s.go

@@ -17,17 +17,14 @@ type GetK8sNamespacesResponse v1.NamespaceList
 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},
+			"cluster_id": []string{cl},
 		}.Encode(), c.BaseURL, projectID),
 		nil,
 	)

+ 24 - 27
cli/cmd/api/project.go

@@ -40,19 +40,19 @@ func (c *Client) GetProject(ctx context.Context, projectID uint) (*GetProjectRes
 	return bodyResp, nil
 }
 
-// GetProjectServiceAccountResponse is the response returned after querying for a
-// given project's service account
-type GetProjectServiceAccountResponse models.ServiceAccountExternal
+// GetProjectClusterResponse is the response returned after querying for a
+// given project's cluster
+type GetProjectClusterResponse models.ClusterExternal
 
-// GetProjectServiceAccount retrieves a project's service account by id
-func (c *Client) GetProjectServiceAccount(
+// GetProjectCluster retrieves a project's cluster by id
+func (c *Client) GetProjectCluster(
 	ctx context.Context,
 	projectID uint,
-	saID uint,
-) (*GetProjectServiceAccountResponse, error) {
+	clusterID uint,
+) (*GetProjectClusterResponse, error) {
 	req, err := http.NewRequest(
 		"GET",
-		fmt.Sprintf("%s/projects/%d/serviceAccounts/%d", c.BaseURL, projectID, saID),
+		fmt.Sprintf("%s/projects/%d/clusters/%d", c.BaseURL, projectID, clusterID),
 		nil,
 	)
 
@@ -61,7 +61,7 @@ func (c *Client) GetProjectServiceAccount(
 	}
 
 	req = req.WithContext(ctx)
-	bodyResp := &GetProjectServiceAccountResponse{}
+	bodyResp := &GetProjectClusterResponse{}
 
 	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
 		if httpErr != nil {
@@ -153,11 +153,12 @@ func (c *Client) CreateProject(
 // which can be resolved to create a service account
 type CreateProjectCandidatesRequest struct {
 	Kubeconfig string `json:"kubeconfig"`
+	IsLocal    bool   `json:"is_local"`
 }
 
 // CreateProjectCandidatesResponse is the list of candidates returned after
 // creating the candidates
-type CreateProjectCandidatesResponse []*models.ServiceAccountCandidateExternal
+type CreateProjectCandidatesResponse []*models.ClusterCandidateExternal
 
 // CreateProjectCandidates creates a service account candidate for a given project,
 // accepting a kubeconfig that gets parsed into a candidate
@@ -174,7 +175,7 @@ func (c *Client) CreateProjectCandidates(
 
 	req, err := http.NewRequest(
 		"POST",
-		fmt.Sprintf("%s/projects/%d/candidates", c.BaseURL, projectID),
+		fmt.Sprintf("%s/projects/%d/clusters/candidates", c.BaseURL, projectID),
 		strings.NewReader(string(data)),
 	)
 
@@ -197,7 +198,7 @@ func (c *Client) CreateProjectCandidates(
 }
 
 // GetProjectCandidatesResponse is the list of service account candidates
-type GetProjectCandidatesResponse []*models.ServiceAccountCandidateExternal
+type GetProjectCandidatesResponse []*models.ClusterCandidateExternal
 
 // GetProjectCandidates returns the service account candidates for a given
 // project id
@@ -207,7 +208,7 @@ func (c *Client) GetProjectCandidates(
 ) (GetProjectCandidatesResponse, error) {
 	req, err := http.NewRequest(
 		"GET",
-		fmt.Sprintf("%s/projects/%d/candidates", c.BaseURL, projectID),
+		fmt.Sprintf("%s/projects/%d/clusters/candidates", c.BaseURL, projectID),
 		nil,
 	)
 
@@ -229,23 +230,19 @@ func (c *Client) GetProjectCandidates(
 	return bodyResp, nil
 }
 
-// CreateProjectServiceAccountRequest is a list of service account actions,
-// which resolve a given service account
-type CreateProjectServiceAccountRequest []*models.ServiceAccountAllActions
+// CreateProjectClusterResponse is the cluster that gets
+// returned after the candidate has been resolved
+type CreateProjectClusterResponse models.ClusterExternal
 
-// 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
+// CreateProjectCluster creates a cluster given a project id
 // and a candidate id, which gets resolved using the list of actions
-func (c *Client) CreateProjectServiceAccount(
+func (c *Client) CreateProjectCluster(
 	ctx context.Context,
 	projectID uint,
 	candidateID uint,
-	createSARequest CreateProjectServiceAccountRequest,
-) (*CreateProjectServiceAccountResponse, error) {
-	data, err := json.Marshal(&createSARequest)
+	createReq *models.ClusterResolverAll,
+) (*CreateProjectClusterResponse, error) {
+	data, err := json.Marshal(&createReq)
 
 	if err != nil {
 		return nil, err
@@ -253,7 +250,7 @@ func (c *Client) CreateProjectServiceAccount(
 
 	req, err := http.NewRequest(
 		"POST",
-		fmt.Sprintf("%s/projects/%d/candidates/%d/resolve", c.BaseURL, projectID, candidateID),
+		fmt.Sprintf("%s/projects/%d/clusters/candidates/%d/resolve", c.BaseURL, projectID, candidateID),
 		strings.NewReader(string(data)),
 	)
 
@@ -262,7 +259,7 @@ func (c *Client) CreateProjectServiceAccount(
 	}
 
 	req = req.WithContext(ctx)
-	bodyResp := &CreateProjectServiceAccountResponse{}
+	bodyResp := &CreateProjectClusterResponse{}
 
 	if httpErr, err := c.sendRequest(req, bodyResp, true); httpErr != nil || err != nil {
 		if httpErr != nil {

+ 75 - 113
cli/cmd/api/project_test.go

@@ -28,7 +28,7 @@ func initProjectCandidate(
 	kubeconfig string,
 	client *api.Client,
 	t *testing.T,
-) *models.ServiceAccountCandidateExternal {
+) *models.ClusterCandidateExternal {
 	t.Helper()
 
 	resp, err := client.CreateProjectCandidates(
@@ -46,23 +46,20 @@ func initProjectCandidate(
 	return resp[0]
 }
 
-func initProjectSA(
+func initProjectCluster(
 	projectID uint,
 	candidateID uint,
 	client *api.Client,
 	t *testing.T,
-) *api.CreateProjectServiceAccountResponse {
+) *api.CreateProjectClusterResponse {
 	t.Helper()
 
-	resp, err := client.CreateProjectServiceAccount(
+	resp, err := client.CreateProjectCluster(
 		context.Background(),
 		projectID,
 		candidateID,
-		api.CreateProjectServiceAccountRequest{
-			&models.ServiceAccountAllActions{
-				Name:             models.OIDCIssuerDataAction,
-				OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
-			},
+		&models.ClusterResolverAll{
+			OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
 		},
 	)
 
@@ -151,10 +148,10 @@ func TestGetProjectServiceAccount(t *testing.T) {
 		Password: "hello1234",
 	})
 	project := initProject("project-test", client, t)
-	saCandidate := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
-	sa := initProjectSA(project.ID, saCandidate.ID, client, t)
+	cc := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
+	cluster := initProjectCluster(project.ID, cc.ID, client, t)
 
-	resp, err := client.GetProjectServiceAccount(context.Background(), project.ID, sa.ID)
+	resp, err := client.GetProjectCluster(context.Background(), project.ID, cluster.ID)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -165,29 +162,13 @@ func TestGetProjectServiceAccount(t *testing.T) {
 		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.Name != "cluster-test" {
+		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "cluster-test", resp.Name)
 	}
 
-	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)
+	if resp.Server != "https://10.10.10.10" {
+		t.Errorf("cluster's server is incorrect: expected %s, got %s\n", "https://10.10.10.10", resp.Server)
 	}
 }
 
@@ -219,34 +200,22 @@ func TestCreateProjectCandidates(t *testing.T) {
 	}
 
 	// 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].Name != "cluster-test" {
+		t.Errorf("cluster name incorrect: expected %s, got %s\n", "cluster-test", resp[0].Name)
 	}
 
-	if resp[0].ClusterEndpoint != "https://localhost" {
-		t.Errorf("cluster endpoint incorrect: expected %s, got %s\n", "https://localhost", resp[0].ClusterEndpoint)
+	if resp[0].Server != "https://10.10.10.10" {
+		t.Errorf("cluster endpoint incorrect: expected %s, got %s\n", "https://10.10.10.10", resp[0].Server)
 	}
 
-	// make sure correct actions need to be performed
-	if len(resp[0].Actions) != 1 {
+	// make sure correct resolvers need to be performed
+	if len(resp[0].Resolvers) != 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) {
@@ -272,34 +241,34 @@ func TestGetProjectCandidates(t *testing.T) {
 	}
 
 	// 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)
-	}
+	// if resp[0].Integration != models.OIDC {
+	// 	t.Errorf("oidc auth mechanism incorrect: expected %s, got %s\n", models.OIDC, resp[0].Integration)
+	// }
+
+	// 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://10.10.10.10" {
+	// 	t.Errorf("cluster endpoint incorrect: expected %s, got %s\n", "https://10.10.10.10", resp[0].ClusterEndpoint)
+	// }
+
+	// // make sure correct actions need to be performed
+	// 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) {
@@ -313,15 +282,12 @@ func TestCreateProjectServiceAccount(t *testing.T) {
 	project := initProject("project-test", client, t)
 	saCandidate := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
 
-	resp, err := client.CreateProjectServiceAccount(
+	resp, err := client.CreateProjectCluster(
 		context.Background(),
 		project.ID,
 		saCandidate.ID,
-		api.CreateProjectServiceAccountRequest{
-			&models.ServiceAccountAllActions{
-				Name:             models.OIDCIssuerDataAction,
-				OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
-			},
+		&models.ClusterResolverAll{
+			OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
 		},
 	)
 
@@ -334,30 +300,30 @@ func TestCreateProjectServiceAccount(t *testing.T) {
 		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.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)
-	}
+	// if resp.Integration != models.OIDC {
+	// 	t.Errorf("service account auth mechanism incorrect: expected %s, got %s\n", models.OIDC, resp.Integration)
+	// }
 
-	// verify clusters
-	if len(resp.Clusters) != 1 {
-		t.Fatalf("length of clusters is not 1")
-	}
+	// // 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].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].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)
-	}
+	// if resp.Clusters[0].Server != "https://10.10.10.10" {
+	// 	t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "https://10.10.10.10", resp.Clusters[0].Server)
+	// }
 }
 
 func TestListProjectClusters(t *testing.T) {
@@ -369,8 +335,8 @@ func TestListProjectClusters(t *testing.T) {
 		Password: "hello1234",
 	})
 	project := initProject("project-test", client, t)
-	saCandidate := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
-	sa := initProjectSA(project.ID, saCandidate.ID, client, t)
+	cc := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
+	initProjectCluster(project.ID, cc.ID, client, t)
 
 	resp, err := client.ListProjectClusters(
 		context.Background(),
@@ -386,16 +352,12 @@ func TestListProjectClusters(t *testing.T) {
 		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)
+	if resp[0].Server != "https://10.10.10.10" {
+		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "https://10.10.10.10", resp[0].Server)
 	}
 }
 
@@ -444,7 +406,7 @@ const OIDCAuthWithoutData string = `
 apiVersion: v1
 clusters:
 - cluster:
-    server: https://localhost
+    server: https://10.10.10.10
     certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
   name: cluster-test
 contexts:
@@ -462,7 +424,7 @@ users:
       config:
         client-id: porter-api
         id-token: token
-        idp-issuer-url: https://localhost
+        idp-issuer-url: https://10.10.10.10
         idp-certificate-authority: /fake/path/to/ca.pem
       name: oidc
 `

+ 172 - 0
cli/cmd/api/registry.go

@@ -0,0 +1,172 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/registry"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// CreateECRRequest represents the accepted fields for creating
+// an ECR registry
+type CreateECRRequest struct {
+	Name             string `json:"name"`
+	AWSIntegrationID uint   `json:"aws_integration_id"`
+}
+
+// CreateECRResponse is the resulting registry after creation
+type CreateECRResponse models.RegistryExternal
+
+// CreateECR creates an Elastic Container Registry integration
+func (c *Client) CreateECR(
+	ctx context.Context,
+	projectID uint,
+	createECR *CreateECRRequest,
+) (*CreateECRResponse, error) {
+	data, err := json.Marshal(createECR)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/registries", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateECRResponse{}
+
+	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
+}
+
+// CreateGCRRequest represents the accepted fields for creating
+// a GCR registry
+type CreateGCRRequest struct {
+	Name             string `json:"name"`
+	GCPIntegrationID uint   `json:"gcp_integration_id"`
+}
+
+// CreateGCRResponse is the resulting registry after creation
+type CreateGCRResponse models.RegistryExternal
+
+// CreateGCR creates an Google Container Registry integration
+func (c *Client) CreateGCR(
+	ctx context.Context,
+	projectID uint,
+	createGCR *CreateGCRRequest,
+) (*CreateGCRResponse, error) {
+	data, err := json.Marshal(createGCR)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/registries", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateGCRResponse{}
+
+	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
+}
+
+// ListRegistryRepositoryResponse is the list of repositories in a registry
+type ListRegistryRepositoryResponse []registry.Repository
+
+// ListRegistryRepositories lists the repositories in a registry
+func (c *Client) ListRegistryRepositories(
+	ctx context.Context,
+	projectID uint,
+	registryID uint,
+) (ListRegistryRepositoryResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/registries/%d/repositories", c.BaseURL, projectID, registryID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &ListRegistryRepositoryResponse{}
+
+	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
+}
+
+// ListImagesResponse is the list of images in a repository
+type ListImagesResponse []registry.Image
+
+// ListImages lists the images (repository+tag) in a repository
+func (c *Client) ListImages(
+	ctx context.Context,
+	projectID uint,
+	registryID uint,
+	repoName string,
+) (ListImagesResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/registries/%d/repositories/%s", c.BaseURL, projectID, registryID, repoName),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &ListImagesResponse{}
+
+	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
+}

+ 0 - 17
cli/cmd/cluster.go

@@ -44,31 +44,14 @@ func init() {
 
 func listNamespaces(user *api.AuthCheckResponse, client *api.Client, args []string) error {
 	pID := getProjectID()
-	clusters, err := client.ListProjectClusters(context.Background(), pID)
-
-	if err != nil {
-		return err
-	}
 
 	// get the service account based on the cluster id
 	cID := getClusterID()
-	var saID uint = 0
-
-	for _, cluster := range clusters {
-		if cluster.ID == cID {
-			saID = cluster.ServiceAccountID
-		}
-	}
-
-	if saID == 0 {
-		return fmt.Errorf("could not find cluster with id %d", cID)
-	}
 
 	// get the list of namespaces
 	namespaces, err := client.GetK8sNamespaces(
 		context.Background(),
 		pID,
-		saID,
 		cID,
 	)
 

+ 60 - 3
cli/cmd/config.go

@@ -11,9 +11,11 @@ import (
 
 // a set of shared flags
 var (
-	host      string
-	projectID uint
-	clusterID uint
+	driver     string
+	host       string
+	projectID  uint
+	registryID uint
+	clusterID  uint
 )
 
 var configCmd = &cobra.Command{
@@ -63,6 +65,27 @@ var setClusterCmd = &cobra.Command{
 	},
 }
 
+var setRegistryCmd = &cobra.Command{
+	Use:   "set-registry [id]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Saves the registry id in the default configuration",
+	Run: func(cmd *cobra.Command, args []string) {
+		registryID, 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 = setRegistry(uint(registryID))
+
+		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),
@@ -83,6 +106,26 @@ func init() {
 	configCmd.AddCommand(setProjectCmd)
 	configCmd.AddCommand(setClusterCmd)
 	configCmd.AddCommand(setHostCmd)
+	configCmd.AddCommand(setRegistryCmd)
+}
+
+func setDriver(driver string) error {
+	viper.Set("driver", driver)
+	err := viper.WriteConfig()
+	color.New(color.FgGreen).Printf("Set the current driver as %s\n", driver)
+	return err
+}
+
+func getDriver() string {
+	if driver != "" {
+		return driver
+	}
+
+	if opts.driver != "" {
+		return opts.driver
+	}
+
+	return viper.GetString("driver")
 }
 
 func setProject(id uint) error {
@@ -97,6 +140,12 @@ func setCluster(id uint) error {
 	return viper.WriteConfig()
 }
 
+func setRegistry(id uint) error {
+	viper.Set("registry", id)
+	color.New(color.FgGreen).Printf("Set the current registry id as %d\n", id)
+	return viper.WriteConfig()
+}
+
 func setHost(host string) error {
 	viper.Set("host", host)
 	err := viper.WriteConfig()
@@ -120,6 +169,14 @@ func getClusterID() uint {
 	return viper.GetUint("cluster")
 }
 
+func getRegistryID() uint {
+	if registryID != 0 {
+		return registryID
+	}
+
+	return viper.GetUint("registry")
+}
+
 func getProjectID() uint {
 	if projectID != 0 {
 		return projectID

+ 62 - 2
cli/cmd/connect.go

@@ -23,7 +23,31 @@ 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)
+		err := checkLoginAndRun(args, runConnectKubeconfig)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var connectECRCmd = &cobra.Command{
+	Use:   "ecr",
+	Short: "Connects an ECR instance to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnectECR)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var connectGCRCmd = &cobra.Command{
+	Use:   "gcr",
+	Short: "Connects a GCR instance to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnectGCR)
 
 		if err != nil {
 			os.Exit(1)
@@ -63,13 +87,49 @@ func init() {
 		nil,
 		"the list of contexts to connect (defaults to the current context)",
 	)
+
+	connectCmd.AddCommand(connectECRCmd)
+	connectCmd.AddCommand(connectGCRCmd)
 }
 
-func runConnect(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+func runConnectKubeconfig(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	isLocal := false
+
+	if getDriver() == "local" {
+		isLocal = true
+	}
+
 	return connect.Kubeconfig(
 		client,
 		kubeconfigPath,
 		*contexts,
 		getProjectID(),
+		isLocal,
+	)
+}
+
+func runConnectECR(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	regID, err := connect.ECR(
+		client,
+		getProjectID(),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return setRegistry(regID)
+}
+
+func runConnectGCR(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	regID, err := connect.GCR(
+		client,
+		getProjectID(),
 	)
+
+	if err != nil {
+		return err
+	}
+
+	return setRegistry(regID)
 }

+ 144 - 0
cli/cmd/connect/ecr.go

@@ -0,0 +1,144 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	awsLocal "github.com/porter-dev/porter/cli/cmd/providers/aws/local"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+// ECR creates an ECR integration
+func ECR(
+	client *api.Client,
+	projectID uint,
+) (uint, error) {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return 0, fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	// query for the region
+	region, err := utils.PromptPlaintext(fmt.Sprintf(`Please provide the AWS region where the ECR instance is located.
+AWS Region: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	userResp, err := utils.PromptPlaintext(
+		fmt.Sprintf(`Porter can set up an IAM user in your AWS account to connect to this ECR instance automatically.
+Would you like to proceed? %s `,
+			color.New(color.FgCyan).Sprintf("[y/n]"),
+		),
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	if userResp := strings.ToLower(userResp); userResp == "y" || userResp == "yes" {
+		agent := awsLocal.NewDefaultAgent()
+
+		creds, err := agent.CreateIAMECRUser(region)
+
+		if err != nil {
+			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
+			return ecrManual(client, projectID, region)
+		}
+
+		integration, err := client.CreateAWSIntegration(
+			context.Background(),
+			projectID,
+			&api.CreateAWSIntegrationRequest{
+				AWSAccessKeyID:     creds.AWSAccessKeyID,
+				AWSSecretAccessKey: creds.AWSSecretAccessKey,
+				AWSRegion:          region,
+			},
+		)
+
+		if err != nil {
+			return 0, err
+		}
+
+		color.New(color.FgGreen).Printf("created aws integration with id %d\n", integration.ID)
+
+		return linkRegistry(client, projectID, integration.ID)
+	}
+
+	return ecrManual(client, projectID, region)
+}
+
+func ecrManual(
+	client *api.Client,
+	projectID uint,
+	region string,
+) (uint, error) {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return 0, fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	// query for the access key id
+	accessKeyID, err := utils.PromptPlaintext(fmt.Sprintf(`AWS Access Key ID: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	// query for the secret access key
+	secretKey, err := utils.PromptPlaintext(fmt.Sprintf(`AWS Secret Access Key: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	// create the aws integration
+	integration, err := client.CreateAWSIntegration(
+		context.Background(),
+		projectID,
+		&api.CreateAWSIntegrationRequest{
+			AWSAccessKeyID:     accessKeyID,
+			AWSSecretAccessKey: secretKey,
+			AWSRegion:          region,
+		},
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	color.New(color.FgGreen).Printf("created aws integration with id %d\n", integration.ID)
+
+	return linkRegistry(client, projectID, integration.ID)
+}
+
+func linkRegistry(client *api.Client, projectID uint, intID uint) (uint, error) {
+	// create the registry
+	// query for registry name
+	regName, err := utils.PromptPlaintext(fmt.Sprintf(`Give this registry a name: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	reg, err := client.CreateECR(
+		context.Background(),
+		projectID,
+		&api.CreateECRRequest{
+			Name:             regName,
+			AWSIntegrationID: intID,
+		},
+	)
+
+	if err != nil {
+		return 0, err
+	}
+
+	color.New(color.FgGreen).Printf("created registry with id %d and name %s\n", reg.ID, reg.Name)
+
+	return reg.ID, nil
+}

+ 82 - 0
cli/cmd/connect/gcr.go

@@ -0,0 +1,82 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+	"io/ioutil"
+	"os"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+// GCR creates a GCR integration
+func GCR(
+	client *api.Client,
+	projectID uint,
+) (uint, error) {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return 0, fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	keyFileLocation, err := utils.PromptPlaintext(fmt.Sprintf(`Please provide the full path to a service account key file.
+Key file location: `))
+
+	if err != nil {
+		return 0, 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 0, err
+		}
+
+		// create the aws integration
+		integration, err := client.CreateGCPIntegration(
+			context.Background(),
+			projectID,
+			&api.CreateGCPIntegrationRequest{
+				GCPKeyData: string(bytes),
+			},
+		)
+
+		if err != nil {
+			return 0, err
+		}
+
+		color.New(color.FgGreen).Printf("created gcp integration with id %d\n", integration.ID)
+
+		// create the registry
+		// query for registry name
+		regName, err := utils.PromptPlaintext(fmt.Sprintf(`Give this registry a name: `))
+
+		if err != nil {
+			return 0, err
+		}
+
+		reg, err := client.CreateGCR(
+			context.Background(),
+			projectID,
+			&api.CreateGCRRequest{
+				Name:             regName,
+				GCPIntegrationID: integration.ID,
+			},
+		)
+
+		if err != nil {
+			return 0, err
+		}
+
+		color.New(color.FgGreen).Printf("created registry with id %d and name %s\n", reg.ID, reg.Name)
+
+		return reg.ID, nil
+	}
+
+	return 0, fmt.Errorf("could not read service account key file")
+}

+ 142 - 133
cli/cmd/connect/kubeconfig.go

@@ -10,10 +10,10 @@ import (
 	"strings"
 
 	"github.com/fatih/color"
+	awsLocal "github.com/porter-dev/porter/cli/cmd/providers/aws/local"
+	gcpLocal "github.com/porter-dev/porter/cli/cmd/providers/gcp/local"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/porter-dev/porter/internal/kubernetes/local"
-	awsLocal "github.com/porter-dev/porter/internal/providers/aws/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"
@@ -26,6 +26,7 @@ func Kubeconfig(
 	kubeconfigPath string,
 	contexts []string,
 	projectID uint,
+	isLocal bool,
 ) error {
 	// if project ID is 0, ask the user to set the project ID or create a project
 	if projectID == 0 {
@@ -40,11 +41,12 @@ func Kubeconfig(
 	}
 
 	// send kubeconfig to client
-	saCandidates, err := client.CreateProjectCandidates(
+	ccs, err := client.CreateProjectCandidates(
 		context.Background(),
 		projectID,
 		&api.CreateProjectCandidatesRequest{
 			Kubeconfig: string(rawBytes),
+			IsLocal:    isLocal,
 		},
 	)
 
@@ -52,16 +54,15 @@ func Kubeconfig(
 		return err
 	}
 
-	for _, saCandidate := range saCandidates {
-		var clusters []models.ClusterExternal
-		var saID uint
+	for _, cc := range ccs {
+		var cluster *models.ClusterExternal
 
-		if len(saCandidate.Actions) > 0 {
-			resolvers := make(api.CreateProjectServiceAccountRequest, 0)
+		if len(cc.Resolvers) > 0 {
+			allResolver := &models.ClusterResolverAll{}
 
-			for _, action := range saCandidate.Actions {
-				switch action.Name {
-				case models.ClusterCADataAction:
+			for _, resolver := range cc.Resolvers {
+				switch resolver.Name {
+				case models.ClusterCAData:
 					absKubeconfigPath, err := local.ResolveKubeconfigPath(kubeconfigPath)
 
 					if err != nil {
@@ -69,7 +70,7 @@ func Kubeconfig(
 					}
 
 					filename, err := utils.GetFileReferenceFromKubeconfig(
-						action.Filename,
+						resolver.Data["filename"],
 						absKubeconfigPath,
 					)
 
@@ -77,14 +78,18 @@ func Kubeconfig(
 						return err
 					}
 
-					resolveAction, err := resolveClusterCAAction(filename)
+					err = resolveClusterCAAction(filename, allResolver)
 
 					if err != nil {
 						return err
 					}
+				case models.ClusterLocalhost:
+					err := resolveLocalhostAction(allResolver)
 
-					resolvers = append(resolvers, resolveAction)
-				case models.ClientCertDataAction:
+					if err != nil {
+						return err
+					}
+				case models.ClientCertData:
 					absKubeconfigPath, err := local.ResolveKubeconfigPath(kubeconfigPath)
 
 					if err != nil {
@@ -92,7 +97,7 @@ func Kubeconfig(
 					}
 
 					filename, err := utils.GetFileReferenceFromKubeconfig(
-						action.Filename,
+						resolver.Data["filename"],
 						absKubeconfigPath,
 					)
 
@@ -100,14 +105,12 @@ func Kubeconfig(
 						return err
 					}
 
-					resolveAction, err := resolveClientCertAction(filename)
+					err = resolveClientCertAction(filename, allResolver)
 
 					if err != nil {
 						return err
 					}
-
-					resolvers = append(resolvers, resolveAction)
-				case models.ClientKeyDataAction:
+				case models.ClientKeyData:
 					absKubeconfigPath, err := local.ResolveKubeconfigPath(kubeconfigPath)
 
 					if err != nil {
@@ -115,7 +118,7 @@ func Kubeconfig(
 					}
 
 					filename, err := utils.GetFileReferenceFromKubeconfig(
-						action.Filename,
+						resolver.Data["filename"],
 						absKubeconfigPath,
 					)
 
@@ -123,14 +126,12 @@ func Kubeconfig(
 						return err
 					}
 
-					resolveAction, err := resolveClientKeyAction(filename)
+					err = resolveClientKeyAction(filename, allResolver)
 
 					if err != nil {
 						return err
 					}
-
-					resolvers = append(resolvers, resolveAction)
-				case models.OIDCIssuerDataAction:
+				case models.OIDCIssuerData:
 					absKubeconfigPath, err := local.ResolveKubeconfigPath(kubeconfigPath)
 
 					if err != nil {
@@ -138,7 +139,7 @@ func Kubeconfig(
 					}
 
 					filename, err := utils.GetFileReferenceFromKubeconfig(
-						action.Filename,
+						resolver.Data["filename"],
 						absKubeconfigPath,
 					)
 
@@ -146,14 +147,12 @@ func Kubeconfig(
 						return err
 					}
 
-					resolveAction, err := resolveOIDCIssuerAction(filename)
+					err = resolveOIDCIssuerAction(filename, allResolver)
 
 					if err != nil {
 						return err
 					}
-
-					resolvers = append(resolvers, resolveAction)
-				case models.TokenDataAction:
+				case models.TokenData:
 					absKubeconfigPath, err := local.ResolveKubeconfigPath(kubeconfigPath)
 
 					if err != nil {
@@ -161,7 +160,7 @@ func Kubeconfig(
 					}
 
 					filename, err := utils.GetFileReferenceFromKubeconfig(
-						action.Filename,
+						resolver.Data["filename"],
 						absKubeconfigPath,
 					)
 
@@ -169,72 +168,68 @@ func Kubeconfig(
 						return err
 					}
 
-					resolveAction, err := resolveTokenDataAction(filename)
+					err = resolveTokenDataAction(filename, allResolver)
 
 					if err != nil {
 						return err
 					}
-
-					resolvers = append(resolvers, resolveAction)
-				case models.GCPKeyDataAction:
-					resolveAction, err := resolveGCPKeyAction(
-						saCandidate.ClusterEndpoint,
-						saCandidate.ClusterName,
+				case models.GCPKeyData:
+					err := resolveGCPKeyAction(
+						cc.Server,
+						cc.Name,
+						allResolver,
 					)
 
 					if err != nil {
 						return err
 					}
-
-					resolvers = append(resolvers, resolveAction)
-				case models.AWSDataAction:
-					resolveAction, err := resolveAWSAction(
-						saCandidate.ClusterEndpoint,
-						saCandidate.ClusterName,
-						saCandidate.AWSClusterIDGuess,
+				case models.AWSData:
+					err := resolveAWSAction(
+						cc.Server,
+						cc.Name,
+						cc.AWSClusterIDGuess,
 						kubeconfigPath,
-						saCandidate.ContextName,
+						cc.ContextName,
+						allResolver,
 					)
 
 					if err != nil {
 						return err
 					}
-
-					resolvers = append(resolvers, resolveAction)
 				}
 			}
 
-			sa, err := client.CreateProjectServiceAccount(
+			resp, err := client.CreateProjectCluster(
 				context.Background(),
 				projectID,
-				saCandidate.ID,
-				resolvers,
+				cc.ID,
+				allResolver,
 			)
 
 			if err != nil {
 				return err
 			}
 
-			clusters = sa.Clusters
-			saID = sa.ID
+			clExt := models.ClusterExternal(*resp)
+
+			cluster = &clExt
 		} else {
-			sa, err := client.GetProjectServiceAccount(
+			resp, err := client.GetProjectCluster(
 				context.Background(),
 				projectID,
-				saCandidate.CreatedServiceAccountID,
+				cc.CreatedClusterID,
 			)
 
 			if err != nil {
 				return err
 			}
 
-			clusters = sa.Clusters
-			saID = sa.ID
-		}
+			clExt := models.ClusterExternal(*resp)
 
-		for _, cluster := range clusters {
-			color.New(color.FgGreen).Printf("created service account for cluster %s with id %d\n", cluster.Name, saID)
+			cluster = &clExt
 		}
+
+		color.New(color.FgGreen).Printf("created cluster %s with id %d\n", cluster.Name, cluster.ID)
 	}
 
 	return nil
@@ -243,85 +238,97 @@ func Kubeconfig(
 // resolves a cluster ca data action
 func resolveClusterCAAction(
 	filename string,
-) (*models.ServiceAccountAllActions, error) {
+	resolver *models.ClusterResolverAll,
+) error {
 	fileBytes, err := ioutil.ReadFile(filename)
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
-	return &models.ServiceAccountAllActions{
-		Name:          models.ClusterCADataAction,
-		ClusterCAData: base64.StdEncoding.EncodeToString(fileBytes),
-	}, nil
+	resolver.ClusterCAData = base64.StdEncoding.EncodeToString(fileBytes)
+
+	return nil
+}
+
+func resolveLocalhostAction(
+	resolver *models.ClusterResolverAll,
+) error {
+	resolver.ClusterHostname = "host.docker.internal"
+
+	return nil
 }
 
 // resolves a client cert data action
 func resolveClientCertAction(
 	filename string,
-) (*models.ServiceAccountAllActions, error) {
+	resolver *models.ClusterResolverAll,
+) error {
 	fileBytes, err := ioutil.ReadFile(filename)
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
-	return &models.ServiceAccountAllActions{
-		Name:           models.ClientCertDataAction,
-		ClientCertData: base64.StdEncoding.EncodeToString(fileBytes),
-	}, nil
+	resolver.ClientCertData = base64.StdEncoding.EncodeToString(fileBytes)
+
+	return nil
 }
 
 // resolves a client key data action
 func resolveClientKeyAction(
 	filename string,
-) (*models.ServiceAccountAllActions, error) {
+	resolver *models.ClusterResolverAll,
+) error {
 	fileBytes, err := ioutil.ReadFile(filename)
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
-	return &models.ServiceAccountAllActions{
-		Name:          models.ClientKeyDataAction,
-		ClientKeyData: base64.StdEncoding.EncodeToString(fileBytes),
-	}, nil
+	resolver.ClientKeyData = base64.StdEncoding.EncodeToString(fileBytes)
+
+	return nil
 }
 
 // resolves an oidc issuer data action
 func resolveOIDCIssuerAction(
 	filename string,
-) (*models.ServiceAccountAllActions, error) {
+	resolver *models.ClusterResolverAll,
+) error {
 	fileBytes, err := ioutil.ReadFile(filename)
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
-	return &models.ServiceAccountAllActions{
-		Name:             models.OIDCIssuerDataAction,
-		OIDCIssuerCAData: base64.StdEncoding.EncodeToString(fileBytes),
-	}, nil
+	resolver.OIDCIssuerCAData = base64.StdEncoding.EncodeToString(fileBytes)
+
+	return nil
 }
 
 // resolves a token data action
 func resolveTokenDataAction(
 	filename string,
-) (*models.ServiceAccountAllActions, error) {
+	resolver *models.ClusterResolverAll,
+) error {
 	fileBytes, err := ioutil.ReadFile(filename)
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
-	return &models.ServiceAccountAllActions{
-		Name:      models.TokenDataAction,
-		TokenData: string(fileBytes),
-	}, nil
+	resolver.TokenData = string(fileBytes)
+
+	return nil
 }
 
 // resolves a gcp key data action
-func resolveGCPKeyAction(endpoint string, clusterName string) (*models.ServiceAccountAllActions, error) {
+func resolveGCPKeyAction(
+	endpoint string,
+	clusterName string,
+	resolver *models.ClusterResolverAll,
+) error {
 	userResp, err := utils.PromptPlaintext(
 		fmt.Sprintf(
 			`Detected GKE cluster in kubeconfig for the endpoint %s (%s). 
@@ -334,7 +341,7 @@ Would you like to proceed? %s `,
 	)
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	if userResp := strings.ToLower(userResp); userResp == "y" || userResp == "yes" {
@@ -342,14 +349,14 @@ Would you like to proceed? %s `,
 
 		if err != nil {
 			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
-			return resolveGCPKeyActionManual(endpoint, clusterName)
+			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
 		projID, err := agent.GetProjectIDForGKECluster(endpoint)
 
 		if err != nil {
 			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
-			return resolveGCPKeyActionManual(endpoint, clusterName)
+			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
 		agent.ProjectID = projID
@@ -361,14 +368,14 @@ Would you like to proceed? %s `,
 
 		if err != nil {
 			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
-			return resolveGCPKeyActionManual(endpoint, clusterName)
+			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
 		err = agent.SetServiceAccountIAMPolicy(resp)
 
 		if err != nil {
 			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
-			return resolveGCPKeyActionManual(endpoint, clusterName)
+			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
 		// get the service account key data to send to the server
@@ -376,24 +383,27 @@ Would you like to proceed? %s `,
 
 		if err != nil {
 			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
-			return resolveGCPKeyActionManual(endpoint, clusterName)
+			return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 		}
 
-		return &models.ServiceAccountAllActions{
-			Name:       models.GCPKeyDataAction,
-			GCPKeyData: string(bytes),
-		}, nil
+		resolver.GCPKeyData = string(bytes)
+
+		return nil
 	}
 
-	return resolveGCPKeyActionManual(endpoint, clusterName)
+	return resolveGCPKeyActionManual(endpoint, clusterName, resolver)
 }
 
-func resolveGCPKeyActionManual(endpoint string, clusterName string) (*models.ServiceAccountAllActions, error) {
+func resolveGCPKeyActionManual(
+	endpoint string,
+	clusterName string,
+	resolver *models.ClusterResolverAll,
+) 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
+		return err
 	}
 
 	// attempt to read the key file location
@@ -402,16 +412,15 @@ Key file location: `))
 		bytes, err := ioutil.ReadFile(keyFileLocation)
 
 		if err != nil {
-			return nil, err
+			return err
 		}
 
-		return &models.ServiceAccountAllActions{
-			Name:       models.GCPKeyDataAction,
-			GCPKeyData: string(bytes),
-		}, nil
+		resolver.GCPKeyData = string(bytes)
+
+		return nil
 	}
 
-	return nil, errors.New("Key file not found")
+	return errors.New("Key file not found")
 }
 
 // resolves an aws key data action
@@ -421,7 +430,8 @@ func resolveAWSAction(
 	awsClusterIDGuess string,
 	kubeconfigPath string,
 	contextName string,
-) (*models.ServiceAccountAllActions, error) {
+	resolver *models.ClusterResolverAll,
+) error {
 	userResp, err := utils.PromptPlaintext(
 		fmt.Sprintf(
 			`Detected AWS cluster in kubeconfig for the endpoint %s (%s). 
@@ -434,41 +444,41 @@ Would you like to proceed? %s `,
 	)
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	if userResp := strings.ToLower(userResp); userResp == "y" || userResp == "yes" {
-		agent, err := awsLocal.NewDefaultAgent(kubeconfigPath, contextName)
+		agent, err := awsLocal.NewDefaultKubernetesAgent(kubeconfigPath, contextName)
 
 		if err != nil {
 			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
-			return resolveAWSActionManual(endpoint, clusterName, awsClusterIDGuess)
+			return resolveAWSActionManual(endpoint, clusterName, awsClusterIDGuess, resolver)
 		}
 
 		creds, err := agent.CreateIAMKubernetesMapping(awsClusterIDGuess)
 
 		if err != nil {
 			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
-			return resolveAWSActionManual(endpoint, clusterName, awsClusterIDGuess)
+			return resolveAWSActionManual(endpoint, clusterName, awsClusterIDGuess, resolver)
 		}
 
-		return &models.ServiceAccountAllActions{
-			Name:               models.AWSDataAction,
-			AWSAccessKeyID:     creds.AWSAccessKeyID,
-			AWSSecretAccessKey: creds.AWSSecretAccessKey,
-			AWSClusterID:       creds.AWSClusterID,
-		}, nil
+		resolver.AWSAccessKeyID = creds.AWSAccessKeyID
+		resolver.AWSSecretAccessKey = creds.AWSSecretAccessKey
+		resolver.AWSClusterID = creds.AWSClusterID
+
+		return nil
 	}
 
 	// fallback to manual
-	return resolveAWSActionManual(endpoint, clusterName, awsClusterIDGuess)
+	return resolveAWSActionManual(endpoint, clusterName, awsClusterIDGuess, resolver)
 }
 
 func resolveAWSActionManual(
 	endpoint string,
 	clusterName string,
 	awsClusterIDGuess string,
-) (*models.ServiceAccountAllActions, error) {
+	resolver *models.ClusterResolverAll,
+) error {
 	// query to see if the AWS cluster ID guess is correct
 	var clusterID string
 
@@ -481,7 +491,7 @@ func resolveAWSActionManual(
 	)
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	if userResp := strings.ToLower(userResp); userResp == "y" || userResp == "yes" {
@@ -490,7 +500,7 @@ func resolveAWSActionManual(
 		clusterID, err = utils.PromptPlaintext(fmt.Sprintf(`Cluster ID: `))
 
 		if err != nil {
-			return nil, err
+			return err
 		}
 	}
 
@@ -498,20 +508,19 @@ func resolveAWSActionManual(
 	accessKeyID, err := utils.PromptPlaintext(fmt.Sprintf(`AWS Access Key ID: `))
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	// query for the secret access key
 	secretKey, err := utils.PromptPlaintext(fmt.Sprintf(`AWS Secret Access Key: `))
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
-	return &models.ServiceAccountAllActions{
-		Name:               models.AWSDataAction,
-		AWSAccessKeyID:     accessKeyID,
-		AWSSecretAccessKey: secretKey,
-		AWSClusterID:       clusterID,
-	}, nil
+	resolver.AWSAccessKeyID = accessKeyID
+	resolver.AWSSecretAccessKey = secretKey
+	resolver.AWSClusterID = clusterID
+
+	return nil
 }

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

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

+ 182 - 0
cli/cmd/providers/aws/agent.go

@@ -0,0 +1,182 @@
+package aws
+
+import (
+	"github.com/aws/aws-sdk-go/aws/session"
+	"github.com/aws/aws-sdk-go/service/iam"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+	"k8s.io/client-go/kubernetes"
+)
+
+type Agent struct {
+	Session    *session.Session
+	IAMService *iam.IAM
+	Clientset  kubernetes.Interface
+}
+
+type PorterAWSCredentials struct {
+	AWSAccessKeyID     string `json:"aws_access_key_id"`
+	AWSSecretAccessKey string `json:"aws_secret_access_key"`
+	AWSClusterID       string `json:"aws_cluster_id"`
+}
+
+func (a *Agent) CreateIAMKubernetesMapping(clusterIDGuess string) (*PorterAWSCredentials, error) {
+	user, err := a.getIAMUserIfExists()
+
+	if err != nil {
+		return nil, err
+	}
+
+	var name string
+
+	if user == nil {
+		// (1) Create a new IAM user called porter-dashboard-[random_string], and attach the policy:
+		name = "porter-dashboard-" + utils.StringWithCharset(6, "abcdefghijklmnopqrstuvwxyz1234567890")
+
+		resp, err := a.IAMService.CreateUser(&iam.CreateUserInput{
+			UserName: &name,
+		})
+
+		if err != nil {
+			return nil, err
+		}
+
+		user = resp.User
+	} else {
+		name = *user.UserName
+	}
+
+	policyArn := "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
+
+	_, err = a.IAMService.AttachUserPolicy(&iam.AttachUserPolicyInput{
+		PolicyArn: &policyArn,
+		UserName:  &name,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	// (2) Create an access key for the porter-dashboard-[random_string] user and return the
+	// access key and secret. Use the guessed cluster ID.
+	resp, err := a.IAMService.CreateAccessKey(&iam.CreateAccessKeyInput{
+		UserName: &name,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	porterCreds := &PorterAWSCredentials{
+		AWSAccessKeyID:     *resp.AccessKey.AccessKeyId,
+		AWSSecretAccessKey: *resp.AccessKey.SecretAccessKey,
+		AWSClusterID:       clusterIDGuess,
+	}
+
+	// (3) Use the eksctl authconfigmap package to map this user to a cluster identity.
+	authCm, err := NewFromClientSet(a.Clientset)
+
+	if err != nil {
+		return nil, err
+	}
+
+	identity, err := NewIdentity(
+		*user.Arn,
+		"admin",
+		[]string{"system:masters"},
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	err = authCm.AddIdentity(identity)
+
+	if err != nil {
+		return nil, err
+	}
+
+	err = authCm.Save()
+
+	if err != nil {
+		return nil, err
+	}
+
+	return porterCreds, nil
+}
+
+// CreateIAMECRUser creates an IAM user if it does not exist, and attaches a ECR-read policy
+// to the user
+func (a *Agent) CreateIAMECRUser(region string) (*PorterAWSCredentials, error) {
+	user, err := a.getIAMUserIfExists()
+
+	if err != nil {
+		return nil, err
+	}
+
+	var name string
+
+	if user == nil {
+		// (1) Create a new IAM user called porter-dashboard-[random_string], and attach the policy:
+		//
+		// arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly
+		name = "porter-dashboard-" + utils.StringWithCharset(6, "abcdefghijklmnopqrstuvwxyz1234567890")
+
+		resp, err := a.IAMService.CreateUser(&iam.CreateUserInput{
+			UserName: &name,
+		})
+
+		if err != nil {
+			return nil, err
+		}
+
+		user = resp.User
+	} else {
+		name = *user.UserName
+	}
+
+	policyArn := "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
+
+	_, err = a.IAMService.AttachUserPolicy(&iam.AttachUserPolicyInput{
+		PolicyArn: &policyArn,
+		UserName:  &name,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	// (2) Create an access key for the porter-dashboard-[random_string] user and return the
+	// access key and secret. Use the guessed cluster ID.
+	resp, err := a.IAMService.CreateAccessKey(&iam.CreateAccessKeyInput{
+		UserName: &name,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	porterCreds := &PorterAWSCredentials{
+		AWSAccessKeyID:     *resp.AccessKey.AccessKeyId,
+		AWSSecretAccessKey: *resp.AccessKey.SecretAccessKey,
+	}
+
+	return porterCreds, nil
+}
+
+func (a *Agent) getIAMUserIfExists() (*iam.User, error) {
+	// resp, err := a.IAMService.ListUsers(&iam.ListUsersInput{})
+
+	// if err != nil {
+	// 	return nil, err
+	// }
+
+	// re := regexp.MustCompile(`porter-dashboard-[a-z1-9]{6}`)
+
+	// for _, user := range resp.Users {
+	// 	if re.MatchString(*user.UserName) {
+	// 		return user, nil
+	// 	}
+	// }
+
+	return nil, nil
+}

+ 0 - 0
internal/providers/aws/authconfigmap.go → cli/cmd/providers/aws/authconfigmap.go


+ 17 - 5
internal/providers/aws/local/config.go → cli/cmd/providers/aws/local/config.go

@@ -2,18 +2,30 @@ package local
 
 import (
 	"github.com/aws/aws-sdk-go/service/iam"
+	"github.com/porter-dev/porter/cli/cmd/providers/aws"
 	"github.com/porter-dev/porter/internal/kubernetes/local"
-	"github.com/porter-dev/porter/internal/providers/aws"
 	"k8s.io/client-go/kubernetes"
 	"k8s.io/client-go/tools/clientcmd"
 
 	"github.com/aws/aws-sdk-go/aws/session"
 )
 
-// NewDefaultAgent returns an agent using Application Default Credentials. If these are not
-// set and the gcloud utility is installed on the machine, this will spawn a setup process
-// to link these credentials.
-func NewDefaultAgent(kubeconfigPath string, contextName string) (*aws.Agent, error) {
+// NewDefaultAgent returns an AWS agent without a k8s clientset
+func NewDefaultAgent() *aws.Agent {
+	sess := session.Must(session.NewSession())
+
+	iamSvc := iam.New(sess)
+
+	// Return a new agent with AWS session and iam service
+	return &aws.Agent{
+		Session:    sess,
+		IAMService: iamSvc,
+		Clientset:  nil,
+	}
+}
+
+// NewDefaultKubernetesAgent returns an AWS agent using local credentials.
+func NewDefaultKubernetesAgent(kubeconfigPath string, contextName string) (*aws.Agent, error) {
 	// (1) Construct a local clientset from the AWS context, and use the eksctl authconfigmap package
 	// to read the current identities of the config map, to make sure user has access. Save the created
 	// clientset.

+ 0 - 0
internal/providers/gcp/agent.go → cli/cmd/providers/gcp/agent.go


+ 1 - 1
internal/providers/gcp/local/config.go → cli/cmd/providers/gcp/local/config.go

@@ -6,7 +6,7 @@ import (
 	"os/exec"
 	"time"
 
-	"github.com/porter-dev/porter/internal/providers/gcp"
+	"github.com/porter-dev/porter/cli/cmd/providers/gcp"
 	"google.golang.org/api/cloudresourcemanager/v1"
 	gke "google.golang.org/api/container/v1"
 

+ 117 - 0
cli/cmd/registry.go

@@ -0,0 +1,117 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"text/tabwriter"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/spf13/cobra"
+)
+
+// registryCmd represents the "porter registry" base command when called
+// without any subcommands
+var registryCmd = &cobra.Command{
+	Use:   "registry",
+	Short: "Commands that read from a connected registry",
+}
+
+var registryCmdListRepos = &cobra.Command{
+	Use:   "repos list",
+	Short: "Lists the repositories in a registry",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, listRepos)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+var registryCmdListImages = &cobra.Command{
+	Use:   "images list [REPO_NAME]",
+	Short: "Lists the images in an image repository",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, listImages)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(registryCmd)
+
+	registryCmd.PersistentFlags().UintVar(
+		&registryID,
+		"registry-id",
+		0,
+		"id of the registry",
+	)
+
+	registryCmd.AddCommand(registryCmdListRepos)
+
+	registryCmd.AddCommand(registryCmdListImages)
+}
+
+func listRepos(user *api.AuthCheckResponse, client *api.Client, args []string) error {
+	pID := getProjectID()
+	rID := getRegistryID()
+
+	// get the list of namespaces
+	repos, err := client.ListRegistryRepositories(
+		context.Background(),
+		pID,
+		rID,
+	)
+
+	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", "NAME", "CREATED_AT")
+
+	for _, repo := range repos {
+		fmt.Fprintf(w, "%s\t%s\n", repo.Name, repo.CreatedAt.String())
+	}
+
+	w.Flush()
+
+	return nil
+}
+
+func listImages(user *api.AuthCheckResponse, client *api.Client, args []string) error {
+	pID := getProjectID()
+	rID := getRegistryID()
+	repoName := args[1]
+
+	// get the list of namespaces
+	imgs, err := client.ListImages(
+		context.Background(),
+		pID,
+		rID,
+		repoName,
+	)
+
+	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", "IMAGE", "DIGEST")
+
+	for _, img := range imgs {
+		fmt.Fprintf(w, "%s\t%s\n", repoName+":"+img.Tag, img.Digest)
+	}
+
+	w.Flush()
+
+	return nil
+}

+ 11 - 1
cli/cmd/root.go

@@ -23,9 +23,19 @@ var home = homedir.HomeDir()
 // Execute adds all child commands to the root command and sets flags appropriately.
 // This is called by main.main(). It only needs to happen once to the rootCmd.
 func Execute() {
+	// check that the .porter folder exists; create if not
+	porterDir := filepath.Join(home, ".porter")
+
+	if _, err := os.Stat(porterDir); os.IsNotExist(err) {
+		os.Mkdir(porterDir, 0700)
+	} else if err != nil {
+		color.New(color.FgRed).Printf("%v\n", err)
+		os.Exit(1)
+	}
+
 	viper.SetConfigName("porter")
 	viper.SetConfigType("yaml")
-	viper.AddConfigPath(filepath.Join(home, ".porter"))
+	viper.AddConfigPath(porterDir)
 
 	err := viper.ReadInConfig()
 

+ 93 - 18
cli/cmd/server.go

@@ -3,9 +3,12 @@ package cmd
 import (
 	"fmt"
 	"os"
+	"os/exec"
+	"path/filepath"
 
 	"github.com/fatih/color"
 	"github.com/porter-dev/porter/cli/cmd/docker"
+	"github.com/porter-dev/porter/cli/cmd/github"
 
 	"github.com/spf13/cobra"
 )
@@ -13,6 +16,7 @@ import (
 type startOps struct {
 	imageTag string `form:"required"`
 	db       string `form:"oneof=sqlite postgres"`
+	driver   string `form:"required"`
 	port     *int   `form:"required"`
 }
 
@@ -28,24 +32,40 @@ var startCmd = &cobra.Command{
 	Use:   "start",
 	Short: "Starts a Porter instance using the Docker engine",
 	Run: func(cmd *cobra.Command, args []string) {
-		err := start(
-			opts.imageTag,
-			opts.db,
-			*opts.port,
-		)
+		if getDriver() == "docker" {
+			setDriver("docker")
 
-		if err != nil {
-			red := color.New(color.FgRed)
-			red.Println("Error running start:", err.Error())
-			red.Println("Shutting down...")
-
-			err = stop()
+			err := startDocker(
+				opts.imageTag,
+				opts.db,
+				*opts.port,
+			)
 
 			if err != nil {
-				red.Println("Shutdown unsuccessful:", err.Error())
+				red := color.New(color.FgRed)
+				red.Println("Error running start:", err.Error())
+				red.Println("Shutting down...")
+
+				err = stopDocker()
+
+				if err != nil {
+					red.Println("Shutdown unsuccessful:", err.Error())
+				}
+
+				os.Exit(1)
 			}
+		} else {
+			setDriver("local")
+			err := startLocal(
+				opts.db,
+				*opts.port,
+			)
 
-			os.Exit(1)
+			if err != nil {
+				red := color.New(color.FgRed)
+				red.Println("Error running start:", err.Error())
+				os.Exit(1)
+			}
 		}
 	},
 }
@@ -54,9 +74,11 @@ var stopCmd = &cobra.Command{
 	Use:   "stop",
 	Short: "Stops a Porter instance running on the Docker engine",
 	Run: func(cmd *cobra.Command, args []string) {
-		if err := stop(); err != nil {
-			color.New(color.FgRed).Println("Shutdown unsuccessful:", err.Error())
-			os.Exit(1)
+		if getDriver() == "docker" {
+			if err := stopDocker(); err != nil {
+				color.New(color.FgRed).Println("Shutdown unsuccessful:", err.Error())
+				os.Exit(1)
+			}
 		}
 	},
 }
@@ -74,6 +96,13 @@ func init() {
 		"the db to use, one of sqlite or postgres",
 	)
 
+	startCmd.PersistentFlags().StringVar(
+		&opts.driver,
+		"driver",
+		"local",
+		"the db to use, one of local or docker",
+	)
+
 	startCmd.PersistentFlags().StringVar(
 		&opts.imageTag,
 		"image-tag",
@@ -89,7 +118,7 @@ func init() {
 	)
 }
 
-func start(
+func startDocker(
 	imageTag string,
 	db string,
 	port int,
@@ -129,7 +158,53 @@ func start(
 	return setHost(fmt.Sprintf("http://localhost:%d", port))
 }
 
-func stop() error {
+func startLocal(
+	db string,
+	port int,
+) error {
+	if db == "postgres" {
+		return fmt.Errorf("postgres not available for local driver, run \"porter server start --db postgres --driver docker\"")
+	}
+
+	setHost(fmt.Sprintf("http://localhost:%d", port))
+
+	porterDir := filepath.Join(home, ".porter")
+	cmdPath := filepath.Join(home, ".porter", "portersvr")
+	sqlLitePath := filepath.Join(home, ".porter", "porter.db")
+	staticFilePath := filepath.Join(home, ".porter", "static")
+
+	if _, err := os.Stat(cmdPath); os.IsNotExist(err) {
+		err := github.DownloadLatestServerRelease(porterDir)
+
+		if err != nil {
+			color.New(color.FgRed).Println("Failed:", err.Error())
+			os.Exit(1)
+		}
+	}
+
+	cmdPorter := exec.Command(cmdPath)
+	cmdPorter.Env = os.Environ()
+	cmdPorter.Env = append(cmdPorter.Env, []string{
+		"IS_LOCAL=true",
+		"SQL_LITE=true",
+		"SQL_LITE_PATH=" + sqlLitePath,
+		"STATIC_FILE_PATH=" + staticFilePath,
+	}...)
+
+	cmdPorter.Stdout = os.Stdout
+	cmdPorter.Stderr = os.Stderr
+
+	err := cmdPorter.Run()
+
+	if err != nil {
+		color.New(color.FgRed).Println("Failed:", err.Error())
+		os.Exit(1)
+	}
+
+	return nil
+}
+
+func stopDocker() error {
 	agent, err := docker.NewAgentFromEnv()
 
 	if err != nil {

+ 28 - 0
cmd/app/main.go

@@ -6,6 +6,7 @@ import (
 	"net/http"
 
 	"github.com/gorilla/sessions"
+	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 
@@ -17,6 +18,8 @@ import (
 	lr "github.com/porter-dev/porter/internal/logger"
 	vr "github.com/porter-dev/porter/internal/validator"
 	"github.com/porter-dev/porter/server/router"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 
 func main() {
@@ -30,6 +33,30 @@ func main() {
 		return
 	}
 
+	err = db.AutoMigrate(
+		&models.Project{},
+		&models.Role{},
+		&models.User{},
+		&models.Session{},
+		&models.GitRepo{},
+		&models.Registry{},
+		&models.Cluster{},
+		&models.ClusterCandidate{},
+		&models.ClusterResolver{},
+		&ints.KubeIntegration{},
+		&ints.OIDCIntegration{},
+		&ints.OAuthIntegration{},
+		&ints.GCPIntegration{},
+		&ints.AWSIntegration{},
+		&ints.TokenCache{},
+		&ints.RegTokenCache{},
+	)
+
+	if err != nil {
+		logger.Fatal().Err(err).Msg("")
+		return
+	}
+
 	var key [32]byte
 
 	for i, b := range []byte(appConf.Db.EncryptionKey) {
@@ -52,6 +79,7 @@ func main() {
 		store,
 		appConf.Server.CookieName,
 		false,
+		appConf.Server.IsLocal,
 		&oauth.Config{
 			ClientID:     appConf.Server.GithubClientID,
 			ClientSecret: appConf.Server.GithubClientSecret,

+ 14 - 6
cmd/migrate/main.go

@@ -7,6 +7,8 @@ import (
 	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/models"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 
 func main() {
@@ -25,14 +27,20 @@ func main() {
 	err = db.AutoMigrate(
 		&models.Project{},
 		&models.Role{},
-		&models.ServiceAccount{},
-		&models.ServiceAccountAction{},
-		&models.ServiceAccountCandidate{},
-		&models.Cluster{},
-		&models.TokenCache{},
 		&models.User{},
 		&models.Session{},
-		&models.RepoClient{},
+		&models.GitRepo{},
+		&models.Registry{},
+		&models.Cluster{},
+		&models.ClusterCandidate{},
+		&models.ClusterResolver{},
+		&ints.KubeIntegration{},
+		&ints.OIDCIntegration{},
+		&ints.OAuthIntegration{},
+		&ints.GCPIntegration{},
+		&ints.AWSIntegration{},
+		&ints.TokenCache{},
+		&ints.RegTokenCache{},
 	)
 
 	if err != nil {

+ 4 - 0
dashboard/src/assets/edit.svg

@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path opacity="0.4" d="M16.6643 21.9897H7.33488C5.88835 22.0796 4.46781 21.5781 3.3989 20.6011C2.4219 19.5312 1.92041 18.1107 2.01032 16.6652V7.33482C1.92041 5.88932 2.4209 4.46878 3.3979 3.39889C4.46781 2.42189 5.88835 1.92041 7.33488 2.01032H16.6643C18.1089 1.92041 19.5284 2.4209 20.5973 3.39789C21.5733 4.46878 22.0758 5.88832 21.9899 7.33482V16.6652C22.0788 18.1107 21.5783 19.5312 20.6013 20.6011C19.5314 21.5781 18.1109 22.0796 16.6643 21.9897Z" fill="white"/>
+<path d="M17.0545 10.3976L10.5018 16.9829C10.161 17.3146 9.7131 17.5 9.24574 17.5H6.95762C6.83105 17.5 6.71421 17.4512 6.62658 17.3634C6.53895 17.2756 6.5 17.1585 6.5 17.0317L6.55842 14.7195C6.56816 14.261 6.75315 13.8317 7.07446 13.5098L11.7189 8.8561C11.7967 8.77805 11.9331 8.77805 12.011 8.8561L13.6399 10.4785C13.747 10.5849 13.9028 10.6541 14.0683 10.6541C14.4286 10.6541 14.7109 10.3615 14.7109 10.0102C14.7109 9.83463 14.6428 9.67854 14.5357 9.56146C14.5065 9.52244 12.9554 7.97805 12.9554 7.97805C12.858 7.88049 12.858 7.71463 12.9554 7.61707L13.6078 6.95366C14.2114 6.34878 15.1851 6.34878 15.7888 6.95366L17.0545 8.22195C17.6485 8.81707 17.6485 9.79268 17.0545 10.3976Z" fill="white"/>
+</svg>

二進制
dashboard/src/assets/tag.png


+ 262 - 0
dashboard/src/components/ResourceTab.tsx

@@ -0,0 +1,262 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { kindToIcon } from '../shared/rosettaStone';
+
+type PropsType = {
+  kind: string,
+  name: string,
+  handleClick?: () => void,
+  selected?: boolean,
+  isLast?: boolean,
+  status?: {
+    label: string,
+    available: number,
+    total: number,
+  } | null
+};
+
+type StateType = {
+  expanded: boolean,
+  showTooltip: boolean,
+};
+
+export default class ResourceTab extends Component<PropsType, StateType> {
+  state = {
+    expanded: false,
+    showTooltip: false,
+  }
+
+  renderDropdownIcon = () => {
+    if (this.props.children) {
+      return (
+        <DropdownIcon expanded={this.state.expanded}>
+          <i className="material-icons">arrow_right</i>
+        </DropdownIcon>
+      );
+    }
+  }
+
+  renderIcon = (kind: string) => {
+    let icon = 'tonality';
+    if (Object.keys(kindToIcon).includes(kind)) {
+      icon = kindToIcon[kind]; 
+    }
+    
+    return (
+      <IconWrapper>
+        <i className="material-icons">{icon}</i>
+      </IconWrapper>
+    );
+  }
+
+  renderTooltip = (x: string): JSX.Element | undefined => {
+    if (this.state.showTooltip) {
+      return (
+        <Tooltip>{x}</Tooltip>
+      );
+    }
+  }
+
+  renderStatus = () => {
+    let { status } = this.props;
+    if (status) {
+      return (
+        <Status>
+          <StatusColor status={status.label} />
+          {status.available}/{status.total}
+        </Status>
+      );
+    }
+  }
+
+  renderExpanded = () => {
+    if (this.props.children && this.state.expanded) {
+      return (
+        <ExpandWrapper>
+          {this.props.children}
+        </ExpandWrapper>
+      );
+    }
+  }
+
+  render() {
+    let { kind, name, children, isLast } = this.props;
+    return (
+      <StyledResourceTab 
+        isLast={isLast}
+        onClick={() => this.props.handleClick && this.props.handleClick()}
+      >
+        <ResourceHeader
+          hasChildren={this.props.children && true}
+          expanded={this.state.expanded || this.props.selected}
+          onClick={() => {
+            if (children) {
+              this.setState({ expanded: !this.state.expanded });
+            }
+          }}
+        >
+          {this.renderDropdownIcon()}
+          <Info>
+            <Metadata>
+              {this.renderIcon(kind)}
+              {kind}
+              <ResourceName
+                showKindLabels={true}
+                onMouseOver={() => { this.setState({ showTooltip: true }) }}
+                onMouseOut={() => { this.setState({ showTooltip: false }) }}
+              >
+                {name}
+              </ResourceName>
+              {this.renderTooltip(name)}
+            </Metadata>
+            {this.renderStatus()}
+          </Info>
+        </ResourceHeader>
+        {this.renderExpanded()}
+      </StyledResourceTab>
+    );
+  }
+}
+
+const StyledResourceTab = styled.div`
+  width: 100%;
+  margin-bottom: 2px;
+  background: #ffffff11;
+  border-bottom-left-radius: ${(props: { isLast: boolean }) => props.isLast ? '5px' : ''};
+`;
+
+const Tooltip = styled.div`
+  position: absolute;
+  right: 0px;
+  top: 25px;
+  white-space: nowrap;
+  height: 18px;
+  padding: 2px 5px;
+  background: #383842dd;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  text-transform: none;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const ExpandWrapper = styled.div`
+  overflow: hidden;
+`;
+
+const ResourceHeader = styled.div`
+  width: 100%;
+  height: 50px;
+  display: flex;
+  align-items: center;
+  color: #ffffff66;
+  user-select: none;
+  padding: 8px 18px;
+  padding-left: ${(props: { expanded: boolean, hasChildren: boolean }) => props.hasChildren ? '10px' : '22px'};
+  text-transform: capitalize;
+  cursor: pointer;
+  background: ${(props: { expanded: boolean, hasChildren: boolean }) => props.expanded ? '#ffffff11' : ''};
+  :hover {
+    background: #ffffff18;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+`;
+
+const Info = styled.div`
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+  width: calc(100% - 30px);
+  height: 100%;
+`;
+
+const Metadata = styled.div`
+  display: flex;
+  max-width: calc(100% - 50px);
+  align-items: center;
+  position: relative;
+`;
+
+const Status = styled.div`
+  display: flex;
+  width: 50px;
+  font-size: 12px;
+  text-transform: capitalize;
+  justify-content: flex-end;
+  align-items: center;
+  font-family: 'Work Sans', sans-serif;
+  color: #aaaabb;
+  animation: fadeIn 0.5s;
+  @keyframes fadeIn {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const StatusColor = styled.div`
+  margin-right: 7px;
+  width: 7px;
+  min-width: 7px;
+  height: 7px;
+  background: ${(props: { status: string }) => (props.status === 'running' ? '#4797ff' : props.status === 'failed' ? "#ed5f85" : "#f5cb42")};
+  border-radius: 20px;
+`;
+
+const ResourceName = styled.div`
+  color: #ffffff;
+  margin-left: ${(props: { showKindLabels: boolean }) => props.showKindLabels ? '10px' : ''};
+  text-transform: none;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+`;
+
+const IconWrapper = styled.div`
+  width: 25px;
+  height: 25px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  > i {
+    font-size: 15px;
+    color: #ffffff;
+    margin-right: 14px;
+  }
+`;
+
+const DropdownIcon = styled.div`
+  > i {
+    margin-top: 2px;
+    margin-right: 11px;
+    font-size: 20px;
+    color: #ffffff66;
+    cursor: pointer;
+    border-radius: 20px;
+    background: ${(props: { expanded: boolean }) => props.expanded ? '#ffffff18' : ''};
+    transform: ${(props: { expanded: boolean }) => props.expanded ? 'rotate(180deg)' : ''};
+    animation: ${(props: { expanded: boolean }) => props.expanded ? 'quarterTurn 0.3s' : ''};
+    animation-fill-mode: forwards;
+
+    @keyframes quarterTurn {
+      from { transform: rotate(0deg) }
+      to { transform: rotate(90deg) }
+    }
+  }
+`;

+ 26 - 1
dashboard/src/components/TabSelector.tsx

@@ -40,16 +40,41 @@ export default class TabSelector extends Component<PropsType, StateType> {
     );
   }
 
+  renderAddendumBuffer = () => {
+
+  }
+
   render() {
     return (
       <StyledTabSelector>
-        {this.renderTabList()}
+        <TabWrapper>
+          {this.renderTabList()}
+          <Tab
+            lastItem={true}
+            highlight={null}
+          >
+            <Buffer />
+          </Tab>
+        </TabWrapper>
         {this.props.addendum}
       </StyledTabSelector>
     );
   }
 }
 
+const Buffer = styled.div`
+  width: 138px;
+  height: 10px;
+`;
+
+// Keeps the scrollbar beneath all tabs on overflow
+const TabWrapper = styled.div`
+  display: flex;
+  overflow-x: auto;
+  padding-bottom: 15px;
+  margin-bottom: -15px;
+`;
+
 const Tab = styled.div`
   height: 30px;
   margin-right: ${(props: { lastItem: boolean, highlight: string }) => props.lastItem ? '' : '30px'};

+ 64 - 0
dashboard/src/components/TooltipParent.tsx

@@ -0,0 +1,64 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+  tooltipText: string
+};
+
+type StateType = {
+  showTooltip: boolean,
+};
+
+export default class TooltipParent extends Component<PropsType, StateType> {
+  state = {
+    showTooltip: false,
+  }
+
+  renderTooltip = (): JSX.Element | undefined => {
+    if (this.state.showTooltip) {
+      return (
+        <Tooltip>{this.props.tooltipText}</Tooltip>
+      );
+    }
+  }
+
+  render() {
+    return (
+      <StyledTooltipParent
+        onMouseOver={() => { this.setState({ showTooltip: true }) }}
+        onMouseOut={() => { this.setState({ showTooltip: false }) }}
+      >
+        {this.props.children}
+        {this.renderTooltip()}
+      </StyledTooltipParent>
+    );
+  }
+}
+
+const Tooltip = styled.div`
+  position: absolute;
+  left: 10px;
+  top: 20px;
+  height: 18px;
+  padding: 2px 5px;
+  background: #383842dd;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex: 1;
+  color: white;
+  font-size: 12px;
+  font-family: "Work Sans", sans-serif;
+  outline: 1px solid #ffffff55;
+  opacity: 0;
+  animation: faded-in 0.2s 0.15s;
+  animation-fill-mode: forwards;
+  @keyframes faded-in {
+    from { opacity: 0 }
+    to { opacity: 1 }
+  }
+`;
+
+const StyledTooltipParent = styled.div`
+  position: relative;
+`;

+ 176 - 47
dashboard/src/components/image-selector/ImageSelector.tsx

@@ -1,85 +1,129 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
 import info from '../../assets/info.svg';
+import edit from '../../assets/edit.svg';
 
 import api from '../../shared/api';
-import { getRegistryIcon } from '../../shared/common';
+import { integrationList } from '../../shared/common';
 import { Context } from '../../shared/Context';
+import { ImageType } from '../../shared/types';
 
 import Loading from '../Loading';
+import TagList from './TagList';
 
 type PropsType = {
   forceExpanded?: boolean,
   selectedImageUrl: string | null,
-  setSelectedImageUrl: (x: string) => void
+  selectedTag: string | null,
+  setSelectedImageUrl: (x: string) => void,
+  setSelectedTag: (x: string) => void,
+  setCurrentView: (x: string) => void,
 };
 
 type StateType = {
   isExpanded: boolean,
   loading: boolean,
   error: boolean,
-  images: any[]
+  images: ImageType[],
+  clickedImage: ImageType | null,
 };
 
-const dummyImages = [
-  {
-    kind: 'docker-hub',
-    source: 'https://index.docker.io/jusrhee/image1',
-  },
-  {
-    kind: 'docker-hub',
-    source: 'https://index.docker.io/jusrhee/image2',
-  },
-  {
-    kind: 'docker-hub',
-    source: 'https://index.docker.io/jusrhee/image3',
-  },
-  {
-    kind: 'gcr',
-    source: 'https://gcr.io/some-registry/image1',
-  },
-  {
-    kind: 'gcr',
-    source: 'https://gcr.io/some-registry/image2',
-  },
-  {
-    kind: 'ecr',
-    source: 'https://aws_account_id.dkr.ecr.region.amazonaws.com/smth/1',
-  },
-  {
-    kind: 'ecr',
-    source: 'https://aws_account_id.dkr.ecr.region.amazonaws.com/smth/2',
-  },
-];
-
 export default class ImageSelector extends Component<PropsType, StateType> {
   state = {
     isExpanded: this.props.forceExpanded,
-    loading: false,
+    loading: true,
     error: false,
-    images: [] as any[]
+    images: [] as ImageType[],
+    clickedImage: null as ImageType | null,
   }
 
   componentDidMount() {
-    this.setState({ images: dummyImages });
+    const { currentProject, setCurrentError } = this.context;
+    let images = [] as ImageType[]
+    let errors = [] as number[]
+    api.getProjectRegistries('<token>', {}, { id: currentProject.id }, async (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        this.setState({ error: true });
+      } else {
+        let registries = res.data;
+        if (registries.length === 0) {
+          this.setState({ loading: false });
+        }
+        registries.forEach(async (registry: any, i: number) => {
+          await new Promise((nextController: (res?: any) => void) => {           
+            api.getImageRepos('<token>', {}, 
+              { 
+                project_id: currentProject.id,
+                registry_id: registry.id,
+              }, (err: any, res: any) => {
+              if (err) {
+                errors.push(1);
+              } else {
+                let newImg = res.data.map((img: any) => {
+                  return {
+                    kind: registry.service, 
+                    source: img.name,
+                    registryId: registry.id,
+                  }
+                })
+                images.push(...newImg)
+                errors.push(0);
+              }
+              
+              if (i == registries.length - 1) {
+                let error = errors.reduce((a, b) => {
+                  return a + b;
+                }) == registries.length ? true : false; 
+
+                this.setState({
+                  images,
+                  loading: false,
+                  error,
+                });
+              }
+
+              nextController()
+            });    
+          })
+        });
+      }
+    });
   }
 
+  /*
+  <Highlight onClick={() => this.props.setCurrentView('integrations')}>
+    Link your registry.
+  </Highlight>
+  */
   renderImageList = () => {
     let { images, loading, error } = this.state;
     if (loading) {
       return <LoadingWrapper><Loading /></LoadingWrapper>
     } else if (error || !images) {
       return <LoadingWrapper>Error loading repos</LoadingWrapper>
+    } else if (images.length === 0) {
+      return (
+        <LoadingWrapper>
+          No registries found. 
+        </LoadingWrapper>
+      );
     }
 
-    return images.map((image: any, i: number) => {
-      let icon = getRegistryIcon(image.kind);
+    return images.map((image: ImageType, i: number) => {
+      let icon = integrationList[image.kind] && integrationList[image.kind].icon;
+      if (!icon) {
+        icon = integrationList['docker'].icon;
+      }
       return (
         <ImageItem
           key={i}
           isSelected={image.source === this.props.selectedImageUrl}
           lastItem={i === images.length - 1}
-          onClick={() => this.props.setSelectedImageUrl(image.source)}
+          onClick={() => { 
+            this.props.setSelectedImageUrl(image.source);
+            this.setState({ clickedImage: image });
+          }}
         >
           <img src={icon && icon} />{image.source}
         </ImageItem>
@@ -87,24 +131,75 @@ export default class ImageSelector extends Component<PropsType, StateType> {
     });
   }
 
+  renderBackButton = () => {
+    let { setSelectedImageUrl } = this.props;
+    if (this.state.clickedImage) {
+      return (
+        <BackButton
+          width='175px'
+          onClick={() => {
+            setSelectedImageUrl('');
+            this.setState({ clickedImage: null });
+          }}
+        >
+          <i className="material-icons">keyboard_backspace</i>
+          Select Image Repo
+        </BackButton>
+      );
+    }
+  }
+
   renderExpanded = () => {
-    return (
-      <ExpandedWrapper>
-        {this.renderImageList()}
-      </ExpandedWrapper>
-    );
+    let { selectedTag, selectedImageUrl, setSelectedTag } = this.props;
+    if (!this.state.clickedImage) {
+      return (
+        <div>
+          <ExpandedWrapper>
+            {this.renderImageList()}
+          </ExpandedWrapper>
+          {this.renderBackButton()}
+        </div>
+      );
+    } else {
+      return (
+        <div>
+          <ExpandedWrapper>
+            <TagList
+              selectedTag={selectedTag}
+              selectedImageUrl={selectedImageUrl}
+              setSelectedTag={setSelectedTag}
+              registryId={this.state.clickedImage.registryId}
+            />
+          </ExpandedWrapper>
+          {this.renderBackButton()}
+        </div>
+      );
+    }
   }
 
   renderSelected = () => {
     let { selectedImageUrl, setSelectedImageUrl } = this.props;
+    let { clickedImage } = this.state;
     let icon = info;
+    if (clickedImage) {
+      icon = clickedImage.kind;
+      icon = integrationList[clickedImage.kind] && integrationList[clickedImage.kind].icon;
+      if (!icon) {
+        icon = integrationList['docker'].icon;
+      }
+    } else if (selectedImageUrl && selectedImageUrl !== '') {
+      icon = edit;
+    }
     return (
       <Label>
         <img src={icon} />
         <Input
           onClick={(e: any) => e.stopPropagation()}
           value={selectedImageUrl}
-          onChange={(e: any) => setSelectedImageUrl(e.value)}
+          onChange={(e: any) => { 
+            setSelectedImageUrl(e.target.value); 
+            this.setState({ clickedImage: null });
+          }}
           placeholder='Enter or select your container image URL'
         />
       </Label>
@@ -137,10 +232,44 @@ export default class ImageSelector extends Component<PropsType, StateType> {
 
 ImageSelector.contextType = Context;
 
+const Highlight = styled.div`
+  text-decoration: underline;
+  margin-left: 10px;
+  color: #949eff;
+  cursor: pointer;
+  padding: 3px 0;
+`;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 10px;
+  cursor: pointer;
+  font-size: 13px;
+  padding: 5px 13px;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+  }
+`;
+
 const Input = styled.input`
   outline: 0;
   background: none;
   border: 0;
+  font-size: 13px;
   width: calc(100% - 60px);
   color: white;
 `;
@@ -217,7 +346,7 @@ const StyledImageSelector = styled.div`
   display: flex;
   align-items: center;
   justify-content: space-between;
-  cursor: ${(props: { isExpanded: boolean, forceExpanded: boolean }) => props.forceExpanded ? '' : 'pointer'};;
+  cursor: ${(props: { isExpanded: boolean, forceExpanded: boolean }) => props.forceExpanded ? '' : 'pointer'};
   :hover {
     background: #ffffff11;
 

+ 141 - 0
dashboard/src/components/image-selector/TagList.tsx

@@ -0,0 +1,141 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+import tag_icon from '../../assets/tag.png';
+import info from '../../assets/info.svg';
+
+import api from '../../shared/api';
+import { Context } from '../../shared/Context';
+
+import Loading from '../Loading';
+
+type PropsType = {
+  setSelectedTag: (x: string) => void,
+  selectedTag: string,
+  selectedImageUrl: string,
+  registryId: number,
+};
+
+type StateType = {
+  loading: boolean,
+  error: boolean,
+  tags: string[],
+  currentTag: string | null,
+};
+
+export default class TagList extends Component<PropsType, StateType> {
+  state = {
+    loading: true,
+    error: false,
+    tags: [] as string[],
+    currentTag: null as string | null,
+  }
+
+  componentDidMount() {
+    const { currentProject } = this.context;
+    api.getImageTags('<token>', {}, 
+      { 
+        project_id: currentProject.id,
+        registry_id: this.props.registryId,
+        repo_name: this.props.selectedImageUrl,
+      }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+        this.setState({ loading: false, error: true });
+      } else {
+        let tags = res.data.map((tag: any, i: number) => {
+          return tag.tag;
+        });
+        this.setState({ tags, loading: false });
+      }
+    });
+  }
+
+  setTag = (tag: string) => {
+    let { selectedTag, setSelectedTag } = this.props;
+    setSelectedTag(tag);
+    this.setState({ currentTag: tag });
+  }
+
+  renderTagList = () => {
+    let { tags, loading, error } = this.state;
+    if (loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>
+    } else if (error || !tags) {
+      return <LoadingWrapper>Error loading tags</LoadingWrapper>
+    }
+
+    return tags.map((tag: string, i: number) => {
+      return (
+        <TagName
+          key={i}
+          isSelected={tag === this.state.currentTag}
+          lastItem={i === tags.length - 1}
+          onClick={() => this.setTag(tag)}
+        >
+          <img src={tag_icon} />{tag}
+        </TagName>
+      );
+    });
+  }
+
+  render() {
+    return (
+      <div>
+        <TagNameAlt>
+          <img src={info} /> Select Image Tag
+        </TagNameAlt>
+        {this.renderTagList()}
+      </div>
+    );
+  }
+}
+
+TagList.contextType = Context;
+
+const TagName = styled.div`
+  display: flex;
+  width: 100%;
+  font-size: 13px;
+  border-bottom: 1px solid ${(props: { lastItem?: boolean, isSelected?: boolean }) => props.lastItem ? '#00000000' : '#606166'};
+  color: #ffffff;
+  user-select: none;
+  align-items: center;
+  padding: 10px 0px;
+  cursor: pointer;
+  background: ${(props: { isSelected?: boolean, lastItem?: boolean }) => props.isSelected ? '#ffffff22' : '#ffffff11'};
+  :hover {
+    background: #ffffff22;
+
+    > i {
+      background: #ffffff22;
+    }
+  }
+
+  > img {
+    width: 18px;
+    height: 18px;
+    margin-left: 12px;
+    margin-right: 12px;
+  }
+`;
+
+const TagNameAlt = styled(TagName)`
+  color: #ffffff55;
+  cursor: default;
+  :hover {
+    background: #ffffff11;
+    > i {
+      background: none;
+    }
+  }
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  color: #ffffff44;
+`;

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

@@ -1,10 +1,8 @@
-import { stringify } from 'querystring';
 import React, { Component } from 'react';
 import styled from 'styled-components';
 import branch_icon from '../../assets/branch.png';
 
 import api from '../../shared/api';
-import { RepoType } from '../../shared/types';
 
 import Loading from '../Loading';
 
@@ -22,7 +20,6 @@ type StateType = {
 
 export default class BranchList extends Component<PropsType, StateType> {
   state = {
-    selectedBranch: '',
     loading: true,
     error: false,
     branches: [] as string[]

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

@@ -279,7 +279,7 @@ const StyledRepoSelector = styled.div`
   display: flex;
   align-items: center;
   justify-content: space-between;
-  cursor: ${(props: { isExpanded: boolean, forceExpanded: boolean }) => props.forceExpanded ? '' : 'pointer'};;
+  cursor: ${(props: { isExpanded: boolean, forceExpanded: boolean }) => props.forceExpanded ? '' : 'pointer'};
   :hover {
     background: #ffffff11;
 

+ 14 - 0
dashboard/src/components/values-form/Heading.tsx

@@ -0,0 +1,14 @@
+import React from 'react';  
+import styled from 'styled-components';
+
+export default function Heading(props: { children: string }) {
+  return <StyledHeading>{props.children}</StyledHeading>;
+}
+
+const StyledHeading = styled.div`
+  color: white;
+  font-weight: 500;
+  font-size: 16px;
+  margin-top: 30px;
+  margin-bottom: 5px;
+`;

+ 14 - 0
dashboard/src/components/values-form/Helper.tsx

@@ -0,0 +1,14 @@
+import React from 'react';  
+import styled from 'styled-components';
+
+export default function Helper(props: { children: string }) {
+  return <StyledHelper>{props.children}</StyledHelper>;
+}
+
+const StyledHelper = styled.div`
+  color: #aaaabb;
+  line-height: 1.6em;
+  font-size: 13px;
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

+ 7 - 1
dashboard/src/components/values-form/InputRow.tsx

@@ -13,9 +13,14 @@ type PropsType = {
 };
 
 type StateType = {
+  readOnly: boolean
 };
 
 export default class InputRow extends Component<PropsType, StateType> {
+  state = {
+    readOnly: true
+  }
+
   handleChange = (e: ChangeEvent<HTMLInputElement>) => {
     if (this.props.type === 'number') {
       this.props.setValue(parseInt(e.target.value));
@@ -23,7 +28,7 @@ export default class InputRow extends Component<PropsType, StateType> {
       this.props.setValue(e.target.value);
     }
   }
-
+  
   render() {
     let { label, value, type, unit, placeholder, width } = this.props;
     return (
@@ -31,6 +36,7 @@ export default class InputRow extends Component<PropsType, StateType> {
         <Label>{label}</Label>
         <InputWrapper>
           <Input
+            readOnly={this.state.readOnly} onFocus={() => this.setState({ readOnly: false })}
             disabled={this.props.disabled}
             placeholder={placeholder}
             width={width}

+ 64 - 0
dashboard/src/components/values-form/TextArea.tsx

@@ -0,0 +1,64 @@
+import React, { ChangeEvent, Component } from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+  label?: string,
+  value: string,
+  setValue: (x: string) => void,
+  placeholder?: string
+  width?: string
+  disabled?: boolean
+};
+
+type StateType = {
+};
+
+export default class TextArea extends Component<PropsType, StateType> {
+  handleChange = (e: any) => {
+    this.props.setValue(e.target.value);
+  }
+
+  render() {
+    let { label, value, placeholder, width } = this.props;
+    return (
+      <StyledTextArea>
+        <Label>{label}</Label>
+        <InputArea
+          disabled={this.props.disabled}
+          placeholder={placeholder}
+          width={width}
+          value={value || ''}
+          onChange={this.handleChange}
+        />
+      </StyledTextArea>
+    );
+  }
+}
+
+const InputArea = styled.textarea`
+  outline: none;
+  border: none;
+  resize: none;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled: boolean, width: string }) => props.width ? props.width : '270px'};
+  color: ${(props: { disabled: boolean, width: string }) => props.disabled ? '#ffffff44' : 'white'};
+  padding: 5px 10px;
+  margin-right: 8px;
+  height: 8em;
+  line-height: 1.5em;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+`;
+
+const StyledTextArea = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

+ 11 - 18
dashboard/src/components/values-form/ValuesForm.tsx

@@ -9,11 +9,15 @@ import SaveButton from '../SaveButton';
 import CheckboxRow from './CheckboxRow';
 import InputRow from './InputRow';
 import SelectRow from './SelectRow';
+import Helper from './Helper';
+import Heading from './Heading';
 
 type PropsType = {
   onSubmit: (formValues: any) => void,
   sections?: Section[],
   disabled?: boolean,
+  saveValuesStatus?: string | null,
+  config?: any, // Chart config object containing existing values
 };
 
 type StateType = any;
@@ -29,6 +33,12 @@ export default class ValuesForm extends Component<PropsType, StateType> {
         let key = item.name || item.variable;
         
         let def = item.settings && item.settings.default;
+
+        // Set default value from chart config if available
+        if (this.props.config) {
+          this.props.config[key] ? def = this.props.config[key] : null;
+        }
+
         switch (item.type) {
           case 'checkbox':
             formState[key] = def ? def : false;
@@ -119,7 +129,6 @@ export default class ValuesForm extends Component<PropsType, StateType> {
   renderFormContents = () => {
     if (this.state) {
       return this.props.sections.map((section: Section, i: number) => {
-
         // Hide collapsible section if deciding field is false
         if (section.show_if) {
           if (!this.state[section.show_if]) {
@@ -147,7 +156,7 @@ export default class ValuesForm extends Component<PropsType, StateType> {
           disabled={this.props.disabled}
           text='Deploy'
           onClick={() => this.props.onSubmit(this.state)}
-          status={null}
+          status={this.props.saveValuesStatus}
           makeFlush={true}
         />
       </Wrapper>
@@ -166,22 +175,6 @@ const Wrapper = styled.div`
   height: 100%;
 `;
 
-const Helper = styled.div`
-  color: #aaaabb;
-  line-height: 1.6em;
-  font-size: 13px;
-  margin-bottom: 15px;
-  margin-top: 20px;
-`;
-
-const Heading = styled.div`
-  color: white;
-  font-weight: 500;
-  font-size: 16px;
-  margin-top: 30px;
-  margin-bottom: 5px;
-`;
-
 const StyledValuesForm = styled.div`
   width: 100%;
   height: 100%;

+ 1 - 1
dashboard/src/main/Main.tsx

@@ -26,7 +26,7 @@ export default class Main extends Component<PropsType, StateType> {
   state = {
     loading: true,
     isLoggedIn : false,
-    initialized: (localStorage.getItem("init") == 'true')
+    initialized: localStorage.getItem("init") === 'true'
   }
 
   componentDidMount() {

+ 15 - 1
dashboard/src/main/home/Home.tsx

@@ -15,6 +15,7 @@ import CreateProjectModal from './modals/CreateProjectModal';
 import UpdateProjectModal from './modals/UpdateProjectModal';
 import ClusterInstructionsModal from './modals/ClusterInstructionsModal';
 import IntegrationsModal from './modals/IntegrationsModal';
+import IntegrationsInstructionsModal from './modals/IntegrationsInstructionsModal';
 
 type PropsType = {
   logOut: () => void
@@ -77,6 +78,7 @@ export default class Home extends Component<PropsType, StateType> {
         <ClusterDashboard
           currentCluster={currentCluster}
           setSidebar={(x: boolean) => this.setState({ forceSidebar: x })}
+          setCurrentView={(x: string) => this.setState({ currentView: x })}
         />
       </DashboardWrapper>
     );
@@ -96,7 +98,11 @@ export default class Home extends Component<PropsType, StateType> {
       return <Integrations />;
     }
 
-    return <Templates />;
+    return (
+      <Templates 
+        setCurrentView={(x: string) => this.setState({ currentView: x })} 
+      />
+    );
   }
 
   render() {
@@ -135,6 +141,14 @@ export default class Home extends Component<PropsType, StateType> {
         >
           <IntegrationsModal />
         </ReactModal>
+        <ReactModal
+          isOpen={currentModal === 'IntegrationsInstructionsModal'}
+          onRequestClose={() => setCurrentModal(null, null)}
+          style={TallModalStyles}
+          ariaHideApp={false}
+        >
+          <IntegrationsInstructionsModal />
+        </ReactModal>
 
         <Sidebar
           logOut={this.props.logOut}

+ 10 - 7
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -13,6 +13,7 @@ import ExpandedChart from './expanded-chart/ExpandedChart';
 type PropsType = {
   currentCluster: Cluster,
   setSidebar: (x: boolean) => void
+  setCurrentView: (x: string) => void,
 };
 
 type StateType = {
@@ -41,7 +42,6 @@ export default class ClusterDashboard extends Component<PropsType, StateType> {
     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,
@@ -75,7 +75,7 @@ export default class ClusterDashboard extends Component<PropsType, StateType> {
   }
 
   renderContents = () => {
-    let { currentCluster, setSidebar } = this.props;
+    let { currentCluster, setSidebar, setCurrentView } = this.props;
 
     if (this.state.currentChart) {
       return (
@@ -84,6 +84,7 @@ export default class ClusterDashboard extends Component<PropsType, StateType> {
           refreshChart={this.refreshChart}
           setCurrentChart={(x: ChartType | null) => this.setState({ currentChart: x })}
           setSidebar={setSidebar}
+          setCurrentView={setCurrentView} // Link to integrations from chart settings
         />
       );
     }
@@ -108,8 +109,10 @@ export default class ClusterDashboard extends Component<PropsType, StateType> {
         <LineBreak />
         
         <ControlRow>
-          <Button disabled={true}>
-            <i className="material-icons">add</i> Deploy a Chart
+          <Button
+            onClick={() => this.props.setCurrentView('templates')}
+          >
+            <i className="material-icons">add</i> Deploy Template
           </Button>
           <NamespaceSelector
             setNamespace={(namespace) => this.setState({ namespace })}
@@ -198,11 +201,11 @@ const Button = styled.div`
   white-space: nowrap;
   text-overflow: ellipsis;
   box-shadow: 0 5px 8px 0px #00000010;
-  cursor: not-allowed;
+  cursor: ${(props: { disabled?: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
 
-  background: ${(props: { disabled: boolean }) => props.disabled ? '#aaaabbee' :'#616FEEcc'};
+  background: ${(props: { disabled?: boolean }) => props.disabled ? '#aaaabbee' : '#616FEEcc'};
   :hover {
-    background: ${(props: { disabled: boolean }) => props.disabled ? '' : '#505edddd'};
+    background: ${(props: { disabled?: boolean }) => props.disabled ? '' : '#505edddd'};
   }
 
   > i {

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/NamespaceSelector.tsx

@@ -28,7 +28,6 @@ export default class NamespaceSelector extends Component<PropsType, StateType> {
 
     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));

+ 17 - 18
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -40,7 +40,6 @@ export default class ChartList extends Component<PropsType, StateType> {
     api.getCharts('<token>', {
       namespace: this.props.namespace,
       cluster_id: currentCluster.id,
-      service_account_id: currentCluster.service_account_id,
       storage: StorageType.Secret,
       limit: 20,
       skip: 0,
@@ -64,49 +63,50 @@ export default class ChartList extends Component<PropsType, StateType> {
 
   setupWebsocket = (kind: string) => {
       let { currentCluster, currentProject } = this.context;
-      let ws = new WebSocket(`ws://localhost:8080/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
+      let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws';
+      let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${kind}/status?cluster_id=${currentCluster.id}`);
       ws.onopen = () => {
-        console.log('connected to websocket')
+        console.log('connected to websocket');
       }
   
       ws.onmessage = (evt: MessageEvent) => {
-        let event = JSON.parse(evt.data)
-        let object = event.Object
-        let chartKey = this.state.chartLookupTable[object.metadata.uid]
+        let event = JSON.parse(evt.data);
+        let object = event.Object;
+        let chartKey = this.state.chartLookupTable[object.metadata.uid];
 
         // ignore if updated object does not belong to any chart in the list.
         if (!chartKey) {
           return;
         }
 
-        let chartControllers = this.state.controllers[chartKey]
-        chartControllers[object.metadata.uid] = object
+        let chartControllers = this.state.controllers[chartKey];
+        chartControllers[object.metadata.uid] = object;
 
         this.setState({
           controllers: {
             ...this.state.controllers,
             [chartKey] : chartControllers
           }
-        })
+        });
       }
   
       ws.onclose = () => {
-        console.log('closing websocket')
+        console.log('closing websocket');
       }
   
       ws.onerror = (err: ErrorEvent) => {
-        console.log(err)
-        ws.close()
+        console.log(err);
+        ws.close();
       }
 
-      return ws
+      return ws;
   }
 
   setControllerWebsockets = (controllers: any[]) => {
     let websockets = controllers.map((kind: string) => {
-      return this.setupWebsocket(kind)
+      return this.setupWebsocket(kind);
     })
-    this.setState({websockets})
+    this.setState({ websockets });
   }
 
   getControllers = (charts: any[]) => {
@@ -120,7 +120,6 @@ export default class ChartList extends Component<PropsType, StateType> {
         api.getChartControllers('<token>', {
           namespace: chart.namespace,
           cluster_id: currentCluster.id,
-          service_account_id: currentCluster.service_account_id,
           storage: StorageType.Secret
         }, {
           id: currentProject.id,
@@ -164,7 +163,7 @@ export default class ChartList extends Component<PropsType, StateType> {
     this.setControllerWebsockets(["deployment", "statefulset", "daemonset", "replicaset"]);
   }
 
-  async componentWillUnmount () {
+  componentWillUnmount() {
     if (this.state.websockets) {
       this.state.websockets.forEach((ws: WebSocket) => {
         ws.close()
@@ -244,5 +243,5 @@ const LoadingWrapper = styled.div`
 `;
 
 const StyledChartList = styled.div`
-  padding-bottom: 100px;
+  padding-bottom: 85px;
 `;

文件差異過大導致無法顯示
+ 3 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx


+ 63 - 18
dashboard/src/main/home/cluster-dashboard/expanded-chart/ListSection.tsx

@@ -1,45 +1,66 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
+import yaml from 'js-yaml';
 
 import { Context } from '../../../../shared/Context';
 import { ResourceType, ChartType } from '../../../../shared/types';
 
-import ResourceItem from './ResourceItem';
 import Loading from '../../../../components/Loading';
+import ResourceTab from '../../../../components/ResourceTab';
+import YamlEditor from '../../../../components/YamlEditor';
 
 type PropsType = {
   currentChart: ChartType,
-  components: ResourceType[]
+  components: ResourceType[],
+  showRevisions: boolean,
 };
 
 type StateType = {
-  showKindLabels: boolean
+  showKindLabels: boolean,
+  yaml: string | null,
+  wrapperHeight: number,
 };
 
 export default class ListSection extends Component<PropsType, StateType> {
   state = {
-    showKindLabels: true
+    showKindLabels: true,
+    yaml: '# Select a resource to view its manifest' as string | null,
+    wrapperHeight: 0,
+  }
+
+  wrapperRef: any = React.createRef();
+
+  componentDidMount() {
+    this.setState({ wrapperHeight: this.wrapperRef.offsetHeight });
+  }
+
+  componentDidUpdate(prevProps: PropsType) {
+    if ((prevProps.showRevisions !== this.props.showRevisions) && this.wrapperRef) {
+      this.setState({ wrapperHeight: this.wrapperRef.offsetHeight });
+    }
   }
 
   renderResourceList = () => {
     return this.props.components.map((resource: ResourceType, i: number) => {
+      let rawYaml = yaml.dump(resource.RawYAML);
       return (
-        <ResourceItem
-          key={i}
-          resource={resource}
-          toggleKindLabels={() => this.setState({ showKindLabels: !this.state.showKindLabels })}
-          showKindLabels={this.state.showKindLabels}
+        <ResourceTab
+          handleClick={() => this.setState({ yaml: rawYaml })}
+          selected={this.state.yaml === rawYaml}
+          kind={resource.Kind}
+          name={resource.Name}
+          isLast={i === this.props.components.length - 1}
         />
       );
     });
   }
 
-  renderContents = () => {
+  renderTabs = () => {
     if (this.props.components && this.props.components.length > 0) {
       return (
-        <ResourceList>
+        <TabWrapper>
           {this.renderResourceList()}
-        </ResourceList>
+        </TabWrapper>
       );
     }
 
@@ -49,7 +70,18 @@ export default class ListSection extends Component<PropsType, StateType> {
   render() {
     return (
       <StyledListSection>
-        {this.renderContents()}
+        {this.renderTabs()}
+        <FlexWrapper ref={element => this.wrapperRef = element}>
+          <YamlWrapper>
+            <YamlEditor
+              value={this.state.yaml}
+              onChange={(e: any) => this.setState({ yaml: e })}
+              height={this.state.wrapperHeight - 2 + 'px'}
+              border={true}
+              readOnly={true}
+            />
+          </YamlWrapper>
+        </FlexWrapper>
       </StyledListSection>
     );
   }
@@ -57,18 +89,31 @@ export default class ListSection extends Component<PropsType, StateType> {
 
 ListSection.contextType = Context;
 
-const ResourceList = styled.div`
+const YamlWrapper = styled.div`
   width: 100%;
-  overflow-y: auto;
-  padding-bottom: 150px;
+  height: 100%;
+`;
+
+const TabWrapper = styled.div`
+  min-width: 200px;
+  width: 35%;
+  margin-right: 10px;
+  border-radius: 5px;
+  overflow: hidden;
+`;
+
+const FlexWrapper = styled.div`
+  display: flex;
+  flex: 1;
+  height: 100%;
 `;
 
 const StyledListSection = styled.div`
   width: 100%;
   height: 100%;
-  background: #ffffff11;
   display: flex;
   position: relative;
-  border-radius: 5px;
   font-size: 13px;
+  border-radius: 5px;
+  overflow: hidden;
 `;

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

@@ -38,7 +38,6 @@ export default class RevisionSection extends Component<PropsType, StateType> {
     api.getRevisions('<token>', {
       namespace: chart.namespace,
       cluster_id: currentCluster.id,
-      service_account_id: currentCluster.service_account_id,
       storage: StorageType.Secret
     }, { id: currentProject.id, name: chart.name }, (err: any, res: any) => {
       if (err) {
@@ -82,7 +81,6 @@ export default class RevisionSection extends Component<PropsType, StateType> {
       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) {
         console.log(err);
@@ -287,7 +285,7 @@ const RevisionHeader = styled.div`
   display: flex;
   align-items: center;
   height: 40px;
-  font-size: 14px;
+  font-size: 13px;
   width: 100%;
   padding-left: 15px;
   cursor: pointer;

+ 58 - 17
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -1,25 +1,63 @@
 import React, { Component } from 'react';
 import styled from 'styled-components';
+import api from '../../../../shared/api';
+import yaml from 'js-yaml';
 
-import { RepoType } from '../../../../shared/types';
+import { ChartType, RepoType, StorageType } from '../../../../shared/types';
+import { Context } from '../../../../shared/Context';
 
-import RepoSelector from '../../../../components/repo-selector/RepoSelector';
+import ImageSelector from '../../../../components/image-selector/ImageSelector';
 import SaveButton from '../../../../components/SaveButton';
 
 type PropsType = {
+  currentChart: ChartType,
+  refreshChart: () => void,
+  setCurrentView: (x: string) => void,
 };
 
 type StateType = {
-  selectedRepo: RepoType | null,
-  selectedBranch: string,
-  subdirectory: string,
+  selectedImageUrl: string | null,
+  selectedTag: string | null,
+  saveValuesStatus: string | null,
+  values: string,
 };
 
 export default class SettingsSection extends Component<PropsType, StateType> {
   state = {
-    selectedRepo: null as RepoType | null,
-    selectedBranch: '',
-    subdirectory: '',
+    selectedImageUrl: '',
+    selectedTag: '',
+    values: '',
+    saveValuesStatus: null as (string | null),
+  }
+
+  redeployWithNewImage = (img: string, tag: string) => {
+    this.setState({saveValuesStatus: 'loading'})
+    let { currentCluster, currentProject } = this.context;
+    let image = {
+      image: {
+        repository: img,
+        tag: tag,
+      }
+    }
+
+    let values = yaml.dump(image);
+    api.upgradeChartValues('<token>', {
+      namespace: this.props.currentChart.namespace,
+      storage: StorageType.Secret,
+      values,
+    }, {
+      id: currentProject.id, 
+      name: this.props.currentChart.name,
+      cluster_id: currentCluster.id,
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+        this.setState({ saveValuesStatus: 'error' });
+      } else {
+        this.setState({ saveValuesStatus: 'successful' });
+        this.props.refreshChart();
+      }
+    });
   }
 
   render() {
@@ -27,26 +65,29 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       <Wrapper>
         <StyledSettingsSection>
           <Subtitle>Connected source</Subtitle>
-          <RepoSelector
-            selectedRepo={this.state.selectedRepo}
-            selectedBranch={this.state.selectedBranch}
-            subdirectory={this.state.subdirectory}
-            setSelectedRepo={(selectedRepo: RepoType) => this.setState({ selectedRepo })}
-            setSelectedBranch={(selectedBranch: string) => this.setState({ selectedBranch })}
-            setSubdirectory={(subdirectory: string) => this.setState({ subdirectory })}
+          <ImageSelector
+            selectedImageUrl={this.state.selectedImageUrl}
+            selectedTag={this.state.selectedTag}
+            setSelectedImageUrl={(x: string) => this.setState({ selectedImageUrl: x })}
+            setSelectedTag={(x: string) => this.setState({ selectedTag: x })}
+            forceExpanded={true}
+            setCurrentView={this.props.setCurrentView}
           />
         </StyledSettingsSection>
         <SaveButton
           text='Save Settings'
-          onClick={() => console.log(this.state)}
-          status={null}
+          onClick={() => this.redeployWithNewImage(this.state.selectedImageUrl, this.state.selectedTag)}
+          status={this.state.saveValuesStatus}
           makeFlush={true}
+          disabled={this.state.selectedImageUrl && this.state.selectedTag ? false : true}
         />
       </Wrapper>
     );
   }
 }
 
+SettingsSection.contextType = Context;
+
 const Subtitle = styled.div`
   color: #aaaabb;
   font-size: 13px;

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

@@ -56,10 +56,9 @@ export default class ValuesYaml extends Component<PropsType, StateType> {
       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);
+        console.log(err)
         this.setState({ saveValuesStatus: 'error' });
       } else {
         this.setState({ saveValuesStatus: 'successful' });

+ 103 - 201
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

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

+ 16 - 10
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/Logs.tsx

@@ -41,8 +41,8 @@ export default class Logs extends Component<PropsType, StateType> {
     let { currentCluster, currentProject } = this.context;
     let { selectedPod } = this.props;
     if (!selectedPod.metadata?.name) return
-
-    let ws = new WebSocket(`ws://localhost:8080/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
+    let protocol = process.env.NODE_ENV == 'production' ? 'wss' : 'ws'
+    let ws = new WebSocket(`${protocol}://${process.env.API_SERVER}/api/projects/${currentProject.id}/k8s/${selectedPod?.metadata?.namespace}/pod/${selectedPod?.metadata?.name}/logs?cluster_id=${currentCluster.id}&service_account_id=${currentCluster.service_account_id}`)
 
     this.setState({ ws }, () => {
       if (!this.state.ws) return;
@@ -72,7 +72,9 @@ export default class Logs extends Component<PropsType, StateType> {
   render() {
     return (
       <LogStream ref={this.scrollRef}>
-        {this.renderLogs()}
+        <Wrapper>
+          {this.renderLogs()}
+        </Wrapper>
       </LogStream>
     );
   }
@@ -80,16 +82,20 @@ export default class Logs extends Component<PropsType, StateType> {
 
 Logs.contextType = Context;
 
-const LogStream = styled.div`
+const Wrapper = styled.div`
+  width: 100%;
+  height: 100%;
   overflow: auto;
-  width: 65%;
+  padding: 25px 30px;
+`;
+
+const LogStream = styled.div`
+  display: flex;
+  flex: 1;
   float: right;
   height: 100%;
   background: #202227;
-  padding: 25px;
   user-select: text;
-  overflow: auto;
-  border-radius: 5px;
 `;
 
 const Message = styled.div`
@@ -99,5 +105,5 @@ const Message = styled.div`
   align-items: center;
   justify-content: center;
   color: #ffffff44;
-  font-size: 14px;
-`
+  font-size: 13px;
+`;

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

@@ -43,13 +43,14 @@ export default class StatusSection extends Component<PropsType, StateType> {
   }
 
   renderTabs = () => {
-    return this.state.controllers.map((c) => {
+    return this.state.controllers.map((c, i) => {
       return (
         <ControllerTab 
           key={c.metadata.uid} 
           selectedPod={this.state.selectedPod} 
           selectPod={this.selectPod.bind(this)}
           controller={c}
+          isLast={i === this.state.controllers.length - 1}
         />
       )
     })
@@ -89,7 +90,6 @@ export default class StatusSection extends Component<PropsType, StateType> {
     api.getChartControllers('<token>', {
       namespace: currentChart.namespace,
       cluster_id: currentCluster.id,
-      service_account_id: currentCluster.service_account_id,
       storage: StorageType.Secret
     }, {
       id: currentProject.id,
@@ -116,14 +116,11 @@ export default class StatusSection extends Component<PropsType, StateType> {
 StatusSection.contextType = Context;
 
 const TabWrapper = styled.div`
-  display: flex;
-  flex-direction: column;
-  overflow: auto;
   width: 35%;
-  float: left;
-  max-height: 100%;
-  background: #ffffff11;
-`
+  min-width: 250px;
+  height: 100%;
+  overflow-y: auto;
+`;
 
 const StyledStatusSection = styled.div`
   width: 100%;
@@ -132,11 +129,14 @@ const StyledStatusSection = styled.div`
   font-size: 13px;
   padding: 0px;
   user-select: text;
+  border-radius: 5px;
+  overflow: hidden;
 `;
 
 const Wrapper = styled.div`
   width: 100%;
   height: 100%;
+  display: flex;
 `;
 
 const NoControllers = styled.div`

+ 9 - 2
dashboard/src/main/home/dashboard/Dashboard.tsx

@@ -50,7 +50,7 @@ export default class Dashboard extends Component<PropsType, StateType> {
 
           <Placeholder>
             🚀 Pipelines coming soon.
-        </Placeholder>
+          </Placeholder>
         </div>
       );
     }
@@ -73,10 +73,17 @@ Dashboard.contextType = Context;
 
 const Placeholder = styled.div`
   width: 100%;
-  margin-top: 200px;
+  height: calc(100vh - 380px);
+  margin-top: 30px;
+  display: flex;
+  padding-bottom: 20px;
+  align-items: center;
+  justify-content: center;
   color: #aaaabb;
+  border-radius: 5px;
   text-align: center;
   font-size: 13px;
+  background: #ffffff08;
   font-family: 'Work Sans', sans-serif;
 `;
 

+ 27 - 38
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -2,62 +2,45 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 
 import { Context } from '../../../shared/Context';
-import { getRegistryIcon } from '../../../shared/common';
+import { integrationList } from '../../../shared/common';
 import api from '../../../shared/api';
 
 type PropsType = {
-  setCurrentIntegration: (x: any) => void
+  setCurrent: (x: any) => void,
+  integrations: string[],
+  isCategory?: boolean
 };
 
 type StateType = {
-  integrations: any[]
 };
 
-const dummyIntegrations = [
-  {
-    name: 'docker-hub',
-    label: 'Docker Hub',
-  },
-  {
-    name: 'gcr',
-    label: 'Google Container Registry (GCR)',
-  },
-  {
-    name: 'ecr',
-    label: 'Amazon Elastic Container Registry (ECR)',
-  },
-];
-
 export default class IntegrationList extends Component<PropsType, StateType> {
-  state = {
-    integrations: [] as any[]
-  }
-
-  componentDidMount() {
-    this.setState({ integrations: dummyIntegrations });
-  }
-
   renderContents = () => {
-    if (this.state.integrations) {
-      return this.state.integrations.map((integration: any, i: number) => {
-        let icon = getRegistryIcon(integration.name);
+    let { integrations, setCurrent, isCategory } = this.props;
+    if (integrations && integrations.length > 0) {
+      return integrations.map((integration: string, i: number) => {
+        let icon = integrationList[integration] && integrationList[integration].icon;
+        let label = integrationList[integration] && integrationList[integration].label;
+        let disabled = integration === 'repo' || integration === 'kubernetes';
         return (
           <Integration
             key={i}
-            onClick={() => this.props.setCurrentIntegration(integration)}
+            onClick={() => disabled ? null : setCurrent(integration)}
+            isCategory={isCategory}
+            disabled={disabled}
           >
             <Flex>
               <Icon src={icon && icon} />
-              <Label>{integration.label}</Label>
+              <Label>{label}</Label>
             </Flex>
-            <i className="material-icons">launch</i>
+            <i className="material-icons">{isCategory ? 'launch' : 'more_vert'}</i>
           </Integration>
         );
       });
     }
     return (
       <Placeholder>
-        You haven't set up any integrations yet.
+        No integrations set up yet.
       </Placeholder>
     );
   }
@@ -85,19 +68,25 @@ const Integration = styled.div`
   align-items: center;
   justify-content: space-between;
   padding: 25px;
-  cursor: pointer;
   background: #26282f;
-  cursor: pointer;
+  cursor: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
   margin-bottom: 15px;
   border-radius: 5px;
   box-shadow: 0 5px 8px 0px #00000033;
   :hover {
-    background: #ffffff11;
+    background: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? '' : '#ffffff11'};
+
+    > i {
+      background: ${(props: { isCategory: boolean, disabled: boolean }) => props.disabled ? '' : '#ffffff11'};
+    }
   }
 
   > i {
+    border-radius: 20px;
     font-size: 18px;
-    color: #616feecc;
+    padding: 5px;
+    color: ${(props: { isCategory: boolean, disabled: boolean }) => props.isCategory ? '#616feecc' : '#ffffff44'};
+    margin-right: -7px;
   }
 `;
 
@@ -109,7 +98,7 @@ const Label = styled.div`
 
 const Icon = styled.img`
   width: 30px;
-  margin-right: 15px;
+  margin-right: 18px;
 `;
 
 const Placeholder = styled.div`

+ 159 - 13
dashboard/src/main/home/integrations/Integrations.tsx

@@ -3,27 +3,105 @@ import styled from 'styled-components';
 
 import { Context } from '../../../shared/Context';
 import api from '../../../shared/api';
-import { getRegistryIcon } from '../../../shared/common';
+import { integrationList } from '../../../shared/common';
+import { ChoiceType } from '../../../shared/types';
 
 import IntegrationList from './IntegrationList';
-import DockerHubForm from './integration-forms/DockerHubForm';
+import IntegrationForm from './integration-form/IntegrationForm';
 
 type PropsType = {
 };
 
 type StateType = {
-  currentIntegration: null | any
+  currentCategory: string | null,
+  currentIntegration: string | null,
+  currentOptions: any[],
+  currentIntegrationData: any[],
 };
 
 export default class Integrations extends Component<PropsType, StateType> {
   state = {
-    currentIntegration: null as null | any,
+    currentCategory: null as string | null,
+    currentIntegration: null as string | null,
+    currentOptions: [] as any[],
+    currentIntegrationData: [] as any[],
+  }
+
+  // TODO: implement once backend is restructured
+  getIntegrations = (categoryType: string) => {
+    let { currentProject } = this.context;
+    switch (categoryType) {
+      case 'kubernetes':
+        api.getProjectClusters('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+          } else {
+            // console.log(res.data)
+          }
+        });
+        break;
+      case 'registry':
+        api.getProjectRegistries('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+          } else {
+            let currentOptions = [] as string[];
+            res.data.forEach((integration: any, i: number) => {
+              currentOptions.includes(integration.service) ? null : currentOptions.push(integration.service);
+            });
+            this.setState({ currentOptions, currentIntegrationData: res.data });
+          }
+        });
+        break;
+      case 'repo':
+        api.getProjectRepos('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+          } else {
+            console.log(res.data);
+          }
+        });
+        break;
+      default:
+        console.log('Unknown integration category.');
+    }
+  }
+
+  componentDidUpdate(prevProps: PropsType, prevState: StateType) {
+    if (this.state.currentCategory && this.state.currentCategory !== prevState.currentCategory) {
+      this.getIntegrations(this.state.currentCategory);
+    }
+  }
+
+  renderIntegrationContents = () => {
+    if (this.state.currentIntegrationData) {
+      let items = this.state.currentIntegrationData.filter(item => item.service === this.state.currentIntegration);
+      if (items.length > 0) {
+        return (
+          <div>
+            <Label>Existing Credentials</Label>
+            {
+              items.map((item: any, i: number) => {
+                return (
+                  <Credential>
+                    <i className="material-icons">admin_panel_settings</i> {item.name}
+                  </Credential>
+                );
+              })
+            }
+            <br />
+          </div>
+        );
+      }
+    }
   }
 
   renderContents = () => {
-    let { currentIntegration } = this.state;
+    let { currentCategory, currentIntegration } = this.state;
+
+    // TODO: Split integration page into separate component
     if (currentIntegration) {
-      let icon = getRegistryIcon(currentIntegration.name);
+      let icon = integrationList[currentIntegration] && integrationList[currentIntegration].icon;
       return (
         <div>
           <TitleSectionAlt>
@@ -32,11 +110,50 @@ export default class Integrations extends Component<PropsType, StateType> {
                 keyboard_backspace
               </i>
               <Icon src={icon && icon} />
-              <Title>{currentIntegration.label}</Title>
+              <Title>{integrationList[currentIntegration].label}</Title>
             </Flex>
           </TitleSectionAlt>
+          {this.renderIntegrationContents()}
+          <IntegrationForm 
+            integrationName={currentIntegration}
+            closeForm={() => {
+              this.setState({ currentIntegration: null });
+              this.getIntegrations(this.state.currentCategory);
+            }}
+          />
+          <Br />
+        </div>
+      );
+    } else if (currentCategory) {
+      let icon = integrationList[currentCategory] && integrationList[currentCategory].icon;
+      let label = integrationList[currentCategory] && integrationList[currentCategory].label;
+      let buttonText = integrationList[currentCategory] && integrationList[currentCategory].buttonText;
+      return (
+        <div>
+          <TitleSectionAlt>
+            <Flex>
+              <i className="material-icons" onClick={() => this.setState({ currentCategory: null })}>
+                keyboard_backspace
+              </i>
+              <Icon src={icon && icon} />
+              <Title>{label}</Title>
+            </Flex>
+
+            <Button 
+              onClick={() => this.context.setCurrentModal('IntegrationsModal', { 
+                category: currentCategory,
+                setCurrentIntegration: (x: string) => this.setState({ currentIntegration: x })
+              })}
+            >
+              <i className="material-icons">add</i>
+              {buttonText}
+            </Button>
+          </TitleSectionAlt>
 
-          <DockerHubForm />
+          <IntegrationList
+            integrations={this.state.currentOptions}
+            setCurrent={(x: string) => this.setState({ currentIntegration: x })}
+          />
         </div>
       );
     }
@@ -44,14 +161,12 @@ export default class Integrations extends Component<PropsType, StateType> {
       <div>
         <TitleSection>
           <Title>Integrations</Title>
-          <Button onClick={() => this.context.setCurrentModal('IntegrationsModal', {})}>
-            <i className="material-icons">add</i>
-            Add Integration
-          </Button>
         </TitleSection>
 
         <IntegrationList
-          setCurrentIntegration={(x: any) => this.setState({ currentIntegration: x })}
+          integrations={['kubernetes', 'registry', 'repo']}
+          setCurrent={(x: any) => this.setState({ currentCategory: x })}
+          isCategory={true}
         />
       </div>
     );
@@ -68,6 +183,37 @@ export default class Integrations extends Component<PropsType, StateType> {
 
 Integrations.contextType = Context;
 
+const Label = styled.div`
+  font-size: 14px;
+  font-weight: 500;
+  margin-bottom: 20px;
+`;
+
+const Credential = styled.div`
+  width: 100%;
+  height: 30px;
+  font-size: 13px;
+  display: flex;
+  align-items: center;
+  padding: 20px;
+  padding-left: 13px;
+  width: 100%;
+  border-radius: 5px;
+  background: #ffffff11;
+  margin-bottom: 5px;
+  
+  > i {
+    font-size: 22px;
+    color: #ffffff44;
+    margin-right: 10px;
+  }
+`;
+
+const Br = styled.div`
+  width: 100%;
+  height: 150px;
+`;
+
 const Icon = styled.img`
   width: 27px;
   margin-right: 12px;

+ 20 - 6
dashboard/src/main/home/integrations/integration-forms/DockerHubForm.tsx → dashboard/src/main/home/integrations/integration-form/DockerHubForm.tsx

@@ -8,6 +8,7 @@ import InputRow from '../../../../components/values-form/InputRow';
 import SaveButton from '../../../../components/SaveButton';
 
 type PropsType = {
+  closeForm: () => void,
 };
 
 type StateType = {
@@ -25,9 +26,21 @@ export default class DockerHubForm extends Component<PropsType, StateType> {
     dockerPassword: ''
   }
 
+  isDisabled = (): boolean => {
+    let { registryURL, dockerEmail, dockerUsername, dockerPassword } = this.state;
+    if (registryURL === '' || dockerEmail === '' || dockerUsername === '' || dockerPassword === '') {
+      return true;
+    }
+    return false;
+  }
+
+  handleSubmit = () => {
+    // TODO: implement once api is restructured
+  }
+
   render() {
     return ( 
-      <StyledDockerHubForm>
+      <StyledForm>
         <CredentialWrapper>
           <InputRow
             type='text'
@@ -63,22 +76,23 @@ export default class DockerHubForm extends Component<PropsType, StateType> {
           />
         </CredentialWrapper>
         <SaveButton
-          text='Save Changes'
+          text='Save Settings'
           makeFlush={true}
-          onClick={() => console.log('unimplemented')}
+          disabled={this.isDisabled()}
+          onClick={this.isDisabled() ? null : this.handleSubmit}
         />
-      </StyledDockerHubForm>
+      </StyledForm>
     );
   }
 }
 
 const CredentialWrapper = styled.div`
-  padding: 10px 40px 25px;
+  padding: 5px 40px 25px;
   background: #ffffff11;
   border-radius: 5px;
 `;
 
-const StyledDockerHubForm = styled.div`
+const StyledForm = styled.div`
   position: relative;
   padding-bottom: 75px;
 `;

+ 128 - 0
dashboard/src/main/home/integrations/integration-form/ECRForm.tsx

@@ -0,0 +1,128 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
+
+import InputRow from '../../../../components/values-form/InputRow';
+import TextArea from '../../../../components/values-form/TextArea';
+import SaveButton from '../../../../components/SaveButton';
+import Heading from '../../../../components/values-form/Heading';
+import Helper from '../../../../components/values-form/Helper';
+
+type PropsType = {
+  closeForm: () => void,
+};
+
+type StateType = {
+  credentialsName: string,
+  awsRegion: string,
+  awsAccessId: string,
+  awsSecretKey: string,
+};
+
+export default class ECRForm extends Component<PropsType, StateType> {
+  state = {
+    credentialsName: '',
+    awsRegion: '',
+    awsAccessId: '',
+    awsSecretKey: '',
+  }
+
+  isDisabled = (): boolean => {
+    let { awsRegion, awsAccessId, awsSecretKey, credentialsName } = this.state;
+    if (awsRegion === '' || awsAccessId === '' || awsSecretKey === '' || credentialsName === '') {
+      return true;
+    }
+    return false;
+  }
+
+  handleSubmit = () => {
+    let { awsRegion, awsAccessId, awsSecretKey, credentialsName } = this.state;
+    let { currentProject } = this.context;
+    api.createAWSIntegration('<token>', {
+      aws_region: awsRegion,
+      aws_access_key_id: awsAccessId,
+      aws_secret_access_key: awsSecretKey,
+    }, { id: currentProject.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        api.createECR('<token>', {
+          name: credentialsName,
+          aws_integration_id: res.data.id,
+        }, { id: currentProject.id }, (err: any, res: any) => {
+          if (err) {
+            console.log(err);
+          } else {
+            this.props.closeForm();
+          }
+        });
+      }
+    });
+  }
+
+  render() {
+    return ( 
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Porter Settings</Heading>
+          <Helper>Give a name to this set of registry credentials (just for Porter).</Helper>
+          <InputRow
+            type='text'
+            value={this.state.credentialsName}
+            setValue={(x: string) => this.setState({ credentialsName: x })}
+            label='🏷️ Registry Name'
+            placeholder='ex: paper-straw'
+            width='100%'
+          />
+          <Heading>AWS Settings</Heading>
+          <Helper>AWS access credentials.</Helper>
+          <InputRow
+            type='text'
+            value={this.state.awsRegion}
+            setValue={(x: string) => this.setState({ awsRegion: x })}
+            label='📍 AWS Region'
+            placeholder='ex: mars-north-12'
+            width='100%'
+          />
+          <InputRow
+            type='text'
+            value={this.state.awsAccessId}
+            setValue={(x: string) => this.setState({ awsAccessId: x })}
+            label='👤 AWS Access ID'
+            placeholder='ex: AKIAIOSFODNN7EXAMPLE'
+            width='100%'
+          />
+          <InputRow
+            type='password'
+            value={this.state.awsSecretKey}
+            setValue={(x: string) => this.setState({ awsSecretKey: x })}
+            label='🔒 AWS Secret Key'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text='Save Settings'
+          makeFlush={true}
+          disabled={this.isDisabled()}
+          onClick={this.isDisabled() ? null : this.handleSubmit}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+ECRForm.contextType = Context;
+
+const CredentialWrapper = styled.div`
+  padding: 5px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 116 - 0
dashboard/src/main/home/integrations/integration-form/EKSForm.tsx

@@ -0,0 +1,116 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
+
+import InputRow from '../../../../components/values-form/InputRow';
+import TextArea from '../../../../components/values-form/TextArea';
+import SaveButton from '../../../../components/SaveButton';
+import Heading from '../../../../components/values-form/Heading';
+import Helper from '../../../../components/values-form/Helper';
+
+type PropsType = {
+  closeForm: () => void,
+};
+
+type StateType = {
+  clusterName: string,
+  clusterEndpoint: string,
+  clusterCA: string,
+  awsAccessId: string,
+  awsSecretKey: string,
+};
+
+export default class EKSForm extends Component<PropsType, StateType> {
+  state = {
+    clusterName: '',
+    clusterEndpoint: '',
+    clusterCA: '',
+    awsAccessId: '',
+    awsSecretKey: '',
+  }
+
+  isDisabled = (): boolean => {
+    let { clusterName, clusterEndpoint, clusterCA, awsAccessId, awsSecretKey } = this.state;
+    if (clusterName === '' || clusterEndpoint === '' || clusterCA === '' 
+      || awsAccessId === '' || awsSecretKey === '') {
+      return true;
+    }
+    return false;
+  }
+
+  handleSubmit = () => {
+    // TODO: implement once api is restructured
+  }
+
+  render() {
+    return ( 
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Cluster Settings</Heading>
+          <Helper>Credentials for accessing your GKE cluster.</Helper>
+          <InputRow
+            type='text'
+            value={this.state.clusterName}
+            setValue={(x: string) => this.setState({ clusterName: x })}
+            label='🏷️ Cluster Name'
+            placeholder='ex: briny-pagelet'
+            width='100%'
+          />
+          <InputRow
+            type='text'
+            value={this.state.clusterEndpoint}
+            setValue={(x: string) => this.setState({ clusterEndpoint: x })}
+            label='🌐 Cluster Endpoint'
+            placeholder='ex: 00.00.000.00'
+            width='100%'
+          />
+          <TextArea
+            value={this.state.clusterCA}
+            setValue={(x: string) => this.setState({ clusterCA: x })}
+            label='🔏 Cluster Certificate'
+            placeholder='(Paste your certificate here)'
+            width='100%'
+          />
+
+          <Heading>AWS Settings</Heading>
+          <Helper>AWS access credentials.</Helper>
+          <InputRow
+            type='text'
+            value={this.state.awsAccessId}
+            setValue={(x: string) => this.setState({ awsAccessId: x })}
+            label='👤 AWS Access ID'
+            placeholder='ex: AKIAIOSFODNN7EXAMPLE'
+            width='100%'
+          />
+          <InputRow
+            type='password'
+            value={this.state.awsSecretKey}
+            setValue={(x: string) => this.setState({ awsSecretKey: x })}
+            label='🔒 AWS Secret Key'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text='Save Settings'
+          makeFlush={true}
+          disabled={this.isDisabled()}
+          onClick={this.isDisabled() ? null : this.handleSubmit}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 5px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 84 - 0
dashboard/src/main/home/integrations/integration-form/GCRForm.tsx

@@ -0,0 +1,84 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
+
+import InputRow from '../../../../components/values-form/InputRow';
+import TextArea from '../../../../components/values-form/TextArea';
+import SaveButton from '../../../../components/SaveButton';
+import Heading from '../../../../components/values-form/Heading';
+import Helper from '../../../../components/values-form/Helper';
+
+type PropsType = {
+  closeForm: () => void,
+};
+
+type StateType = {
+  credentialsName: string,
+  serviceAccountKey: string,
+};
+
+export default class GCRForm extends Component<PropsType, StateType> {
+  state = {
+    credentialsName: '',
+    serviceAccountKey: '',
+  }
+
+  isDisabled = (): boolean => {
+    let { credentialsName, serviceAccountKey } = this.state;
+    if (credentialsName === '' || serviceAccountKey === '') {
+      return true;
+    }
+    return false;
+  }
+  
+  handleSubmit = () => {
+    // TODO: implement once api is restructured
+  }
+
+  render() {
+    return ( 
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Porter Settings</Heading>
+          <Helper>Give a name to this set of registry credentials (just for Porter).</Helper>
+          <InputRow
+            type='text'
+            value={this.state.credentialsName}
+            setValue={(x: string) => this.setState({ credentialsName: x })}
+            label='🏷️ Registry Name'
+            placeholder='ex: paper-straw'
+            width='100%'
+          />
+          <Heading>GCP Settings</Heading>
+          <Helper>Service account credentials for GCP permissions.</Helper>
+          <TextArea
+            value={this.state.serviceAccountKey}
+            setValue={(x: string) => this.setState({ serviceAccountKey: x })}
+            label='🔑 Service Account Key (JSON)'
+            placeholder='(Paste your JSON service account key here)'
+            width='100%'
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text='Save Settings'
+          makeFlush={true}
+          disabled={this.isDisabled()}
+          onClick={this.isDisabled() ? null : this.handleSubmit}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 5px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 104 - 0
dashboard/src/main/home/integrations/integration-form/GKEForm.tsx

@@ -0,0 +1,104 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../../shared/Context';
+import api from '../../../../shared/api';
+
+import InputRow from '../../../../components/values-form/InputRow';
+import TextArea from '../../../../components/values-form/TextArea';
+import SaveButton from '../../../../components/SaveButton';
+import Heading from '../../../../components/values-form/Heading';
+import Helper from '../../../../components/values-form/Helper';
+
+type PropsType = {
+  closeForm: () => void,
+};
+
+type StateType = {
+  clusterName: string,
+  clusterEndpoint: string,
+  clusterCA: string,
+  serviceAccountKey: string
+};
+
+export default class GKEForm extends Component<PropsType, StateType> {
+  state = {
+    clusterName: '',
+    clusterEndpoint: '',
+    clusterCA: '',
+    serviceAccountKey: ''
+  }
+
+  isDisabled = (): boolean => {
+    let { clusterName, clusterEndpoint, clusterCA, serviceAccountKey } = this.state;
+    if (clusterName === '' || clusterEndpoint === '' || clusterCA === '' || serviceAccountKey === '') {
+      return true;
+    }
+    return false;
+  }
+
+  handleSubmit = () => {
+    // TODO: implement once api is restructured
+  }
+
+  render() {
+    return ( 
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Cluster Settings</Heading>
+          <Helper>Credentials for accessing your GKE cluster.</Helper>
+          <InputRow
+            type='text'
+            value={this.state.clusterName}
+            setValue={(x: string) => this.setState({ clusterName: x })}
+            label='🏷️ Cluster Name'
+            placeholder='ex: briny-pagelet'
+            width='100%'
+          />
+          <InputRow
+            type='text'
+            value={this.state.clusterEndpoint}
+            setValue={(x: string) => this.setState({ clusterEndpoint: x })}
+            label='🌐 Cluster Endpoint'
+            placeholder='ex: 00.00.000.00'
+            width='100%'
+          />
+          <TextArea
+            value={this.state.clusterCA}
+            setValue={(x: string) => this.setState({ clusterCA: x })}
+            label='🔏 Cluster Certificate'
+            placeholder='(Paste your certificate here)'
+            width='100%'
+          />
+
+          <Heading>GCP Settings</Heading>
+          <Helper>Service account credentials for GCP permissions.</Helper>
+          <TextArea
+            value={this.state.serviceAccountKey}
+            setValue={(x: string) => this.setState({ serviceAccountKey: x })}
+            label='🔑 Service Account Key (JSON)'
+            placeholder='(Paste your JSON service account key here)'
+            width='100%'
+          />
+        </CredentialWrapper>
+        <SaveButton
+          text='Save Settings'
+          makeFlush={true}
+          disabled={this.isDisabled()}
+          onClick={this.isDisabled() ? null : this.handleSubmit}
+        />
+      </StyledForm>
+    );
+  }
+}
+
+const CredentialWrapper = styled.div`
+  padding: 5px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 39 - 0
dashboard/src/main/home/integrations/integration-form/IntegrationForm.tsx

@@ -0,0 +1,39 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import DockerHubForm from './DockerHubForm';
+import GKEForm from './GKEForm';
+import EKSForm from './EKSForm';
+import GCRForm from './GCRForm';
+import ECRForm from './ECRForm';
+
+type PropsType = {
+  integrationName: string,
+  closeForm: () => void,
+};
+
+type StateType = {
+};
+
+export default class IntegrationForm extends Component<PropsType, StateType> {
+  state = {
+  }
+
+  render() {
+    let { closeForm } = this.props;
+    switch (this.props.integrationName) {
+      case 'docker-hub':
+        return <DockerHubForm closeForm={closeForm} />;
+      case 'gke':
+        return <GKEForm closeForm={closeForm} />;
+      case 'eks':
+        return <EKSForm closeForm={closeForm} />;
+      case 'ecr':
+        return <ECRForm closeForm={closeForm} />;
+      case 'gcr':
+        return <GCRForm closeForm={closeForm} />;
+      default:
+        return null;
+    }
+  }
+}

+ 106 - 32
dashboard/src/main/home/modals/ClusterInstructionsModal.tsx

@@ -10,6 +10,7 @@ type PropsType = {
 
 type StateType = {
   currentTab: string,
+  currentPage: number,
 };
 
 const tabOptions = [
@@ -18,10 +19,64 @@ const tabOptions = [
 
 export default class ClusterInstructionsModal extends Component<PropsType, StateType> {
   state = {
-    currentTab: 'mac'
+    currentTab: 'mac',
+    currentPage: 0,
+  }
+
+  renderPage = () => {
+    switch (this.state.currentPage) {
+      case 0:
+        return (
+          <Placeholder>
+            1. To install the Porter CLI, first retrieve the latest binary:
+            <Code>
+              &#123;<br />
+              name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*porter_.*_Darwin_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")<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 config set-host {location.protocol + '//' + location.host}<br/>
+              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 connect kubeconfig
+            </Code>
+          </Placeholder>
+        );
+      case 1:
+        return (
+          <Placeholder>
+            <Bold>Passing a kubeconfig explicitly</Bold>
+            You can pass a path to a kubeconfig file explicitly via:
+            <Code>
+              porter connect kubeconfig --kubeconfig path/to/kubeconfig
+            </Code>
+            <Bold>Passing a context list</Bold>
+            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:
+            <Code>
+              porter connect kubeconfig --contexts minikube --contexts staging
+            </Code>
+          </Placeholder>
+        );
+      default:
+        return
+    }
   }
  
   render() {
+    let { currentPage, currentTab } = this.state;
     return (
       <StyledClusterInstructionsModal>
         <CloseButton onClick={() => {
@@ -34,38 +89,26 @@ export default class ClusterInstructionsModal extends Component<PropsType, State
 
         <TabSelector
           options={tabOptions}
-          currentTab={this.state.currentTab}
+          currentTab={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>
-        
+        {this.renderPage()}
+        <PageSection>
+          <PageCount>{currentPage + 1}/2</PageCount>
+          <i 
+            className="material-icons"
+            onClick={() => currentPage > 0 ? this.setState({ currentPage: currentPage - 1 }) : null}
+          >
+            arrow_back
+          </i>
+          <i 
+            className="material-icons"
+            onClick={() => currentPage < 1 ? this.setState({ currentPage: currentPage + 1 }) : null}
+          >
+            arrow_forward
+          </i>
+        </PageSection>
       </StyledClusterInstructionsModal>
     );
   }
@@ -73,6 +116,35 @@ export default class ClusterInstructionsModal extends Component<PropsType, State
 
 ClusterInstructionsModal.contextType = Context;
 
+const PageCount = styled.div`
+  margin-right: 9px;
+  user-select: none;
+  letter-spacing: 2px;
+`;
+
+const PageSection = styled.div`
+  position: absolute;
+  bottom: 22px;
+  right: 20px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  color: #ffffff;
+  justify-content: flex-end;
+  user-select: none;
+  
+  > i {
+    font-size: 18px;
+    margin-left: 2px;
+    cursor: pointer;
+    border-radius: 20px;
+    padding: 5px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
 const Code = styled.div`
   background: #181B21;
   padding: 10px 15px;
@@ -82,6 +154,7 @@ const Code = styled.div`
   color: #ffffff;
   font-size: 13px;
   user-select: text;
+  line-height: 1em;
   font-family: monospace;
 `;
 
@@ -96,12 +169,13 @@ const Placeholder = styled.div`
   font-size: 13px;
   margin-left: 0px;
   margin-top: 25px;
+  line-height: 1.5em;
   user-select: none;
 `;
 
 const Bold = styled.div`
-  font-weight: bold;
-  font-size: 20px;
+  font-weight: 600;
+  margin-bottom: 7px;
 `;
 
 const Subtitle = styled.div`

+ 197 - 0
dashboard/src/main/home/modals/IntegrationsInstructionsModal.tsx

@@ -0,0 +1,197 @@
+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,
+  currentPage: number,
+};
+
+const tabOptions = [
+  { label: 'MacOS', value: 'mac' }
+];
+
+export default class ClusterInstructionsModal extends Component<PropsType, StateType> {
+  state = {
+    currentTab: 'mac',
+    currentPage: 0,
+  }
+
+  renderPage = () => {
+    switch (this.state.currentPage) {
+      case 0:
+        return (
+          <Placeholder>
+            <Bold>Elastic Container Registry (ECR):</Bold>
+            1. Run the following command on the Porter CLI.
+            <Code>
+              porter connect ecr
+            </Code>
+            2. Enter the region your ECR instance belongs to. For example:
+            <Code>
+              AWS Region: us-west-2
+            </Code>
+            3. Porter will automatically set up an IAM user in your AWS account to grant ECR access. Once this is done, it will prompt you to enter a name for the registry. Here you may enter any name you'd like.
+            <Code>
+              Give this registry a name: my-awesome-registry
+            </Code>
+          </Placeholder>
+        );
+      default:
+        return
+    }
+  }
+ 
+  render() {
+    let { currentPage, currentTab } = this.state;
+    return (
+      <StyledClusterInstructionsModal>
+        <CloseButton onClick={() => {
+          this.context.setCurrentModal(null, null);
+        }}>
+          <CloseButtonImg src={close} />
+        </CloseButton>
+
+        <ModalTitle>Connecting to an Image Registry</ModalTitle>
+
+        <TabSelector
+          options={tabOptions}
+          currentTab={currentTab}
+          setCurrentTab={(value: string) => this.setState({ currentTab: value })}
+        />
+
+        {this.renderPage()}
+      </StyledClusterInstructionsModal>
+    );
+  }
+}
+
+ClusterInstructionsModal.contextType = Context;
+
+const PageCount = styled.div`
+  margin-right: 9px;
+  user-select: none;
+  letter-spacing: 2px;
+`;
+
+const PageSection = styled.div`
+  position: absolute;
+  bottom: 22px;
+  right: 20px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  color: #ffffff;
+  justify-content: flex-end;
+  user-select: none;
+  
+  > i {
+    font-size: 18px;
+    margin-left: 2px;
+    cursor: pointer;
+    border-radius: 20px;
+    padding: 5px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+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;
+  line-height: 1em;
+  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;
+  line-height: 1.5em;
+  user-select: none;
+`;
+
+const Bold = styled.div`
+  font-weight: 600;
+  margin-bottom: 7px;
+`;
+
+const Subtitle = styled.div`
+  padding: 10px 0px 20px;
+  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;
+`;

+ 56 - 31
dashboard/src/main/home/modals/IntegrationsModal.tsx

@@ -3,50 +3,74 @@ import styled from 'styled-components';
 import close from '../../../assets/close.png';
 
 import { Context } from '../../../shared/Context';
-import { getRegistryIcon } from '../../../shared/common';
+import api from '../../../shared/api';
+import { integrationList } from '../../../shared/common';
 
 type PropsType = {
 };
 
 type StateType = {
-  integrations: any[]
+  integrations: any[],
 };
 
-const dummyIntegrations = [
-  {
-    name: 'docker-hub',
-    label: 'Docker Hub',
-  },
-  {
-    name: 'gcr',
-    label: 'Google Container Registry (GCR)',
-  },
-  {
-    name: 'ecr',
-    label: 'Amazon Elastic Container Registry (ECR)',
-  },
-];
-
 export default class IntegrationsModal extends Component<PropsType, StateType> {
   state = {
-    currentTab: 'mac',
-    integrations: [] as any[]
+    integrations: [] as any[],
   }
 
   componentDidMount() {
-    this.setState({ integrations: dummyIntegrations });
+    let { category } = this.context.currentModalData;
+    if (category === 'kubernetes') {
+      api.getClusterIntegrations('<token>', {}, {}, (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+        } else {
+          this.setState({ integrations: res.data });
+        }
+      });
+    } else if (category === 'registry') {
+      api.getRegistryIntegrations('<token>', {}, {}, (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+        } else {
+          // console.log(res.data)
+          this.setState({ integrations: res.data });
+        }
+      });
+    } else {
+      api.getRepoIntegrations('<token>', {}, {}, (err: any, res: any) => {
+        if (err) {
+          console.log(err);
+        } else {
+          this.setState({ integrations: res.data });
+        }
+      });
+    }
   }
 
   renderIntegrationsCatalog = () => {
-    return this.state.integrations.map((integration: any, i: number) => {
-      let icon = getRegistryIcon(integration.name);
-      return (
-        <IntegrationOption key={i}>
-          <Icon src={icon && icon} />
-          <Label>{integration.label}</Label>
-        </IntegrationOption>
-      );
-    });
+    if (this.context.currentModalData) {
+      let { setCurrentIntegration } = this.context.currentModalData;
+      return this.state.integrations.map((integration: any, i: number) => {
+        let icon = integrationList[integration.service] && integrationList[integration.service].icon;
+        let disabled = integration.service === 'kube' || integration.service === 'docker' || integration.service === 'gcr';
+        return (
+          <IntegrationOption 
+            key={i}
+            disabled={disabled}
+            onClick={() => {
+              if (!disabled) {
+                setCurrentIntegration(integration.service);
+                this.context.setCurrentModal(null, null);
+              }
+            }}
+          >
+            <Icon src={icon && icon} />
+            <Label>{integrationList[integration.service].label}</Label>
+          </IntegrationOption>
+        );
+      });
+    }
   }
  
   render() {
@@ -84,14 +108,15 @@ const Icon = styled.img`
 
 const IntegrationOption = styled.div`
   height: 60px;
+  user-select: none;
   width: 100%;
   border-bottom: 1px solid #ffffff44;
   display: flex;
   align-items: center;
   padding: 20px;
-  cursor: pointer;
+  cursor: ${(props: { disabled: boolean }) => props.disabled ? 'not-allowed' : 'pointer'};
   :hover {
-    background: #ffffff22;
+    background: ${(props: { disabled: boolean }) => props.disabled ? '' : '#ffffff22'};
   }
 `;
 

+ 1 - 1
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -175,7 +175,7 @@ const Option = styled.div`
   font-size: 13px;
   align-items: center;
   padding-left: 10px;
-  cursor: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '' : 'pointer'};;
+  cursor: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '' : 'pointer'};
   padding-right: 10px;
   background: ${(props: { selected: boolean, lastItem?: boolean }) => props.selected ? '#ffffff11' : ''};
   :hover {

+ 3 - 2
dashboard/src/main/home/sidebar/Sidebar.tsx

@@ -124,8 +124,9 @@ export default class Sidebar extends Component<PropsType, StateType> {
             Templates
           </NavButton>
           <NavButton
-            onClick={() => this.props.setCurrentView('integrations')}
-            selected={this.props.currentView === 'integrations'}
+            // onClick={() => this.props.setCurrentView('integrations')}
+            // selected={this.props.currentView === 'integrations'}
+            onClick={() => this.context.setCurrentModal('IntegrationsInstructionsModal', {})}
           >
             <img src={integrations} />
             Integrations

+ 21 - 0
dashboard/src/main/home/templates/Templates.tsx

@@ -14,6 +14,7 @@ const tabOptions = [
 ];
 
 type PropsType = {
+  setCurrentView: (x: string) => void, // Link to add integration from source selector
 };
 
 type StateType = {
@@ -40,6 +41,7 @@ export default class Templates extends Component<PropsType, StateType> {
       if (err) {
         this.setState({ loading: false, error: true });
       } else {
+        console.log(res.data)
         this.setState({ porterCharts: res.data, loading: false, error: false });
       }
     });
@@ -96,6 +98,7 @@ export default class Templates extends Component<PropsType, StateType> {
         <ExpandedTemplate
           currentTemplate={this.state.currentTemplate}
           setCurrentTemplate={(currentTemplate: PorterChart) => this.setState({ currentTemplate })}
+          setCurrentView={this.props.setCurrentView}
         />
       );
     }
@@ -104,6 +107,9 @@ export default class Templates extends Component<PropsType, StateType> {
       <TemplatesWrapper>
         <TitleSection>
           <Title>Template Explorer</Title>
+          <a href='https://docs.getporter.dev/docs/porter-templates' target='_blank'>
+            <i className="material-icons">help_outline</i>
+          </a>
         </TitleSection>
         <TabSelector
           options={tabOptions}
@@ -235,6 +241,21 @@ const TitleSection = styled.div`
   display: flex;
   flex-direction: row;
   align-items: center;
+
+  > a {
+    > i {
+      display: flex;
+      align-items: center;
+      margin-bottom: -2px;
+      font-size: 18px;
+      margin-left: 18px;
+      color: #858FAAaa;
+      cursor: pointer;
+      :hover {
+        color: #aaaabb;
+      }
+    }
+  }
 `;
 
 const TemplatesWrapper = styled.div`

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

@@ -8,7 +8,8 @@ import LaunchTemplate from './LaunchTemplate';
 
 type PropsType = {
   currentTemplate: PorterChart,
-  setCurrentTemplate: (x: PorterChart) => void
+  setCurrentTemplate: (x: PorterChart) => void,
+  setCurrentView: (x: string) => void,
 };
 
 type StateType = {
@@ -26,6 +27,7 @@ export default class ExpandedTemplate extends Component<PropsType, StateType> {
         <LaunchTemplate
           currentTemplate={this.props.currentTemplate}
           hideLaunch={() => this.setState({ showLaunchTemplate: false })}
+          setCurrentView={this.props.setCurrentView}
         />
       );
     }

+ 19 - 5
dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx

@@ -13,6 +13,7 @@ import ValuesForm from '../../../../components/values-form/ValuesForm';
 type PropsType = {
   currentTemplate: PorterChart,
   hideLaunch: () => void,
+  setCurrentView: (x: string) => void,
 };
 
 type StateType = {
@@ -20,6 +21,7 @@ type StateType = {
   clusterOptions: { label: string, value: string }[],
   selectedCluster: string,
   selectedImageUrl: string | null,
+  selectedTag: string | null,
   tabOptions: ChoiceType[],
   tabContents: any
 };
@@ -29,7 +31,8 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     currentView: 'repo',
     clusterOptions: [] as { label: string, value: string }[],
     selectedCluster: this.context.currentCluster.name,
-    selectedImageUrl: '',
+    selectedImageUrl: '' as string | null,
+    selectedTag: '' as string | null,
     tabOptions: [] as ChoiceType[],
     tabContents: [] as any,
   };
@@ -44,7 +47,6 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     }, {
       id: currentProject.id,
       cluster_id: currentCluster.id,
-      service_account_id: currentCluster.service_account_id,
     }, (err: any, res: any) => {
       if (err) {
         console.log(err)
@@ -54,7 +56,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     });
   }
 
-  componentDidMount() {
+  refreshTabs = () => {
     // Generate settings tabs from the provided form
     let tabOptions = [] as ChoiceType[];
     let tabContents = [] as any;
@@ -66,14 +68,17 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
             <ValuesForm 
               sections={tab.sections} 
               onSubmit={this.onSubmit}
-              // disabled={this.state.selectedImageUrl === ''}
-              disabled={false}
+              disabled={!this.state.selectedImageUrl || this.state.selectedImageUrl === ''}
             />
           </ValuesFormWrapper>
         ),
       });
     });
     this.setState({ tabOptions, tabContents });
+  }
+
+  componentDidMount() {
+    this.refreshTabs();
 
     // TODO: query with selected filter once implemented
     let { currentProject } = this.context;
@@ -89,6 +94,12 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     });
   }
 
+  componentDidUpdate(prevProps: PropsType, prevState: StateType) {
+    if (this.state.selectedImageUrl != prevState.selectedImageUrl) {
+      this.refreshTabs();
+    }
+  }
+
   renderIcon = (icon: string) => {
     if (icon) {
       return <Icon src={icon} />
@@ -136,9 +147,12 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
         <Subtitle>Select the container image you would like to connect to this template.</Subtitle>
         <Br />
         <ImageSelector
+          selectedTag={this.state.selectedTag}
           selectedImageUrl={this.state.selectedImageUrl}
           setSelectedImageUrl={(x: string) => this.setState({ selectedImageUrl: x })}
+          setSelectedTag={(x: string) => this.setState({ selectedTag: x })}
           forceExpanded={true}
+          setCurrentView={this.props.setCurrentView}
         />
 
         <br />

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

@@ -118,7 +118,7 @@ const TagSection = styled.div`
   align-items: center;
 
   > i {
-    font-size: 20px;
+    font-size: 18px;
     margin-right: 10px;
     color: #aaaabb;
   }

+ 70 - 20
dashboard/src/shared/api.tsx

@@ -14,7 +14,7 @@ import { StorageType } from './types';
 const checkAuth = baseApi('GET', '/api/auth/check');
 
 const registerUser = baseApi<{ 
-  email: string, 
+  email: string,
   password: string
 }>('POST', '/api/users');
 
@@ -43,7 +43,6 @@ const getClusters = baseApi<{}, { id: number }>('GET', pathParams => {
 const getCharts = baseApi<{
   namespace: string,
   cluster_id: number,
-  service_account_id: number,
   storage: StorageType,
   limit: number,
   skip: number,
@@ -56,7 +55,6 @@ const getCharts = baseApi<{
 const getChart = baseApi<{
   namespace: string,
   cluster_id: number,
-  service_account_id: number,
   storage: StorageType
 }, { id: number, name: string, revision: number }>('GET', pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}`;
@@ -65,7 +63,6 @@ const getChart = baseApi<{
 const getChartComponents = baseApi<{
   namespace: string,
   cluster_id: number,
-  service_account_id: number,
   storage: StorageType
 }, { id: number, name: string, revision: number }>('GET', pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/components`;
@@ -74,7 +71,6 @@ const getChartComponents = baseApi<{
 const getChartControllers = baseApi<{
   namespace: string,
   cluster_id: number,
-  service_account_id: number,
   storage: StorageType
 }, { id: number, name: string, revision: number }>('GET', pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/controllers`;
@@ -82,14 +78,12 @@ const getChartControllers = baseApi<{
 
 const getNamespaces = baseApi<{
   cluster_id: number,
-  service_account_id: number,
 }, { id: number }>('GET', pathParams => {
   return `/api/projects/${pathParams.id}/k8s/namespaces`;
 });
 
 const getMatchingPods = baseApi<{
   cluster_id: number,
-  service_account_id: number,
   selectors: string[]
 }, { id: number }>('GET', pathParams => {
   return `/api/projects/${pathParams.id}/k8s/pods`;
@@ -98,7 +92,6 @@ const getMatchingPods = baseApi<{
 const getRevisions = baseApi<{
   namespace: string,
   cluster_id: number,
-  service_account_id: number,
   storage: StorageType
 }, { id: number, name: string }>('GET', pathParams => {
   return `/api/projects/${pathParams.id}/releases/${pathParams.name}/history`;
@@ -112,10 +105,9 @@ const rollbackChart = baseApi<{
   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}`;
+}>('POST', pathParams => {
+  let { id, name, cluster_id } = pathParams;
+  return `/api/projects/${id}/releases/${name}/rollback?cluster_id=${cluster_id}`;
 });
 
 const upgradeChartValues = baseApi<{
@@ -126,10 +118,9 @@ const upgradeChartValues = baseApi<{
   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}`;
+}>('POST', pathParams => {
+  let { id, name, cluster_id } = pathParams;
+  return `/api/projects/${id}/releases/${name}/upgrade?cluster_id=${cluster_id}`;
 });
 
 const getTemplates = baseApi('GET', '/api/templates');
@@ -167,11 +158,60 @@ const deployTemplate = baseApi<{
   imageURL: string,
   formValues: any,
   storage: StorageType,
-}, { id: number, cluster_id: number, service_account_id: number }>('POST', pathParams => {
-  let {id, cluster_id, service_account_id} = pathParams;
-  return `/api/projects/${id}/deploy?cluster_id=${cluster_id}&service_account_id=${service_account_id}`;
+}, { id: number, cluster_id: number }>('POST', pathParams => {
+  let { cluster_id, id } = pathParams;
+  return `/api/projects/${id}/deploy?cluster_id=${cluster_id}`;
+});
+
+const getClusterIntegrations = baseApi('GET', '/api/integrations/cluster');
+
+const getRegistryIntegrations = baseApi('GET', '/api/integrations/registry');
+
+const getRepoIntegrations = baseApi('GET', '/api/integrations/repo');
+
+const getProjectClusters = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/clusters`;
+});
+
+const getProjectRegistries = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/registries`;
+});
+
+const getProjectRepos = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/repos`;
 });
 
+const createAWSIntegration = baseApi<{
+  aws_region: string,
+  aws_access_key_id: string,
+  aws_secret_access_key: string,
+}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/integrations/aws`;
+});
+
+const createECR = baseApi<{
+  name: string,
+  aws_integration_id: string,
+}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/registries`;
+});
+
+const getImageRepos = baseApi<{}, {   
+  project_id: number,
+  registry_id: number,
+}>('GET', pathParams => {
+  return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}/repositories`;
+});
+
+const getImageTags = baseApi<{}, {   
+  project_id: number,
+  registry_id: number,
+  repo_name: string,
+ }>('GET', pathParams => {
+  return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}/repositories/${pathParams.repo_name}`;
+});
+
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -197,5 +237,15 @@ export default {
   getProjects,
   createProject,
   deleteProject,
-  deployTemplate
+  deployTemplate,
+  getClusterIntegrations,
+  getRegistryIntegrations,
+  getRepoIntegrations,
+  getProjectClusters,
+  getProjectRegistries,
+  getProjectRepos,
+  createAWSIntegration,
+  createECR,
+  getImageRepos,
+  getImageTags,
 }

+ 1 - 1
dashboard/src/shared/baseApi.tsx

@@ -1,7 +1,7 @@
 import axios from 'axios';
 import qs from 'qs';
 
-// axios.defaults.timeout = 2500;
+axios.defaults.timeout = 10000;
 
 // Partial function that accepts a generic params type and returns an api method
 export const baseApi = <T extends {}, S = {}>(requestType: string, endpoint: ((pathParams: S) => string) | string) => {

+ 41 - 12
dashboard/src/shared/common.tsx

@@ -1,15 +1,44 @@
-export const getRegistryIcon = (kind: string) => {
-  switch (kind) {
-    case 'docker-hub':
-      return 'https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png';
-    case 'gcr':
-      return 'https://carlossanchez.files.wordpress.com/2019/06/21046548.png?w=640';
-    case 'ecr':
-      return 'https://avatars2.githubusercontent.com/u/52505464?s=400&u=da920f994c67665c7ad6c606a5286557d4f8555f&v=4';
-    default:
-      return null
-  }
-}
+export const integrationList: any = {
+  'kubernetes': {
+    icon: 'https://uxwing.com/wp-content/themes/uxwing/download/10-brands-and-social-media/kubernetes.png',
+    label: 'Kubernetes',
+    buttonText: 'Add a Cluster',
+  },
+  'repo': {
+    icon: 'https://3.bp.blogspot.com/-xhNpNJJyQhk/XIe4GY78RQI/AAAAAAAAItc/ouueFUj2Hqo5dntmnKqEaBJR4KQ4Q2K3ACK4BGAYYCw/s1600/logo%2Bgit%2Bicon.png',
+    label: 'Git Repository',
+    buttonText: 'Add a Repository',
+  },
+  'registry': {
+    icon: 'https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png',
+    label: 'Docker Registry',
+    buttonText: 'Add a Registry',
+  },
+  'gke': {
+    icon: 'https://sysdig.com/wp-content/uploads/2016/08/GKE_color.png',
+    label: 'Google Kubernetes Engine (GKE)',
+  },
+  'eks': {
+    icon: 'https://img.stackshare.io/service/7991/amazon-eks.png',
+    label: 'Amazon Elastic Kubernetes Service (EKS)',
+  },
+  'kube': {
+    icon: 'https://uxwing.com/wp-content/themes/uxwing/download/10-brands-and-social-media/kubernetes.png',
+    label: 'Upload Kubeconfig'
+  },
+  'docker': {
+    icon: 'https://cdn4.iconfinder.com/data/icons/logos-and-brands/512/97_Docker_logo_logos-512.png',
+    label: 'Docker Hub',
+  },
+  'gcr': {
+    icon: 'https://carlossanchez.files.wordpress.com/2019/06/21046548.png?w=640',
+    label: 'Google Container Registry (GCR)',
+  },
+  'ecr': {
+    icon: 'https://avatars2.githubusercontent.com/u/52505464?s=400&u=da920f994c67665c7ad6c606a5286557d4f8555f&v=4',
+    label: 'Elastic Container Registry (ECR)',
+  },
+};
 
 export const getIgnoreCase = (object: any, key: string) => {
   return object[Object.keys(object)

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

@@ -131,4 +131,10 @@ export interface ProjectType {
 export interface ChoiceType {
   value: string,
   label: string
+}
+
+export interface ImageType {
+  kind: string,
+  source: string,
+  registryId: number,
 }

+ 26 - 0
docs/GCR.md

@@ -0,0 +1,26 @@
+# Google Container Registry (GCR) Connection
+
+To authenticate a private GCR registry, you will first need a Google Cloud service account with registry viewing permissions. To create a new service account, go to your Google Cloud console and navigate to the **IAM & Admin** tab in the navigation menu and select **Service Accounts**:
+
+<img src="https://files.readme.io/a0c0c75-Screen_Shot_2020-06-24_at_2.51.46_PM.png" width="80%">
+
+Select **Create Service Account** and provide a name and brief description for the new service account. Next, choose the role **Viewer** when you are prompted to grant permissions to your service account:
+
+<img src="https://files.readme.io/aa8cda5-Screen_Shot_2020-06-24_at_4.03.33_PM.png" width="80%">
+
+After the service account has been created, you need to create a JSON key for your service account by going to **Actions** -> **Create key** and then selecting JSON as your key type. Once your JSON key file has downloaded, use the `porter connect gcr` command to add the registry to your project. 
+
+For example, for a key named `gcp-key-file.json` on Mac:
+
+```diff
+$ cd ~/Downloads
+$ porter connect gcr 
+Please provide the full path to a service account key file.
+Key file location: ./gcp-key-file.json
++ created gcp integration with id 3
+Give this registry a name: gcr-registry
++ created registry with id 16 and name gcr-test
++ Set the current registry id as 16
+```
+
+Having issues authenticating your private registry? You can reach us at [contact@getporter.dev](mailto:contact@getporter.dev).

+ 1 - 0
go.mod

@@ -31,6 +31,7 @@ require (
 	github.com/gorilla/securecookie v1.1.1
 	github.com/gorilla/sessions v1.2.1
 	github.com/gorilla/websocket v1.4.2
+	github.com/hashicorp/consul/api v1.3.0
 	github.com/imdario/mergo v0.3.11 // indirect
 	github.com/jinzhu/gorm v1.9.16
 	github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd

+ 19 - 0
go.sum

@@ -56,26 +56,32 @@ github.com/Azure/azure-sdk-for-go v38.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9mo
 github.com/Azure/azure-sdk-for-go v42.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
 github.com/Azure/azure-service-bus-go v0.9.1/go.mod h1:yzBx6/BUGfjfeqbRZny9AQIbIe3AcV9WZbAdpkoXOa0=
 github.com/Azure/azure-storage-blob-go v0.8.0/go.mod h1:lPI3aLPpuLTeUwh1sViKXFxwl2B6teiRqI0deQUvsw0=
+github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 h1:w+iIsaOQNcT7OZ575w+acHgRric5iCyQh+xv+KJ4HB8=
 github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
 github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
 github.com/Azure/go-autorest v11.7.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
 github.com/Azure/go-autorest v12.0.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
+github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
 github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
 github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI=
 github.com/Azure/go-autorest/autorest v0.9.3/go.mod h1:GsRuLYvwzLjjjRoWEIyMUaYq8GNUx2nRB378IPt/1p0=
 github.com/Azure/go-autorest/autorest v0.10.0/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630=
 github.com/Azure/go-autorest/autorest v0.10.2/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630=
+github.com/Azure/go-autorest/autorest v0.11.1 h1:eVvIXUKiTgv++6YnWb42DUA1YL7qDugnKP0HljexdnQ=
 github.com/Azure/go-autorest/autorest v0.11.1/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw=
 github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0=
 github.com/Azure/go-autorest/autorest/adal v0.8.0/go.mod h1:Z6vX6WXXuyieHAXwMj0S6HY6e6wcHn37qQMBQlvY3lc=
 github.com/Azure/go-autorest/autorest/adal v0.8.1/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q=
 github.com/Azure/go-autorest/autorest/adal v0.8.2/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q=
+github.com/Azure/go-autorest/autorest/adal v0.9.0 h1:SigMbuFNuKgc1xcGhaeapbh+8fgsu+GxgDRFyg7f5lM=
 github.com/Azure/go-autorest/autorest/adal v0.9.0/go.mod h1:/c022QCutn2P7uY+/oQWWNcK9YU+MH96NgK+jErpbcg=
+github.com/Azure/go-autorest/autorest/adal v0.9.5 h1:Y3bBUV4rTuxenJJs41HU3qmqsb+auo+a3Lz+PlJPpL0=
 github.com/Azure/go-autorest/autorest/adal v0.9.5/go.mod h1:B7KF7jKIeC9Mct5spmyCB/A8CG/sEz1vwIRGv/bbw7A=
 github.com/Azure/go-autorest/autorest/azure/auth v0.4.2/go.mod h1:90gmfKdlmKgfjUpnCEpOJzsUEjrWDSLwHIG73tSXddM=
 github.com/Azure/go-autorest/autorest/azure/cli v0.3.1/go.mod h1:ZG5p860J94/0kI9mNJVoIoLgXcirM2gF5i2kWloofxw=
 github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA=
 github.com/Azure/go-autorest/autorest/date v0.2.0/go.mod h1:vcORJHLJEh643/Ioh9+vPmf1Ij9AEBM5FuBIXLmIy0g=
+github.com/Azure/go-autorest/autorest/date v0.3.0 h1:7gUk1U5M/CQbp9WoqinNzJar+8KY+LPI6wiWrP/myHw=
 github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74=
 github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=
 github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0=
@@ -87,8 +93,10 @@ github.com/Azure/go-autorest/autorest/to v0.3.0/go.mod h1:MgwOyqaIuKdG4TL/2ywSsI
 github.com/Azure/go-autorest/autorest/validation v0.1.0/go.mod h1:Ha3z/SqBeaalWQvokg3NZAlQTalVMtOIAs1aGK7G6u8=
 github.com/Azure/go-autorest/autorest/validation v0.2.0/go.mod h1:3EEqHnBxQGHXRYq3HT1WyXAvT7LLY3tl70hw6tQIbjI=
 github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc=
+github.com/Azure/go-autorest/logger v0.2.0 h1:e4RVHVZKC5p6UANLJHkM4OfR1UKZPj8Wt8Pcx+3oqrE=
 github.com/Azure/go-autorest/logger v0.2.0/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
 github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk=
+github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo=
 github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
 github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
@@ -161,6 +169,7 @@ github.com/aphistic/golf v0.0.0-20180712155816-02c07f170c5a/go.mod h1:3NqKYiepwy
 github.com/aphistic/sweet v0.2.0/go.mod h1:fWDlIh/isSE9n6EPsRmC0det+whmX6dJid3stzu0Xys=
 github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
 github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
+github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da h1:8GUt8eRujhVEGZFFEjBj46YV4rDjvGrNxb0KMWYkL2I=
 github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
 github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
 github.com/aryann/difflib v0.0.0-20170710044230-e206f873d14a/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
@@ -317,6 +326,7 @@ github.com/denverdino/aliyungo v0.0.0-20180316152028-2581e433b270/go.mod h1:dV8l
 github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8lFg6daOBZbT6/BDGIz6Y3WFGn8juu6G+CQ6LHtl0=
 github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY=
 github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
+github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
 github.com/digitalocean/godo v1.19.0/go.mod h1:AAPQ+tiM4st79QHlEBTg8LM7JQNre4SAQCbn56wEyKY=
@@ -389,6 +399,7 @@ github.com/fluxcd/flux/pkg/install v0.0.0-20201001122558-cb08da1b356a/go.mod h1:
 github.com/fluxcd/go-git-providers v0.0.3/go.mod h1:iaXf3nEq8MB/LzxfbNcCl48sAtIReUU7jqjJ7CEnfFQ=
 github.com/fluxcd/helm-operator/pkg/install v0.0.0-20200729150005-1467489f7ee4/go.mod h1:ijsiZLK3c4Qu4sFqHu5pJdwjmMEjvKpwivq3uAdffBk=
 github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
+github.com/form3tech-oss/jwt-go v3.2.2+incompatible h1:TcekIExNqud5crz4xD2pavyTgWiPvpYe4Xau31I0PRk=
 github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k=
 github.com/fortytw2/leaktest v1.2.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
 github.com/fortytw2/leaktest v1.3.0/go.mod h1:jDsjWgpAGjm2CA7WthBh/CdZYEPF31XHquHwclZch5g=
@@ -649,6 +660,7 @@ github.com/googleapis/gnostic v0.2.2/go.mod h1:sJBsCZ4ayReDTBIg8b9dl28c5xFWyhBTV
 github.com/gookit/color v1.2.4/go.mod h1:AhIE+pS6D4Ql0SQWbBeXPHw7gY0/sjHoA4s/n1KB7xg=
 github.com/gophercloud/gophercloud v0.0.0-20180807015416-4ea085781bae/go.mod h1:3WdhXV3rUYy9p6AUW8d94kr+HS62Y4VL9mBnFxsD8q4=
 github.com/gophercloud/gophercloud v0.0.0-20190216224116-dcc6e84aef1b/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8=
+github.com/gophercloud/gophercloud v0.1.0 h1:P/nh25+rzXouhytV2pUHBb65fnds26Ghl8/391+sT5o=
 github.com/gophercloud/gophercloud v0.1.0/go.mod h1:vxM41WHh5uqHVBMZHzuwNOHh8XEoIEcSTewFxm1c5g8=
 github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
 github.com/goreleaser/goreleaser v0.136.0/go.mod h1:wiKrPUeSNh6Wu8nUHxZydSOVQ/OZvOaO7DTtFqie904=
@@ -684,20 +696,24 @@ github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t
 github.com/grpc-ecosystem/grpc-gateway v1.9.2/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
 github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
 github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
+github.com/hashicorp/consul/api v1.3.0 h1:HXNYlRkkM/t+Y/Yhxtwcy02dlYwIaoxzvxPnS+cqy78=
 github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE=
 github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
 github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
 github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/errwrap v0.0.0-20180715044906-d6c0cd880357/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-cleanhttp v0.5.1 h1:dH3aiDG9Jvb5r5+bYHsikaOUIpcM0xvgMXVoDkXMzJM=
 github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
 github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
+github.com/hashicorp/go-immutable-radix v1.0.0 h1:AKDB1HM5PWEA7i4nhcpwOrO2byshxBjXVn/J/3+z5/0=
 github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
 github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
 github.com/hashicorp/go-multierror v0.0.0-20161216184304-ed905158d874/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I=
 github.com/hashicorp/go-multierror v0.0.0-20180717150148-3d5d8f294aa0/go.mod h1:JMRHfdO9jKNzS/+BTlxCjKNQHg/jZAft8U7LloJvN7I=
 github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
 github.com/hashicorp/go-retryablehttp v0.6.4/go.mod h1:vAew36LZh98gCBJNLH42IQ1ER/9wtLZZ8meHqQvEYWY=
+github.com/hashicorp/go-rootcerts v1.0.0 h1:Rqb66Oo1X/eSV1x66xbDccZjhJigjg0+e82kpwzSwCI=
 github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
 github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
 github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
@@ -718,6 +734,7 @@ github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T
 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=
 github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
+github.com/hashicorp/serf v0.8.2 h1:YZ7UKsJv+hKjqGVUUbtE3HNj79Eln2oQ75tniF6iPt0=
 github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
 github.com/heketi/heketi v9.0.1-0.20190917153846-c2e2a4ab7ab9+incompatible/go.mod h1:bB9ly3RchcQqsQ9CpyaQwvva7RS5ytVoSoholZQON6o=
 github.com/heketi/tests v0.0.0-20151005000721-f3775cbcefd6/go.mod h1:xGMAM8JLi7UkZt1i4FQeQy0R2T8GLUwQhOP5M1gBhy4=
@@ -734,6 +751,7 @@ github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJ
 github.com/imdario/mergo v0.3.11 h1:3tnifQM4i+fbajXKBHXWEH+KvNHqojZ778UH75j3bGA=
 github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
 github.com/inconshreveable/log15 v0.0.0-20200109203555-b30bc20e4fd1/go.mod h1:cOaXtrgN4ScfRrD9Bre7U1thNq5RtJ8ZoP4iXVGRj6o=
+github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
 github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo=
 github.com/instrumenta/kubeval v0.0.0-20190918223246-8d013ec9fc56/go.mod h1:bpiMYvNpVxWjdJsS0hDRu9TrobT5GfWCZwJseGUstxE=
@@ -961,6 +979,7 @@ github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceT
 github.com/mitchellh/copystructure v1.0.0 h1:Laisrj+bAB6b/yJwB5Bt3ITZhGJdqmxquMKeZ+mmkFQ=
 github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
 github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
+github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
 github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
 github.com/mitchellh/go-ps v0.0.0-20170309133038-4fdf99ab2936/go.mod h1:r1VsdOzOPt1ZSrGZWFoNhsAedKnEd6r9Np1+5blZCWk=
 github.com/mitchellh/go-ps v0.0.0-20190716172923-621e5597135b/go.mod h1:r1VsdOzOPt1ZSrGZWFoNhsAedKnEd6r9Np1+5blZCWk=

+ 1 - 0
internal/config/config.go

@@ -25,6 +25,7 @@ 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"`
+	IsLocal        bool          `env:"IS_LOCAL,default=false"`
 
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`

+ 0 - 327
internal/forms/action.go

@@ -1,327 +0,0 @@
-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 len(authInfo.Token) > 0 {
-		sar.SA.Token = []byte(authInfo.Token)
-	}
-
-	if len(authInfo.Username) > 0 {
-		sar.SA.Username = []byte(authInfo.Username)
-	}
-
-	if len(authInfo.Password) > 0 {
-		sar.SA.Password = []byte(authInfo.Password)
-	}
-
-	if authInfo.AuthProvider != nil && authInfo.AuthProvider.Name == "oidc" {
-		if url, ok := authInfo.AuthProvider.Config["idp-issuer-url"]; ok {
-			sar.SA.OIDCIssuerURL = []byte(url)
-		}
-
-		if clientID, ok := authInfo.AuthProvider.Config["client-id"]; ok {
-			sar.SA.OIDCClientID = []byte(clientID)
-		}
-
-		if clientSecret, ok := authInfo.AuthProvider.Config["client-secret"]; ok {
-			sar.SA.OIDCClientSecret = []byte(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 = []byte(caData)
-		}
-
-		if idToken, ok := authInfo.AuthProvider.Config["id-token"]; ok {
-			sar.SA.OIDCIDToken = []byte(idToken)
-		}
-
-		if refreshToken, ok := authInfo.AuthProvider.Config["refresh-token"]; ok {
-			sar.SA.OIDCRefreshToken = []byte(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 = []byte(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 = []byte(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.GCPKeyData = []byte(gkda.GCPKeyData)
-
-	return nil
-}
-
-// AWSDataAction contains the AWS data (access id, key)
-type AWSDataAction struct {
-	*ServiceAccountActionResolver
-	AWSAccessKeyID     string `json:"aws_access_key_id" form:"required"`
-	AWSSecretAccessKey string `json:"aws_secret_access_key" form:"required"`
-	AWSClusterID       string `json:"aws_cluster_id" form:"required"`
-}
-
-// PopulateServiceAccount will add GCP key data to a ServiceAccount
-func (akda *AWSDataAction) PopulateServiceAccount(
-	repo repository.ServiceAccountRepository,
-) error {
-	err := akda.ServiceAccountActionResolver.PopulateServiceAccount(repo)
-
-	if err != nil {
-		return err
-	}
-
-	akda.ServiceAccountActionResolver.SA.AWSAccessKeyID = []byte(akda.AWSAccessKeyID)
-	akda.ServiceAccountActionResolver.SA.AWSSecretAccessKey = []byte(akda.AWSSecretAccessKey)
-	akda.ServiceAccountActionResolver.SA.AWSClusterID = []byte(akda.AWSClusterID)
-
-	return nil
-}

+ 0 - 688
internal/forms/action_test.go

@@ -1,688 +0,0 @@
-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 string(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.GCPKeyData) != string(gcpKeyData) {
-		t.Errorf("service account token data is wrong: expected %s, got %s\n",
-			string(sa.GCPKeyData), string(gcpKeyData))
-	}
-}
-
-func TestPopulateServiceAccountAWSKeyDataAction(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(AWSEKSGetTokenExec))
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	for _, saCandidate := range saCandidates {
-		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
-	}
-
-	// create a new form
-	form := forms.AWSDataAction{
-		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
-			ServiceAccountCandidateID: 1,
-		},
-		AWSAccessKeyID:     "ALSDKJFADSF",
-		AWSSecretAccessKey: "ASDLFKJALSDKFJ",
-		AWSClusterID:       "cluster-test",
-	}
-
-	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.AWSAccessKeyID) != "ALSDKJFADSF" {
-		t.Errorf("service account aws access key id is wrong: expected %s, got %s\n",
-			"ALSDKJFADSF", sa.AWSAccessKeyID)
-	}
-
-	if string(sa.AWSSecretAccessKey) != "ASDLFKJALSDKFJ" {
-		t.Errorf("service account aws access secret key is wrong: expected %s, got %s\n",
-			"ASDLFKJALSDKFJ", sa.AWSSecretAccessKey)
-	}
-
-	if string(sa.AWSClusterID) != "cluster-test" {
-		t.Errorf("service account aws cluster id is wrong: expected %s, got %s\n",
-			"cluster-test", sa.AWSClusterID)
-	}
-}
-
-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
-`

+ 20 - 8
internal/forms/candidate.go

@@ -5,24 +5,36 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 )
 
-// CreateServiceAccountCandidatesForm represents the accepted values for
-// creating a list of ServiceAccountCandidates from a kubeconfig
-type CreateServiceAccountCandidatesForm struct {
+// CreateClusterCandidatesForm represents the accepted values for
+// creating a list of ClusterCandidates from a kubeconfig
+type CreateClusterCandidatesForm struct {
 	ProjectID  uint   `json:"project_id"`
 	Kubeconfig string `json:"kubeconfig"`
+
+	// Represents whether the auth mechanism should be designated as
+	// "local": if so, the auth mechanism uses local plugins/mechanisms purely from the
+	// kubeconfig.
+	IsLocal bool `json:"is_local"`
 }
 
-// ToServiceAccountCandidates creates a ServiceAccountCandidate from the kubeconfig and
+// ToClusterCandidates creates a ClusterCandidate from the kubeconfig and
 // project id
-func (csa *CreateServiceAccountCandidatesForm) ToServiceAccountCandidates() ([]*models.ServiceAccountCandidate, error) {
-	candidates, err := kubernetes.GetServiceAccountCandidates([]byte(csa.Kubeconfig))
+func (csa *CreateClusterCandidatesForm) ToClusterCandidates(
+	isServerLocal bool,
+) ([]*models.ClusterCandidate, error) {
+	candidates, err := kubernetes.GetClusterCandidatesFromKubeconfig(
+		[]byte(csa.Kubeconfig),
+		csa.ProjectID,
+		// can only use "local" auth mechanism if the server is running locally
+		isServerLocal && csa.IsLocal,
+	)
 
 	if err != nil {
 		return nil, err
 	}
 
-	for _, saCandidate := range candidates {
-		saCandidate.ProjectID = csa.ProjectID
+	for _, cc := range candidates {
+		cc.ProjectID = csa.ProjectID
 	}
 
 	return candidates, nil

+ 495 - 0
internal/forms/cluster.go

@@ -0,0 +1,495 @@
+package forms
+
+import (
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"net/url"
+	"regexp"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"k8s.io/client-go/tools/clientcmd/api"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// CreateClusterForm represents the accepted values for creating a
+// cluster through manual configuration (not through a kubeconfig)
+type CreateClusterForm struct {
+	Name      string `json:"name" form:"required"`
+	ProjectID uint   `json:"project_id" form:"required"`
+	Server    string `json:"server" form:"required"`
+
+	GCPIntegrationID uint `json:"gcp_integration_id"`
+	AWSIntegrationID uint `json:"aws_integration_id"`
+
+	CertificateAuthorityData string `json:"certificate_authority_data,omitempty"`
+}
+
+// ToCluster converts the form to a cluster
+func (ccf *CreateClusterForm) ToCluster() (*models.Cluster, error) {
+	var authMechanism models.ClusterAuth
+
+	if ccf.GCPIntegrationID != 0 {
+		authMechanism = models.GCP
+	} else if ccf.AWSIntegrationID != 0 {
+		authMechanism = models.AWS
+	} else {
+		return nil, fmt.Errorf("must include aws or gcp integration id")
+	}
+
+	cert := make([]byte, 0)
+
+	if ccf.CertificateAuthorityData != "" {
+		// determine if data is base64 decoded using regex
+		re := regexp.MustCompile(`^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$`)
+
+		// if it matches the base64 regex, decode it
+		if re.MatchString(ccf.CertificateAuthorityData) {
+			decoded, err := base64.StdEncoding.DecodeString(ccf.CertificateAuthorityData)
+
+			if err != nil {
+				return nil, err
+			}
+
+			cert = []byte(decoded)
+		}
+	}
+
+	return &models.Cluster{
+		AuthMechanism:            authMechanism,
+		Name:                     ccf.Name,
+		Server:                   ccf.Server,
+		GCPIntegrationID:         ccf.GCPIntegrationID,
+		AWSIntegrationID:         ccf.AWSIntegrationID,
+		CertificateAuthorityData: cert,
+	}, nil
+}
+
+// ResolveClusterForm will resolve a cluster candidate and create a new cluster
+type ResolveClusterForm struct {
+	Resolver *models.ClusterResolverAll `form:"required"`
+
+	ClusterCandidateID uint `json:"cluster_candidate_id" form:"required"`
+	ProjectID          uint `json:"project_id" form:"required"`
+	UserID             uint `json:"user_id" form:"required"`
+
+	// populated during the ResolveIntegration step
+	IntegrationID    uint
+	ClusterCandidate *models.ClusterCandidate
+	RawConf          *api.Config
+}
+
+// ResolveIntegration creates an integration in the DB
+func (rcf *ResolveClusterForm) ResolveIntegration(
+	repo repository.Repository,
+) error {
+	cc, err := repo.Cluster.ReadClusterCandidate(rcf.ClusterCandidateID)
+
+	if err != nil {
+		return err
+	}
+
+	rcf.ClusterCandidate = cc
+
+	rawConf, err := kubernetes.GetRawConfigFromBytes(cc.Kubeconfig)
+
+	if err != nil {
+		return err
+	}
+
+	rcf.RawConf = rawConf
+
+	context := rawConf.Contexts[rawConf.CurrentContext]
+
+	authInfoName := context.AuthInfo
+	authInfo := rawConf.AuthInfos[authInfoName]
+
+	// iterate through the resolvers, and use the ClusterResolverAll to populate
+	// the required fields
+	var id uint
+
+	switch cc.AuthMechanism {
+	case models.X509:
+		id, err = rcf.resolveX509(repo, authInfo)
+	case models.Bearer:
+		id, err = rcf.resolveToken(repo, authInfo)
+	case models.Basic:
+		id, err = rcf.resolveBasic(repo, authInfo)
+	case models.Local:
+		id, err = rcf.resolveLocal(repo, authInfo)
+	case models.OIDC:
+		id, err = rcf.resolveOIDC(repo, authInfo)
+	case models.GCP:
+		id, err = rcf.resolveGCP(repo, authInfo)
+	case models.AWS:
+		id, err = rcf.resolveAWS(repo, authInfo)
+	}
+
+	if err != nil {
+		return err
+	}
+
+	rcf.IntegrationID = id
+
+	return nil
+}
+
+func (rcf *ResolveClusterForm) resolveX509(
+	repo repository.Repository,
+	authInfo *api.AuthInfo,
+) (uint, error) {
+	ki := &ints.KubeIntegration{
+		Mechanism: ints.KubeX509,
+		UserID:    rcf.UserID,
+		ProjectID: rcf.ProjectID,
+	}
+
+	// attempt to construct cert and key from raw config
+	if len(authInfo.ClientCertificateData) > 0 {
+		ki.ClientCertificateData = authInfo.ClientCertificateData
+	}
+
+	if len(authInfo.ClientKeyData) > 0 {
+		ki.ClientKeyData = authInfo.ClientKeyData
+	}
+
+	// override with resolver
+	if rcf.Resolver.ClientCertData != "" {
+		decoded, err := base64.StdEncoding.DecodeString(rcf.Resolver.ClientCertData)
+
+		if err != nil {
+			return 0, err
+		}
+
+		ki.ClientCertificateData = decoded
+	}
+
+	if rcf.Resolver.ClientKeyData != "" {
+		decoded, err := base64.StdEncoding.DecodeString(rcf.Resolver.ClientKeyData)
+
+		if err != nil {
+			return 0, err
+		}
+
+		ki.ClientKeyData = decoded
+	}
+
+	// if resolvable, write kube integration to repo
+	if len(ki.ClientCertificateData) == 0 || len(ki.ClientKeyData) == 0 {
+		return 0, errors.New("could not resolve kube integration (x509)")
+	}
+
+	// return integration id if exists
+	ki, err := repo.KubeIntegration.CreateKubeIntegration(ki)
+
+	if err != nil {
+		return 0, err
+	}
+
+	return ki.Model.ID, nil
+}
+
+func (rcf *ResolveClusterForm) resolveToken(
+	repo repository.Repository,
+	authInfo *api.AuthInfo,
+) (uint, error) {
+	ki := &ints.KubeIntegration{
+		Mechanism: ints.KubeBearer,
+		UserID:    rcf.UserID,
+		ProjectID: rcf.ProjectID,
+	}
+
+	// attempt to construct token from raw config
+	if len(authInfo.Token) > 0 {
+		ki.Token = []byte(authInfo.Token)
+	}
+
+	// supplement with resolver
+	if rcf.Resolver.TokenData != "" {
+		ki.Token = []byte(rcf.Resolver.TokenData)
+	}
+
+	// if resolvable, write kube integration to repo
+	if len(ki.Token) == 0 {
+		return 0, errors.New("could not resolve kube integration (token)")
+	}
+
+	// return integration id if exists
+	ki, err := repo.KubeIntegration.CreateKubeIntegration(ki)
+
+	if err != nil {
+		return 0, err
+	}
+
+	return ki.Model.ID, nil
+}
+
+func (rcf *ResolveClusterForm) resolveBasic(
+	repo repository.Repository,
+	authInfo *api.AuthInfo,
+) (uint, error) {
+	ki := &ints.KubeIntegration{
+		Mechanism: ints.KubeBasic,
+		UserID:    rcf.UserID,
+		ProjectID: rcf.ProjectID,
+	}
+
+	if len(authInfo.Username) > 0 {
+		ki.Username = []byte(authInfo.Username)
+	}
+
+	if len(authInfo.Password) > 0 {
+		ki.Password = []byte(authInfo.Password)
+	}
+
+	if len(ki.Username) == 0 || len(ki.Password) == 0 {
+		return 0, errors.New("could not resolve kube integration (basic)")
+	}
+
+	// return integration id if exists
+	ki, err := repo.KubeIntegration.CreateKubeIntegration(ki)
+
+	if err != nil {
+		return 0, err
+	}
+
+	return ki.Model.ID, nil
+}
+
+func (rcf *ResolveClusterForm) resolveLocal(
+	repo repository.Repository,
+	authInfo *api.AuthInfo,
+) (uint, error) {
+	ki := &ints.KubeIntegration{
+		Mechanism:  ints.KubeLocal,
+		UserID:     rcf.UserID,
+		ProjectID:  rcf.ProjectID,
+		Kubeconfig: rcf.ClusterCandidate.Kubeconfig,
+	}
+
+	// return integration id if exists
+	ki, err := repo.KubeIntegration.CreateKubeIntegration(ki)
+
+	if err != nil {
+		return 0, err
+	}
+
+	return ki.Model.ID, nil
+}
+
+func (rcf *ResolveClusterForm) resolveOIDC(
+	repo repository.Repository,
+	authInfo *api.AuthInfo,
+) (uint, error) {
+	oidc := &ints.OIDCIntegration{
+		Client:    ints.OIDCKube,
+		UserID:    rcf.UserID,
+		ProjectID: rcf.ProjectID,
+	}
+
+	if url, ok := authInfo.AuthProvider.Config["idp-issuer-url"]; ok {
+		oidc.IssuerURL = []byte(url)
+	}
+
+	if clientID, ok := authInfo.AuthProvider.Config["client-id"]; ok {
+		oidc.ClientID = []byte(clientID)
+	}
+
+	if clientSecret, ok := authInfo.AuthProvider.Config["client-secret"]; ok {
+		oidc.ClientSecret = []byte(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
+		oidc.CertificateAuthorityData = []byte(caData)
+	}
+
+	if idToken, ok := authInfo.AuthProvider.Config["id-token"]; ok {
+		oidc.IDToken = []byte(idToken)
+	}
+
+	if refreshToken, ok := authInfo.AuthProvider.Config["refresh-token"]; ok {
+		oidc.RefreshToken = []byte(refreshToken)
+	}
+
+	// override with resolver
+	if rcf.Resolver.OIDCIssuerCAData != "" {
+		// 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
+		oidc.CertificateAuthorityData = []byte(rcf.Resolver.OIDCIssuerCAData)
+	}
+
+	// return integration id if exists
+	oidc, err := repo.OIDCIntegration.CreateOIDCIntegration(oidc)
+
+	if err != nil {
+		return 0, err
+	}
+
+	return oidc.Model.ID, nil
+}
+
+func (rcf *ResolveClusterForm) resolveGCP(
+	repo repository.Repository,
+	authInfo *api.AuthInfo,
+) (uint, error) {
+	// TODO -- add GCP project ID and GCP email so that source is trackable
+	gcp := &ints.GCPIntegration{
+		UserID:    rcf.UserID,
+		ProjectID: rcf.ProjectID,
+	}
+
+	// supplement with resolver
+	if rcf.Resolver.GCPKeyData != "" {
+		gcp.GCPKeyData = []byte(rcf.Resolver.GCPKeyData)
+	}
+
+	// throw error if no data
+	if len(gcp.GCPKeyData) == 0 {
+		return 0, errors.New("could not resolve gcp integration")
+	}
+
+	// return integration id if exists
+	gcp, err := repo.GCPIntegration.CreateGCPIntegration(gcp)
+
+	if err != nil {
+		return 0, err
+	}
+
+	return gcp.Model.ID, nil
+}
+
+func (rcf *ResolveClusterForm) resolveAWS(
+	repo repository.Repository,
+	authInfo *api.AuthInfo,
+) (uint, error) {
+	// TODO -- add AWS session token as an optional param
+	// TODO -- add AWS entity and user ARN
+	aws := &ints.AWSIntegration{
+		UserID:    rcf.UserID,
+		ProjectID: rcf.ProjectID,
+	}
+
+	// override with resolver
+	if rcf.Resolver.AWSClusterID != "" {
+		aws.AWSClusterID = []byte(rcf.Resolver.AWSClusterID)
+	}
+
+	if rcf.Resolver.AWSAccessKeyID != "" {
+		aws.AWSAccessKeyID = []byte(rcf.Resolver.AWSAccessKeyID)
+	}
+
+	if rcf.Resolver.AWSSecretAccessKey != "" {
+		aws.AWSSecretAccessKey = []byte(rcf.Resolver.AWSSecretAccessKey)
+	}
+
+	// throw error if no data
+	if len(aws.AWSClusterID) == 0 || len(aws.AWSAccessKeyID) == 0 || len(aws.AWSSecretAccessKey) == 0 {
+		return 0, errors.New("could not resolve aws integration")
+	}
+
+	// return integration id if exists
+	aws, err := repo.AWSIntegration.CreateAWSIntegration(aws)
+
+	if err != nil {
+		return 0, err
+	}
+
+	return aws.Model.ID, nil
+}
+
+// ResolveCluster writes a new cluster to the DB -- this must be called after
+// rcf.ResolveIntegration, since it relies on the previously created integration.
+func (rcf *ResolveClusterForm) ResolveCluster(
+	repo repository.Repository,
+) (*models.Cluster, error) {
+	// build a cluster from the candidate
+	cluster, err := rcf.buildCluster()
+
+	if err != nil {
+		return nil, err
+	}
+
+	// save cluster to db
+	return repo.Cluster.CreateCluster(cluster)
+}
+
+func (rcf *ResolveClusterForm) buildCluster() (*models.Cluster, error) {
+	rawConf := rcf.RawConf
+
+	kcContext := rawConf.Contexts[rawConf.CurrentContext]
+
+	kcAuthInfoName := kcContext.AuthInfo
+	kcAuthInfo := rawConf.AuthInfos[kcAuthInfoName]
+
+	kcClusterName := kcContext.Cluster
+	kcCluster := rawConf.Clusters[kcClusterName]
+
+	cc := rcf.ClusterCandidate
+
+	cluster := &models.Cluster{
+		AuthMechanism:           cc.AuthMechanism,
+		ProjectID:               cc.ProjectID,
+		Name:                    cc.Name,
+		Server:                  cc.Server,
+		ClusterLocationOfOrigin: kcCluster.LocationOfOrigin,
+		TLSServerName:           kcCluster.TLSServerName,
+		InsecureSkipTLSVerify:   kcCluster.InsecureSkipTLSVerify,
+		UserLocationOfOrigin:    kcAuthInfo.LocationOfOrigin,
+		UserImpersonate:         kcAuthInfo.Impersonate,
+	}
+
+	if len(kcAuthInfo.ImpersonateGroups) > 0 {
+		cluster.UserImpersonateGroups = strings.Join(kcAuthInfo.ImpersonateGroups, ",")
+	}
+
+	if len(kcCluster.CertificateAuthorityData) > 0 {
+		cluster.CertificateAuthorityData = kcCluster.CertificateAuthorityData
+	}
+
+	if rcf.Resolver.ClusterCAData != "" {
+		decoded, err := base64.StdEncoding.DecodeString(rcf.Resolver.ClusterCAData)
+
+		// skip if decoding error
+		if err != nil {
+			return nil, err
+		}
+
+		cluster.CertificateAuthorityData = decoded
+	}
+
+	if rcf.Resolver.ClusterHostname != "" {
+		serverURL, err := url.Parse(cluster.Server)
+		if err != nil {
+			return nil, err
+		}
+
+		if serverURL.Port() == "" {
+			serverURL.Host = rcf.Resolver.ClusterHostname
+		} else {
+			serverURL.Host = rcf.Resolver.ClusterHostname + ":" + serverURL.Port()
+		}
+
+		cluster.Server = serverURL.String()
+	}
+
+	switch cc.AuthMechanism {
+	case models.X509, models.Bearer, models.Basic, models.Local:
+		cluster.KubeIntegrationID = rcf.IntegrationID
+	case models.OIDC:
+		cluster.OIDCIntegrationID = rcf.IntegrationID
+	case models.GCP:
+		cluster.GCPIntegrationID = rcf.IntegrationID
+	case models.AWS:
+		cluster.AWSIntegrationID = rcf.IntegrationID
+	}
+
+	return cluster, nil
+}

+ 1053 - 0
internal/forms/cluster_test.go

@@ -0,0 +1,1053 @@
+package forms_test
+
+import (
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/kubernetes/fixtures"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+	"k8s.io/client-go/tools/clientcmd"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type clusterTest struct {
+	name    string
+	raw     string
+	isLocal bool
+
+	resolver       *models.ClusterResolverAll
+	expIntegration interface{}
+	expCluster     *models.Cluster
+}
+
+var ClusterTests = []clusterTest{
+	clusterTest{
+		name:     "local test should preserve kubeconfig",
+		raw:      fixtures.ClusterCAWithData,
+		isLocal:  true,
+		resolver: &models.ClusterResolverAll{},
+		expIntegration: &ints.KubeIntegration{
+			Mechanism:  ints.KubeLocal,
+			UserID:     1,
+			ProjectID:  1,
+			Kubeconfig: []byte(fixtures.ClusterCAWithData),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:            models.Local,
+			ProjectID:                1,
+			Name:                     "cluster-test",
+			Server:                   "https://10.10.10.10",
+			KubeIntegrationID:        1,
+			CertificateAuthorityData: []byte("-----BEGIN CER"),
+		},
+	},
+	clusterTest{
+		name:     "cluster with data",
+		raw:      fixtures.ClusterCAWithData,
+		isLocal:  false,
+		resolver: &models.ClusterResolverAll{},
+		expIntegration: &ints.KubeIntegration{
+			Mechanism:             ints.KubeX509,
+			UserID:                1,
+			ProjectID:             1,
+			ClientCertificateData: []byte("-----BEGIN CER"),
+			ClientKeyData:         []byte("-----BEGIN CER"),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:            models.X509,
+			ProjectID:                1,
+			Name:                     "cluster-test",
+			Server:                   "https://10.10.10.10",
+			KubeIntegrationID:        2,
+			CertificateAuthorityData: []byte("-----BEGIN CER"),
+		},
+	},
+	clusterTest{
+		name:    "cluster without data",
+		raw:     fixtures.ClusterCAWithoutData,
+		isLocal: false,
+		resolver: &models.ClusterResolverAll{
+			ClusterCAData: "LS0tLS1CRUdJTiBDRVJ=",
+		},
+		expIntegration: &ints.KubeIntegration{
+			Mechanism:             ints.KubeX509,
+			UserID:                1,
+			ProjectID:             1,
+			ClientCertificateData: []byte("-----BEGIN CER"),
+			ClientKeyData:         []byte("-----BEGIN CER"),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:            models.X509,
+			ProjectID:                1,
+			Name:                     "cluster-test",
+			Server:                   "https://10.10.10.10",
+			KubeIntegrationID:        3,
+			CertificateAuthorityData: []byte("-----BEGIN CER"),
+		},
+	},
+	clusterTest{
+		name:    "cluster localhost",
+		raw:     fixtures.ClusterLocalhost,
+		isLocal: false,
+		resolver: &models.ClusterResolverAll{
+			ClusterHostname: "example.com",
+		},
+		expIntegration: &ints.KubeIntegration{
+			Mechanism:             ints.KubeX509,
+			UserID:                1,
+			ProjectID:             1,
+			ClientCertificateData: []byte("-----BEGIN CER"),
+			ClientKeyData:         []byte("-----BEGIN CER"),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:     models.X509,
+			ProjectID:         1,
+			Name:              "cluster-test",
+			Server:            "https://example.com:30000",
+			KubeIntegrationID: 4,
+		},
+	},
+	clusterTest{
+		name:     "x509 cert and key data",
+		raw:      fixtures.X509WithData,
+		isLocal:  false,
+		resolver: &models.ClusterResolverAll{},
+		expIntegration: &ints.KubeIntegration{
+			Mechanism:             ints.KubeX509,
+			UserID:                1,
+			ProjectID:             1,
+			ClientCertificateData: []byte("-----BEGIN CER"),
+			ClientKeyData:         []byte("-----BEGIN CER"),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:     models.X509,
+			ProjectID:         1,
+			Name:              "cluster-test",
+			Server:            "https://10.10.10.10",
+			KubeIntegrationID: 5,
+		},
+	},
+	clusterTest{
+		name:    "x509 no cert data",
+		raw:     fixtures.X509WithoutCertData,
+		isLocal: false,
+		resolver: &models.ClusterResolverAll{
+			ClientCertData: "LS0tLS1CRUdJTiBDRVJ=",
+		},
+		expIntegration: &ints.KubeIntegration{
+			Mechanism:             ints.KubeX509,
+			UserID:                1,
+			ProjectID:             1,
+			ClientCertificateData: []byte("-----BEGIN CER"),
+			ClientKeyData:         []byte("-----BEGIN CER"),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:     models.X509,
+			ProjectID:         1,
+			Name:              "cluster-test",
+			Server:            "https://10.10.10.10",
+			KubeIntegrationID: 6,
+		},
+	},
+	clusterTest{
+		name:    "x509 no key data",
+		raw:     fixtures.X509WithoutKeyData,
+		isLocal: false,
+		resolver: &models.ClusterResolverAll{
+			ClientKeyData: "LS0tLS1CRUdJTiBDRVJ=",
+		},
+		expIntegration: &ints.KubeIntegration{
+			Mechanism:             ints.KubeX509,
+			UserID:                1,
+			ProjectID:             1,
+			ClientCertificateData: []byte("-----BEGIN CER"),
+			ClientKeyData:         []byte("-----BEGIN CER"),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:     models.X509,
+			ProjectID:         1,
+			Name:              "cluster-test",
+			Server:            "https://10.10.10.10",
+			KubeIntegrationID: 7,
+		},
+	},
+	clusterTest{
+		name:    "x509 no cert and key data",
+		raw:     fixtures.X509WithoutCertAndKeyData,
+		isLocal: false,
+		resolver: &models.ClusterResolverAll{
+			ClientCertData: "LS0tLS1CRUdJTiBDRVJ=",
+			ClientKeyData:  "LS0tLS1CRUdJTiBDRVJ=",
+		},
+		expIntegration: &ints.KubeIntegration{
+			Mechanism:             ints.KubeX509,
+			UserID:                1,
+			ProjectID:             1,
+			ClientCertificateData: []byte("-----BEGIN CER"),
+			ClientKeyData:         []byte("-----BEGIN CER"),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:     models.X509,
+			ProjectID:         1,
+			Name:              "cluster-test",
+			Server:            "https://10.10.10.10",
+			KubeIntegrationID: 8,
+		},
+	},
+	clusterTest{
+		name:     "bearer token with data",
+		raw:      fixtures.BearerTokenWithData,
+		isLocal:  false,
+		resolver: &models.ClusterResolverAll{},
+		expIntegration: &ints.KubeIntegration{
+			Mechanism: ints.KubeBearer,
+			UserID:    1,
+			ProjectID: 1,
+			Token:     []byte("LS0tLS1CRUdJTiBDRVJ="),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:     models.Bearer,
+			ProjectID:         1,
+			Name:              "cluster-test",
+			Server:            "https://10.10.10.10",
+			KubeIntegrationID: 9,
+		},
+	},
+	clusterTest{
+		name:    "bearer token without data",
+		raw:     fixtures.BearerTokenWithoutData,
+		isLocal: false,
+		resolver: &models.ClusterResolverAll{
+			TokenData: "tokentoken",
+		},
+		expIntegration: &ints.KubeIntegration{
+			Mechanism: ints.KubeBearer,
+			UserID:    1,
+			ProjectID: 1,
+			Token:     []byte("tokentoken"),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:     models.Bearer,
+			ProjectID:         1,
+			Name:              "cluster-test",
+			Server:            "https://10.10.10.10",
+			KubeIntegrationID: 10,
+		},
+	},
+	clusterTest{
+		name:     "basic auth",
+		raw:      fixtures.BasicAuth,
+		isLocal:  false,
+		resolver: &models.ClusterResolverAll{},
+		expIntegration: &ints.KubeIntegration{
+			Mechanism: ints.KubeBasic,
+			UserID:    1,
+			ProjectID: 1,
+			Username:  []byte("admin"),
+			Password:  []byte("changeme"),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:            models.Basic,
+			ProjectID:                1,
+			Name:                     "cluster-test",
+			Server:                   "https://10.10.10.10",
+			KubeIntegrationID:        11,
+			CertificateAuthorityData: []byte("-----BEGIN CER"),
+		},
+	},
+	clusterTest{
+		name:    "gcp plugin",
+		raw:     fixtures.GCPPlugin,
+		isLocal: false,
+		resolver: &models.ClusterResolverAll{
+			GCPKeyData: `{"key":"data"}`,
+		},
+		expIntegration: &ints.GCPIntegration{
+			UserID:     1,
+			ProjectID:  1,
+			GCPKeyData: []byte(`{"key":"data"}`),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:            models.GCP,
+			ProjectID:                1,
+			Name:                     "cluster-test",
+			Server:                   "https://10.10.10.10",
+			GCPIntegrationID:         1,
+			CertificateAuthorityData: []byte("-----BEGIN CER"),
+		},
+	},
+	clusterTest{
+		name:    "aws iam authenticator",
+		raw:     fixtures.AWSIamAuthenticatorExec,
+		isLocal: false,
+		resolver: &models.ClusterResolverAll{
+			AWSAccessKeyID:     "accesskey",
+			AWSClusterID:       "cluster-test-aws-id-guess",
+			AWSSecretAccessKey: "secret",
+		},
+		expIntegration: &ints.AWSIntegration{
+			UserID:             1,
+			ProjectID:          1,
+			AWSAccessKeyID:     []byte("accesskey"),
+			AWSClusterID:       []byte("cluster-test-aws-id-guess"),
+			AWSSecretAccessKey: []byte("secret"),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:            models.AWS,
+			ProjectID:                1,
+			Name:                     "cluster-test",
+			Server:                   "https://10.10.10.10",
+			AWSIntegrationID:         1,
+			CertificateAuthorityData: []byte("-----BEGIN CER"),
+		},
+	},
+	clusterTest{
+		name:    "aws eks get token",
+		raw:     fixtures.AWSEKSGetTokenExec,
+		isLocal: false,
+		resolver: &models.ClusterResolverAll{
+			AWSAccessKeyID:     "accesskey",
+			AWSClusterID:       "cluster-test-aws-id-guess",
+			AWSSecretAccessKey: "secret",
+		},
+		expIntegration: &ints.AWSIntegration{
+			UserID:             1,
+			ProjectID:          1,
+			AWSAccessKeyID:     []byte("accesskey"),
+			AWSClusterID:       []byte("cluster-test-aws-id-guess"),
+			AWSSecretAccessKey: []byte("secret"),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:            models.AWS,
+			ProjectID:                1,
+			Name:                     "cluster-test",
+			Server:                   "https://10.10.10.10",
+			AWSIntegrationID:         2,
+			CertificateAuthorityData: []byte("-----BEGIN CER"),
+		},
+	},
+	clusterTest{
+		name:    "oidc without idp issuer data",
+		raw:     fixtures.OIDCAuthWithoutData,
+		isLocal: false,
+		resolver: &models.ClusterResolverAll{
+			OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
+		},
+		expIntegration: &ints.OIDCIntegration{
+			Client:                   ints.OIDCKube,
+			UserID:                   1,
+			ProjectID:                1,
+			IssuerURL:                []byte("https://10.10.10.10"),
+			ClientID:                 []byte("porter-api"),
+			CertificateAuthorityData: []byte("LS0tLS1CRUdJTiBDRVJ="),
+			IDToken:                  []byte("token"),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:            models.OIDC,
+			ProjectID:                1,
+			Name:                     "cluster-test",
+			Server:                   "https://10.10.10.10",
+			OIDCIntegrationID:        1,
+			CertificateAuthorityData: []byte("-----BEGIN CER"),
+		},
+	},
+	clusterTest{
+		name:     "oidc with idp issuer data",
+		raw:      fixtures.OIDCAuthWithData,
+		isLocal:  false,
+		resolver: &models.ClusterResolverAll{},
+		expIntegration: &ints.OIDCIntegration{
+			Client:                   ints.OIDCKube,
+			UserID:                   1,
+			ProjectID:                1,
+			IssuerURL:                []byte("https://10.10.10.10"),
+			ClientID:                 []byte("porter-api"),
+			CertificateAuthorityData: []byte("LS0tLS1CRUdJTiBDRVJ="),
+			IDToken:                  []byte("token"),
+		},
+		expCluster: &models.Cluster{
+			AuthMechanism:            models.OIDC,
+			ProjectID:                1,
+			Name:                     "cluster-test",
+			Server:                   "https://10.10.10.10",
+			OIDCIntegrationID:        2,
+			CertificateAuthorityData: []byte("-----BEGIN CER"),
+		},
+	},
+}
+
+func TestClusters(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./cluster_test.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	for _, c := range ClusterTests {
+		// create cluster candidate
+		ccForm := &forms.CreateClusterCandidatesForm{
+			ProjectID:  tester.initProjects[0].ID,
+			Kubeconfig: c.raw,
+			IsLocal:    c.isLocal,
+		}
+
+		ccs, err := ccForm.ToClusterCandidates(c.isLocal)
+
+		if err != nil {
+			t.Fatalf("%v\n", err)
+		}
+
+		var cc *models.ClusterCandidate
+
+		for _, _cc := range ccs {
+			cc, err = tester.repo.Cluster.CreateClusterCandidate(_cc)
+
+			if err != nil {
+				t.Fatalf("%v\n", err)
+			}
+
+			cc, err = tester.repo.Cluster.ReadClusterCandidate(cc.ID)
+
+			if err != nil {
+				t.Fatalf("%v\n", err)
+			}
+		}
+
+		form := &forms.ResolveClusterForm{
+			Resolver:           c.resolver,
+			ClusterCandidateID: cc.ID,
+			ProjectID:          tester.initProjects[0].ID,
+			UserID:             tester.initUsers[0].ID,
+		}
+
+		// resolve integration (should be kube with local)
+		err = form.ResolveIntegration(*tester.repo)
+
+		if err != nil {
+			t.Fatalf("%v\n", err)
+		}
+
+		switch c.expIntegration.(type) {
+		case *ints.KubeIntegration:
+			// make sure integration is equal, read integration from DB
+			gotIntegration, err := tester.repo.KubeIntegration.ReadKubeIntegration(form.IntegrationID)
+
+			if err != nil {
+				t.Fatalf("%v\n", err)
+			}
+
+			// reset got integration model
+			gotIntegration.Model = gorm.Model{}
+
+			ki, _ := c.expIntegration.(*ints.KubeIntegration)
+
+			// if kubeconfig, compare
+			if len(ki.Kubeconfig) > 0 {
+				compareKubeconfig(t, gotIntegration.Kubeconfig, ki.Kubeconfig)
+
+				// reset kubeconfig fields for deep.Equal
+				gotIntegration.Kubeconfig = []byte{}
+				ki.Kubeconfig = []byte{}
+			}
+
+			if diff := deep.Equal(ki, gotIntegration); diff != nil {
+				t.Errorf("incorrect kube integration")
+				t.Error(diff)
+			}
+		case *ints.OIDCIntegration:
+			// make sure integration is equal, read integration from DB
+			gotIntegration, err := tester.repo.OIDCIntegration.ReadOIDCIntegration(form.IntegrationID)
+
+			if err != nil {
+				t.Fatalf("%v\n", err)
+			}
+
+			// reset got integration model
+			gotIntegration.Model = gorm.Model{}
+
+			oidc, _ := c.expIntegration.(*ints.OIDCIntegration)
+
+			if diff := deep.Equal(oidc, gotIntegration); diff != nil {
+				t.Errorf("incorrect oidc integration")
+				t.Error(diff)
+			}
+		case *ints.GCPIntegration:
+			// make sure integration is equal, read integration from DB
+			gotIntegration, err := tester.repo.GCPIntegration.ReadGCPIntegration(form.IntegrationID)
+
+			if err != nil {
+				t.Fatalf("%v\n", err)
+			}
+
+			// reset got integration model
+			gotIntegration.Model = gorm.Model{}
+
+			gcp, _ := c.expIntegration.(*ints.GCPIntegration)
+
+			if diff := deep.Equal(gcp, gotIntegration); diff != nil {
+				t.Errorf("incorrect gcp integration")
+				t.Error(diff)
+			}
+		case *ints.AWSIntegration:
+			// make sure integration is equal, read integration from DB
+			gotIntegration, err := tester.repo.AWSIntegration.ReadAWSIntegration(form.IntegrationID)
+
+			if err != nil {
+				t.Fatalf("%v\n", err)
+			}
+
+			// reset got integration model
+			gotIntegration.Model = gorm.Model{}
+
+			aws, _ := c.expIntegration.(*ints.AWSIntegration)
+
+			if diff := deep.Equal(aws, gotIntegration); diff != nil {
+				t.Errorf("incorrect aws integration")
+				t.Error(diff)
+			}
+		}
+
+		// resolve cluster
+		gotCluster, err := form.ResolveCluster(*tester.repo)
+
+		if err != nil {
+			t.Fatalf("%v\n", err)
+		}
+
+		gotCluster.Model = gorm.Model{}
+
+		if diff := deep.Equal(c.expCluster, gotCluster); diff != nil {
+			t.Errorf("incorrect cluster")
+			t.Error(diff)
+		}
+	}
+}
+
+func compareKubeconfig(t *testing.T, resKube []byte, expKube []byte) {
+	// compare kubeconfig by transforming into a client config
+	resConfig, _ := clientcmd.NewClientConfigFromBytes(resKube)
+	expConfig, err := clientcmd.NewClientConfigFromBytes(expKube)
+
+	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 diff := deep.Equal(expRawConf, resRawConf); diff != nil {
+		t.Errorf("incorrect kubeconfigs")
+		t.Error(diff)
+	}
+}
+
+// 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), false)
+
+// 	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.Integration != "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 TestPopulateServiceAccountClusterLocalhostAction(t *testing.T) {
+// 	// create the in-memory repository
+// 	repo := test.NewRepository(true)
+
+// 	// create a new project
+// 	repo.Project.CreateProject(&models.Project{
+// 		Name: "test-project",
+// 	})
+
+// 	// create a ServiceAccountCandidate from a kubeconfig
+// 	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClusterLocalhost), false)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	for _, saCandidate := range saCandidates {
+// 		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+// 	}
+
+// 	// create a new form
+// 	form := forms.ClusterLocalhostAction{
+// 		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+// 			ServiceAccountCandidateID: 1,
+// 		},
+// 		ClusterHostname: "host.docker.internal",
+// 	}
+
+// 	err = form.PopulateServiceAccount(repo.ServiceAccount)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	sa, err := repo.ServiceAccount.CreateServiceAccount(form.ServiceAccountActionResolver.SA)
+// 	decodedStr, _ := base64.StdEncoding.DecodeString("LS0tLS1CRUdJTiBDRVJ=")
+
+// 	if len(sa.Clusters) != 1 {
+// 		t.Fatalf("cluster not written\n")
+// 	}
+
+// 	if sa.Clusters[0].ServiceAccountID != 1 {
+// 		t.Errorf("service account ID of joined cluster is not 1")
+// 	}
+
+// 	if sa.Clusters[0].Server != "https://host.docker.internal:30000" {
+// 		t.Errorf("service account cluster server is incorrect: expected %s, got %s\n",
+// 			"https://host.docker.internal:30000", sa.Clusters[0].Server)
+// 	}
+
+// 	if sa.Integration != "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), false)
+
+// 	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.Integration != "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), false)
+
+// 	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.Integration != "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), false)
+
+// 	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.Integration != models.Bearer {
+// 		t.Errorf("service account auth mechanism is not %s\n", models.Bearer)
+// 	}
+
+// 	if string(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), false)
+
+// 	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.Integration != models.GCP {
+// 		t.Errorf("service account auth mechanism is not %s\n", models.GCP)
+// 	}
+
+// 	if string(sa.GCPKeyData) != string(gcpKeyData) {
+// 		t.Errorf("service account token data is wrong: expected %s, got %s\n",
+// 			string(sa.GCPKeyData), string(gcpKeyData))
+// 	}
+// }
+
+// func TestPopulateServiceAccountAWSKeyDataAction(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(AWSEKSGetTokenExec), false)
+
+// 	if err != nil {
+// 		t.Fatalf("%v\n", err)
+// 	}
+
+// 	for _, saCandidate := range saCandidates {
+// 		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+// 	}
+
+// 	// create a new form
+// 	form := forms.AWSDataAction{
+// 		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
+// 			ServiceAccountCandidateID: 1,
+// 		},
+// 		AWSAccessKeyID:     "ALSDKJFADSF",
+// 		AWSSecretAccessKey: "ASDLFKJALSDKFJ",
+// 		AWSClusterID:       "cluster-test",
+// 	}
+
+// 	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.Integration != models.AWS {
+// 		t.Errorf("service account auth mechanism is not %s\n", models.AWS)
+// 	}
+
+// 	if string(sa.AWSAccessKeyID) != "ALSDKJFADSF" {
+// 		t.Errorf("service account aws access key id is wrong: expected %s, got %s\n",
+// 			"ALSDKJFADSF", sa.AWSAccessKeyID)
+// 	}
+
+// 	if string(sa.AWSSecretAccessKey) != "ASDLFKJALSDKFJ" {
+// 		t.Errorf("service account aws access secret key is wrong: expected %s, got %s\n",
+// 			"ASDLFKJALSDKFJ", sa.AWSSecretAccessKey)
+// 	}
+
+// 	if string(sa.AWSClusterID) != "cluster-test" {
+// 		t.Errorf("service account aws cluster id is wrong: expected %s, got %s\n",
+// 			"cluster-test", sa.AWSClusterID)
+// 	}
+// }
+
+// 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), false)
+
+// 	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.Integration != 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=")
+// 	}
+// }

+ 358 - 0
internal/forms/helper_test.go

@@ -0,0 +1,358 @@
+package forms_test
+
+import (
+	"os"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/repository/gorm"
+)
+
+type tester struct {
+	repo         *repository.Repository
+	key          *[32]byte
+	dbFileName   string
+	initUsers    []*models.User
+	initProjects []*models.Project
+	initGRs      []*models.GitRepo
+	initClusters []*models.Cluster
+	initCCs      []*models.ClusterCandidate
+	initKIs      []*ints.KubeIntegration
+	initOIDCs    []*ints.OIDCIntegration
+	initOAuths   []*ints.OAuthIntegration
+	initGCPs     []*ints.GCPIntegration
+	initAWSs     []*ints.AWSIntegration
+}
+
+func setupTestEnv(tester *tester, t *testing.T) {
+	t.Helper()
+
+	db, err := adapter.New(&config.DBConf{
+		EncryptionKey: "__random_strong_encryption_key__",
+		SQLLite:       true,
+		SQLLitePath:   tester.dbFileName,
+	})
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	err = db.AutoMigrate(
+		&models.Project{},
+		&models.Role{},
+		&models.User{},
+		&models.Session{},
+		&models.GitRepo{},
+		&models.Cluster{},
+		&models.ClusterCandidate{},
+		&models.ClusterResolver{},
+		&ints.KubeIntegration{},
+		&ints.OIDCIntegration{},
+		&ints.OAuthIntegration{},
+		&ints.GCPIntegration{},
+		&ints.AWSIntegration{},
+		&ints.TokenCache{},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	var key [32]byte
+
+	for i, b := range []byte("__random_strong_encryption_key__") {
+		key[i] = b
+	}
+
+	tester.key = &key
+
+	tester.repo = gorm.NewRepository(db, &key)
+}
+
+func cleanup(tester *tester, t *testing.T) {
+	t.Helper()
+
+	// remove the created file file
+	os.Remove(tester.dbFileName)
+}
+
+func initUser(tester *tester, t *testing.T) {
+	t.Helper()
+
+	user := &models.User{
+		Email:    "example@example.com",
+		Password: "hello1234",
+	}
+
+	user, err := tester.repo.User.CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initUsers = append(tester.initUsers, user)
+}
+
+func initProject(tester *tester, t *testing.T) {
+	t.Helper()
+
+	proj := &models.Project{
+		Name: "project-test",
+	}
+
+	proj, err := tester.repo.Project.CreateProject(proj)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initProjects = append(tester.initProjects, proj)
+}
+
+func initProjectRole(tester *tester, t *testing.T) {
+	t.Helper()
+
+	role := &models.Role{
+		Kind:      models.RoleAdmin,
+		UserID:    tester.initUsers[0].Model.ID,
+		ProjectID: tester.initProjects[0].Model.ID,
+	}
+
+	role, err := tester.repo.Project.CreateProjectRole(tester.initProjects[0], role)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+}
+
+func initKubeIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	ki := &ints.KubeIntegration{
+		Mechanism:  ints.KubeLocal,
+		ProjectID:  tester.initProjects[0].ID,
+		UserID:     tester.initUsers[0].ID,
+		Kubeconfig: []byte("current-context: testing\n"),
+	}
+
+	ki, err := tester.repo.KubeIntegration.CreateKubeIntegration(ki)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initKIs = append(tester.initKIs, ki)
+}
+
+func initOIDCIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	oidc := &ints.OIDCIntegration{
+		Client:       ints.OIDCKube,
+		ProjectID:    tester.initProjects[0].ID,
+		UserID:       tester.initUsers[0].ID,
+		IssuerURL:    []byte("https://oidc.example.com"),
+		ClientID:     []byte("exampleclientid"),
+		ClientSecret: []byte("exampleclientsecret"),
+		IDToken:      []byte("idtoken"),
+		RefreshToken: []byte("refreshtoken"),
+	}
+
+	oidc, err := tester.repo.OIDCIntegration.CreateOIDCIntegration(oidc)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initOIDCs = append(tester.initOIDCs, oidc)
+}
+
+func initOAuthIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	oauth := &ints.OAuthIntegration{
+		Client:       ints.OAuthGithub,
+		ProjectID:    tester.initProjects[0].ID,
+		UserID:       tester.initUsers[0].ID,
+		ClientID:     []byte("exampleclientid"),
+		AccessToken:  []byte("idtoken"),
+		RefreshToken: []byte("refreshtoken"),
+	}
+
+	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initOAuths = append(tester.initOAuths, oauth)
+}
+
+func initGCPIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	gcp := &ints.GCPIntegration{
+		ProjectID:    tester.initProjects[0].ID,
+		UserID:       tester.initUsers[0].ID,
+		GCPProjectID: "test-proj-123456",
+		GCPUserEmail: "test@test.it",
+		GCPKeyData:   []byte("{\"test\":\"key\"}"),
+	}
+
+	gcp, err := tester.repo.GCPIntegration.CreateGCPIntegration(gcp)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initGCPs = append(tester.initGCPs, gcp)
+}
+
+func initAWSIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	aws := &ints.AWSIntegration{
+		ProjectID:          tester.initProjects[0].ID,
+		UserID:             tester.initUsers[0].ID,
+		AWSEntityID:        "entity",
+		AWSCallerID:        "caller",
+		AWSClusterID:       []byte("example-cluster-0"),
+		AWSAccessKeyID:     []byte("accesskey"),
+		AWSSecretAccessKey: []byte("secret"),
+		AWSSessionToken:    []byte("optional"),
+	}
+
+	aws, err := tester.repo.AWSIntegration.CreateAWSIntegration(aws)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initAWSs = append(tester.initAWSs, aws)
+}
+
+func initClusterCandidate(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	cc := &models.ClusterCandidate{
+		AuthMechanism:     models.AWS,
+		ProjectID:         tester.initProjects[0].ID,
+		CreatedClusterID:  0,
+		Resolvers:         []models.ClusterResolver{},
+		Name:              "cluster-test",
+		Server:            "https://localhost",
+		ContextName:       "context-test",
+		AWSClusterIDGuess: []byte("example-cluster-0"),
+		Kubeconfig:        []byte("current-context: testing\n"),
+	}
+
+	cc, err := tester.repo.Cluster.CreateClusterCandidate(cc)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initCCs = append(tester.initCCs, cc)
+}
+
+func initCluster(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initKIs) == 0 {
+		initKubeIntegration(tester, t)
+	}
+
+	cluster := &models.Cluster{
+		ProjectID:                tester.initProjects[0].ID,
+		Name:                     "cluster-test",
+		Server:                   "https://localhost",
+		KubeIntegrationID:        tester.initKIs[0].ID,
+		CertificateAuthorityData: []byte("-----BEGIN"),
+	}
+
+	cluster, err := tester.repo.Cluster.CreateCluster(cluster)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initClusters = append(tester.initClusters, cluster)
+}
+
+func initGitRepo(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initOAuths) == 0 {
+		initOAuthIntegration(tester, t)
+	}
+
+	gr := &models.GitRepo{
+		ProjectID:          tester.initProjects[0].ID,
+		RepoEntity:         "porter-dev",
+		OAuthIntegrationID: tester.initOAuths[0].ID,
+	}
+
+	gr, err := tester.repo.GitRepo.CreateGitRepo(gr)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initGRs = append(tester.initGRs, gr)
+}

+ 45 - 0
internal/forms/integration.go

@@ -0,0 +1,45 @@
+package forms
+
+import (
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// CreateGCPIntegrationForm represents the accepted values for creating a
+// GCP Integration
+type CreateGCPIntegrationForm struct {
+	UserID     uint   `json:"user_id" form:"required"`
+	ProjectID  uint   `json:"project_id" form:"required"`
+	GCPKeyData string `json:"gcp_key_data" form:"required"`
+}
+
+// ToGCPIntegration converts the project to a gorm project model
+func (cgf *CreateGCPIntegrationForm) ToGCPIntegration() (*ints.GCPIntegration, error) {
+	return &ints.GCPIntegration{
+		UserID:     cgf.UserID,
+		ProjectID:  cgf.ProjectID,
+		GCPKeyData: []byte(cgf.GCPKeyData),
+	}, nil
+}
+
+// CreateAWSIntegrationForm represents the accepted values for creating an
+// AWS Integration
+type CreateAWSIntegrationForm struct {
+	UserID             uint   `json:"user_id" form:"required"`
+	ProjectID          uint   `json:"project_id" form:"required"`
+	AWSRegion          string `json:"aws_region"`
+	AWSClusterID       string `json:"aws_cluster_id"`
+	AWSAccessKeyID     string `json:"aws_access_key_id"`
+	AWSSecretAccessKey string `json:"aws_secret_access_key"`
+}
+
+// ToAWSIntegration converts the project to a gorm project model
+func (caf *CreateAWSIntegrationForm) ToAWSIntegration() (*ints.AWSIntegration, error) {
+	return &ints.AWSIntegration{
+		UserID:             caf.UserID,
+		ProjectID:          caf.ProjectID,
+		AWSRegion:          caf.AWSRegion,
+		AWSClusterID:       []byte(caf.AWSClusterID),
+		AWSAccessKeyID:     []byte(caf.AWSAccessKeyID),
+		AWSSecretAccessKey: []byte(caf.AWSSecretAccessKey),
+	}, nil
+}

+ 3 - 13
internal/forms/k8s.go

@@ -17,7 +17,7 @@ type K8sForm struct {
 // url.Values (the parsed query params)
 func (kf *K8sForm) PopulateK8sOptionsFromQueryParams(
 	vals url.Values,
-	repo repository.ServiceAccountRepository,
+	repo repository.ClusterRepository,
 ) error {
 	if clusterID, ok := vals["cluster_id"]; ok && len(clusterID) == 1 {
 		id, err := strconv.ParseUint(clusterID[0], 10, 64)
@@ -26,23 +26,13 @@ func (kf *K8sForm) PopulateK8sOptionsFromQueryParams(
 			return err
 		}
 
-		kf.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))
+		cluster, err := repo.ReadCluster(uint(id))
 
 		if err != nil {
 			return err
 		}
 
-		kf.ServiceAccount = sa
+		kf.Cluster = cluster
 	}
 
 	return nil

+ 24 - 0
internal/forms/registry.go

@@ -0,0 +1,24 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// CreateRegistry represents the accepted values for creating a
+// registry
+type CreateRegistry struct {
+	Name             string `json:"name" form:"required"`
+	ProjectID        uint   `json:"project_id" form:"required"`
+	GCPIntegrationID uint   `json:"gcp_integration_id"`
+	AWSIntegrationID uint   `json:"aws_integration_id"`
+}
+
+// ToRegistry converts the form to a gorm registry model
+func (cr *CreateRegistry) ToRegistry() (*models.Registry, error) {
+	return &models.Registry{
+		Name:             cr.Name,
+		ProjectID:        cr.ProjectID,
+		GCPIntegrationID: cr.GCPIntegrationID,
+		AWSIntegrationID: cr.AWSIntegrationID,
+	}, nil
+}

+ 4 - 14
internal/forms/release.go

@@ -17,7 +17,7 @@ type ReleaseForm struct {
 // url.Values (the parsed query params)
 func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(
 	vals url.Values,
-	repo repository.ServiceAccountRepository,
+	repo repository.ClusterRepository,
 ) error {
 	if clusterID, ok := vals["cluster_id"]; ok && len(clusterID) == 1 {
 		id, err := strconv.ParseUint(clusterID[0], 10, 64)
@@ -26,23 +26,13 @@ func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(
 			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))
+		cluster, err := repo.ReadCluster(uint(id))
 
 		if err != nil {
 			return err
 		}
 
-		rf.ServiceAccount = sa
+		rf.Cluster = cluster
 	}
 
 	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
@@ -66,7 +56,7 @@ type ListReleaseForm struct {
 // url.Values (the parsed query params)
 func (lrf *ListReleaseForm) PopulateListFromQueryParams(
 	vals url.Values,
-	_ repository.ServiceAccountRepository,
+	_ repository.ClusterRepository,
 ) error {
 	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
 		lrf.ListFilter.Namespace = namespace[0]

+ 7 - 8
internal/helm/config.go

@@ -7,6 +7,7 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chartutil"
 	"helm.sh/helm/v3/pkg/kube"
@@ -18,11 +19,10 @@ import (
 // Form represents the options for connecting to a cluster and
 // creating a Helm agent
 type Form struct {
-	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"`
-	UpdateTokenCache kubernetes.UpdateTokenCacheFunc
+	Cluster   *models.Cluster `form:"required"`
+	Repo      *repository.Repository
+	Storage   string `json:"storage" form:"oneof=secret configmap memory"`
+	Namespace string `json:"namespace"`
 }
 
 // GetAgentOutOfClusterConfig creates a new Agent from outside the cluster using
@@ -30,9 +30,8 @@ type Form struct {
 func GetAgentOutOfClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 	// create a kubernetes agent
 	conf := &kubernetes.OutOfClusterConfig{
-		ServiceAccount:   form.ServiceAccount,
-		ClusterID:        form.ClusterID,
-		UpdateTokenCache: form.UpdateTokenCache,
+		Cluster: form.Cluster,
+		Repo:    form.Repo,
 	}
 
 	k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(conf)

+ 191 - 14
internal/kubernetes/config.go

@@ -1,12 +1,14 @@
 package kubernetes
 
 import (
+	"errors"
 	"path/filepath"
 	"regexp"
 	"strings"
 	"time"
 
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
 	"k8s.io/apimachinery/pkg/api/meta"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
@@ -17,10 +19,13 @@ import (
 	"k8s.io/client-go/rest"
 	"k8s.io/client-go/restmapper"
 	"k8s.io/client-go/tools/clientcmd"
+	"k8s.io/client-go/tools/clientcmd/api"
 	"k8s.io/client-go/util/homedir"
 
-	// add oidc provider here
-	_ "k8s.io/client-go/plugin/pkg/client/auth/oidc"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+
+	// this line will register plugins
+	_ "k8s.io/client-go/plugin/pkg/client/auth"
 )
 
 // GetAgentOutOfClusterConfig creates a new Agent using the OutOfClusterConfig
@@ -60,16 +65,11 @@ func GetAgentTesting(objects ...runtime.Object) *Agent {
 	return &Agent{&fakeRESTClientGetter{}, fake.NewSimpleClientset(objects...)}
 }
 
-// UpdateTokenCacheFunc is a function that updates the token cache
-// with a new token and expiry time
-type UpdateTokenCacheFunc func(token string, expiry time.Time) error
-
 // OutOfClusterConfig is the set of parameters required for an out-of-cluster connection.
 // This implements RESTClientGetter
 type OutOfClusterConfig struct {
-	ServiceAccount   *models.ServiceAccount `form:"required"`
-	ClusterID        uint                   `json:"cluster_id" form:"required"`
-	UpdateTokenCache UpdateTokenCacheFunc
+	Cluster *models.Cluster
+	Repo    *repository.Repository
 }
 
 // ToRESTConfig creates a kubernetes REST client factory -- it calls ClientConfig on
@@ -89,11 +89,7 @@ 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, _ := GetClientConfigFromServiceAccount(
-		conf.ServiceAccount,
-		conf.ClusterID,
-		conf.UpdateTokenCache,
-	)
+	cmdConf, _ := conf.GetClientConfigFromCluster()
 
 	return cmdConf
 }
@@ -135,6 +131,187 @@ func (conf *OutOfClusterConfig) ToRESTMapper() (meta.RESTMapper, error) {
 	return expander, nil
 }
 
+// GetClientConfigFromCluster will construct new clientcmd.ClientConfig using
+// the configuration saved within a Cluster model
+func (conf *OutOfClusterConfig) GetClientConfigFromCluster() (clientcmd.ClientConfig, error) {
+	cluster := conf.Cluster
+
+	if cluster.AuthMechanism == models.Local {
+		kubeAuth, err := conf.Repo.KubeIntegration.ReadKubeIntegration(
+			cluster.KubeIntegrationID,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		return clientcmd.NewClientConfigFromBytes(kubeAuth.Kubeconfig)
+	}
+
+	apiConfig, err := conf.createRawConfigFromCluster()
+
+	if err != nil {
+		return nil, err
+	}
+
+	config := clientcmd.NewDefaultClientConfig(*apiConfig, &clientcmd.ConfigOverrides{})
+
+	return config, nil
+}
+
+func (conf *OutOfClusterConfig) createRawConfigFromCluster() (*api.Config, error) {
+	cluster := conf.Cluster
+
+	apiConfig := &api.Config{}
+
+	clusterMap := make(map[string]*api.Cluster)
+
+	clusterMap[cluster.Name] = &api.Cluster{
+		Server:                   cluster.Server,
+		LocationOfOrigin:         cluster.ClusterLocationOfOrigin,
+		TLSServerName:            cluster.TLSServerName,
+		InsecureSkipTLSVerify:    cluster.InsecureSkipTLSVerify,
+		CertificateAuthorityData: cluster.CertificateAuthorityData,
+	}
+
+	// construct the auth infos
+	authInfoName := cluster.Name + "-" + string(cluster.AuthMechanism)
+
+	authInfoMap := make(map[string]*api.AuthInfo)
+
+	authInfoMap[authInfoName] = &api.AuthInfo{
+		LocationOfOrigin: cluster.UserLocationOfOrigin,
+		Impersonate:      cluster.UserImpersonate,
+	}
+
+	if groups := strings.Split(cluster.UserImpersonateGroups, ","); len(groups) > 0 && groups[0] != "" {
+		authInfoMap[authInfoName].ImpersonateGroups = groups
+	}
+
+	switch cluster.AuthMechanism {
+	case models.X509:
+		kubeAuth, err := conf.Repo.KubeIntegration.ReadKubeIntegration(
+			cluster.KubeIntegrationID,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		authInfoMap[authInfoName].ClientCertificateData = kubeAuth.ClientCertificateData
+		authInfoMap[authInfoName].ClientKeyData = kubeAuth.ClientKeyData
+	case models.Basic:
+		kubeAuth, err := conf.Repo.KubeIntegration.ReadKubeIntegration(
+			cluster.KubeIntegrationID,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		authInfoMap[authInfoName].Username = string(kubeAuth.Username)
+		authInfoMap[authInfoName].Password = string(kubeAuth.Password)
+	case models.Bearer:
+		kubeAuth, err := conf.Repo.KubeIntegration.ReadKubeIntegration(
+			cluster.KubeIntegrationID,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		authInfoMap[authInfoName].Token = string(kubeAuth.Token)
+	case models.OIDC:
+		oidcAuth, err := conf.Repo.OIDCIntegration.ReadOIDCIntegration(
+			cluster.OIDCIntegrationID,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		authInfoMap[authInfoName].AuthProvider = &api.AuthProviderConfig{
+			Name: "oidc",
+			Config: map[string]string{
+				"idp-issuer-url":                 string(oidcAuth.IssuerURL),
+				"client-id":                      string(oidcAuth.ClientID),
+				"client-secret":                  string(oidcAuth.ClientSecret),
+				"idp-certificate-authority-data": string(oidcAuth.CertificateAuthorityData),
+				"id-token":                       string(oidcAuth.IDToken),
+				"refresh-token":                  string(oidcAuth.RefreshToken),
+			},
+		}
+	case models.GCP:
+		gcpAuth, err := conf.Repo.GCPIntegration.ReadGCPIntegration(
+			cluster.GCPIntegrationID,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		tok, err := gcpAuth.GetBearerToken(conf.getTokenCache, conf.setTokenCache)
+
+		if err != nil {
+			return nil, err
+		}
+
+		// add this as a bearer token
+		authInfoMap[authInfoName].Token = tok
+	case models.AWS:
+		awsAuth, err := conf.Repo.AWSIntegration.ReadAWSIntegration(
+			cluster.AWSIntegrationID,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		tok, err := awsAuth.GetBearerToken(conf.getTokenCache, conf.setTokenCache)
+
+		if err != nil {
+			return nil, err
+		}
+
+		// add this as a bearer token
+		authInfoMap[authInfoName].Token = tok
+	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.ClusterLocationOfOrigin,
+		Cluster:          cluster.Name,
+		AuthInfo:         authInfoName,
+	}
+
+	apiConfig.Clusters = clusterMap
+	apiConfig.AuthInfos = authInfoMap
+	apiConfig.Contexts = contextMap
+	apiConfig.CurrentContext = cluster.Name
+
+	return apiConfig, nil
+}
+
+func (conf *OutOfClusterConfig) getTokenCache() (tok *ints.TokenCache, err error) {
+	return &conf.Cluster.TokenCache, nil
+}
+
+func (conf *OutOfClusterConfig) setTokenCache(token string, expiry time.Time) error {
+	_, err := conf.Repo.Cluster.UpdateClusterTokenCache(
+		&ints.TokenCache{
+			ClusterID: conf.Cluster.ID,
+			Token:     []byte(token),
+			Expiry:    expiry,
+		},
+	)
+
+	return err
+}
+
 // newRESTClientGetterFromInClusterConfig returns a RESTClientGetter using
 // default values set from the *rest.Config
 func newRESTClientGetterFromInClusterConfig(conf *rest.Config) genericclioptions.RESTClientGetter {

+ 344 - 0
internal/kubernetes/fixtures/kubeconfig.go

@@ -0,0 +1,344 @@
+package fixtures
+
+// This file contains test fixtures to test various packages.
+// These are not meant to be workable kubeconfigs, but rather
+// are meant to test parsers and auth mechanism detection.
+// As a result, certificates are simply base-64 encoded versions
+// of "-----BEGIN CER", and all paths + key data are fake.
+
+const ClusterCAWithData string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://10.10.10.10
+    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://10.10.10.10
+    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 ClusterLocalhost string = `
+apiVersion: v1
+kind: Config
+clusters:
+- name: cluster-test
+  cluster:
+    server: https://localhost:30000
+contexts:
+- context:
+    cluster: cluster-test
+    user: test-admin
+  name: context-test
+users:
+- name: test-admin
+  user:
+    client-certificate-data: LS0tLS1CRUdJTiBDRVJ=
+    client-key-data: LS0tLS1CRUdJTiBDRVJ=
+current-context: context-test
+`
+
+const X509WithData string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: context-test
+clusters:
+- cluster:
+    server: https://10.10.10.10
+  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-data: LS0tLS1CRUdJTiBDRVJ=
+`
+
+const X509WithoutCertData string = `
+apiVersion: v1
+kind: Config
+preferences: {}
+current-context: context-test
+clusters:
+- cluster:
+    server: https://10.10.10.10
+  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://10.10.10.10
+  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://10.10.10.10
+  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://10.10.10.10
+  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://10.10.10.10
+  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://10.10.10.10
+    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://10.10.10.10
+    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-aws-id-guess"
+`
+
+const AWSEKSGetTokenExec = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://10.10.10.10
+    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-aws-id-guess"
+`
+
+const OIDCAuthWithoutData = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://10.10.10.10
+    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://10.10.10.10
+        idp-certificate-authority: /fake/path/to/ca.pem
+      name: oidc
+`
+
+const OIDCAuthWithData = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://10.10.10.10
+    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://10.10.10.10
+        idp-certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=
+      name: oidc
+`
+
+const BasicAuth = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://10.10.10.10
+    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
+`

+ 186 - 386
internal/kubernetes/kubeconfig.go

@@ -1,25 +1,26 @@
 package kubernetes
 
 import (
-	"context"
+	"encoding/json"
 	"errors"
-	"strings"
+	"net/url"
 
 	"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"
-
-	"github.com/aws/aws-sdk-go/aws"
-
-	"github.com/aws/aws-sdk-go/aws/credentials"
-	"github.com/aws/aws-sdk-go/aws/session"
-	token "sigs.k8s.io/aws-iam-authenticator/pkg/token"
 )
 
-// GetServiceAccountCandidates parses a kubeconfig for a list of service account
+// GetClusterCandidatesFromKubeconfig parses a kubeconfig for a list of cluster
 // candidates.
-func GetServiceAccountCandidates(kubeconfig []byte) ([]*models.ServiceAccountCandidate, error) {
+//
+// The local boolean represents whether the auth mechanism should be designated as
+// "local": if so, the auth mechanism uses local plugins/mechanisms purely from the
+// kubeconfig.
+func GetClusterCandidatesFromKubeconfig(
+	kubeconfig []byte,
+	projectID uint,
+	local bool,
+) ([]*models.ClusterCandidate, error) {
 	config, err := clientcmd.NewClientConfigFromBytes(kubeconfig)
 
 	if err != nil {
@@ -32,26 +33,29 @@ func GetServiceAccountCandidates(kubeconfig []byte) ([]*models.ServiceAccountCan
 		return nil, err
 	}
 
-	res := make([]*models.ServiceAccountCandidate, 0)
+	res := make([]*models.ClusterCandidate, 0)
 
 	for contextName, context := range rawConf.Contexts {
 		clusterName := context.Cluster
 		awsClusterID := ""
 		authInfoName := context.AuthInfo
 
-		// get the auth mechanism and actions
-		authMechanism, authInfoActions := parseAuthInfoForActions(rawConf.AuthInfos[authInfoName])
-		clusterActions := parseClusterForActions(rawConf.Clusters[clusterName])
-
-		actions := append(authInfoActions, clusterActions...)
+		resolvers := make([]models.ClusterResolver, 0)
+		var authMechanism models.ClusterAuth
 
-		// if auth mechanism is unsupported, we'll skip it
-		if authMechanism == models.NotAvailable {
-			continue
-		} else if authMechanism == models.AWS {
-			// if the auth mechanism is AWS, we need to parse more explicitly
-			// for the cluster id
-			awsClusterID = parseAuthInfoForAWSClusterID(rawConf.AuthInfos[authInfoName], clusterName)
+		if local {
+			authMechanism = models.Local
+		} else {
+			// get the resolvers, if needed
+			authMechanism, resolvers = parseAuthInfoForResolvers(rawConf.AuthInfos[authInfoName])
+			clusterResolvers := parseClusterForResolvers(rawConf.Clusters[clusterName])
+			resolvers = append(resolvers, clusterResolvers...)
+
+			if authMechanism == models.AWS {
+				// if the auth mechanism is AWS, we need to parse more explicitly
+				// for the cluster id
+				awsClusterID = parseAuthInfoForAWSClusterID(rawConf.AuthInfos[authInfoName], clusterName)
+			}
 		}
 
 		// construct the raw kubeconfig that's relevant for that context
@@ -64,15 +68,15 @@ func GetServiceAccountCandidates(kubeconfig []byte) ([]*models.ServiceAccountCan
 		rawBytes, err := clientcmd.Write(*contextConf)
 
 		if err == nil {
-			// create the candidate service account
-			res = append(res, &models.ServiceAccountCandidate{
-				Actions:           actions,
-				Kind:              "connector",
-				ContextName:       contextName,
-				ClusterName:       clusterName,
-				ClusterEndpoint:   rawConf.Clusters[clusterName].Server,
+			// create the candidate cluster
+			res = append(res, &models.ClusterCandidate{
 				AuthMechanism:     authMechanism,
-				AWSClusterIDGuess: awsClusterID,
+				ProjectID:         projectID,
+				Resolvers:         resolvers,
+				ContextName:       contextName,
+				Name:              clusterName,
+				Server:            rawConf.Clusters[clusterName].Server,
+				AWSClusterIDGuess: []byte(awsClusterID),
 				Kubeconfig:        rawBytes,
 			})
 		}
@@ -81,6 +85,80 @@ func GetServiceAccountCandidates(kubeconfig []byte) ([]*models.ServiceAccountCan
 	return res, nil
 }
 
+// GetServiceAccountCandidates parses a kubeconfig for a list of service account
+// candidates.
+//
+// The local boolean represents whether the auth mechanism should be designated as
+// "local": if so, the auth mechanism uses local plugins/mechanisms purely from the
+// kubeconfig.
+// func GetServiceAccountCandidates(kubeconfig []byte, local bool) ([]*models.ServiceAccountCandidate, error) {
+// 	config, err := clientcmd.NewClientConfigFromBytes(kubeconfig)
+
+// 	if err != nil {
+// 		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
+// 		awsClusterID := ""
+// 		authInfoName := context.AuthInfo
+
+// 		resolvers := make([]models.ServiceAccountResolver, 0)
+// 		var integration string
+
+// 		if local {
+// 			integration = models.Local
+// 		} else {
+// 			// get the auth mechanism and resolvers
+// 			integration, resolvers = parseAuthInfoForResolvers(rawConf.AuthInfos[authInfoName])
+// 			clusterResolvers := parseClusterForResolvers(rawConf.Clusters[clusterName])
+// 			resolvers = append(resolvers, clusterResolvers...)
+
+// 			// if auth mechanism is unsupported, we'll skip it
+// 			if integration == models.NotAvailable {
+// 				continue
+// 			} else if integration == models.AWS {
+// 				// if the auth mechanism is AWS, we need to parse more explicitly
+// 				// for the cluster id
+// 				awsClusterID = parseAuthInfoForAWSClusterID(rawConf.AuthInfos[authInfoName], clusterName)
+// 			}
+// 		}
+
+// 		// construct the raw kubeconfig that's relevant for that context
+// 		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{
+// 				Resolvers:           resolvers,
+// 				Kind:              "connector",
+// 				ContextName:       contextName,
+// 				ClusterName:       clusterName,
+// 				ClusterEndpoint:   rawConf.Clusters[clusterName].Server,
+// 				Integration:     integration,
+// 				AWSClusterIDGuess: awsClusterID,
+// 				Kubeconfig:        rawBytes,
+// 			})
+// 		}
+// 	}
+
+// 	return res, nil
+// }
+
 // GetRawConfigFromBytes returns the clientcmdapi.Config from kubeconfig
 // bytes
 func GetRawConfigFromBytes(kubeconfig []byte) (*api.Config, error) {
@@ -107,28 +185,40 @@ func GetRawConfigFromBytes(kubeconfig []byte) (*api.Config, error) {
 // (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)
+func parseAuthInfoForResolvers(authInfo *api.AuthInfo) (authMechanism models.ClusterAuth, resolvers []models.ClusterResolver) {
+	resolvers = make([]models.ClusterResolver, 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,
+			fn := map[string]string{
+				"filename": authInfo.ClientCertificate,
+			}
+
+			fnBytes, _ := json.Marshal(&fn)
+
+			resolvers = append(resolvers, models.ClusterResolver{
+				Name:     models.ClientCertData,
 				Resolved: false,
-				Filename: authInfo.ClientCertificate,
+				Data:     fnBytes,
 			})
 		}
 
 		if len(authInfo.ClientKeyData) == 0 {
-			actions = append(actions, models.ServiceAccountAction{
-				Name:     models.ClientKeyDataAction,
+			fn := map[string]string{
+				"filename": authInfo.ClientKey,
+			}
+
+			fnBytes, _ := json.Marshal(&fn)
+
+			resolvers = append(resolvers, models.ClusterResolver{
+				Name:     models.ClientKeyData,
 				Resolved: false,
-				Filename: authInfo.ClientKey,
+				Data:     fnBytes,
 			})
 		}
 
-		return models.X509, actions
+		return models.X509, resolvers
 	}
 
 	if authInfo.AuthProvider != nil {
@@ -138,20 +228,26 @@ func parseAuthInfoForActions(authInfo *api.AuthInfo) (authMechanism string, acti
 			data, isData := authInfo.AuthProvider.Config["idp-certificate-authority-data"]
 
 			if isFile && (!isData || data == "") {
-				return models.OIDC, []models.ServiceAccountAction{
-					models.ServiceAccountAction{
-						Name:     models.OIDCIssuerDataAction,
+				fn := map[string]string{
+					"filename": filename,
+				}
+
+				fnBytes, _ := json.Marshal(&fn)
+
+				return models.OIDC, []models.ClusterResolver{
+					models.ClusterResolver{
+						Name:     models.OIDCIssuerData,
 						Resolved: false,
-						Filename: filename,
+						Data:     fnBytes,
 					},
 				}
 			}
 
-			return models.OIDC, actions
+			return models.OIDC, resolvers
 		case "gcp":
-			return models.GCP, []models.ServiceAccountAction{
-				models.ServiceAccountAction{
-					Name:     models.GCPKeyDataAction,
+			return models.GCP, []models.ClusterResolver{
+				models.ClusterResolver{
+					Name:     models.GCPKeyData,
 					Resolved: false,
 				},
 			}
@@ -160,9 +256,9 @@ func parseAuthInfoForActions(authInfo *api.AuthInfo) (authMechanism string, acti
 
 	if authInfo.Exec != nil {
 		if authInfo.Exec.Command == "aws" || authInfo.Exec.Command == "aws-iam-authenticator" {
-			return models.AWS, []models.ServiceAccountAction{
-				models.ServiceAccountAction{
-					Name:     models.AWSDataAction,
+			return models.AWS, []models.ClusterResolver{
+				models.ClusterResolver{
+					Name:     models.AWSData,
 					Resolved: false,
 				},
 			}
@@ -171,41 +267,62 @@ func parseAuthInfoForActions(authInfo *api.AuthInfo) (authMechanism string, acti
 
 	if authInfo.Token != "" || authInfo.TokenFile != "" {
 		if authInfo.Token == "" {
-			return models.Bearer, []models.ServiceAccountAction{
-				models.ServiceAccountAction{
-					Name:     models.TokenDataAction,
+			fn := map[string]string{
+				"filename": authInfo.TokenFile,
+			}
+
+			fnBytes, _ := json.Marshal(&fn)
+
+			return models.Bearer, []models.ClusterResolver{
+				models.ClusterResolver{
+					Name:     models.TokenData,
 					Resolved: false,
-					Filename: authInfo.TokenFile,
+					Data:     fnBytes,
 				},
 			}
 		}
 
-		return models.Bearer, actions
+		return models.Bearer, resolvers
 	}
 
 	if authInfo.Username != "" && authInfo.Password != "" {
-		return models.Basic, actions
+		return models.Basic, resolvers
 	}
 
-	return models.NotAvailable, actions
+	return models.X509, resolvers
 }
 
-// Parses the cluster object to determine actions -- only currently supported action is
+// Parses the cluster object to determine resolvers -- only currently supported resolver is
 // population of the cluster certificate authority data
-func parseClusterForActions(cluster *api.Cluster) (actions []models.ServiceAccountAction) {
-	actions = make([]models.ServiceAccountAction, 0)
+func parseClusterForResolvers(cluster *api.Cluster) (resolvers []models.ClusterResolver) {
+	resolvers = make([]models.ClusterResolver, 0)
 
 	if cluster.CertificateAuthority != "" && len(cluster.CertificateAuthorityData) == 0 {
-		return []models.ServiceAccountAction{
-			models.ServiceAccountAction{
-				Name:     models.ClusterCADataAction,
+		fn := map[string]string{
+			"filename": cluster.CertificateAuthority,
+		}
+
+		fnBytes, _ := json.Marshal(&fn)
+
+		resolvers = append(resolvers, models.ClusterResolver{
+			Name:     models.ClusterCAData,
+			Resolved: false,
+			Data:     fnBytes,
+		})
+	}
+
+	serverURL, err := url.Parse(cluster.Server)
+
+	if err == nil {
+		if hostname := serverURL.Hostname(); hostname == "127.0.0.1" || hostname == "localhost" {
+			resolvers = append(resolvers, models.ClusterResolver{
+				Name:     models.ClusterLocalhost,
 				Resolved: false,
-				Filename: cluster.CertificateAuthority,
-			},
+			})
 		}
 	}
 
-	return actions
+	return resolvers
 }
 
 func parseAuthInfoForAWSClusterID(authInfo *api.AuthInfo, fallback string) string {
@@ -230,7 +347,7 @@ func parseAuthInfoForAWSClusterID(authInfo *api.AuthInfo, fallback string) strin
 	return fallback
 }
 
-// getKubeconfigForContext returns the raw kubeconfig associated with only a
+// getConfigForContext returns the raw kubeconfig associated with only a
 // single context of the raw config
 func getConfigForContext(
 	rawConf *api.Config,
@@ -265,323 +382,6 @@ func getConfigForContext(
 	return copyConf, nil
 }
 
-// GetClientConfigFromServiceAccount will construct new clientcmd.ClientConfig using
-// the configuration saved within a ServiceAccount model
-func GetClientConfigFromServiceAccount(
-	sa *models.ServiceAccount,
-	clusterID uint,
-	updateTokenCache UpdateTokenCacheFunc,
-) (clientcmd.ClientConfig, error) {
-	apiConfig, err := createRawConfigFromServiceAccount(sa, clusterID, updateTokenCache)
-
-	if err != nil {
-		return nil, err
-	}
-
-	config := clientcmd.NewDefaultClientConfig(*apiConfig, &clientcmd.ConfigOverrides{})
-
-	return config, nil
-}
-
-func createRawConfigFromServiceAccount(
-	sa *models.ServiceAccount,
-	clusterID uint,
-	updateTokenCache UpdateTokenCacheFunc,
-) (*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 = string(sa.Username)
-		authInfoMap[authInfoName].Password = string(sa.Password)
-	case models.Bearer:
-		authInfoMap[authInfoName].Token = string(sa.Token)
-	case models.OIDC:
-		authInfoMap[authInfoName].AuthProvider = &api.AuthProviderConfig{
-			Name: "oidc",
-			Config: map[string]string{
-				"idp-issuer-url":                 string(sa.OIDCIssuerURL),
-				"client-id":                      string(sa.OIDCClientID),
-				"client-secret":                  string(sa.OIDCClientSecret),
-				"idp-certificate-authority-data": string(sa.OIDCCertificateAuthorityData),
-				"id-token":                       string(sa.OIDCIDToken),
-				"refresh-token":                  string(sa.OIDCRefreshToken),
-			},
-		}
-	case models.GCP:
-		tok, err := getGCPToken(sa, updateTokenCache)
-
-		if err != nil {
-			return nil, err
-		}
-
-		// add this as a bearer token
-		authInfoMap[authInfoName].Token = tok
-	case models.AWS:
-		tok, err := getAWSToken(sa, updateTokenCache)
-
-		if err != nil {
-			return nil, err
-		}
-
-		// add this as a bearer token
-		authInfoMap[authInfoName].Token = tok
-	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
-}
-
-func getGCPToken(
-	sa *models.ServiceAccount,
-	updateTokenCache UpdateTokenCacheFunc,
-) (string, error) {
-	// check the token cache for a non-expired token
-	if tok := sa.TokenCache.Token; !sa.TokenCache.IsExpired() && len(tok) > 0 {
-		return string(tok), nil
-	}
-
-	creds, err := google.CredentialsFromJSON(
-		context.Background(),
-		sa.GCPKeyData,
-		"https://www.googleapis.com/auth/cloud-platform",
-	)
-
-	if err != nil {
-		return "", err
-	}
-
-	tok, err := creds.TokenSource.Token()
-
-	if err != nil {
-		return "", err
-	}
-
-	// update the token cache
-	updateTokenCache(tok.AccessToken, tok.Expiry)
-
-	return tok.AccessToken, nil
-}
-
-func getAWSToken(
-	sa *models.ServiceAccount,
-	updateTokenCache UpdateTokenCacheFunc,
-) (string, error) {
-	// check the token cache for a non-expired token
-	if tok := sa.TokenCache.Token; !sa.TokenCache.IsExpired() && len(tok) > 0 {
-		return string(tok), nil
-	}
-
-	generator, err := token.NewGenerator(false, false)
-
-	if err != nil {
-		return "", err
-	}
-
-	sess, err := session.NewSessionWithOptions(session.Options{
-		SharedConfigState: session.SharedConfigEnable,
-		Config: aws.Config{
-			Credentials: credentials.NewStaticCredentials(
-				string(sa.AWSAccessKeyID),
-				string(sa.AWSSecretAccessKey),
-				"",
-			),
-		},
-	})
-
-	if err != nil {
-		return "", err
-	}
-
-	tok, err := generator.GetWithOptions(&token.GetTokenOptions{
-		Session:   sess,
-		ClusterID: string(sa.AWSClusterID),
-	})
-
-	if err != nil {
-		return "", err
-	}
-
-	updateTokenCache(tok.Token, tok.Expiration)
-
-	return tok.Token, nil
-}
-
-// GetRestrictedClientConfigFromBytes returns a clientcmd.ClientConfig from a raw kubeconfig,
-// a context name, and the set of allowed contexts.
-func GetRestrictedClientConfigFromBytes(
-	bytes []byte,
-	contextName string,
-	allowedContexts []string,
-) (clientcmd.ClientConfig, error) {
-	config, err := clientcmd.NewClientConfigFromBytes(bytes)
-
-	if err != nil {
-		return nil, err
-	}
-
-	rawConf, err := config.RawConfig()
-
-	if err != nil {
-		return nil, err
-	}
-
-	// grab a copy to get the pointer and set clusters, authinfos, and contexts to empty
-	copyConf := rawConf.DeepCopy()
-
-	copyConf.Clusters = make(map[string]*api.Cluster)
-	copyConf.AuthInfos = make(map[string]*api.AuthInfo)
-	copyConf.Contexts = make(map[string]*api.Context)
-	copyConf.CurrentContext = contextName
-
-	// put allowed clusters in a map
-	aContextMap := CreateAllowedContextMap(allowedContexts)
-
-	context, ok := rawConf.Contexts[contextName]
-
-	if ok {
-		userName := context.AuthInfo
-		clusterName := context.Cluster
-		authInfo, userFound := rawConf.AuthInfos[userName]
-		cluster, clusterFound := rawConf.Clusters[clusterName]
-
-		// make sure the cluster is "allowed"
-		_, isAllowed := aContextMap[contextName]
-
-		if userFound && clusterFound && isAllowed {
-			copyConf.Clusters[clusterName] = cluster
-			copyConf.AuthInfos[userName] = authInfo
-			copyConf.Contexts[contextName] = context
-		}
-	}
-
-	// validate the copyConf and create a ClientConfig
-	err = clientcmd.Validate(*copyConf)
-
-	if err != nil {
-		return nil, err
-	}
-
-	clientConf := clientcmd.NewDefaultClientConfig(*copyConf, &clientcmd.ConfigOverrides{})
-
-	return clientConf, nil
-}
-
-// GetContextsFromBytes converts a raw string to a set of Contexts
-// by unmarshaling and calling toContexts
-func GetContextsFromBytes(bytes []byte, allowedContexts []string) ([]models.Context, error) {
-	config, err := clientcmd.NewClientConfigFromBytes(bytes)
-
-	if err != nil {
-		return nil, err
-	}
-
-	rawConf, err := config.RawConfig()
-
-	if err != nil {
-		return nil, err
-	}
-
-	err = clientcmd.Validate(rawConf)
-
-	if err != nil {
-		return nil, err
-	}
-
-	contexts := toContexts(&rawConf, allowedContexts)
-
-	return contexts, nil
-}
-
-func toContexts(rawConf *api.Config, allowedContexts []string) []models.Context {
-	contexts := make([]models.Context, 0)
-
-	// put allowed clusters in map
-	aContextMap := CreateAllowedContextMap(allowedContexts)
-
-	// iterate through contexts and switch on selected
-	for name, context := range rawConf.Contexts {
-		_, isAllowed := aContextMap[name]
-		_, userFound := rawConf.AuthInfos[context.AuthInfo]
-		cluster, clusterFound := rawConf.Clusters[context.Cluster]
-
-		if userFound && clusterFound && isAllowed {
-			contexts = append(contexts, models.Context{
-				Name:     name,
-				Server:   cluster.Server,
-				Cluster:  context.Cluster,
-				User:     context.AuthInfo,
-				Selected: true,
-			})
-		} else if userFound && clusterFound {
-			contexts = append(contexts, models.Context{
-				Name:     name,
-				Server:   cluster.Server,
-				Cluster:  context.Cluster,
-				User:     context.AuthInfo,
-				Selected: false,
-			})
-		}
-	}
-
-	return contexts
-}
-
 // CreateAllowedContextMap creates a dummy map from context name to context name
 func CreateAllowedContextMap(contexts []string) map[string]string {
 	aContextMap := make(map[string]string)

+ 218 - 777
internal/kubernetes/kubeconfig_test.go

@@ -1,472 +1,330 @@
 package kubernetes_test
 
 import (
-	"reflect"
-	"strings"
 	"testing"
 
+	"github.com/go-test/deep"
 	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/kubernetes/fixtures"
 	"github.com/porter-dev/porter/internal/models"
 	"k8s.io/client-go/tools/clientcmd"
 )
 
-type kubeConfigTest struct {
-	msg             string
-	raw             []byte
-	allowedContexts []string
-	expected        []models.Context
-}
-
-type kubeConfigTestValidateError struct {
-	msg             string
-	raw             []byte
-	allowedContexts []string
-	contextName     string
-	errorContains   string // a string that the error message should contain
-}
-
-var ValidateErrorTests = []kubeConfigTestValidateError{
-	kubeConfigTestValidateError{
-		msg:             "No configuration",
-		raw:             []byte(""),
-		allowedContexts: []string{},
-		contextName:     "",
-		errorContains:   "invalid configuration: no configuration has been provided",
-	},
-	kubeConfigTestValidateError{
-		msg:             "Context name does not exist",
-		raw:             []byte(noContexts),
-		allowedContexts: []string{"porter-test-1"},
-		contextName:     "context-test",
-		errorContains:   "invalid configuration: context was not found for specified context: context-test",
-	},
-	kubeConfigTestValidateError{
-		msg:             "Cluster to join does not exist",
-		raw:             []byte(noClusters),
-		allowedContexts: []string{"porter-test-1"},
-		contextName:     "context-test",
-		errorContains:   "invalid configuration: context was not found for specified context: context-test",
-	},
-	kubeConfigTestValidateError{
-		msg:             "User to join does not exist",
-		raw:             []byte(noUsers),
-		allowedContexts: []string{"porter-test-1"},
-		contextName:     "context-test",
-		errorContains:   "invalid configuration: context was not found for specified context: context-test",
-	},
-}
-
-func TestValidateErrors(t *testing.T) {
-	for _, c := range ValidateErrorTests {
-
-		_, err := kubernetes.GetRestrictedClientConfigFromBytes(c.raw, c.contextName, c.allowedContexts)
-
-		if err == nil {
-			t.Fatalf("Testing %s did not return an error\n", c.msg)
-		}
-
-		if !strings.Contains(err.Error(), c.errorContains) {
-			t.Errorf("Testing %s -- Error was:\n \"%s\" \n It did not contain string \"%s\"\n", c.msg, err.Error(), c.errorContains)
-		}
-	}
-}
-
-var BasicContextAllowedTests = []kubeConfigTest{
-	kubeConfigTest{
-		msg:             "basic test",
-		raw:             []byte(basic),
-		allowedContexts: []string{"context-test"},
-		expected: []models.Context{
-			models.Context{
-				Name:     "context-test",
-				Server:   "https://localhost",
-				Cluster:  "cluster-test",
-				User:     "test-admin",
-				Selected: true,
-			},
-		},
-	},
-}
-
-func TestBasicAllowed(t *testing.T) {
-	for _, c := range BasicContextAllowedTests {
-		res, err := kubernetes.GetContextsFromBytes(c.raw, c.allowedContexts)
-
-		if err != nil {
-			t.Fatalf("Testing %s returned an error: %v\n", c.msg, err.Error())
-		}
-
-		isEqual := reflect.DeepEqual(c.expected, res)
-
-		if !isEqual {
-			t.Errorf("Testing: %s, Expected: %v, Got: %v\n", c.msg, c.expected, res)
-		}
-	}
-}
-
-var BasicContextAllTests = []kubeConfigTest{
-	kubeConfigTest{
-		msg:             "basic test",
-		raw:             []byte(basic),
-		allowedContexts: []string{},
-		expected: []models.Context{
-			models.Context{
-				Name:     "context-test",
-				Server:   "https://localhost",
-				Cluster:  "cluster-test",
-				User:     "test-admin",
-				Selected: false,
-			},
-		},
-	},
-}
-
-func TestBasicAll(t *testing.T) {
-	for _, c := range BasicContextAllTests {
-		res, err := kubernetes.GetContextsFromBytes(c.raw, c.allowedContexts)
-
-		if err != nil {
-			t.Fatalf("Testing %s returned an error: %v\n", c.msg, err.Error())
-		}
-
-		isEqual := reflect.DeepEqual(c.expected, res)
-
-		if !isEqual {
-			t.Errorf("Testing: %s, Expected: %v, Got: %v\n", c.msg, c.expected, res)
-		}
-	}
-}
-
-func TestGetRestrictedClientConfig(t *testing.T) {
-	contexts := []string{"context-test"}
-	contextName := "context-test"
-
-	clientConf, err := kubernetes.GetRestrictedClientConfigFromBytes([]byte(basic), contextName, contexts)
-
-	if err != nil {
-		t.Fatalf("Fatal error: %s\n", err.Error())
-	}
-
-	rawConf, err := clientConf.RawConfig()
-
-	if err != nil {
-		t.Fatalf("Fatal error: %s\n", err.Error())
-	}
-
-	if cluster, clusterFound := rawConf.Clusters["cluster-test"]; !clusterFound || cluster.Server != "https://localhost" {
-		t.Errorf("invalid cluster returned")
-	}
-
-	if _, contextFound := rawConf.Contexts["context-test"]; !contextFound {
-		t.Errorf("invalid context returned")
-	}
-
-	if _, authInfoFound := rawConf.AuthInfos["test-admin"]; !authInfoFound {
-		t.Errorf("invalid auth info returned")
-	}
-}
-
-type saCandidatesTest struct {
+type ccsTest struct {
 	name     string
 	raw      []byte
-	expected []*models.ServiceAccountCandidate
+	expected []*models.ClusterCandidate
 }
 
-var SACandidatesTests = []saCandidatesTest{
-	saCandidatesTest{
+var ClusterCandidatesTests = []ccsTest{
+	ccsTest{
 		name: "test without cluster ca data",
-		raw:  []byte(ClusterCAWithoutData),
-		expected: []*models.ServiceAccountCandidate{
-			&models.ServiceAccountCandidate{
-				Actions: []models.ServiceAccountAction{
-					models.ServiceAccountAction{
-						Name:     "upload-cluster-ca-data",
+		raw:  []byte(fixtures.ClusterCAWithoutData),
+		expected: []*models.ClusterCandidate{
+			&models.ClusterCandidate{
+				AuthMechanism: models.X509,
+				ProjectID:     1,
+				Resolvers: []models.ClusterResolver{
+					models.ClusterResolver{
+						Name:     models.ClusterCAData,
+						Resolved: false,
+						Data:     []byte(`{"filename":"/fake/path/to/ca.pem"}`),
+					},
+				},
+				Name:              "cluster-test",
+				Server:            "https://10.10.10.10",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.ClusterCAWithoutData),
+				AWSClusterIDGuess: []byte{},
+			},
+		},
+	},
+	ccsTest{
+		name: "test cluster localhost",
+		raw:  []byte(fixtures.ClusterLocalhost),
+		expected: []*models.ClusterCandidate{
+			&models.ClusterCandidate{
+				AuthMechanism: models.X509,
+				ProjectID:     1,
+				Resolvers: []models.ClusterResolver{
+					models.ClusterResolver{
+						Name:     models.ClusterLocalhost,
 						Resolved: false,
-						Filename: "/fake/path/to/ca.pem",
 					},
 				},
-				Kind:            "connector",
-				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
-				AuthMechanism:   models.X509,
-				Kubeconfig:      []byte(ClusterCAWithoutData),
+				Name:              "cluster-test",
+				Server:            "https://localhost:30000",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.ClusterLocalhost),
+				AWSClusterIDGuess: []byte{},
 			},
 		},
 	},
-	saCandidatesTest{
+	ccsTest{
 		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),
+		raw:  []byte(fixtures.X509WithData),
+		expected: []*models.ClusterCandidate{
+			&models.ClusterCandidate{
+				AuthMechanism:     models.X509,
+				ProjectID:         1,
+				Resolvers:         []models.ClusterResolver{},
+				Name:              "cluster-test",
+				Server:            "https://10.10.10.10",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.X509WithData),
+				AWSClusterIDGuess: []byte{},
 			},
 		},
 	},
-	saCandidatesTest{
+	ccsTest{
 		name: "x509 test without cert data",
-		raw:  []byte(x509WithoutCertData),
-		expected: []*models.ServiceAccountCandidate{
-			&models.ServiceAccountCandidate{
-				Actions: []models.ServiceAccountAction{
-					models.ServiceAccountAction{
+		raw:  []byte(fixtures.X509WithoutCertData),
+		expected: []*models.ClusterCandidate{
+			&models.ClusterCandidate{
+				AuthMechanism: models.X509,
+				ProjectID:     1,
+				Resolvers: []models.ClusterResolver{
+					models.ClusterResolver{
 						Name:     "upload-client-cert-data",
 						Resolved: false,
-						Filename: "/fake/path/to/cert.pem",
+						Data:     []byte(`{"filename":"/fake/path/to/cert.pem"}`),
 					},
 				},
-				Kind:            "connector",
-				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
-				AuthMechanism:   models.X509,
-				Kubeconfig:      []byte(x509WithoutCertData),
+				Name:              "cluster-test",
+				Server:            "https://10.10.10.10",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.X509WithoutCertData),
+				AWSClusterIDGuess: []byte{},
 			},
 		},
 	},
-	saCandidatesTest{
+	ccsTest{
 		name: "x509 test without key data",
-		raw:  []byte(x509WithoutKeyData),
-		expected: []*models.ServiceAccountCandidate{
-			&models.ServiceAccountCandidate{
-				Actions: []models.ServiceAccountAction{
-					models.ServiceAccountAction{
+		raw:  []byte(fixtures.X509WithoutKeyData),
+		expected: []*models.ClusterCandidate{
+			&models.ClusterCandidate{
+				AuthMechanism: models.X509,
+				ProjectID:     1,
+				Resolvers: []models.ClusterResolver{
+					models.ClusterResolver{
 						Name:     "upload-client-key-data",
 						Resolved: false,
-						Filename: "/fake/path/to/key.pem",
+						Data:     []byte(`{"filename":"/fake/path/to/key.pem"}`),
 					},
 				},
-				Kind:            "connector",
-				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
-				AuthMechanism:   models.X509,
-				Kubeconfig:      []byte(x509WithoutKeyData),
+				Name:              "cluster-test",
+				Server:            "https://10.10.10.10",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.X509WithoutKeyData),
+				AWSClusterIDGuess: []byte{},
 			},
 		},
 	},
-	saCandidatesTest{
+	ccsTest{
 		name: "x509 test without cert and key data",
-		raw:  []byte(x509WithoutCertAndKeyData),
-		expected: []*models.ServiceAccountCandidate{
-			&models.ServiceAccountCandidate{
-				Actions: []models.ServiceAccountAction{
-					models.ServiceAccountAction{
+		raw:  []byte(fixtures.X509WithoutCertAndKeyData),
+		expected: []*models.ClusterCandidate{
+			&models.ClusterCandidate{
+				AuthMechanism: models.X509,
+				ProjectID:     1,
+				Resolvers: []models.ClusterResolver{
+					models.ClusterResolver{
 						Name:     "upload-client-cert-data",
 						Resolved: false,
-						Filename: "/fake/path/to/cert.pem",
+						Data:     []byte(`{"filename":"/fake/path/to/cert.pem"}`),
 					},
-					models.ServiceAccountAction{
+					models.ClusterResolver{
 						Name:     "upload-client-key-data",
 						Resolved: false,
-						Filename: "/fake/path/to/key.pem",
+						Data:     []byte(`{"filename":"/fake/path/to/key.pem"}`),
 					},
 				},
-				Kind:            "connector",
-				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
-				AuthMechanism:   models.X509,
-				Kubeconfig:      []byte(x509WithoutCertAndKeyData),
+				Name:              "cluster-test",
+				Server:            "https://10.10.10.10",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.X509WithoutCertAndKeyData),
+				AWSClusterIDGuess: []byte{},
 			},
 		},
 	},
-	saCandidatesTest{
+	ccsTest{
 		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),
+		raw:  []byte(fixtures.BearerTokenWithData),
+		expected: []*models.ClusterCandidate{
+			&models.ClusterCandidate{
+				AuthMechanism:     models.Bearer,
+				ProjectID:         1,
+				Resolvers:         []models.ClusterResolver{},
+				Name:              "cluster-test",
+				Server:            "https://10.10.10.10",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.BearerTokenWithData),
+				AWSClusterIDGuess: []byte{},
 			},
 		},
 	},
-	saCandidatesTest{
+	ccsTest{
 		name: "bearer token test without data",
-		raw:  []byte(BearerTokenWithoutData),
-		expected: []*models.ServiceAccountCandidate{
-			&models.ServiceAccountCandidate{
-				Actions: []models.ServiceAccountAction{
-					models.ServiceAccountAction{
+		raw:  []byte(fixtures.BearerTokenWithoutData),
+		expected: []*models.ClusterCandidate{
+			&models.ClusterCandidate{
+				AuthMechanism: models.Bearer,
+				ProjectID:     1,
+				Resolvers: []models.ClusterResolver{
+					models.ClusterResolver{
 						Name:     "upload-token-data",
 						Resolved: false,
-						Filename: "/path/to/token/file.txt",
+						Data:     []byte(`{"filename":"/path/to/token/file.txt"}`),
 					},
 				},
-				Kind:            "connector",
-				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
-				AuthMechanism:   models.Bearer,
-				Kubeconfig:      []byte(BearerTokenWithoutData),
+				Name:              "cluster-test",
+				Server:            "https://10.10.10.10",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.BearerTokenWithoutData),
+				AWSClusterIDGuess: []byte{},
 			},
 		},
 	},
-	saCandidatesTest{
+	ccsTest{
 		name: "gcp test",
-		raw:  []byte(GCPPlugin),
-		expected: []*models.ServiceAccountCandidate{
-			&models.ServiceAccountCandidate{
-				Actions: []models.ServiceAccountAction{
-					models.ServiceAccountAction{
+		raw:  []byte(fixtures.GCPPlugin),
+		expected: []*models.ClusterCandidate{
+			&models.ClusterCandidate{
+				AuthMechanism: models.GCP,
+				ProjectID:     1,
+				Resolvers: []models.ClusterResolver{
+					models.ClusterResolver{
 						Name:     "upload-gcp-key-data",
 						Resolved: false,
 					},
 				},
-				Kind:            "connector",
-				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
-				AuthMechanism:   models.GCP,
-				Kubeconfig:      []byte(GCPPlugin),
+				Name:              "cluster-test",
+				Server:            "https://10.10.10.10",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.GCPPlugin),
+				AWSClusterIDGuess: []byte{},
 			},
 		},
 	},
-	saCandidatesTest{
+	ccsTest{
 		name: "aws iam authenticator test",
-		raw:  []byte(AWSIamAuthenticatorExec),
-		expected: []*models.ServiceAccountCandidate{
-			&models.ServiceAccountCandidate{
-				Actions: []models.ServiceAccountAction{
-					models.ServiceAccountAction{
+		raw:  []byte(fixtures.AWSIamAuthenticatorExec),
+		expected: []*models.ClusterCandidate{
+			&models.ClusterCandidate{
+				AuthMechanism: models.AWS,
+				ProjectID:     1,
+				Resolvers: []models.ClusterResolver{
+					models.ClusterResolver{
 						Name:     "upload-aws-data",
 						Resolved: false,
 					},
 				},
-				Kind:            "connector",
-				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
-				AuthMechanism:   models.AWS,
-				Kubeconfig:      []byte(AWSIamAuthenticatorExec),
+				Name:              "cluster-test",
+				Server:            "https://10.10.10.10",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.AWSIamAuthenticatorExec),
+				AWSClusterIDGuess: []byte("cluster-test-aws-id-guess"),
 			},
 		},
 	},
-	saCandidatesTest{
+	ccsTest{
 		name: "aws eks get-token test",
-		raw:  []byte(AWSEKSGetTokenExec),
-		expected: []*models.ServiceAccountCandidate{
-			&models.ServiceAccountCandidate{
-				Actions: []models.ServiceAccountAction{
-					models.ServiceAccountAction{
+		raw:  []byte(fixtures.AWSEKSGetTokenExec),
+		expected: []*models.ClusterCandidate{
+			&models.ClusterCandidate{
+				AuthMechanism: models.AWS,
+				ProjectID:     1,
+				Resolvers: []models.ClusterResolver{
+					models.ClusterResolver{
 						Name:     "upload-aws-data",
 						Resolved: false,
 					},
 				},
-				Kind:            "connector",
-				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
-				AuthMechanism:   models.AWS,
-				Kubeconfig:      []byte(AWSEKSGetTokenExec),
+				Name:              "cluster-test",
+				Server:            "https://10.10.10.10",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.AWSEKSGetTokenExec),
+				AWSClusterIDGuess: []byte("cluster-test-aws-id-guess"),
 			},
 		},
 	},
-	saCandidatesTest{
+	ccsTest{
 		name: "oidc without ca data",
-		raw:  []byte(OIDCAuthWithoutData),
-		expected: []*models.ServiceAccountCandidate{
-			&models.ServiceAccountCandidate{
-				Actions: []models.ServiceAccountAction{
-					models.ServiceAccountAction{
+		raw:  []byte(fixtures.OIDCAuthWithoutData),
+		expected: []*models.ClusterCandidate{
+			&models.ClusterCandidate{
+				AuthMechanism: models.OIDC,
+				ProjectID:     1,
+				Resolvers: []models.ClusterResolver{
+					models.ClusterResolver{
 						Name:     "upload-oidc-idp-issuer-ca-data",
 						Resolved: false,
-						Filename: "/fake/path/to/ca.pem",
+						Data:     []byte(`{"filename":"/fake/path/to/ca.pem"}`),
 					},
 				},
-				Kind:            "connector",
-				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
-				AuthMechanism:   models.OIDC,
-				Kubeconfig:      []byte(OIDCAuthWithoutData),
+				Name:              "cluster-test",
+				Server:            "https://10.10.10.10",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.OIDCAuthWithoutData),
+				AWSClusterIDGuess: []byte{},
 			},
 		},
 	},
-	saCandidatesTest{
+	ccsTest{
 		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),
+		raw:  []byte(fixtures.OIDCAuthWithData),
+		expected: []*models.ClusterCandidate{
+			&models.ClusterCandidate{
+				AuthMechanism:     models.OIDC,
+				ProjectID:         1,
+				Resolvers:         []models.ClusterResolver{},
+				Name:              "cluster-test",
+				Server:            "https://10.10.10.10",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.OIDCAuthWithData),
+				AWSClusterIDGuess: []byte{},
 			},
 		},
 	},
-	saCandidatesTest{
+	ccsTest{
 		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),
+		raw:  []byte(fixtures.BasicAuth),
+		expected: []*models.ClusterCandidate{
+			&models.ClusterCandidate{
+				AuthMechanism:     models.Basic,
+				ProjectID:         1,
+				Resolvers:         []models.ClusterResolver{},
+				Name:              "cluster-test",
+				Server:            "https://10.10.10.10",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.BasicAuth),
+				AWSClusterIDGuess: []byte{},
 			},
 		},
 	},
 }
 
-func TestGetServiceAccountCandidates(t *testing.T) {
-	for _, c := range SACandidatesTests {
-		result, err := kubernetes.GetServiceAccountCandidates(c.raw)
+func TestGetClusterCandidatesNonLocal(t *testing.T) {
+	for _, c := range ClusterCandidatesTests {
+		result, err := kubernetes.GetClusterCandidatesFromKubeconfig(c.raw, 1, false)
 
 		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)
+		resMap := make(map[string]*models.ClusterCandidate)
 
 		for _, res := range result {
-			resMap[res.Kind+"-"+res.ClusterEndpoint+"-"+res.AuthMechanism] = res
+			resMap[res.Server+"-"+string(res.AuthMechanism)] = res
 		}
 
 		for _, exp := range c.expected {
-			res, ok := resMap[exp.Kind+"-"+exp.ClusterEndpoint+"-"+exp.AuthMechanism]
+			res, ok := resMap[exp.Server+"-"+string(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)
-					}
-				}
+					exp.Server+"-"+string(exp.AuthMechanism))
 			}
 
 			// compare kubeconfig by transforming into a client config
@@ -484,436 +342,19 @@ func TestGetServiceAccountCandidates(t *testing.T) {
 				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)
+			if diff := deep.Equal(expRawConf, resRawConf); diff != nil {
+				t.Errorf("incorrect kubeconfigs")
+				t.Error(diff)
 			}
-		}
-	}
-}
-
-func TestAWSClusterIDGuess(t *testing.T) {
-	result, err := kubernetes.GetServiceAccountCandidates([]byte(AWSIamAuthenticatorExec))
-
-	if err != nil {
-		t.Fatalf("error occurred %v\n", err)
-	}
-
-	if len(result) != 1 {
-		t.Fatalf("result length was not 1\n")
-	}
-
-	if result[0].AWSClusterIDGuess != "cluster-test-aws-id-guess" {
-		t.Errorf("Guess AWS cluster id failed: expected %s, got %s\n", "cluster-test-aws-id-guess", result[0].AWSClusterIDGuess)
-	}
-
-	result, err = kubernetes.GetServiceAccountCandidates([]byte(AWSEKSGetTokenExec))
 
-	if err != nil {
-		t.Fatalf("error occurred %v\n", err)
-	}
-
-	if len(result) != 1 {
-		t.Fatalf("result length was not 1\n")
-	}
+			// reset kubeconfigs since they won't be exactly "deep equal"
+			exp.Kubeconfig = []byte{}
+			res.Kubeconfig = []byte{}
 
-	if result[0].AWSClusterIDGuess != "cluster-test-aws-id-guess" {
-		t.Errorf("Guess AWS cluster id failed: expected %s, got %s\n", "cluster-test-aws-id-guess", result[0].AWSClusterIDGuess)
+			if diff := deep.Equal(exp, res); diff != nil {
+				t.Errorf("incorrect cluster candidate")
+				t.Error(diff)
+			}
+		}
 	}
 }
-
-const noContexts string = `
-apiVersion: v1
-kind: Config
-preferences: {}
-clusters:
-- cluster:
-    server: https://localhost
-  name: porter-test-1
-current-context: context-test
-users:
-- name: test-admin
-  user:
-`
-
-const noClusters string = `
-apiVersion: v1
-kind: Config
-preferences: {}
-current-context: context-test
-contexts:
-- context:
-    cluster: porter-test-1
-    user: test-admin
-  name: context-test
-users:
-- name: test-admin
-  user:
-`
-
-const noUsers string = `
-apiVersion: v1
-kind: Config
-preferences: {}
-current-context: default
-clusters:
-- cluster:
-    server: https://localhost
-  name: porter-test-1
-contexts:
-- context:
-    cluster: porter-test-1
-    user: test-admin
-  name: context-test
-`
-
-const noContextClusters string = `
-apiVersion: v1
-kind: Config
-preferences: {}
-current-context: default
-clusters:
-- cluster:
-    server: https://localhost
-  name: porter-test-1
-contexts:
-- context:
-    # cluster: porter-test-1
-    user: test-admin
-  name: context-test
-users:
-- name: test-admin
-  user:
-`
-
-const noContextUsers string = `
-apiVersion: v1
-kind: Config
-preferences: {}
-current-context: default
-clusters:
-- cluster:
-    server: https://localhost
-  name: porter-test-1
-contexts:
-- context:
-    cluster: porter-test-1
-    # user: test-admin
-  name: context-test
-users:
-- name: test-admin
-  user:
-`
-
-const basic 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
-`
-
-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: {}
-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-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-aws-id-guess"
-`
-
-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-aws-id-guess"
-`
-
-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: 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
-`

+ 0 - 120
internal/models/action.go

@@ -1,120 +0,0 @@
-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"
-	AWSDataAction               = "upload-aws-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"`
-	AWSAccessKeyID     string `json:"aws_access_key_id"`
-	AWSSecretAccessKey string `json:"aws_secret_access_key"`
-	AWSClusterID       string `json:"aws_cluster_id"`
-}
-
-// 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-data": ServiceAccountActionInfo{
-		Name:   AWSDataAction,
-		Docs:   "https://github.com/porter-dev/porter",
-		Fields: "aws_access_key_id,aws_secret_access_key,aws_cluster_id",
-	},
-}

+ 316 - 25
internal/models/cluster.go

@@ -1,43 +1,334 @@
 package models
 
-import "gorm.io/gorm"
+import (
+	"encoding/json"
 
-// Cluster type that extends gorm.Model
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"gorm.io/gorm"
+)
+
+// ClusterAuth is an auth mechanism that a cluster candidate can resolve
+type ClusterAuth string
+
+// The support cluster candidate auth mechanisms
+const (
+	X509   ClusterAuth = "x509"
+	Basic  ClusterAuth = "basic"
+	Bearer ClusterAuth = "bearerToken"
+	OIDC   ClusterAuth = "oidc"
+	GCP    ClusterAuth = "gcp-sa"
+	AWS    ClusterAuth = "aws-sa"
+	Local  ClusterAuth = "local"
+)
+
+// Cluster is an integration that can connect to a Kubernetes cluster via
+// a specific auth mechanism
 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"`
+	// The auth mechanism that this cluster will use
+	AuthMechanism ClusterAuth `json:"auth_mechanism"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// Name of the cluster
+	Name string `json:"name"`
+
+	// Server endpoint for the cluster
+	Server string `json:"server"`
+
+	// Additional fields optionally used by the kube client
+	ClusterLocationOfOrigin string `json:"location_of_origin,omitempty"`
+	TLSServerName           string `json:"tls-server-name,omitempty"`
+	InsecureSkipTLSVerify   bool   `json:"insecure-skip-tls-verify,omitempty"`
+	ProxyURL                string `json:"proxy-url,omitempty"`
+	UserLocationOfOrigin    string
+	UserImpersonate         string `json:"act-as,omitempty"`
+	UserImpersonateGroups   string `json:"act-as-groups,omitempty"`
+
+	// ------------------------------------------------------------------
+	// All fields below this line are encrypted before storage
+	// ------------------------------------------------------------------
+
+	// The various auth mechanisms available to the integration
+	KubeIntegrationID uint
+	OIDCIntegrationID uint
+	GCPIntegrationID  uint
+	AWSIntegrationID  uint
+
+	// A token cache that can be used by an auth mechanism, if desired
+	TokenCache integrations.TokenCache `json:"token_cache"`
 
-	// CertificateAuthorityData is encrypted at rest
+	// CertificateAuthorityData for the cluster, encrypted at rest
 	CertificateAuthorityData []byte `json:"certificate-authority-data,omitempty"`
 }
 
-// ClusterExternal is the external cluster type to be sent over REST
+// ClusterExternal is an external Cluster to be shared 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"`
+	ID uint `json:"id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// Name of the cluster
+	Name string `json:"name"`
+
+	// Server endpoint for the cluster
+	Server string `json:"server"`
+
+	// The integration service for this cluster
+	Service integrations.IntegrationService `json:"service"`
 }
 
 // Externalize generates an external Cluster to be shared over REST
 func (c *Cluster) Externalize() *ClusterExternal {
+	serv := integrations.Kube
+
+	if c.AWSIntegrationID != 0 {
+		serv = integrations.EKS
+	} else if c.GCPIntegrationID != 0 {
+		serv = integrations.GKE
+	}
+
 	return &ClusterExternal{
-		ID:                    c.Model.ID,
-		ServiceAccountID:      c.ServiceAccountID,
-		Name:                  c.Name,
-		Server:                c.Server,
-		TLSServerName:         c.TLSServerName,
-		InsecureSkipTLSVerify: c.InsecureSkipTLSVerify,
-		ProxyURL:              c.ProxyURL,
+		ID:        c.ID,
+		ProjectID: c.ProjectID,
+		Name:      c.Name,
+		Server:    c.Server,
+		Service:   serv,
+	}
+}
+
+// ClusterCandidate is a cluster integration that requires additional action
+// from the user to set up.
+type ClusterCandidate struct {
+	gorm.Model
+
+	// The auth mechanism that this candidate will parse for
+	AuthMechanism ClusterAuth `json:"auth_mechanism"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// CreatedClusterID is the ID of the cluster that's eventually
+	// created
+	CreatedClusterID uint `json:"created_cluster_id"`
+
+	// Resolvers are the list of resolvers: once all resolvers are "resolved," the
+	// cluster will be created
+	Resolvers []ClusterResolver `json:"resolvers"`
+
+	// Name of the cluster
+	Name string `json:"name"`
+
+	// Server endpoint for the cluster
+	Server string `json:"server"`
+
+	// Name of the context that this was created from, if it exists
+	ContextName string `json:"context_name"`
+
+	// ------------------------------------------------------------------
+	// All fields below this line are encrypted before storage
+	// ------------------------------------------------------------------
+
+	// The best-guess for the AWSClusterID, which is required by aws auth mechanisms
+	// See https://github.com/kubernetes-sigs/aws-iam-authenticator#what-is-a-cluster-id
+	AWSClusterIDGuess []byte `json:"aws_cluster_id_guess"`
+
+	// The raw kubeconfig
+	Kubeconfig []byte `json:"kubeconfig"`
+}
+
+// ClusterCandidateExternal represents the ClusterCandidate to be sent over REST
+type ClusterCandidateExternal struct {
+	ID uint `json:"id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// CreatedClusterID is the ID of the cluster that's eventually
+	// created
+	CreatedClusterID uint `json:"created_cluster_id"`
+
+	// Name of the cluster
+	Name string `json:"name"`
+
+	// Server endpoint for the cluster
+	Server string `json:"server"`
+
+	// Name of the context that this was created from, if it exists
+	ContextName string `json:"context_name"`
+
+	// Resolvers are the list of resolvers: once all resolvers are "resolved," the
+	// cluster will be created
+	Resolvers []ClusterResolverExternal `json:"resolvers"`
+
+	// The best-guess for the AWSClusterID, which is required by aws auth mechanisms
+	// See https://github.com/kubernetes-sigs/aws-iam-authenticator#what-is-a-cluster-id
+	AWSClusterIDGuess string `json:"aws_cluster_id_guess"`
+}
+
+// Externalize generates an external ClusterCandidateExternal to be shared over REST
+func (cc *ClusterCandidate) Externalize() *ClusterCandidateExternal {
+	resolvers := make([]ClusterResolverExternal, 0)
+
+	for _, resolver := range cc.Resolvers {
+		resolvers = append(resolvers, *resolver.Externalize())
+	}
+
+	return &ClusterCandidateExternal{
+		ID:                cc.ID,
+		ProjectID:         cc.ProjectID,
+		CreatedClusterID:  cc.CreatedClusterID,
+		Name:              cc.Name,
+		Server:            cc.Server,
+		ContextName:       cc.ContextName,
+		Resolvers:         resolvers,
+		AWSClusterIDGuess: string(cc.AWSClusterIDGuess),
+	}
+}
+
+// ClusterResolverName is the name for a cluster resolve
+type ClusterResolverName string
+
+// Options for the cluster resolver names
+const (
+	ClusterCAData    ClusterResolverName = "upload-cluster-ca-data"
+	ClusterLocalhost                     = "rewrite-cluster-localhost"
+	ClientCertData                       = "upload-client-cert-data"
+	ClientKeyData                        = "upload-client-key-data"
+	OIDCIssuerData                       = "upload-oidc-idp-issuer-ca-data"
+	TokenData                            = "upload-token-data"
+	GCPKeyData                           = "upload-gcp-key-data"
+	AWSData                              = "upload-aws-data"
+)
+
+// ClusterResolverInfo contains the information for actions to be
+// performed in order to initialize a cluster
+type ClusterResolverInfo struct {
+	// Docs is a link to documentation that helps resolve this manually
+	Docs string `json:"docs"`
+
+	// a comma-separated list of required fields to send in an action request
+	Fields string `json:"fields"`
+}
+
+// ClusterResolverInfos is a map of the information for actions to be
+// performed in order to initialize a cluster
+var ClusterResolverInfos = map[ClusterResolverName]ClusterResolverInfo{
+	ClusterCAData: ClusterResolverInfo{
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "cluster_ca_data",
+	},
+	ClusterLocalhost: ClusterResolverInfo{
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "cluster_hostname",
+	},
+	ClientCertData: ClusterResolverInfo{
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "client_cert_data",
+	},
+	ClientKeyData: ClusterResolverInfo{
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "client_key_data",
+	},
+	OIDCIssuerData: ClusterResolverInfo{
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "oidc_idp_issuer_ca_data",
+	},
+	TokenData: ClusterResolverInfo{
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "token_data",
+	},
+	GCPKeyData: ClusterResolverInfo{
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "gcp_key_data",
+	},
+	AWSData: ClusterResolverInfo{
+		Docs:   "https://github.com/porter-dev/porter",
+		Fields: "aws_access_key_id,aws_secret_access_key,aws_cluster_id",
+	},
+}
+
+// ClusterResolverAll is a helper type that contains the fields for
+// all possible resolvers, so that raw bytes can be unmarshaled in a single
+// read
+type ClusterResolverAll struct {
+	ClusterCAData      string `json:"cluster_ca_data,omitempty"`
+	ClusterHostname    string `json:"cluster_hostname,omitempty"`
+	ClientCertData     string `json:"client_cert_data,omitempty"`
+	ClientKeyData      string `json:"client_key_data,omitempty"`
+	OIDCIssuerCAData   string `json:"oidc_idp_issuer_ca_data,omitempty"`
+	TokenData          string `json:"token_data,omitempty"`
+	GCPKeyData         string `json:"gcp_key_data,omitempty"`
+	AWSAccessKeyID     string `json:"aws_access_key_id"`
+	AWSSecretAccessKey string `json:"aws_secret_access_key"`
+	AWSClusterID       string `json:"aws_cluster_id"`
+}
+
+// ClusterResolver is an action that must be resolved to set up
+// a Cluster
+type ClusterResolver struct {
+	gorm.Model
+
+	// The ClusterCandidate that this is resolving
+	ClusterCandidateID uint `json:"cluster_candidate_id"`
+
+	// One of the ClusterResolverNames
+	Name ClusterResolverName `json:"name"`
+
+	// Resolved is true if this has been resolved, false otherwise
+	Resolved bool `json:"resolved"`
+
+	// Data is additional data for resolving the action, for example a file name,
+	// context name, etc
+	Data []byte `json:"data,omitempty"`
+}
+
+// ClusterResolverData is a map of key names to fields, which gets marshaled from
+// the raw JSON bytes stored in the ClusterResolver
+type ClusterResolverData map[string]string
+
+// ClusterResolverExternal is an external ClusterResolver to be shared over REST
+type ClusterResolverExternal struct {
+	ID uint `json:"id"`
+
+	// The ClusterCandidate that this is resolving
+	ClusterCandidateID uint `json:"cluster_candidate_id"`
+
+	// One of the ClusterResolverNames
+	Name ClusterResolverName `json:"name"`
+
+	// Resolved is true if this has been resolved, false otherwise
+	Resolved bool `json:"resolved"`
+
+	// Docs is a link to documentation that helps resolve this manually
+	Docs string `json:"docs"`
+
+	// Fields is a list of fields that must be sent with the resolving request
+	Fields string `json:"fields"`
+
+	// Data is additional data for resolving the action, for example a file name,
+	// context name, etc
+	Data ClusterResolverData `json:"data,omitempty"`
+}
+
+// Externalize generates an external ClusterResolver to be shared over REST
+func (cr *ClusterResolver) Externalize() *ClusterResolverExternal {
+	info := ClusterResolverInfos[cr.Name]
+
+	data := make(ClusterResolverData)
+
+	json.Unmarshal(cr.Data, &data)
+
+	return &ClusterResolverExternal{
+		ID:                 cr.ID,
+		ClusterCandidateID: cr.ClusterCandidateID,
+		Name:               cr.Name,
+		Resolved:           cr.Resolved,
+		Docs:               info.Docs,
+		Fields:             info.Fields,
+		Data:               data,
 	}
 }

+ 34 - 0
internal/models/cluster_test.go

@@ -0,0 +1,34 @@
+package models_test
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+func TestClusterResolverExternalize(t *testing.T) {
+	crData := models.ClusterResolverData{
+		"filename": "/hello/there.pem",
+		"key":      "value",
+	}
+
+	bytes, err := json.Marshal(crData)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// test that the data gets unmarshalled properly
+	cr := &models.ClusterResolver{
+		Data: bytes,
+	}
+
+	crExternal := cr.Externalize()
+
+	if diff := deep.Equal(crExternal.Data, crData); diff != nil {
+		t.Errorf("incorrect cluster resolver data")
+		t.Error(diff)
+	}
+}

+ 0 - 16
internal/models/context.go

@@ -1,16 +0,0 @@
-package models
-
-// Context represents the configuration for a single cluster-user pair
-type Context struct {
-	// Name is the name of the context
-	Name string `json:"name"`
-	// Server is the endpoint of the kube apiserver for a cluster
-	Server string `json:"server"`
-	// Cluster is the name of the cluster
-	Cluster string `json:"cluster"`
-	// User is the name of the user for a cluster
-	User string `json:"user"`
-	// Selected determines if the context has been selected for use in the
-	// dashboard
-	Selected bool `json:"selected"`
-}

+ 45 - 0
internal/models/gitrepo.go

@@ -0,0 +1,45 @@
+package models
+
+import (
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"gorm.io/gorm"
+)
+
+// GitRepo is an integration that can connect to a remote git repo via an auth
+// mechanism (currently only oauth)
+type GitRepo struct {
+	gorm.Model
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// The username/organization that this repo integration is linked to
+	RepoEntity string `json:"repo_entity"`
+
+	// The various auth mechanisms available to the integration
+	OAuthIntegrationID uint
+}
+
+// GitRepoExternal is a repository to be shared over REST
+type GitRepoExternal struct {
+	ID uint `json:"id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// The username/organization that this repo integration is linked to
+	RepoEntity string `json:"repo_entity"`
+
+	// The integration service for this git repo
+	Service integrations.IntegrationService `json:"service"`
+}
+
+// Externalize generates an external Repo to be shared over REST
+func (r *GitRepo) Externalize() *GitRepoExternal {
+	return &GitRepoExternal{
+		ID:         r.Model.ID,
+		ProjectID:  r.ProjectID,
+		RepoEntity: r.RepoEntity,
+		Service:    integrations.Github,
+	}
+}

+ 152 - 0
internal/models/integrations/aws.go

@@ -0,0 +1,152 @@
+package integrations
+
+import (
+	"gorm.io/gorm"
+
+	"github.com/aws/aws-sdk-go/aws"
+
+	"github.com/aws/aws-sdk-go/aws/credentials"
+	"github.com/aws/aws-sdk-go/aws/session"
+	token "sigs.k8s.io/aws-iam-authenticator/pkg/token"
+)
+
+// AWSIntegration is an auth mechanism that uses a AWS IAM user to
+// authenticate
+type AWSIntegration struct {
+	gorm.Model
+
+	// The id of the user that linked this auth mechanism
+	UserID uint `json:"user_id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// The AWS entity this is linked to (individual or organization)
+	AWSEntityID string `json:"aws-entity-id"`
+
+	// The AWS caller identity (ARN) which linked this service
+	AWSCallerID string `json:"aws-caller-id"`
+
+	// The optional AWS region (required by some session configurations)
+	AWSRegion string `json:"aws_region"`
+
+	// ------------------------------------------------------------------
+	// All fields encrypted before storage.
+	// ------------------------------------------------------------------
+
+	// The AWS cluster ID
+	// See https://github.com/kubernetes-sigs/aws-iam-authenticator#what-is-a-cluster-id
+	AWSClusterID []byte `json:"aws_cluster_id"`
+
+	// The AWS access key for this IAM user
+	AWSAccessKeyID []byte `json:"aws_access_key_id"`
+
+	// The AWS secret key for this IAM user
+	AWSSecretAccessKey []byte `json:"aws_secret_access_key"`
+
+	// An optional session token, if the user is assuming a role
+	AWSSessionToken []byte `json:"aws_session_token"`
+}
+
+// AWSIntegrationExternal is a AWSIntegration to be shared over REST
+type AWSIntegrationExternal struct {
+	ID uint `json:"id"`
+
+	// The id of the user that linked this auth mechanism
+	UserID uint `json:"user_id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// The AWS entity this is linked to (individual or organization)
+	AWSEntityID string `json:"aws-entity-id"`
+
+	// The AWS caller identity (ARN) which linked this service
+	AWSCallerID string `json:"aws-caller-id"`
+}
+
+// Externalize generates an external KubeIntegration to be shared over REST
+func (a *AWSIntegration) Externalize() *AWSIntegrationExternal {
+	return &AWSIntegrationExternal{
+		ID:          a.ID,
+		UserID:      a.UserID,
+		ProjectID:   a.ProjectID,
+		AWSEntityID: a.AWSEntityID,
+		AWSCallerID: a.AWSCallerID,
+	}
+}
+
+// ToProjectIntegration converts an aws integration to a project integration
+func (a *AWSIntegration) ToProjectIntegration(
+	category string,
+	service IntegrationService,
+) *ProjectIntegration {
+	return &ProjectIntegration{
+		ID:            a.ID,
+		ProjectID:     a.ProjectID,
+		AuthMechanism: "aws",
+		Category:      category,
+		Service:       service,
+	}
+}
+
+// GetSession retrieves an AWS session to use based on the access key and secret
+// access key
+func (a *AWSIntegration) GetSession() (*session.Session, error) {
+	awsConf := &aws.Config{
+		Credentials: credentials.NewStaticCredentials(
+			string(a.AWSAccessKeyID),
+			string(a.AWSSecretAccessKey),
+			string(a.AWSSessionToken),
+		),
+	}
+
+	if a.AWSRegion != "" {
+		awsConf.Region = &a.AWSRegion
+	}
+
+	return session.NewSessionWithOptions(session.Options{
+		SharedConfigState: session.SharedConfigEnable,
+		Config:            *awsConf,
+	})
+}
+
+// GetBearerToken retrieves a bearer token for an AWS account
+func (a *AWSIntegration) GetBearerToken(
+	getTokenCache GetTokenCacheFunc,
+	setTokenCache SetTokenCacheFunc,
+) (string, error) {
+	cache, err := getTokenCache()
+
+	// check the token cache for a non-expired token
+	if cache != nil {
+		if tok := cache.Token; err == nil && !cache.IsExpired() && len(tok) > 0 {
+			return string(tok), nil
+		}
+	}
+
+	generator, err := token.NewGenerator(false, false)
+
+	if err != nil {
+		return "", err
+	}
+
+	sess, err := a.GetSession()
+
+	if err != nil {
+		return "", err
+	}
+
+	tok, err := generator.GetWithOptions(&token.GetTokenOptions{
+		Session:   sess,
+		ClusterID: string(a.AWSClusterID),
+	})
+
+	if err != nil {
+		return "", err
+	}
+
+	setTokenCache(tok.Token, tok.Expiration)
+
+	return tok.Token, nil
+}

部分文件因文件數量過多而無法顯示