Просмотр исходного кода

Merge branch 'beta.2.integration-backend' of https://github.com/porter-dev/porter

jusrhee 5 лет назад
Родитель
Сommit
30c88efdbb
100 измененных файлов с 9484 добавлено и 5611 удалено
  1. 12 12
      .darwin.goreleaser.yml
  2. 9 13
      .goreleaser.yml
  3. 58 0
      cli/cmd/api/integration.go
  4. 1 4
      cli/cmd/api/k8s.go
  5. 23 27
      cli/cmd/api/project.go
  6. 71 109
      cli/cmd/api/project_test.go
  7. 92 0
      cli/cmd/api/registry.go
  8. 0 17
      cli/cmd/cluster.go
  9. 45 4
      cli/cmd/config.go
  10. 23 2
      cli/cmd/connect.go
  11. 84 0
      cli/cmd/connect/ecr.go
  12. 134 142
      cli/cmd/connect/kubeconfig.go
  13. 27 5
      cli/cmd/github/release.go
  14. 0 0
      cli/cmd/providers/aws/agent.go
  15. 0 0
      cli/cmd/providers/aws/authconfigmap.go
  16. 1 1
      cli/cmd/providers/aws/local/config.go
  17. 0 0
      cli/cmd/providers/gcp/agent.go
  18. 1 1
      cli/cmd/providers/gcp/local/config.go
  19. 72 0
      cli/cmd/registry.go
  20. 61 43
      cli/cmd/server.go
  21. 13 6
      cmd/app/main.go
  22. 13 6
      cmd/migrate/main.go
  23. 1 0
      go.mod
  24. 9 0
      go.sum
  25. 0 376
      internal/forms/action.go
  26. 0 773
      internal/forms/action_test.go
  27. 14 10
      internal/forms/candidate.go
  28. 440 0
      internal/forms/cluster.go
  29. 1053 0
      internal/forms/cluster_test.go
  30. 358 0
      internal/forms/helper_test.go
  31. 45 0
      internal/forms/integration.go
  32. 3 13
      internal/forms/k8s.go
  33. 24 0
      internal/forms/registry.go
  34. 4 14
      internal/forms/release.go
  35. 7 8
      internal/helm/config.go
  36. 189 13
      internal/kubernetes/config.go
  37. 344 0
      internal/kubernetes/fixtures/kubeconfig.go
  38. 165 389
      internal/kubernetes/kubeconfig.go
  39. 211 809
      internal/kubernetes/kubeconfig_test.go
  40. 0 127
      internal/models/action.go
  41. 304 25
      internal/models/cluster.go
  42. 34 0
      internal/models/cluster_test.go
  43. 0 16
      internal/models/context.go
  44. 40 0
      internal/models/gitrepo.go
  45. 150 0
      internal/models/integrations/aws.go
  46. 109 0
      internal/models/integrations/gcp.go
  47. 96 0
      internal/models/integrations/integration.go
  48. 85 0
      internal/models/integrations/kube.go
  49. 77 0
      internal/models/integrations/oauth.go
  50. 90 0
      internal/models/integrations/oidc.go
  51. 11 3
      internal/models/integrations/token_cache.go
  52. 31 16
      internal/models/project.go
  53. 44 0
      internal/models/registry.go
  54. 0 51
      internal/models/repoclient.go
  55. 0 172
      internal/models/serviceaccount.go
  56. 90 0
      internal/registry/registry.go
  57. 20 0
      internal/repository/cluster.go
  58. 11 0
      internal/repository/gitrepo.go
  59. 909 0
      internal/repository/gorm/auth.go
  60. 461 0
      internal/repository/gorm/auth_test.go
  61. 340 0
      internal/repository/gorm/cluster.go
  62. 375 0
      internal/repository/gorm/cluster_test.go
  63. 64 0
      internal/repository/gorm/gitrepo.go
  64. 93 0
      internal/repository/gorm/gitrepo_test.go
  65. 240 54
      internal/repository/gorm/helpers_test.go
  66. 64 0
      internal/repository/gorm/registry.go
  67. 84 0
      internal/repository/gorm/registry_test.go
  68. 0 134
      internal/repository/gorm/repoclient.go
  69. 0 112
      internal/repository/gorm/repoclient_test.go
  70. 11 5
      internal/repository/gorm/repository.go
  71. 0 609
      internal/repository/gorm/serviceaccount.go
  72. 0 418
      internal/repository/gorm/serviceaccount_test.go
  73. 45 0
      internal/repository/integrations.go
  74. 10 0
      internal/repository/registry.go
  75. 0 11
      internal/repository/repoclient.go
  76. 11 5
      internal/repository/repository.go
  77. 0 18
      internal/repository/serviceaccount.go
  78. 330 0
      internal/repository/test/auth.go
  79. 160 0
      internal/repository/test/cluster.go
  80. 67 0
      internal/repository/test/gitrepo.go
  81. 73 0
      internal/repository/test/registry.go
  82. 0 66
      internal/repository/test/repoclient.go
  83. 11 4
      internal/repository/test/repository.go
  84. 0 185
      internal/repository/test/serviceaccount.go
  85. 2 2
      server/api/deploy_handler.go
  86. 21 21
      server/api/deploy_handler_test.go
  87. 177 0
      server/api/integration_handler.go
  88. 228 0
      server/api/integration_handler_test.go
  89. 8 21
      server/api/k8s_handler.go
  90. 2 3
      server/api/k8s_handler_test.go
  91. 180 180
      server/api/oauth_github_handler.go
  92. 92 156
      server/api/project_handler.go
  93. 96 99
      server/api/project_handler_test.go
  94. 126 0
      server/api/registry_handler.go
  95. 141 2
      server/api/registry_handler_test.go
  96. 13 13
      server/api/release_handler.go
  97. 36 70
      server/api/release_handler_test.go
  98. 173 173
      server/api/repo_handler.go
  99. 21 22
      server/api/repo_handler_test.go
  100. 21 21
      server/api/template_handler_test.go

+ 12 - 12
.darwin.goreleaser.yml

@@ -16,18 +16,18 @@ builds:
       - -tags=cli
     hooks:
       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
+  # - 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:

+ 9 - 13
.goreleaser.yml

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

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

@@ -0,0 +1,58 @@
+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
+}

+ 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,
 	)

+ 23 - 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 {
@@ -158,7 +158,7 @@ type CreateProjectCandidatesRequest struct {
 
 // 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
@@ -175,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)),
 	)
 
@@ -198,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
@@ -208,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,
 	)
 
@@ -230,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
@@ -254,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)),
 	)
 
@@ -263,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 {

+ 71 - 109
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://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)
+	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://10.10.10.10" {
-		t.Errorf("cluster endpoint incorrect: expected %s, got %s\n", "https://10.10.10.10", 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://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)
-	}
+	// 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://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)
-	}
+	// 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,10 +352,6 @@ 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)
 	}

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

@@ -0,0 +1,92 @@
+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
+}
+
+// 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
+}

+ 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,
 	)
 

+ 45 - 4
cli/cmd/config.go

@@ -11,10 +11,11 @@ import (
 
 // a set of shared flags
 var (
-	driver    string
-	host      string
-	projectID uint
-	clusterID uint
+	driver     string
+	host       string
+	projectID  uint
+	registryID uint
+	clusterID  uint
 )
 
 var configCmd = &cobra.Command{
@@ -64,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),
@@ -84,6 +106,7 @@ func init() {
 	configCmd.AddCommand(setProjectCmd)
 	configCmd.AddCommand(setClusterCmd)
 	configCmd.AddCommand(setHostCmd)
+	configCmd.AddCommand(setRegistryCmd)
 }
 
 func setDriver(driver string) error {
@@ -98,6 +121,10 @@ func getDriver() string {
 		return driver
 	}
 
+	if opts.driver != "" {
+		return opts.driver
+	}
+
 	return viper.GetString("driver")
 }
 
@@ -113,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()
@@ -136,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

+ 23 - 2
cli/cmd/connect.go

@@ -23,7 +23,19 @@ 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)
@@ -63,9 +75,11 @@ func init() {
 		nil,
 		"the list of contexts to connect (defaults to the current context)",
 	)
+
+	connectCmd.AddCommand(connectECRCmd)
 }
 
-func runConnect(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+func runConnectKubeconfig(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 	isLocal := false
 
 	if getDriver() == "local" {
@@ -80,3 +94,10 @@ func runConnect(_ *api.AuthCheckResponse, client *api.Client, _ []string) error
 		isLocal,
 	)
 }
+
+func runConnectECR(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	return connect.ECR(
+		client,
+		getProjectID(),
+	)
+}

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

@@ -0,0 +1,84 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+)
+
+// ECR creates an ECR integration
+func ECR(
+	client *api.Client,
+	projectID uint,
+) error {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	// query for the access key id
+	accessKeyID, err := utils.PromptPlaintext(fmt.Sprintf(`AWS Access Key ID: `))
+
+	if err != nil {
+		return err
+	}
+
+	// query for the secret access key
+	secretKey, err := utils.PromptPlaintext(fmt.Sprintf(`AWS Secret Access Key: `))
+
+	if err != nil {
+		return err
+	}
+
+	// query for the region
+	region, err := utils.PromptPlaintext(fmt.Sprintf(`AWS Region: `))
+
+	if err != nil {
+		return err
+	}
+
+	// create the aws integration
+	integration, err := client.CreateAWSIntegration(
+		context.Background(),
+		projectID,
+		&api.CreateAWSIntegrationRequest{
+			AWSAccessKeyID:     accessKeyID,
+			AWSSecretAccessKey: secretKey,
+			AWSRegion:          region,
+		},
+	)
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Printf("created aws 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 err
+	}
+
+	reg, err := client.CreateECR(
+		context.Background(),
+		projectID,
+		&api.CreateECRRequest{
+			Name:             regName,
+			AWSIntegrationID: integration.ID,
+		},
+	)
+
+	if err != nil {
+		return err
+	}
+
+	color.New(color.FgGreen).Printf("created registry with id %d and name %s\n", reg.ID, reg.Name)
+
+	return nil
+}

+ 134 - 142
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"
@@ -41,7 +41,7 @@ func Kubeconfig(
 	}
 
 	// send kubeconfig to client
-	saCandidates, err := client.CreateProjectCandidates(
+	ccs, err := client.CreateProjectCandidates(
 		context.Background(),
 		projectID,
 		&api.CreateProjectCandidatesRequest{
@@ -54,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 {
@@ -71,7 +70,7 @@ func Kubeconfig(
 					}
 
 					filename, err := utils.GetFileReferenceFromKubeconfig(
-						action.Filename,
+						resolver.Data["filename"],
 						absKubeconfigPath,
 					)
 
@@ -79,22 +78,18 @@ func Kubeconfig(
 						return err
 					}
 
-					resolveAction, err := resolveClusterCAAction(filename)
+					err = resolveClusterCAAction(filename, allResolver)
 
 					if err != nil {
 						return err
 					}
-
-					resolvers = append(resolvers, resolveAction)
-				case models.ClusterLocalhostAction:
-					resolveAction, err := resolveLocalhostAction()
+				case models.ClusterLocalhost:
+					err := resolveLocalhostAction(allResolver)
 
 					if err != nil {
 						return err
 					}
-
-					resolvers = append(resolvers, resolveAction)
-				case models.ClientCertDataAction:
+				case models.ClientCertData:
 					absKubeconfigPath, err := local.ResolveKubeconfigPath(kubeconfigPath)
 
 					if err != nil {
@@ -102,7 +97,7 @@ func Kubeconfig(
 					}
 
 					filename, err := utils.GetFileReferenceFromKubeconfig(
-						action.Filename,
+						resolver.Data["filename"],
 						absKubeconfigPath,
 					)
 
@@ -110,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 {
@@ -125,7 +118,7 @@ func Kubeconfig(
 					}
 
 					filename, err := utils.GetFileReferenceFromKubeconfig(
-						action.Filename,
+						resolver.Data["filename"],
 						absKubeconfigPath,
 					)
 
@@ -133,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 {
@@ -148,7 +139,7 @@ func Kubeconfig(
 					}
 
 					filename, err := utils.GetFileReferenceFromKubeconfig(
-						action.Filename,
+						resolver.Data["filename"],
 						absKubeconfigPath,
 					)
 
@@ -156,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 {
@@ -171,7 +160,7 @@ func Kubeconfig(
 					}
 
 					filename, err := utils.GetFileReferenceFromKubeconfig(
-						action.Filename,
+						resolver.Data["filename"],
 						absKubeconfigPath,
 					)
 
@@ -179,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
@@ -253,92 +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() (*models.ServiceAccountAllActions, error) {
-	return &models.ServiceAccountAllActions{
-		Name:            models.ClusterLocalhostAction,
-		ClusterHostname: "host.docker.internal",
-	}, 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). 
@@ -351,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" {
@@ -359,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
@@ -378,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
@@ -393,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
@@ -419,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
@@ -438,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). 
@@ -451,7 +444,7 @@ Would you like to proceed? %s `,
 	)
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	if userResp := strings.ToLower(userResp); userResp == "y" || userResp == "yes" {
@@ -459,33 +452,33 @@ 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 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
 
@@ -498,7 +491,7 @@ func resolveAWSActionManual(
 	)
 
 	if err != nil {
-		return nil, err
+		return err
 	}
 
 	if userResp := strings.ToLower(userResp); userResp == "y" || userResp == "yes" {
@@ -507,7 +500,7 @@ func resolveAWSActionManual(
 		clusterID, err = utils.PromptPlaintext(fmt.Sprintf(`Cluster ID: `))
 
 		if err != nil {
-			return nil, err
+			return err
 		}
 	}
 
@@ -515,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
 }

+ 27 - 5
cli/cmd/github/release.go

@@ -15,13 +15,13 @@ import (
 	"github.com/google/go-github/github"
 )
 
-func getLatestReleaseDownloadURL() (string, error) {
+func getLatestReleaseDownloadURL() (string, string, error) {
 	client := github.NewClient(nil)
 
 	rel, _, err := client.Repositories.GetLatestRelease(context.Background(), "porter-dev", "porter")
 
 	if err != nil {
-		return "", err
+		return "", "", err
 	}
 
 	var re *regexp.Regexp
@@ -35,23 +35,27 @@ func getLatestReleaseDownloadURL() (string, error) {
 		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
-			break
+		} else if staticRE.MatchString(downloadURL) {
+			staticReleaseURL = downloadURL
 		}
 	}
 
-	return releaseURL, nil
+	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, err := getLatestReleaseDownloadURL()
+	releaseURL, staticReleaseURL, err := getLatestReleaseDownloadURL()
 	fmt.Println(releaseURL)
 
 	if err != nil {
@@ -68,10 +72,28 @@ func DownloadLatestServerRelease(porterDir string) error {
 
 	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)
 

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


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


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

@@ -2,8 +2,8 @@ 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"
 

+ 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"
 

+ 72 - 0
cli/cmd/registry.go

@@ -0,0 +1,72 @@
+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)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(registryCmd)
+
+	registryCmd.PersistentFlags().UintVar(
+		&registryID,
+		"registry-id",
+		0,
+		"id of the registry",
+	)
+
+	registryCmd.AddCommand(registryCmdListRepos)
+}
+
+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
+}

+ 61 - 43
cli/cmd/server.go

@@ -8,6 +8,7 @@ import (
 
 	"github.com/fatih/color"
 	"github.com/porter-dev/porter/cli/cmd/docker"
+	"github.com/porter-dev/porter/cli/cmd/github"
 
 	"github.com/spf13/cobra"
 )
@@ -26,53 +27,14 @@ var serverCmd = &cobra.Command{
 	Short: "Commands to control a local Porter server",
 }
 
-var testCmd = &cobra.Command{
-	Use:   "test",
-	Short: "Testing",
-	Run: func(cmd *cobra.Command, args []string) {
-		setDriver("local")
-
-		// TODO -- DOWNLOAD THE LATEST RELEASE, IF NOT EXIST
-		// porterDir := filepath.Join(home, ".porter")
-
-		// err := github.DownloadLatestServerRelease(porterDir)
-
-		// if err != nil {
-		// 	color.New(color.FgRed).Println("Failed:", err.Error())
-		// 	os.Exit(1)
-		// }
-
-		cmdPath := filepath.Join(home, ".porter", "portersvr")
-		sqlLitePath := filepath.Join(home, ".porter", "porter.db")
-		staticFilePath := filepath.Join(home, ".porter", "static")
-
-		cmdPorter := exec.Command(cmdPath)
-		cmdPorter.Env = os.Environ()
-		cmdPorter.Env = append(cmdPorter.Env, []string{
-			"IS_LOCAL=true",
-			"SQL_LITE=true",
-			"SQL_LITE_PATH=" + sqlLitePath,
-			"STATIC_FILE_PATH=" + staticFilePath,
-		}...)
-
-		cmdPorter.Stdout = os.Stdout
-		cmdPorter.Stderr = os.Stderr
-
-		err := cmdPorter.Run()
-
-		if err != nil {
-			color.New(color.FgRed).Println("Failed:", err.Error())
-			os.Exit(1)
-		}
-	},
-}
-
 // startCmd represents the start command
 var startCmd = &cobra.Command{
 	Use:   "start",
 	Short: "Starts a Porter instance using the Docker engine",
 	Run: func(cmd *cobra.Command, args []string) {
 		if getDriver() == "docker" {
+			setDriver("docker")
+
 			err := startDocker(
 				opts.imageTag,
 				opts.db,
@@ -90,6 +52,18 @@ var startCmd = &cobra.Command{
 					red.Println("Shutdown unsuccessful:", err.Error())
 				}
 
+				os.Exit(1)
+			}
+		} else {
+			setDriver("local")
+			err := startLocal(
+				opts.db,
+				*opts.port,
+			)
+
+			if err != nil {
+				red := color.New(color.FgRed)
+				red.Println("Error running start:", err.Error())
 				os.Exit(1)
 			}
 		}
@@ -110,8 +84,6 @@ var stopCmd = &cobra.Command{
 }
 
 func init() {
-	rootCmd.AddCommand(testCmd)
-
 	rootCmd.AddCommand(serverCmd)
 
 	serverCmd.AddCommand(startCmd)
@@ -186,6 +158,52 @@ func startDocker(
 	return setHost(fmt.Sprintf("http://localhost:%d", port))
 }
 
+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()
 

+ 13 - 6
cmd/app/main.go

@@ -18,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() {
@@ -34,14 +36,19 @@ 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{},
 	)
 
 	if err != nil {

+ 13 - 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,19 @@ 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{},
 	)
 
 	if err != nil {

+ 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

+ 9 - 0
go.sum

@@ -56,6 +56,7 @@ 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=
@@ -168,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=
@@ -694,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=
@@ -728,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=
@@ -744,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=
@@ -971,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=

+ 0 - 376
internal/forms/action.go

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

@@ -1,773 +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), false)
-
-	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), 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.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 TestPopulateServiceAccountClusterLocalhostAction(t *testing.T) {
-	// create the in-memory repository
-	repo := test.NewRepository(true)
-
-	// create a new project
-	repo.Project.CreateProject(&models.Project{
-		Name: "test-project",
-	})
-
-	// create a ServiceAccountCandidate from a kubeconfig
-	saCandidates, err := kubernetes.GetServiceAccountCandidates([]byte(ClusterLocalhost), false)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	for _, saCandidate := range saCandidates {
-		repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
-	}
-
-	// create a new form
-	form := forms.ClusterLocalhostAction{
-		ServiceAccountActionResolver: &forms.ServiceAccountActionResolver{
-			ServiceAccountCandidateID: 1,
-		},
-		ClusterHostname: "host.docker.internal",
-	}
-
-	err = form.PopulateServiceAccount(repo.ServiceAccount)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	sa, err := repo.ServiceAccount.CreateServiceAccount(form.ServiceAccountActionResolver.SA)
-	decodedStr, _ := base64.StdEncoding.DecodeString("LS0tLS1CRUdJTiBDRVJ=")
-
-	if len(sa.Clusters) != 1 {
-		t.Fatalf("cluster not written\n")
-	}
-
-	if sa.Clusters[0].ServiceAccountID != 1 {
-		t.Errorf("service account ID of joined cluster is not 1")
-	}
-
-	if sa.Clusters[0].Server != "https://host.docker.internal:30000" {
-		t.Errorf("service account cluster server is incorrect: expected %s, got %s\n",
-			"https://host.docker.internal:30000", sa.Clusters[0].Server)
-	}
-
-	if sa.AuthMechanism != "x509" {
-		t.Errorf("service account auth mechanism is not x509")
-	}
-
-	if string(sa.ClientCertificateData) != string(decodedStr) {
-		t.Errorf("service account cert data and input do not match: expected %s, got %s\n",
-			string(sa.ClientCertificateData), string(decodedStr))
-	}
-
-	if string(sa.ClientKeyData) != string(decodedStr) {
-		t.Errorf("service account key data and input do not match: expected %s, got %s\n",
-			string(sa.ClientKeyData), string(decodedStr))
-	}
-}
-
-func TestPopulateServiceAccountClientCertAction(t *testing.T) {
-	// create the in-memory repository
-	repo := test.NewRepository(true)
-
-	// 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.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), 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.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), 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.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), 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.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), 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.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), 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.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 ClusterLocalhost string = `
-apiVersion: v1
-kind: Config
-clusters:
-- name: cluster-test
-  cluster:
-    server: https://localhost:30000
-contexts:
-- context:
-    cluster: cluster-test
-    user: test-admin
-  name: context-test
-users:
-- name: test-admin
-  user:
-    client-certificate-data: LS0tLS1CRUdJTiBDRVJ=
-    client-key-data: LS0tLS1CRUdJTiBDRVJ=
-current-context: context-test
-`
-
-const ClientWithoutCertData string = `
-apiVersion: v1
-kind: Config
-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
-`

+ 14 - 10
internal/forms/candidate.go

@@ -5,9 +5,9 @@ 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"`
 
@@ -17,20 +17,24 @@ type CreateServiceAccountCandidatesForm struct {
 	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(
+func (csa *CreateClusterCandidatesForm) ToClusterCandidates(
 	isServerLocal bool,
-) ([]*models.ServiceAccountCandidate, error) {
-	// can only use "local" auth mechanism if the server is running locally
-	candidates, err := kubernetes.GetServiceAccountCandidates([]byte(csa.Kubeconfig), isServerLocal && csa.IsLocal)
+) ([]*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

+ 440 - 0
internal/forms/cluster.go

@@ -0,0 +1,440 @@
+package forms
+
+import (
+	"encoding/base64"
+	"errors"
+	"net/url"
+	"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"
+)
+
+// 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)

+ 189 - 13
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,9 +19,12 @@ 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
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+
+	// this line will register plugins
 	_ "k8s.io/client-go/plugin/pkg/client/auth"
 )
 
@@ -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,186 @@ 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{
+			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
+`

+ 165 - 389
internal/kubernetes/kubeconfig.go

@@ -1,30 +1,26 @@
 package kubernetes
 
 import (
-	"context"
+	"encoding/json"
 	"errors"
 	"net/url"
-	"strings"
 
 	"github.com/porter-dev/porter/internal/models"
-	"golang.org/x/oauth2/google"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/clientcmd/api"
-
-	"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.
 //
 // 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) {
+func GetClusterCandidatesFromKubeconfig(
+	kubeconfig []byte,
+	projectID uint,
+	local bool,
+) ([]*models.ClusterCandidate, error) {
 	config, err := clientcmd.NewClientConfigFromBytes(kubeconfig)
 
 	if err != nil {
@@ -37,28 +33,25 @@ func GetServiceAccountCandidates(kubeconfig []byte, local bool) ([]*models.Servi
 		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
 
-		actions := make([]models.ServiceAccountAction, 0)
-		var authMechanism string
+		resolvers := make([]models.ClusterResolver, 0)
+		var authMechanism models.ClusterAuth
 
 		if local {
 			authMechanism = models.Local
 		} else {
-			// get the auth mechanism and actions
-			authMechanism, actions = parseAuthInfoForActions(rawConf.AuthInfos[authInfoName])
-			clusterActions := parseClusterForActions(rawConf.Clusters[clusterName])
-			actions = append(actions, clusterActions...)
-
-			// if auth mechanism is unsupported, we'll skip it
-			if authMechanism == models.NotAvailable {
-				continue
-			} else if authMechanism == models.AWS {
+			// 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)
@@ -75,15 +68,15 @@ func GetServiceAccountCandidates(kubeconfig []byte, local bool) ([]*models.Servi
 		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,
 			})
 		}
@@ -92,6 +85,80 @@ func GetServiceAccountCandidates(kubeconfig []byte, local bool) ([]*models.Servi
 	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) {
@@ -118,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 {
@@ -149,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,
 				},
 			}
@@ -171,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,
 				},
 			}
@@ -182,35 +267,47 @@ 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 {
-		actions = append(actions, 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,
-			Filename: cluster.CertificateAuthority,
+			Data:     fnBytes,
 		})
 	}
 
@@ -218,14 +315,14 @@ func parseClusterForActions(cluster *api.Cluster) (actions []models.ServiceAccou
 
 	if err == nil {
 		if hostname := serverURL.Hostname(); hostname == "127.0.0.1" || hostname == "localhost" {
-			actions = append(actions, models.ServiceAccountAction{
-				Name:     models.ClusterLocalhostAction,
+			resolvers = append(resolvers, models.ClusterResolver{
+				Name:     models.ClusterLocalhost,
 				Resolved: false,
 			})
 		}
 	}
 
-	return actions
+	return resolvers
 }
 
 func parseAuthInfoForAWSClusterID(authInfo *api.AuthInfo, fallback string) string {
@@ -250,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,
@@ -285,327 +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) {
-	if sa.AuthMechanism == models.Local {
-		return clientcmd.NewClientConfigFromBytes(sa.Kubeconfig)
-	}
-
-	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)

+ 211 - 809
internal/kubernetes/kubeconfig_test.go

@@ -1,491 +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://10.10.10.10",
-				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://10.10.10.10",
-				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://10.10.10.10" {
-		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,
-						Filename: "/fake/path/to/ca.pem",
+						Data:     []byte(`{"filename":"/fake/path/to/ca.pem"}`),
 					},
 				},
-				Kind:            "connector",
-				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://10.10.10.10",
-				AuthMechanism:   models.X509,
-				Kubeconfig:      []byte(ClusterCAWithoutData),
+				Name:              "cluster-test",
+				Server:            "https://10.10.10.10",
+				ContextName:       "context-test",
+				Kubeconfig:        []byte(fixtures.ClusterCAWithoutData),
+				AWSClusterIDGuess: []byte{},
 			},
 		},
 	},
-	saCandidatesTest{
+	ccsTest{
 		name: "test cluster localhost",
-		raw:  []byte(ClusterLocalhost),
-		expected: []*models.ServiceAccountCandidate{
-			&models.ServiceAccountCandidate{
-				Actions: []models.ServiceAccountAction{
-					models.ServiceAccountAction{
-						Name:     "fix-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,
 					},
 				},
-				Kind:            "connector",
-				ClusterName:     "cluster-test",
-				ClusterEndpoint: "https://localhost",
-				AuthMechanism:   models.X509,
-				Kubeconfig:      []byte(ClusterLocalhost),
+				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://10.10.10.10",
-				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://10.10.10.10",
-				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://10.10.10.10",
-				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://10.10.10.10",
-				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://10.10.10.10",
-				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://10.10.10.10",
-				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://10.10.10.10",
-				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://10.10.10.10",
-				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://10.10.10.10",
-				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://10.10.10.10",
-				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://10.10.10.10",
-				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://10.10.10.10",
-				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 TestGetServiceAccountCandidatesNonLocal(t *testing.T) {
-	for _, c := range SACandidatesTests {
-		result, err := kubernetes.GetServiceAccountCandidates(c.raw, false)
+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
@@ -503,456 +342,19 @@ func TestGetServiceAccountCandidatesNonLocal(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), false)
-
-	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), false)
 
-	if err != nil {
-		t.Fatalf("error occurred %v\n", err)
-	}
+			// reset kubeconfigs since they won't be exactly "deep equal"
+			exp.Kubeconfig = []byte{}
+			res.Kubeconfig = []byte{}
 
-	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)
+			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://10.10.10.10
-  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://10.10.10.10
-  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://10.10.10.10
-  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://10.10.10.10
-  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://10.10.10.10
-  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://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
-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
-`

+ 0 - 127
internal/models/action.go

@@ -1,127 +0,0 @@
-package models
-
-import "gorm.io/gorm"
-
-// Action names
-const (
-	ClusterCADataAction    string = "upload-cluster-ca-data"
-	ClusterLocalhostAction        = "fix-cluster-localhost"
-	ClientCertDataAction          = "upload-client-cert-data"
-	ClientKeyDataAction           = "upload-client-key-data"
-	OIDCIssuerDataAction          = "upload-oidc-idp-issuer-ca-data"
-	TokenDataAction               = "upload-token-data"
-	GCPKeyDataAction              = "upload-gcp-key-data"
-	AWSDataAction                 = "upload-aws-data"
-)
-
-// ServiceAccountAction is an action that must be resolved to set up
-// 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"`
-	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"`
-}
-
-// 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",
-	},
-	"fix-cluster-localhost": ServiceAccountActionInfo{
-		Name:   ClusterLocalhostAction,
-		Docs:   "https://github.com/porter-dev/porter",
-		Fields: "cluster_hostname",
-	},
-	"upload-client-cert-data": ServiceAccountActionInfo{
-		Name:   ClientCertDataAction,
-		Docs:   "https://github.com/porter-dev/porter",
-		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",
-	},
-}

+ 304 - 25
internal/models/cluster.go

@@ -1,43 +1,322 @@
 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              = "basic"
+	Bearer             = "bearerToken"
+	OIDC               = "oidc"
+	GCP                = "gcp-sa"
+	AWS                = "aws-sa"
+	Local              = "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
 
-	// CertificateAuthorityData is encrypted at rest
+	// A token cache that can be used by an auth mechanism, if desired
+	TokenCache integrations.TokenCache `json:"token_cache"`
+
+	// 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"`
 }
 
 // Externalize generates an external Cluster to be shared over REST
 func (c *Cluster) Externalize() *ClusterExternal {
 	return &ClusterExternal{
-		ID:                    c.Model.ID,
-		ServiceAccountID:      c.ServiceAccountID,
-		Name:                  c.Name,
-		Server:                c.Server,
-		TLSServerName:         c.TLSServerName,
-		InsecureSkipTLSVerify: c.InsecureSkipTLSVerify,
-		ProxyURL:              c.ProxyURL,
+		ID:        c.ID,
+		ProjectID: c.ProjectID,
+		Name:      c.Name,
+		Server:    c.Server,
+	}
+}
+
+// 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"`
-}

+ 40 - 0
internal/models/gitrepo.go

@@ -0,0 +1,40 @@
+package models
+
+import (
+	"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"`
+}
+
+// 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,
+	}
+}

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

@@ -0,0 +1,150 @@
+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 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
+}

+ 109 - 0
internal/models/integrations/gcp.go

@@ -0,0 +1,109 @@
+package integrations
+
+import (
+	"context"
+
+	"golang.org/x/oauth2/google"
+	"gorm.io/gorm"
+)
+
+// GCPIntegration is an auth mechanism that uses a GCP service account to
+// authenticate
+type GCPIntegration 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 GCP project id where the service account for this auth mechanism persists
+	GCPProjectID string `json:"gcp-project-id"`
+
+	// The GCP user email that linked this service account
+	GCPUserEmail string `json:"gcp-user-email"`
+
+	// ------------------------------------------------------------------
+	// All fields encrypted before storage.
+	// ------------------------------------------------------------------
+
+	// KeyData for a service account for GCP connectors
+	GCPKeyData []byte `json:"gcp_key_data"`
+}
+
+// GCPIntegrationExternal is a GCPIntegration to be shared over REST
+type GCPIntegrationExternal 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 GCP project id where the service account for this auth mechanism persists
+	GCPProjectID string `json:"gcp-project-id"`
+
+	// The GCP user email that linked this service account
+	GCPUserEmail string `json:"gcp-user-email"`
+}
+
+// Externalize generates an external KubeIntegration to be shared over REST
+func (g *GCPIntegration) Externalize() *GCPIntegrationExternal {
+	return &GCPIntegrationExternal{
+		ID:           g.ID,
+		UserID:       g.UserID,
+		ProjectID:    g.ProjectID,
+		GCPProjectID: g.GCPProjectID,
+		GCPUserEmail: g.GCPUserEmail,
+	}
+}
+
+// ToProjectIntegration converts a gcp integration to a project integration
+func (g *GCPIntegration) ToProjectIntegration(
+	category string,
+	service IntegrationService,
+) *ProjectIntegration {
+	return &ProjectIntegration{
+		ID:            g.ID,
+		ProjectID:     g.ProjectID,
+		AuthMechanism: "gcp",
+		Category:      category,
+		Service:       service,
+	}
+}
+
+// GetBearerToken retrieves a bearer token for a GCP account
+func (g *GCPIntegration) GetBearerToken(
+	getTokenCache GetTokenCacheFunc,
+	setTokenCache SetTokenCacheFunc,
+) (string, error) {
+	cache, err := getTokenCache()
+
+	// check the token cache for a non-expired token
+	if tok := cache.Token; err == nil && !cache.IsExpired() && len(tok) > 0 {
+		return string(tok), nil
+	}
+
+	creds, err := google.CredentialsFromJSON(
+		context.Background(),
+		g.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
+	setTokenCache(tok.AccessToken, tok.Expiry)
+
+	return tok.AccessToken, nil
+}

+ 96 - 0
internal/models/integrations/integration.go

@@ -0,0 +1,96 @@
+package integrations
+
+// IntegrationService is the name of a third-party service
+type IntegrationService string
+
+// The list of supported third-party services
+const (
+	GKE    IntegrationService = "gke"
+	EKS                       = "eks"
+	Kube                      = "kube"
+	GCR                       = "gcr"
+	ECR                       = "ecr"
+	Github                    = "github"
+	Docker                    = "docker"
+)
+
+// PorterIntegration is a supported integration service, specifying an auth
+// mechanism and the category of integration. A single service can have multiple
+// auth mechanisms. For example, a GKE integration can have both an "oauth" mechanism
+// and a "gcp" mechanism:
+//
+// PorterIntegration{
+// 	AuthMechanism: "oauth",
+// 	Category: "cluster",
+// 	Service: GKE,
+// }
+//
+// PorterIntegration{
+// 	AuthMechanism: "gcp",
+// 	Category: "cluster",
+// 	Service: GKE,
+// }
+type PorterIntegration struct {
+	AuthMechanism string             `json:"auth_mechanism"`
+	Category      string             `json:"category"`
+	Service       IntegrationService `json:"service"`
+}
+
+// PorterClusterIntegrations are the supported cluster integrations
+var PorterClusterIntegrations = []PorterIntegration{
+	PorterIntegration{
+		AuthMechanism: "gcp",
+		Category:      "cluster",
+		Service:       GKE,
+	},
+	PorterIntegration{
+		AuthMechanism: "aws",
+		Category:      "cluster",
+		Service:       EKS,
+	},
+	PorterIntegration{
+		AuthMechanism: "kube",
+		Category:      "cluster",
+		Service:       Kube,
+	},
+}
+
+// PorterRegistryIntegrations are the supported registry integrations
+var PorterRegistryIntegrations = []PorterIntegration{
+	PorterIntegration{
+		AuthMechanism: "gcp",
+		Category:      "registry",
+		Service:       GCR,
+	},
+	PorterIntegration{
+		AuthMechanism: "aws",
+		Category:      "registry",
+		Service:       ECR,
+	},
+	PorterIntegration{
+		AuthMechanism: "oauth",
+		Category:      "registry",
+		Service:       Docker,
+	},
+}
+
+// PorterRepoIntegrations are the supported repo integrations
+var PorterRepoIntegrations = []PorterIntegration{
+	PorterIntegration{
+		AuthMechanism: "oauth",
+		Category:      "repo",
+		Service:       Github,
+	},
+}
+
+// ProjectIntegration is the top-level integration object for various integrations.
+// Although the integrations are stored in the DB by auth mechanism, the integrations
+// are cast to a ProjectIntegration for consolidation before passing on to the client.
+type ProjectIntegration struct {
+	ID        uint `json:"id"`
+	ProjectID uint `json:"project_id"`
+
+	AuthMechanism string             `json:"auth_mechanism"`
+	Category      string             `json:"category"`
+	Service       IntegrationService `json:"service"`
+}

+ 85 - 0
internal/models/integrations/kube.go

@@ -0,0 +1,85 @@
+package integrations
+
+import "gorm.io/gorm"
+
+// KubeIntegrationName is the name of a kube auth mechanism
+type KubeIntegrationName string
+
+// The supported kube auth mechanisms
+const (
+	KubeX509   KubeIntegrationName = "x509"
+	KubeBasic                      = "basic"
+	KubeBearer                     = "bearer"
+	KubeLocal                      = "local"
+)
+
+// KubeIntegration represents the kube-native auth mechanisms: using x509 certs,
+// basic (username/password), bearer tokens, or local (using local kubeconfig)
+type KubeIntegration struct {
+	gorm.Model
+
+	// The name of the auth mechanism
+	Mechanism KubeIntegrationName `json:"mechanism"`
+
+	// 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"`
+
+	// ------------------------------------------------------------------
+	// All fields encrypted before storage.
+	// ------------------------------------------------------------------
+
+	// Certificate data is used by x509 auth mechanisms over TLS
+	ClientCertificateData []byte `json:"client-certificate-data,omitempty"`
+	ClientKeyData         []byte `json:"client-key-data,omitempty"`
+
+	// Token is used for bearer-token auth mechanisms
+	Token []byte `json:"token,omitempty"`
+
+	// Username/Password for basic authentication to a cluster
+	Username []byte `json:"username,omitempty"`
+	Password []byte `json:"password,omitempty"`
+
+	// The raw kubeconfig, used by local auth mechanisms
+	Kubeconfig []byte `json:"kubeconfig"`
+}
+
+// KubeIntegrationExternal is a KubeIntegration to be shared over REST
+type KubeIntegrationExternal struct {
+	ID uint `json:"id"`
+
+	// The name of the auth mechanism
+	Mechanism KubeIntegrationName `json:"mechanism"`
+
+	// 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"`
+}
+
+// Externalize generates an external KubeIntegration to be shared over REST
+func (k *KubeIntegration) Externalize() *KubeIntegrationExternal {
+	return &KubeIntegrationExternal{
+		ID:        k.ID,
+		Mechanism: k.Mechanism,
+		UserID:    k.UserID,
+		ProjectID: k.ProjectID,
+	}
+}
+
+// ToProjectIntegration converts a gcp integration to a project integration
+func (k *KubeIntegration) ToProjectIntegration(
+	category string,
+	service IntegrationService,
+) *ProjectIntegration {
+	return &ProjectIntegration{
+		ID:            k.ID,
+		ProjectID:     k.ProjectID,
+		AuthMechanism: "kube",
+		Category:      category,
+		Service:       service,
+	}
+}

+ 77 - 0
internal/models/integrations/oauth.go

@@ -0,0 +1,77 @@
+package integrations
+
+import "gorm.io/gorm"
+
+// OAuthIntegrationClient is the name of an OAuth mechanism client
+type OAuthIntegrationClient string
+
+// The supported oauth mechanism clients
+const (
+	OAuthGithub OAuthIntegrationClient = "github"
+)
+
+// OAuthIntegration is an auth mechanism that uses oauth
+// https://tools.ietf.org/html/rfc6749
+type OAuthIntegration struct {
+	gorm.Model
+
+	// The name of the auth mechanism
+	Client OAuthIntegrationClient `json:"client"`
+
+	// 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"`
+
+	// ------------------------------------------------------------------
+	// All fields encrypted before storage.
+	// ------------------------------------------------------------------
+
+	// The ID issued to the client
+	ClientID []byte `json:"client-id"`
+
+	// The end-users's access token
+	AccessToken []byte `json:"access-token"`
+
+	// The end-user's refresh token
+	RefreshToken []byte `json:"refresh-token"`
+}
+
+// OAuthIntegrationExternal is an OAuthIntegration to be shared over REST
+type OAuthIntegrationExternal struct {
+	ID uint `json:"id"`
+
+	// The name of the auth mechanism
+	Client OAuthIntegrationClient `json:"client"`
+
+	// 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"`
+}
+
+// Externalize generates an external KubeIntegration to be shared over REST
+func (o *OAuthIntegration) Externalize() *OAuthIntegrationExternal {
+	return &OAuthIntegrationExternal{
+		ID:        o.ID,
+		Client:    o.Client,
+		UserID:    o.UserID,
+		ProjectID: o.ProjectID,
+	}
+}
+
+// ToProjectIntegration converts a gcp integration to a project integration
+func (o *OAuthIntegration) ToProjectIntegration(
+	category string,
+	service IntegrationService,
+) *ProjectIntegration {
+	return &ProjectIntegration{
+		ID:            o.ID,
+		ProjectID:     o.ProjectID,
+		AuthMechanism: "oauth",
+		Category:      category,
+		Service:       service,
+	}
+}

+ 90 - 0
internal/models/integrations/oidc.go

@@ -0,0 +1,90 @@
+package integrations
+
+import "gorm.io/gorm"
+
+// OIDCIntegrationClient is the name of an OIDC auth mechanism client
+type OIDCIntegrationClient string
+
+// The supported OIDC auth mechanism clients
+const (
+	OIDCKube OIDCIntegrationClient = "kube"
+)
+
+// OIDCIntegration is an auth mechanism that uses oidc. Spec:
+// https://openid.net/specs/openid-connect-core-1_0.html
+type OIDCIntegration struct {
+	gorm.Model
+
+	// The name of the auth mechanism
+	Client OIDCIntegrationClient `json:"client"`
+
+	// 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"`
+
+	// ------------------------------------------------------------------
+	// All fields encrypted before storage.
+	// ------------------------------------------------------------------
+
+	// The "Issuer Identifier" of the OIDC spec (16.15)
+	IssuerURL []byte `json:"idp-issuer-url"`
+
+	// The ID issued to the Relying Party
+	ClientID []byte `json:"client-id"`
+
+	// The secret issued to the Relying Party
+	//
+	// This is present because it used to be a required field in a kubeconfig.
+	// However, because the kube apiserver acts as a Relying Party, the client
+	// secret is not necessary.
+	ClientSecret []byte `json:"client-secret"`
+
+	// The CA data -- certificate check must be performed (16.17)
+	CertificateAuthorityData []byte `json:"idp-certificate-authority-data"`
+
+	// The user's JWT id token
+	IDToken []byte `json:"id-token"`
+
+	// The user's refresh token
+	RefreshToken []byte `json:"refresh-token"`
+}
+
+// OIDCIntegrationExternal is a OIDCIntegration to be shared over REST
+type OIDCIntegrationExternal struct {
+	ID uint `json:"id"`
+
+	// The name of the auth mechanism
+	Client OIDCIntegrationClient `json:"client"`
+
+	// 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"`
+}
+
+// Externalize generates an external KubeIntegration to be shared over REST
+func (o *OIDCIntegration) Externalize() *OIDCIntegrationExternal {
+	return &OIDCIntegrationExternal{
+		ID:        o.ID,
+		Client:    o.Client,
+		UserID:    o.UserID,
+		ProjectID: o.ProjectID,
+	}
+}
+
+// ToProjectIntegration converts a gcp integration to a project integration
+func (o *OIDCIntegration) ToProjectIntegration(
+	category string,
+	service IntegrationService,
+) *ProjectIntegration {
+	return &ProjectIntegration{
+		ID:            o.ID,
+		ProjectID:     o.ProjectID,
+		AuthMechanism: "oidc",
+		Category:      category,
+		Service:       service,
+	}
+}

+ 11 - 3
internal/models/tokencache.go → internal/models/integrations/token_cache.go

@@ -1,4 +1,4 @@
-package models
+package integrations
 
 import (
 	"time"
@@ -6,14 +6,22 @@ import (
 	"gorm.io/gorm"
 )
 
+// GetTokenCacheFunc is a function that retrieves the token and expiry
+// time from the db
+type GetTokenCacheFunc func() (tok *TokenCache, err error)
+
+// SetTokenCacheFunc is a function that updates the token cache
+// with a new token and expiry time
+type SetTokenCacheFunc func(token string, expiry time.Time) error
+
 // TokenCache stores a token and an expiration for the token for a
 // service account. This will never be shared over REST, so no need
 // to externalize.
 type TokenCache struct {
 	gorm.Model
 
-	ServiceAccountID uint      `json:"service_account_id"`
-	Expiry           time.Time `json:"expiry,omitempty"`
+	ClusterID uint      `json:"cluster_id"`
+	Expiry    time.Time `json:"expiry,omitempty"`
 
 	// ------------------------------------------------------------------
 	// All fields below this line are encrypted before storage

+ 31 - 16
internal/models/project.go

@@ -2,26 +2,41 @@ package models
 
 import (
 	"gorm.io/gorm"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 
 // Project type that extends gorm.Model
 type Project struct {
 	gorm.Model
 
-	Name        string       `json:"name"`
-	Roles       []Role       `json:"roles"`
-	RepoClients []RepoClient `json:"repo_clients,omitempty"`
+	Name  string `json:"name"`
+	Roles []Role `json:"roles"`
+
+	// linked repos
+	GitRepos []GitRepo `json:"git_repos,omitempty"`
+
+	// linked registries
+	Registries []Registry `json:"registries,omitempty"`
+
+	// linked clusters
+	Clusters          []Cluster          `json:"clusters"`
+	ClusterCandidates []ClusterCandidate `json:"cluster_candidates"`
 
-	ServiceAccountCandidates []ServiceAccountCandidate `json:"sa_candidates"`
-	ServiceAccounts          []ServiceAccount          `json:"serviceaccounts"`
+	// auth mechanisms
+	KubeIntegrations  []ints.KubeIntegration  `json:"kube_integrations"`
+	OIDCIntegrations  []ints.OIDCIntegration  `json:"oidc_integrations"`
+	OAuthIntegrations []ints.OAuthIntegration `json:"oauth_integrations"`
+	AWSIntegrations   []ints.AWSIntegration   `json:"aws_integrations"`
+	GCPIntegrations   []ints.GCPIntegration   `json:"gcp_integrations"`
 }
 
 // ProjectExternal represents the Project type that is sent over REST
 type ProjectExternal struct {
-	ID          uint                 `json:"id"`
-	Name        string               `json:"name"`
-	Roles       []RoleExternal       `json:"roles"`
-	RepoClients []RepoClientExternal `json:"repo_clients,omitempty"`
+	ID       uint              `json:"id"`
+	Name     string            `json:"name"`
+	Roles    []RoleExternal    `json:"roles"`
+	GitRepos []GitRepoExternal `json:"git_repos,omitempty"`
 }
 
 // Externalize generates an external Project to be shared over REST
@@ -32,16 +47,16 @@ func (p *Project) Externalize() *ProjectExternal {
 		roles = append(roles, *role.Externalize())
 	}
 
-	repoClients := make([]RepoClientExternal, 0)
+	repos := make([]GitRepoExternal, 0)
 
-	for _, repoClient := range p.RepoClients {
-		repoClients = append(repoClients, *repoClient.Externalize())
+	for _, repo := range p.GitRepos {
+		repos = append(repos, *repo.Externalize())
 	}
 
 	return &ProjectExternal{
-		ID:          p.ID,
-		Name:        p.Name,
-		Roles:       roles,
-		RepoClients: repoClients,
+		ID:       p.ID,
+		Name:     p.Name,
+		Roles:    roles,
+		GitRepos: repos,
 	}
 }

+ 44 - 0
internal/models/registry.go

@@ -0,0 +1,44 @@
+package models
+
+import (
+	"gorm.io/gorm"
+)
+
+// Registry is an integration that can connect to a Docker image registry via
+// a specific auth mechanism
+type Registry struct {
+	gorm.Model
+
+	// Name of the registry
+	Name string `json:"name"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// ------------------------------------------------------------------
+	// All fields below this line are encrypted before storage
+	// ------------------------------------------------------------------
+
+	GCPIntegrationID uint
+	AWSIntegrationID uint
+}
+
+// RegistryExternal is an external Registry to be shared over REST
+type RegistryExternal struct {
+	ID uint `json:"id"`
+
+	// The project that this integration belongs to
+	ProjectID uint `json:"project_id"`
+
+	// Name of the registry
+	Name string `json:"name"`
+}
+
+// Externalize generates an external Registry to be shared over REST
+func (r *Registry) Externalize() *RegistryExternal {
+	return &RegistryExternal{
+		ID:        r.ID,
+		ProjectID: r.ProjectID,
+		Name:      r.Name,
+	}
+}

+ 0 - 51
internal/models/repoclient.go

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

+ 0 - 172
internal/models/serviceaccount.go

@@ -1,172 +0,0 @@
-package models
-
-import (
-	"gorm.io/gorm"
-)
-
-// Supported auth mechanisms
-const (
-	X509         string = "x509"
-	Basic               = "basic"
-	Bearer              = "bearerToken"
-	OIDC                = "oidc"
-	GCP                 = "gcp-sa"
-	AWS                 = "aws-sa"
-	Local               = "local"
-	NotAvailable        = "n/a"
-)
-
-// ServiceAccountCandidate is a service account that requires an action
-// from the user to set up.
-type ServiceAccountCandidate struct {
-	gorm.Model
-
-	ProjectID uint   `json:"project_id"`
-	Kind      string `json:"kind"`
-
-	Actions []ServiceAccountAction `json:"actions"`
-
-	ContextName     string `json:"context_name"`
-	ClusterName     string `json:"cluster_name"`
-	ClusterEndpoint string `json:"cluster_endpoint"`
-	AuthMechanism   string `json:"auth_mechanism"`
-
-	// CreatedServiceAccountID is the ID of the service account that's eventually
-	// created
-	CreatedServiceAccountID uint `json:"create_sa_id"`
-
-	// 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"`
-
-	// ------------------------------------------------------------------
-	// All fields below this line are encrypted before storage
-	// ------------------------------------------------------------------
-
-	Kubeconfig []byte `json:"kubeconfig"`
-}
-
-// ServiceAccountCandidateExternal represents the ServiceAccountCandidate type that is
-// sent over REST
-type ServiceAccountCandidateExternal struct {
-	ID                      uint                           `json:"id"`
-	Actions                 []ServiceAccountActionExternal `json:"actions"`
-	ProjectID               uint                           `json:"project_id"`
-	Kind                    string                         `json:"kind"`
-	ContextName             string                         `json:"context_name"`
-	ClusterName             string                         `json:"cluster_name"`
-	ClusterEndpoint         string                         `json:"cluster_endpoint"`
-	AuthMechanism           string                         `json:"auth_mechanism"`
-	CreatedServiceAccountID uint                           `json:"created_sa_id"`
-	AWSClusterIDGuess       string                         `json:"aws_cluster_id_guess"`
-}
-
-// Externalize generates an external ServiceAccountCandidate to be shared over REST
-func (s *ServiceAccountCandidate) Externalize() *ServiceAccountCandidateExternal {
-	actions := make([]ServiceAccountActionExternal, 0)
-
-	for _, action := range s.Actions {
-		actions = append(actions, *action.Externalize())
-	}
-
-	return &ServiceAccountCandidateExternal{
-		ID:                      s.ID,
-		Actions:                 actions,
-		ProjectID:               s.ProjectID,
-		Kind:                    s.Kind,
-		ContextName:             s.ContextName,
-		ClusterName:             s.ClusterName,
-		ClusterEndpoint:         s.ClusterEndpoint,
-		AuthMechanism:           s.AuthMechanism,
-		CreatedServiceAccountID: s.CreatedServiceAccountID,
-		AWSClusterIDGuess:       s.AWSClusterIDGuess,
-	}
-}
-
-// ServiceAccount type that extends gorm.Model
-type ServiceAccount struct {
-	gorm.Model
-
-	ProjectID uint `json:"project_id"`
-
-	// Kind can either be "connector" or "provisioner"
-	Kind string `json:"kind"`
-
-	// Clusters is a list of clusters that this ServiceAccount can connect
-	// to or has provisioned
-	Clusters []Cluster `json:"clusters"`
-
-	// AuthMechanism is the strategy used for either connecting to or provisioning
-	// the cluster. Supported mechanisms are: basic,x509,bearerToken,oidc,gcp-sa,aws-sa
-	AuthMechanism string `json:"auth_mechanism"`
-
-	// These fields are used by all auth mechanisms
-	LocationOfOrigin  string
-	Impersonate       string `json:"act-as,omitempty"`
-	ImpersonateGroups string `json:"act-as-groups,omitempty"`
-
-	// ------------------------------------------------------------------
-	// All fields below this line are encrypted before storage, so type is
-	// byte.
-	// ------------------------------------------------------------------
-
-	// Certificate data is used by x509 auth mechanisms over TLS
-	ClientCertificateData []byte `json:"client-certificate-data,omitempty"`
-	ClientKeyData         []byte `json:"client-key-data,omitempty"`
-
-	// Token is used for bearer-token auth mechanisms
-	Token []byte `json:"token,omitempty"`
-
-	// Username/Password for basic authentication to a cluster
-	Username []byte `json:"username,omitempty"`
-	Password []byte `json:"password,omitempty"`
-
-	// TokenCache is a cache for bearer tokens with an expiry time
-	// Used by GCP and AWS mechanisms
-	TokenCache TokenCache `json:"token_cache"`
-
-	// KeyData for a service account for GCP connectors
-	GCPKeyData []byte `json:"gcp_key_data"`
-
-	// AWS data
-	AWSAccessKeyID     []byte `json:"aws_access_key_id"`
-	AWSSecretAccessKey []byte `json:"aws_secret_access_key"`
-	AWSClusterID       []byte `json:"aws_cluster_id"`
-
-	// OIDC-related fields
-	OIDCIssuerURL                []byte `json:"idp-issuer-url"`
-	OIDCClientID                 []byte `json:"client-id"`
-	OIDCClientSecret             []byte `json:"client-secret"`
-	OIDCCertificateAuthorityData []byte `json:"idp-certificate-authority-data"`
-	OIDCIDToken                  []byte `json:"id-token"`
-	OIDCRefreshToken             []byte `json:"refresh-token"`
-
-	// The raw kubeconfig, used by local auth mechanisms
-	Kubeconfig []byte `json:"kubeconfig"`
-}
-
-// ServiceAccountExternal is an external ServiceAccount to be shared over REST
-type ServiceAccountExternal struct {
-	ID            uint              `json:"id"`
-	ProjectID     uint              `json:"project_id"`
-	Kind          string            `json:"kind"`
-	Clusters      []ClusterExternal `json:"clusters"`
-	AuthMechanism string            `json:"auth_mechanism"`
-}
-
-// Externalize generates an external ServiceAccount to be shared over REST
-func (s *ServiceAccount) Externalize() *ServiceAccountExternal {
-	clusters := make([]ClusterExternal, 0)
-
-	for _, cluster := range s.Clusters {
-		clusters = append(clusters, *cluster.Externalize())
-	}
-
-	return &ServiceAccountExternal{
-		ID:            s.ID,
-		ProjectID:     s.ProjectID,
-		Kind:          s.Kind,
-		Clusters:      clusters,
-		AuthMechanism: s.AuthMechanism,
-	}
-}

+ 90 - 0
internal/registry/registry.go

@@ -0,0 +1,90 @@
+package registry
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/aws/aws-sdk-go/service/ecr"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+)
+
+// Registry wraps the gorm Registry model
+type Registry models.Registry
+
+// Repository is a collection of images
+type Repository struct {
+	// Name of the repository
+	Name string `json:"name"`
+
+	// When the repository was created
+	CreatedAt time.Time `json:"created_at,omitempty"`
+}
+
+// Image is a Docker image type
+type Image struct {
+	// The sha256 digest of the image manifest.
+	Digest string `json:"digest"`
+
+	// The tag used for the image.
+	Tag string `json:"tag"`
+
+	// The image manifest associated with the image.
+	Manifest string `json:"manifest"`
+
+	// The name of the repository associated with the image.
+	RepositoryName string `json:"repository_name"`
+}
+
+// ListRepositories lists the repositories for a registry
+func (r *Registry) ListRepositories(repo repository.Repository) ([]*Repository, error) {
+	// switch on the auth mechanism to get a token
+	if r.AWSIntegrationID != 0 {
+		return r.listECRRepositories(repo.AWSIntegration)
+	}
+
+	return nil, fmt.Errorf("error listing repositories")
+}
+
+// ListImages lists the images for an image repository
+func (r *Registry) ListImages(
+	repo repository.Repository,
+	regName string,
+) ([]*Image, error) {
+	return nil, nil
+}
+
+func (r *Registry) listECRRepositories(repo repository.AWSIntegrationRepository) ([]*Repository, error) {
+	aws, err := repo.ReadAWSIntegration(
+		r.AWSIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	sess, err := aws.GetSession()
+
+	if err != nil {
+		return nil, err
+	}
+
+	svc := ecr.New(sess)
+
+	resp, err := svc.DescribeRepositories(&ecr.DescribeRepositoriesInput{})
+
+	if err != nil {
+		return nil, err
+	}
+
+	res := make([]*Repository, 0)
+
+	for _, repo := range resp.Repositories {
+		res = append(res, &Repository{
+			Name:      *repo.RepositoryName,
+			CreatedAt: *repo.CreatedAt,
+		})
+	}
+
+	return res, nil
+}

+ 20 - 0
internal/repository/cluster.go

@@ -0,0 +1,20 @@
+package repository
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// ClusterRepository represents the set of queries on the
+// Cluster model
+type ClusterRepository interface {
+	CreateClusterCandidate(cc *models.ClusterCandidate) (*models.ClusterCandidate, error)
+	ReadClusterCandidate(id uint) (*models.ClusterCandidate, error)
+	ListClusterCandidatesByProjectID(projectID uint) ([]*models.ClusterCandidate, error)
+	UpdateClusterCandidateCreatedClusterID(id uint, createdClusterID uint) (*models.ClusterCandidate, error)
+
+	CreateCluster(cluster *models.Cluster) (*models.Cluster, error)
+	ReadCluster(id uint) (*models.Cluster, error)
+	ListClustersByProjectID(projectID uint) ([]*models.Cluster, error)
+	UpdateClusterTokenCache(tokenCache *ints.TokenCache) (*models.Cluster, error)
+}

+ 11 - 0
internal/repository/gitrepo.go

@@ -0,0 +1,11 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+// GitRepoRepository represents the set of queries on the
+// GitRepo model
+type GitRepoRepository interface {
+	CreateGitRepo(gr *models.GitRepo) (*models.GitRepo, error)
+	ReadGitRepo(id uint) (*models.GitRepo, error)
+	ListGitReposByProjectID(projectID uint) ([]*models.GitRepo, error)
+}

+ 909 - 0
internal/repository/gorm/auth.go

@@ -0,0 +1,909 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// KubeIntegrationRepository uses gorm.DB for querying the database
+type KubeIntegrationRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewKubeIntegrationRepository returns a KubeIntegrationRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewKubeIntegrationRepository(
+	db *gorm.DB,
+	key *[32]byte,
+) repository.KubeIntegrationRepository {
+	return &KubeIntegrationRepository{db, key}
+}
+
+// CreateKubeIntegration creates a new kube auth mechanism
+func (repo *KubeIntegrationRepository) CreateKubeIntegration(
+	am *ints.KubeIntegration,
+) (*ints.KubeIntegration, error) {
+	err := repo.EncryptKubeIntegrationData(am, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", am.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("KubeIntegrations")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(am); err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}
+
+// ReadKubeIntegration finds a kube auth mechanism by id
+func (repo *KubeIntegrationRepository) ReadKubeIntegration(
+	id uint,
+) (*ints.KubeIntegration, error) {
+	ki := &ints.KubeIntegration{}
+
+	if err := repo.db.Where("id = ?", id).First(&ki).Error; err != nil {
+		return nil, err
+	}
+
+	err := repo.DecryptKubeIntegrationData(ki, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return ki, nil
+}
+
+// ListKubeIntegrationsByProjectID finds all kube auth mechanisms
+// for a given project id
+func (repo *KubeIntegrationRepository) ListKubeIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.KubeIntegration, error) {
+	kis := []*ints.KubeIntegration{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&kis).Error; err != nil {
+		return nil, err
+	}
+
+	for _, ki := range kis {
+		repo.DecryptKubeIntegrationData(ki, repo.key)
+	}
+
+	return kis, nil
+}
+
+// EncryptKubeIntegrationData will encrypt the kube integration data before
+// writing to the DB
+func (repo *KubeIntegrationRepository) EncryptKubeIntegrationData(
+	ki *ints.KubeIntegration,
+	key *[32]byte,
+) error {
+	if len(ki.ClientCertificateData) > 0 {
+		cipherData, err := repository.Encrypt(ki.ClientCertificateData, key)
+
+		if err != nil {
+			return err
+		}
+
+		ki.ClientCertificateData = cipherData
+	}
+
+	if len(ki.ClientKeyData) > 0 {
+		cipherData, err := repository.Encrypt(ki.ClientKeyData, key)
+
+		if err != nil {
+			return err
+		}
+
+		ki.ClientKeyData = cipherData
+	}
+
+	if len(ki.Token) > 0 {
+		cipherData, err := repository.Encrypt(ki.Token, key)
+
+		if err != nil {
+			return err
+		}
+
+		ki.Token = cipherData
+	}
+
+	if len(ki.Username) > 0 {
+		cipherData, err := repository.Encrypt(ki.Username, key)
+
+		if err != nil {
+			return err
+		}
+
+		ki.Username = cipherData
+	}
+
+	if len(ki.Password) > 0 {
+		cipherData, err := repository.Encrypt(ki.Password, key)
+
+		if err != nil {
+			return err
+		}
+
+		ki.Password = cipherData
+	}
+
+	if len(ki.Kubeconfig) > 0 {
+		cipherData, err := repository.Encrypt(ki.Kubeconfig, key)
+
+		if err != nil {
+			return err
+		}
+
+		ki.Kubeconfig = cipherData
+	}
+
+	return nil
+}
+
+// DecryptKubeIntegrationData will decrypt the kube integration data before
+// returning it from the DB
+func (repo *KubeIntegrationRepository) DecryptKubeIntegrationData(
+	ki *ints.KubeIntegration,
+	key *[32]byte,
+) error {
+	if len(ki.ClientCertificateData) > 0 {
+		plaintext, err := repository.Decrypt(ki.ClientCertificateData, key)
+
+		if err != nil {
+			return err
+		}
+
+		ki.ClientCertificateData = plaintext
+	}
+
+	if len(ki.ClientKeyData) > 0 {
+		plaintext, err := repository.Decrypt(ki.ClientKeyData, key)
+
+		if err != nil {
+			return err
+		}
+
+		ki.ClientKeyData = plaintext
+	}
+
+	if len(ki.Token) > 0 {
+		plaintext, err := repository.Decrypt(ki.Token, key)
+
+		if err != nil {
+			return err
+		}
+
+		ki.Token = plaintext
+	}
+
+	if len(ki.Username) > 0 {
+		plaintext, err := repository.Decrypt(ki.Username, key)
+
+		if err != nil {
+			return err
+		}
+
+		ki.Username = plaintext
+	}
+
+	if len(ki.Password) > 0 {
+		plaintext, err := repository.Decrypt(ki.Password, key)
+
+		if err != nil {
+			return err
+		}
+
+		ki.Password = plaintext
+	}
+
+	if len(ki.Kubeconfig) > 0 {
+		plaintext, err := repository.Decrypt(ki.Kubeconfig, key)
+
+		if err != nil {
+			return err
+		}
+
+		ki.Kubeconfig = plaintext
+	}
+
+	return nil
+}
+
+// OIDCIntegrationRepository uses gorm.DB for querying the database
+type OIDCIntegrationRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewOIDCIntegrationRepository returns a OIDCIntegrationRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewOIDCIntegrationRepository(
+	db *gorm.DB,
+	key *[32]byte,
+) repository.OIDCIntegrationRepository {
+	return &OIDCIntegrationRepository{db, key}
+}
+
+// CreateOIDCIntegration creates a new oidc auth mechanism
+func (repo *OIDCIntegrationRepository) CreateOIDCIntegration(
+	am *ints.OIDCIntegration,
+) (*ints.OIDCIntegration, error) {
+	err := repo.EncryptOIDCIntegrationData(am, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", am.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("OIDCIntegrations")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(am); err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}
+
+// ReadOIDCIntegration finds a oidc auth mechanism by id
+func (repo *OIDCIntegrationRepository) ReadOIDCIntegration(
+	id uint,
+) (*ints.OIDCIntegration, error) {
+	oidc := &ints.OIDCIntegration{}
+
+	if err := repo.db.Where("id = ?", id).First(&oidc).Error; err != nil {
+		return nil, err
+	}
+
+	err := repo.DecryptOIDCIntegrationData(oidc, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return oidc, nil
+}
+
+// ListOIDCIntegrationsByProjectID finds all oidc auth mechanisms
+// for a given project id
+func (repo *OIDCIntegrationRepository) ListOIDCIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.OIDCIntegration, error) {
+	oidcs := []*ints.OIDCIntegration{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&oidcs).Error; err != nil {
+		return nil, err
+	}
+
+	for _, oidc := range oidcs {
+		repo.DecryptOIDCIntegrationData(oidc, repo.key)
+	}
+
+	return oidcs, nil
+}
+
+// EncryptOIDCIntegrationData will encrypt the oidc integration data before
+// writing to the DB
+func (repo *OIDCIntegrationRepository) EncryptOIDCIntegrationData(
+	oidc *ints.OIDCIntegration,
+	key *[32]byte,
+) error {
+	if len(oidc.IssuerURL) > 0 {
+		cipherData, err := repository.Encrypt(oidc.IssuerURL, key)
+
+		if err != nil {
+			return err
+		}
+
+		oidc.IssuerURL = cipherData
+	}
+
+	if len(oidc.ClientID) > 0 {
+		cipherData, err := repository.Encrypt(oidc.ClientID, key)
+
+		if err != nil {
+			return err
+		}
+
+		oidc.ClientID = cipherData
+	}
+
+	if len(oidc.ClientSecret) > 0 {
+		cipherData, err := repository.Encrypt(oidc.ClientSecret, key)
+
+		if err != nil {
+			return err
+		}
+
+		oidc.ClientSecret = cipherData
+	}
+
+	if len(oidc.CertificateAuthorityData) > 0 {
+		cipherData, err := repository.Encrypt(oidc.CertificateAuthorityData, key)
+
+		if err != nil {
+			return err
+		}
+
+		oidc.CertificateAuthorityData = cipherData
+	}
+
+	if len(oidc.IDToken) > 0 {
+		cipherData, err := repository.Encrypt(oidc.IDToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		oidc.IDToken = cipherData
+	}
+
+	if len(oidc.RefreshToken) > 0 {
+		cipherData, err := repository.Encrypt(oidc.RefreshToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		oidc.RefreshToken = cipherData
+	}
+
+	return nil
+}
+
+// DecryptOIDCIntegrationData will decrypt the kube integration data before
+// returning it from the DB
+func (repo *OIDCIntegrationRepository) DecryptOIDCIntegrationData(
+	oidc *ints.OIDCIntegration,
+	key *[32]byte,
+) error {
+	if len(oidc.IssuerURL) > 0 {
+		plaintext, err := repository.Decrypt(oidc.IssuerURL, key)
+
+		if err != nil {
+			return err
+		}
+
+		oidc.IssuerURL = plaintext
+	}
+
+	if len(oidc.ClientID) > 0 {
+		plaintext, err := repository.Decrypt(oidc.ClientID, key)
+
+		if err != nil {
+			return err
+		}
+
+		oidc.ClientID = plaintext
+	}
+
+	if len(oidc.ClientSecret) > 0 {
+		plaintext, err := repository.Decrypt(oidc.ClientSecret, key)
+
+		if err != nil {
+			return err
+		}
+
+		oidc.ClientSecret = plaintext
+	}
+
+	if len(oidc.CertificateAuthorityData) > 0 {
+		plaintext, err := repository.Decrypt(oidc.CertificateAuthorityData, key)
+
+		if err != nil {
+			return err
+		}
+
+		oidc.CertificateAuthorityData = plaintext
+	}
+
+	if len(oidc.IDToken) > 0 {
+		plaintext, err := repository.Decrypt(oidc.IDToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		oidc.IDToken = plaintext
+	}
+
+	if len(oidc.RefreshToken) > 0 {
+		plaintext, err := repository.Decrypt(oidc.RefreshToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		oidc.RefreshToken = plaintext
+	}
+
+	return nil
+}
+
+// OAuthIntegrationRepository uses gorm.DB for querying the database
+type OAuthIntegrationRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewOAuthIntegrationRepository returns a OAuthIntegrationRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewOAuthIntegrationRepository(
+	db *gorm.DB,
+	key *[32]byte,
+) repository.OAuthIntegrationRepository {
+	return &OAuthIntegrationRepository{db, key}
+}
+
+// CreateOAuthIntegration creates a new oauth auth mechanism
+func (repo *OAuthIntegrationRepository) CreateOAuthIntegration(
+	am *ints.OAuthIntegration,
+) (*ints.OAuthIntegration, error) {
+	err := repo.EncryptOAuthIntegrationData(am, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", am.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("OAuthIntegrations")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(am); err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}
+
+// ReadOAuthIntegration finds a oauth auth mechanism by id
+func (repo *OAuthIntegrationRepository) ReadOAuthIntegration(
+	id uint,
+) (*ints.OAuthIntegration, error) {
+	oauth := &ints.OAuthIntegration{}
+
+	if err := repo.db.Where("id = ?", id).First(&oauth).Error; err != nil {
+		return nil, err
+	}
+
+	err := repo.DecryptOAuthIntegrationData(oauth, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return oauth, nil
+}
+
+// ListOAuthIntegrationsByProjectID finds all oauth auth mechanisms
+// for a given project id
+func (repo *OAuthIntegrationRepository) ListOAuthIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.OAuthIntegration, error) {
+	oauths := []*ints.OAuthIntegration{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&oauths).Error; err != nil {
+		return nil, err
+	}
+
+	for _, oauth := range oauths {
+		repo.DecryptOAuthIntegrationData(oauth, repo.key)
+	}
+
+	return oauths, nil
+}
+
+// EncryptOAuthIntegrationData will encrypt the oauth integration data before
+// writing to the DB
+func (repo *OAuthIntegrationRepository) EncryptOAuthIntegrationData(
+	oauth *ints.OAuthIntegration,
+	key *[32]byte,
+) error {
+	if len(oauth.ClientID) > 0 {
+		cipherData, err := repository.Encrypt(oauth.ClientID, key)
+
+		if err != nil {
+			return err
+		}
+
+		oauth.ClientID = cipherData
+	}
+
+	if len(oauth.AccessToken) > 0 {
+		cipherData, err := repository.Encrypt(oauth.AccessToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		oauth.AccessToken = cipherData
+	}
+
+	if len(oauth.RefreshToken) > 0 {
+		cipherData, err := repository.Encrypt(oauth.RefreshToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		oauth.RefreshToken = cipherData
+	}
+
+	return nil
+}
+
+// DecryptOAuthIntegrationData will decrypt the oauth integration data before
+// returning it from the DB
+func (repo *OAuthIntegrationRepository) DecryptOAuthIntegrationData(
+	oauth *ints.OAuthIntegration,
+	key *[32]byte,
+) error {
+	if len(oauth.ClientID) > 0 {
+		plaintext, err := repository.Decrypt(oauth.ClientID, key)
+
+		if err != nil {
+			return err
+		}
+
+		oauth.ClientID = plaintext
+	}
+
+	if len(oauth.AccessToken) > 0 {
+		plaintext, err := repository.Decrypt(oauth.AccessToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		oauth.AccessToken = plaintext
+	}
+
+	if len(oauth.RefreshToken) > 0 {
+		plaintext, err := repository.Decrypt(oauth.RefreshToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		oauth.RefreshToken = plaintext
+	}
+
+	return nil
+}
+
+// GCPIntegrationRepository uses gorm.DB for querying the database
+type GCPIntegrationRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewGCPIntegrationRepository returns a GCPIntegrationRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewGCPIntegrationRepository(
+	db *gorm.DB,
+	key *[32]byte,
+) repository.GCPIntegrationRepository {
+	return &GCPIntegrationRepository{db, key}
+}
+
+// CreateGCPIntegration creates a new gcp auth mechanism
+func (repo *GCPIntegrationRepository) CreateGCPIntegration(
+	am *ints.GCPIntegration,
+) (*ints.GCPIntegration, error) {
+	err := repo.EncryptGCPIntegrationData(am, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", am.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("GCPIntegrations")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(am); err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}
+
+// ReadGCPIntegration finds a gcp auth mechanism by id
+func (repo *GCPIntegrationRepository) ReadGCPIntegration(
+	id uint,
+) (*ints.GCPIntegration, error) {
+	gcp := &ints.GCPIntegration{}
+
+	if err := repo.db.Where("id = ?", id).First(&gcp).Error; err != nil {
+		return nil, err
+	}
+
+	err := repo.DecryptGCPIntegrationData(gcp, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return gcp, nil
+}
+
+// ListGCPIntegrationsByProjectID finds all gcp auth mechanisms
+// for a given project id
+func (repo *GCPIntegrationRepository) ListGCPIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.GCPIntegration, error) {
+	gcps := []*ints.GCPIntegration{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&gcps).Error; err != nil {
+		return nil, err
+	}
+
+	for _, gcp := range gcps {
+		repo.DecryptGCPIntegrationData(gcp, repo.key)
+	}
+
+	return gcps, nil
+}
+
+// EncryptGCPIntegrationData will encrypt the gcp integration data before
+// writing to the DB
+func (repo *GCPIntegrationRepository) EncryptGCPIntegrationData(
+	gcp *ints.GCPIntegration,
+	key *[32]byte,
+) error {
+	if len(gcp.GCPKeyData) > 0 {
+		cipherData, err := repository.Encrypt(gcp.GCPKeyData, key)
+
+		if err != nil {
+			return err
+		}
+
+		gcp.GCPKeyData = cipherData
+	}
+
+	return nil
+}
+
+// DecryptGCPIntegrationData will decrypt the gcp integration data before
+// returning it from the DB
+func (repo *GCPIntegrationRepository) DecryptGCPIntegrationData(
+	gcp *ints.GCPIntegration,
+	key *[32]byte,
+) error {
+	if len(gcp.GCPKeyData) > 0 {
+		plaintext, err := repository.Decrypt(gcp.GCPKeyData, key)
+
+		if err != nil {
+			return err
+		}
+
+		gcp.GCPKeyData = plaintext
+	}
+
+	return nil
+}
+
+// AWSIntegrationRepository uses gorm.DB for querying the database
+type AWSIntegrationRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewAWSIntegrationRepository returns a AWSIntegrationRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewAWSIntegrationRepository(
+	db *gorm.DB,
+	key *[32]byte,
+) repository.AWSIntegrationRepository {
+	return &AWSIntegrationRepository{db, key}
+}
+
+// CreateAWSIntegration creates a new aws auth mechanism
+func (repo *AWSIntegrationRepository) CreateAWSIntegration(
+	am *ints.AWSIntegration,
+) (*ints.AWSIntegration, error) {
+	err := repo.EncryptAWSIntegrationData(am, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", am.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("AWSIntegrations")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(am); err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}
+
+// ReadAWSIntegration finds a aws auth mechanism by id
+func (repo *AWSIntegrationRepository) ReadAWSIntegration(
+	id uint,
+) (*ints.AWSIntegration, error) {
+	aws := &ints.AWSIntegration{}
+
+	if err := repo.db.Where("id = ?", id).First(&aws).Error; err != nil {
+		return nil, err
+	}
+
+	err := repo.DecryptAWSIntegrationData(aws, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return aws, nil
+}
+
+// ListAWSIntegrationsByProjectID finds all aws auth mechanisms
+// for a given project id
+func (repo *AWSIntegrationRepository) ListAWSIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.AWSIntegration, error) {
+	awss := []*ints.AWSIntegration{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&awss).Error; err != nil {
+		return nil, err
+	}
+
+	for _, aws := range awss {
+		repo.DecryptAWSIntegrationData(aws, repo.key)
+	}
+
+	return awss, nil
+}
+
+// EncryptAWSIntegrationData will encrypt the aws integration data before
+// writing to the DB
+func (repo *AWSIntegrationRepository) EncryptAWSIntegrationData(
+	aws *ints.AWSIntegration,
+	key *[32]byte,
+) error {
+	if len(aws.AWSClusterID) > 0 {
+		cipherData, err := repository.Encrypt(aws.AWSClusterID, key)
+
+		if err != nil {
+			return err
+		}
+
+		aws.AWSClusterID = cipherData
+	}
+
+	if len(aws.AWSAccessKeyID) > 0 {
+		cipherData, err := repository.Encrypt(aws.AWSAccessKeyID, key)
+
+		if err != nil {
+			return err
+		}
+
+		aws.AWSAccessKeyID = cipherData
+	}
+
+	if len(aws.AWSSecretAccessKey) > 0 {
+		cipherData, err := repository.Encrypt(aws.AWSSecretAccessKey, key)
+
+		if err != nil {
+			return err
+		}
+
+		aws.AWSSecretAccessKey = cipherData
+	}
+
+	if len(aws.AWSSessionToken) > 0 {
+		cipherData, err := repository.Encrypt(aws.AWSSessionToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		aws.AWSSessionToken = cipherData
+	}
+
+	return nil
+}
+
+// DecryptAWSIntegrationData will decrypt the aws integration data before
+// returning it from the DB
+func (repo *AWSIntegrationRepository) DecryptAWSIntegrationData(
+	aws *ints.AWSIntegration,
+	key *[32]byte,
+) error {
+	if len(aws.AWSClusterID) > 0 {
+		plaintext, err := repository.Decrypt(aws.AWSClusterID, key)
+
+		if err != nil {
+			return err
+		}
+
+		aws.AWSClusterID = plaintext
+	}
+
+	if len(aws.AWSAccessKeyID) > 0 {
+		plaintext, err := repository.Decrypt(aws.AWSAccessKeyID, key)
+
+		if err != nil {
+			return err
+		}
+
+		aws.AWSAccessKeyID = plaintext
+	}
+
+	if len(aws.AWSSecretAccessKey) > 0 {
+		plaintext, err := repository.Decrypt(aws.AWSSecretAccessKey, key)
+
+		if err != nil {
+			return err
+		}
+
+		aws.AWSSecretAccessKey = plaintext
+	}
+
+	if len(aws.AWSSessionToken) > 0 {
+		plaintext, err := repository.Decrypt(aws.AWSSessionToken, key)
+
+		if err != nil {
+			return err
+		}
+
+		aws.AWSSessionToken = plaintext
+	}
+
+	return nil
+}

+ 461 - 0
internal/repository/gorm/auth_test.go

@@ -0,0 +1,461 @@
+package gorm_test
+
+import (
+	"testing"
+
+	"github.com/go-test/deep"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	orm "gorm.io/gorm"
+)
+
+func TestCreateKubeIntegration(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_ki.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	ki := &ints.KubeIntegration{
+		Mechanism:  ints.KubeLocal,
+		ProjectID:  tester.initProjects[0].ID,
+		UserID:     tester.initUsers[0].ID,
+		Kubeconfig: []byte("current-context: testing\n"),
+	}
+
+	expKI := *ki
+
+	ki, err := tester.repo.KubeIntegration.CreateKubeIntegration(ki)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	ki, err = tester.repo.KubeIntegration.ReadKubeIntegration(ki.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if ki.Model.ID != 1 {
+		t.Errorf("incorrect kube integration ID: expected %d, got %d\n", 1, ki.Model.ID)
+	}
+
+	// reset fields for deep.Equal
+	ki.Model = orm.Model{}
+
+	if diff := deep.Equal(expKI, *ki); diff != nil {
+		t.Errorf("incorrect kube integration")
+		t.Error(diff)
+	}
+}
+
+func TestListKubeIntegrationsByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_kis.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initKubeIntegration(tester, t)
+	defer cleanup(tester, t)
+
+	kis, err := tester.repo.KubeIntegration.ListKubeIntegrationsByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(kis) != 1 {
+		t.Fatalf("length of kube integrations incorrect: expected %d, got %d\n", 1, len(kis))
+	}
+
+	// make sure data is correct
+	expKI := ints.KubeIntegration{
+		Mechanism:  ints.KubeLocal,
+		ProjectID:  tester.initProjects[0].ID,
+		UserID:     tester.initUsers[0].ID,
+		Kubeconfig: []byte("current-context: testing\n"),
+	}
+
+	ki := kis[0]
+
+	// reset fields for reflect.DeepEqual
+	ki.Model = orm.Model{}
+
+	if diff := deep.Equal(expKI, *ki); diff != nil {
+		t.Errorf("incorrect kube integration")
+		t.Error(diff)
+	}
+}
+
+func TestCreateOIDCIntegration(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_oidc.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	defer cleanup(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"),
+	}
+
+	expOIDC := *oidc
+
+	oidc, err := tester.repo.OIDCIntegration.CreateOIDCIntegration(oidc)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	oidc, err = tester.repo.OIDCIntegration.ReadOIDCIntegration(oidc.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if oidc.Model.ID != 1 {
+		t.Errorf("incorrect oidc integration ID: expected %d, got %d\n", 1, oidc.Model.ID)
+	}
+
+	// reset fields for deep.Equal
+	oidc.Model = orm.Model{}
+
+	if diff := deep.Equal(expOIDC, *oidc); diff != nil {
+		t.Errorf("incorrect oidc integration")
+		t.Error(diff)
+	}
+}
+
+func TestListOIDCIntegrationsByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_oidcs.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initOIDCIntegration(tester, t)
+	defer cleanup(tester, t)
+
+	oidcs, err := tester.repo.OIDCIntegration.ListOIDCIntegrationsByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(oidcs) != 1 {
+		t.Fatalf("length of oidc integrations incorrect: expected %d, got %d\n", 1, len(oidcs))
+	}
+
+	// make sure data is correct
+	expOIDC := 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 := oidcs[0]
+
+	// reset fields for reflect.DeepEqual
+	oidc.Model = orm.Model{}
+
+	if diff := deep.Equal(expOIDC, *oidc); diff != nil {
+		t.Errorf("incorrect oidc integration")
+		t.Error(diff)
+	}
+}
+
+func TestCreateOAuthIntegration(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_oauth.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	defer cleanup(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"),
+	}
+
+	expOAuth := *oauth
+
+	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	oauth, err = tester.repo.OAuthIntegration.ReadOAuthIntegration(oauth.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if oauth.Model.ID != 1 {
+		t.Errorf("incorrect oauth integration ID: expected %d, got %d\n", 1, oauth.Model.ID)
+	}
+
+	// reset fields for deep.Equal
+	oauth.Model = orm.Model{}
+
+	if diff := deep.Equal(expOAuth, *oauth); diff != nil {
+		t.Errorf("incorrect oauth integration")
+		t.Error(diff)
+	}
+}
+
+func TestListOAuthIntegrationsByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_oauths.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initOAuthIntegration(tester, t)
+	defer cleanup(tester, t)
+
+	oauths, err := tester.repo.OAuthIntegration.ListOAuthIntegrationsByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(oauths) != 1 {
+		t.Fatalf("length of oauth integrations incorrect: expected %d, got %d\n", 1, len(oauths))
+	}
+
+	// make sure data is correct
+	expOAuth := 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 := oauths[0]
+
+	// reset fields for reflect.DeepEqual
+	oauth.Model = orm.Model{}
+
+	if diff := deep.Equal(expOAuth, *oauth); diff != nil {
+		t.Errorf("incorrect oauth integration")
+		t.Error(diff)
+	}
+}
+
+func TestCreateGCPIntegration(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_gcp.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	defer cleanup(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\"}"),
+	}
+
+	expGCP := *gcp
+
+	gcp, err := tester.repo.GCPIntegration.CreateGCPIntegration(gcp)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	gcp, err = tester.repo.GCPIntegration.ReadGCPIntegration(gcp.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if gcp.Model.ID != 1 {
+		t.Errorf("incorrect gcp integration ID: expected %d, got %d\n", 1, gcp.Model.ID)
+	}
+
+	// reset fields for deep.Equal
+	gcp.Model = orm.Model{}
+
+	if diff := deep.Equal(expGCP, *gcp); diff != nil {
+		t.Errorf("incorrect gcp integration")
+		t.Error(diff)
+	}
+}
+
+func TestListGCPIntegrationsByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_gcps.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initGCPIntegration(tester, t)
+	defer cleanup(tester, t)
+
+	gcps, err := tester.repo.GCPIntegration.ListGCPIntegrationsByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(gcps) != 1 {
+		t.Fatalf("length of gcp integrations incorrect: expected %d, got %d\n", 1, len(gcps))
+	}
+
+	// make sure data is correct
+	expGCP := 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 := gcps[0]
+
+	// reset fields for reflect.DeepEqual
+	gcp.Model = orm.Model{}
+
+	if diff := deep.Equal(expGCP, *gcp); diff != nil {
+		t.Errorf("incorrect gcp integration")
+		t.Error(diff)
+	}
+}
+
+func TestCreateAWSIntegration(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_aws.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	defer cleanup(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"),
+	}
+
+	expAWS := *aws
+
+	aws, err := tester.repo.AWSIntegration.CreateAWSIntegration(aws)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	aws, err = tester.repo.AWSIntegration.ReadAWSIntegration(aws.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if aws.Model.ID != 1 {
+		t.Errorf("incorrect aws integration ID: expected %d, got %d\n", 1, aws.Model.ID)
+	}
+
+	// reset fields for deep.Equal
+	aws.Model = orm.Model{}
+
+	if diff := deep.Equal(expAWS, *aws); diff != nil {
+		t.Errorf("incorrect aws integration")
+		t.Error(diff)
+	}
+}
+
+func TestListAWSIntegrationsByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_awss.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initAWSIntegration(tester, t)
+	defer cleanup(tester, t)
+
+	awss, err := tester.repo.AWSIntegration.ListAWSIntegrationsByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(awss) != 1 {
+		t.Fatalf("length of aws integrations incorrect: expected %d, got %d\n", 1, len(awss))
+	}
+
+	// make sure data is correct
+	expAWS := 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 := awss[0]
+
+	// reset fields for reflect.DeepEqual
+	aws.Model = orm.Model{}
+
+	if diff := deep.Equal(expAWS, *aws); diff != nil {
+		t.Errorf("incorrect aws integration")
+		t.Error(diff)
+	}
+}

+ 340 - 0
internal/repository/gorm/cluster.go

@@ -0,0 +1,340 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// ClusterRepository uses gorm.DB for querying the database
+type ClusterRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewClusterRepository returns a ClusterRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewClusterRepository(db *gorm.DB, key *[32]byte) repository.ClusterRepository {
+	return &ClusterRepository{db, key}
+}
+
+// CreateClusterCandidate creates a new cluster candidate
+func (repo *ClusterRepository) CreateClusterCandidate(
+	cc *models.ClusterCandidate,
+) (*models.ClusterCandidate, error) {
+	err := repo.EncryptClusterCandidateData(cc, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", cc.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("ClusterCandidates")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(cc); err != nil {
+		return nil, err
+	}
+
+	// decrypt at the end to return
+	err = repo.DecryptClusterCandidateData(cc, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return cc, nil
+}
+
+// ReadClusterCandidate finds a cluster candidate by id
+func (repo *ClusterRepository) ReadClusterCandidate(
+	id uint,
+) (*models.ClusterCandidate, error) {
+	cc := &models.ClusterCandidate{}
+
+	if err := repo.db.Preload("Resolvers").Where("id = ?", id).First(&cc).Error; err != nil {
+		return nil, err
+	}
+
+	repo.DecryptClusterCandidateData(cc, repo.key)
+
+	return cc, nil
+}
+
+// ListClusterCandidatesByProjectID finds all cluster candidates
+// for a given project id
+func (repo *ClusterRepository) ListClusterCandidatesByProjectID(
+	projectID uint,
+) ([]*models.ClusterCandidate, error) {
+	ccs := []*models.ClusterCandidate{}
+
+	if err := repo.db.Preload("Resolvers").Where("project_id = ?", projectID).Find(&ccs).Error; err != nil {
+		return nil, err
+	}
+
+	for _, cc := range ccs {
+		repo.DecryptClusterCandidateData(cc, repo.key)
+	}
+
+	return ccs, nil
+}
+
+// UpdateClusterCandidateCreatedClusterID updates the CreatedClusterID for
+// a candidate, after the candidate has been resolved.
+func (repo *ClusterRepository) UpdateClusterCandidateCreatedClusterID(
+	id uint,
+	createdClusterID uint,
+) (*models.ClusterCandidate, error) {
+	cc := &models.ClusterCandidate{}
+
+	if err := repo.db.Where("id = ?", id).First(&cc).Error; err != nil {
+		return nil, err
+	}
+
+	cc.CreatedClusterID = createdClusterID
+
+	if err := repo.db.Save(cc).Error; err != nil {
+		return nil, err
+	}
+
+	repo.DecryptClusterCandidateData(cc, repo.key)
+
+	return cc, nil
+}
+
+// CreateCluster creates a new cluster
+func (repo *ClusterRepository) CreateCluster(
+	cluster *models.Cluster,
+) (*models.Cluster, error) {
+	err := repo.EncryptClusterData(cluster, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", cluster.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("Clusters")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(cluster); err != nil {
+		return nil, err
+	}
+
+	// create a token cache by default
+	assoc = repo.db.Model(cluster).Association("TokenCache")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(&cluster.TokenCache); err != nil {
+		return nil, err
+	}
+
+	err = repo.DecryptClusterData(cluster, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return cluster, nil
+}
+
+// ReadCluster finds a cluster by id
+func (repo *ClusterRepository) ReadCluster(
+	id uint,
+) (*models.Cluster, error) {
+	cluster := &models.Cluster{}
+
+	// preload Clusters association
+	if err := repo.db.Preload("TokenCache").Where("id = ?", id).First(&cluster).Error; err != nil {
+		return nil, err
+	}
+
+	repo.DecryptClusterData(cluster, repo.key)
+
+	return cluster, nil
+}
+
+// ListClustersByProjectID finds all clusters
+// for a given project id
+func (repo *ClusterRepository) ListClustersByProjectID(
+	projectID uint,
+) ([]*models.Cluster, error) {
+	clusters := []*models.Cluster{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&clusters).Error; err != nil {
+		return nil, err
+	}
+
+	for _, cluster := range clusters {
+		repo.DecryptClusterData(cluster, repo.key)
+	}
+
+	return clusters, nil
+}
+
+// UpdateClusterTokenCache updates the token cache for a cluster
+func (repo *ClusterRepository) UpdateClusterTokenCache(
+	tokenCache *ints.TokenCache,
+) (*models.Cluster, error) {
+	if tok := tokenCache.Token; len(tok) > 0 {
+		cipherData, err := repository.Encrypt(tok, repo.key)
+
+		if err != nil {
+			return nil, err
+		}
+
+		tokenCache.Token = cipherData
+	}
+
+	cluster := &models.Cluster{}
+
+	if err := repo.db.Where("id = ?", tokenCache.ClusterID).First(&cluster).Error; err != nil {
+		return nil, err
+	}
+
+	cluster.TokenCache.Token = tokenCache.Token
+	cluster.TokenCache.Expiry = tokenCache.Expiry
+
+	if err := repo.db.Save(cluster).Error; err != nil {
+		return nil, err
+	}
+
+	return cluster, nil
+}
+
+// EncryptClusterData will encrypt the user's service account data before writing
+// to the DB
+func (repo *ClusterRepository) EncryptClusterData(
+	cluster *models.Cluster,
+	key *[32]byte,
+) error {
+	if len(cluster.CertificateAuthorityData) > 0 {
+		cipherData, err := repository.Encrypt(cluster.CertificateAuthorityData, key)
+
+		if err != nil {
+			return err
+		}
+
+		cluster.CertificateAuthorityData = cipherData
+	}
+
+	if tok := cluster.TokenCache.Token; len(tok) > 0 {
+		cipherData, err := repository.Encrypt(tok, key)
+
+		if err != nil {
+			return err
+		}
+
+		cluster.TokenCache.Token = cipherData
+	}
+
+	return nil
+}
+
+// EncryptClusterCandidateData will encrypt the service account candidate data before
+// writing to the DB
+func (repo *ClusterRepository) EncryptClusterCandidateData(
+	cc *models.ClusterCandidate,
+	key *[32]byte,
+) error {
+	if len(cc.AWSClusterIDGuess) > 0 {
+		cipherData, err := repository.Encrypt(cc.AWSClusterIDGuess, key)
+
+		if err != nil {
+			return err
+		}
+
+		cc.AWSClusterIDGuess = cipherData
+	}
+
+	if len(cc.Kubeconfig) > 0 {
+		cipherData, err := repository.Encrypt(cc.Kubeconfig, key)
+
+		if err != nil {
+			return err
+		}
+
+		cc.Kubeconfig = cipherData
+	}
+
+	return nil
+}
+
+// DecryptClusterData will decrypt the user's service account data before
+// returning it from the DB
+func (repo *ClusterRepository) DecryptClusterData(
+	cluster *models.Cluster,
+	key *[32]byte,
+) error {
+	if len(cluster.CertificateAuthorityData) > 0 {
+		plaintext, err := repository.Decrypt(cluster.CertificateAuthorityData, key)
+
+		if err != nil {
+			return err
+		}
+
+		cluster.CertificateAuthorityData = plaintext
+	}
+
+	if tok := cluster.TokenCache.Token; len(tok) > 0 {
+		plaintext, err := repository.Decrypt(tok, key)
+
+		if err != nil {
+			return err
+		}
+
+		cluster.TokenCache.Token = plaintext
+	}
+
+	return nil
+}
+
+// DecryptClusterCandidateData will decrypt the service account candidate data before
+// returning it from the DB
+func (repo *ClusterRepository) DecryptClusterCandidateData(
+	cc *models.ClusterCandidate,
+	key *[32]byte,
+) error {
+	if len(cc.AWSClusterIDGuess) > 0 {
+		plaintext, err := repository.Decrypt(cc.AWSClusterIDGuess, key)
+
+		if err != nil {
+			return err
+		}
+
+		cc.AWSClusterIDGuess = plaintext
+	}
+
+	if len(cc.Kubeconfig) > 0 {
+		plaintext, err := repository.Decrypt(cc.Kubeconfig, key)
+
+		if err != nil {
+			return err
+		}
+
+		cc.Kubeconfig = plaintext
+	}
+
+	return nil
+}

+ 375 - 0
internal/repository/gorm/cluster_test.go

@@ -0,0 +1,375 @@
+package gorm_test
+
+import (
+	"testing"
+	"time"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	orm "gorm.io/gorm"
+)
+
+func TestCreateClusterCandidate(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_cc.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	defer cleanup(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"),
+	}
+
+	expCC := *cc
+
+	cc, err := tester.repo.Cluster.CreateClusterCandidate(cc)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	cc, err = tester.repo.Cluster.ReadClusterCandidate(cc.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if cc.Model.ID != 1 {
+		t.Errorf("incorrect cluster candidate ID: expected %d, got %d\n", 1, cc.Model.ID)
+	}
+
+	// reset fields for deep.Equal
+	cc.Model = orm.Model{}
+
+	if diff := deep.Equal(expCC, *cc); diff != nil {
+		t.Errorf("incorrect cluster candidate")
+		t.Error(diff)
+	}
+}
+
+func TestCreateClusterCandidateWithResolvers(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_cc.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	cc := &models.ClusterCandidate{
+		AuthMechanism:    models.AWS,
+		ProjectID:        tester.initProjects[0].ID,
+		CreatedClusterID: 0,
+		Resolvers: []models.ClusterResolver{
+			models.ClusterResolver{
+				Name:     models.ClusterLocalhost,
+				Resolved: false,
+			},
+		},
+		Name:              "cluster-test",
+		Server:            "https://localhost",
+		ContextName:       "context-test",
+		AWSClusterIDGuess: []byte("example-cluster-0"),
+		Kubeconfig:        []byte("current-context: testing\n"),
+	}
+
+	expCC := *cc
+
+	cc, err := tester.repo.Cluster.CreateClusterCandidate(cc)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	cc, err = tester.repo.Cluster.ReadClusterCandidate(cc.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if cc.Model.ID != 1 {
+		t.Errorf("incorrect cluster candidate ID: expected %d, got %d\n", 1, cc.Model.ID)
+	}
+
+	// make sure length of resolvers is 1
+	if len(cc.Resolvers) != 1 {
+		t.Fatalf("incorrect cluster candidate resolvers length: expected %d, got %d\n", 1, len(cc.Resolvers))
+	}
+
+	// make sure resolver cluster candidate id is 1
+	if cc.Resolvers[0].ClusterCandidateID != 1 {
+		t.Errorf("incorrect resolver ClusterCandidateID: expected %d, got %d\n", 1, cc.Resolvers[0].ClusterCandidateID)
+	}
+
+	// reset fields for deep.Equal
+	cc.Model = orm.Model{}
+	cc.Resolvers[0].Model = orm.Model{}
+	expCC.Resolvers[0].Model = orm.Model{}
+	expCC.Resolvers[0].ClusterCandidateID = 1
+
+	if diff := deep.Equal(expCC, *cc); diff != nil {
+		t.Errorf("incorrect cluster candidate")
+		t.Error(diff)
+	}
+}
+
+func TestListClusterCandidatesByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_ccs.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initClusterCandidate(tester, t)
+	defer cleanup(tester, t)
+
+	ccs, err := tester.repo.Cluster.ListClusterCandidatesByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(ccs) != 1 {
+		t.Fatalf("length of cluster candidates incorrect: expected %d, got %d\n", 1, len(ccs))
+	}
+
+	// make sure data is correct
+	expCC := 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 := ccs[0]
+
+	// reset fields for reflect.DeepEqual
+	cc.Model = orm.Model{}
+
+	if diff := deep.Equal(expCC, *cc); diff != nil {
+		t.Errorf("incorrect cluster candidate")
+		t.Error(diff)
+	}
+}
+
+func TestUpdateClusterCandidateCreatedClusterID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_update_cc_cluster_id.db",
+	}
+
+	setupTestEnv(tester, t)
+	initClusterCandidate(tester, t)
+	initCluster(tester, t)
+	defer cleanup(tester, t)
+
+	cc, err := tester.repo.Cluster.UpdateClusterCandidateCreatedClusterID(
+		tester.initCCs[0].ID,
+		tester.initClusters[0].ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	expCC := models.ClusterCandidate{
+		AuthMechanism:     models.AWS,
+		ProjectID:         tester.initProjects[0].ID,
+		CreatedClusterID:  tester.initClusters[0].ID,
+		Name:              "cluster-test",
+		Server:            "https://localhost",
+		ContextName:       "context-test",
+		AWSClusterIDGuess: []byte("example-cluster-0"),
+		Kubeconfig:        []byte("current-context: testing\n"),
+	}
+
+	// reset fields for reflect.DeepEqual
+	cc.Model = orm.Model{}
+
+	if diff := deep.Equal(expCC, *cc); diff != nil {
+		t.Errorf("incorrect cluster candidate")
+		t.Error(diff)
+	}
+}
+
+func TestCreateCluster(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_cluster.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initKubeIntegration(tester, t)
+	defer cleanup(tester, t)
+
+	cluster := &models.Cluster{
+		ProjectID:                tester.initProjects[0].ID,
+		Name:                     "cluster-test",
+		Server:                   "https://localhost",
+		KubeIntegrationID:        tester.initKIs[0].ID,
+		CertificateAuthorityData: []byte("-----BEGIN"),
+	}
+
+	expCluster := *cluster
+
+	cluster, err := tester.repo.Cluster.CreateCluster(cluster)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	cluster, err = tester.repo.Cluster.ReadCluster(cluster.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if cluster.Model.ID != 1 {
+		t.Errorf("incorrect cluster ID: expected %d, got %d\n", 1, cluster.Model.ID)
+	}
+
+	// reset fields for deep.Equal
+	cluster.Model = orm.Model{}
+
+	if diff := deep.Equal(expCluster, *cluster); diff != nil {
+		t.Errorf("incorrect cluster")
+		t.Error(diff)
+	}
+}
+
+func TestListClustersByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_clusters.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	defer cleanup(tester, t)
+
+	clusters, err := tester.repo.Cluster.ListClustersByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(clusters) != 1 {
+		t.Fatalf("length of clusters incorrect: expected %d, got %d\n", 1, len(clusters))
+	}
+
+	// make sure data is correct
+	expCluster := models.Cluster{
+		ProjectID:                tester.initProjects[0].ID,
+		Name:                     "cluster-test",
+		Server:                   "https://localhost",
+		KubeIntegrationID:        tester.initKIs[0].ID,
+		CertificateAuthorityData: []byte("-----BEGIN"),
+	}
+
+	cluster := clusters[0]
+
+	// reset fields for reflect.DeepEqual
+	cluster.Model = orm.Model{}
+
+	if diff := deep.Equal(expCluster, *cluster); diff != nil {
+		t.Errorf("incorrect cluster")
+		t.Error(diff)
+	}
+}
+
+func TestUpdateClusterToken(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_test_update_cluster_token.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initKubeIntegration(tester, t)
+	defer cleanup(tester, t)
+
+	cluster := &models.Cluster{
+		ProjectID:                tester.initProjects[0].ID,
+		Name:                     "cluster-test",
+		Server:                   "https://localhost",
+		KubeIntegrationID:        tester.initKIs[0].ID,
+		CertificateAuthorityData: []byte("-----BEGIN"),
+		TokenCache: ints.TokenCache{
+			Token:  []byte("token-1"),
+			Expiry: time.Now().Add(-1 * time.Hour),
+		},
+	}
+
+	cluster, err := tester.repo.Cluster.CreateCluster(cluster)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	cluster, err = tester.repo.Cluster.ReadCluster(cluster.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure cluster id of token is 1
+	if cluster.TokenCache.ClusterID != 1 {
+		t.Fatalf("incorrect cluster id in token cache: expected %d, got %d\n", 1, cluster.TokenCache.ClusterID)
+	}
+
+	// make sure old token is expired
+	if isExpired := cluster.TokenCache.IsExpired(); !isExpired {
+		t.Fatalf("token was not expired\n")
+	}
+
+	cluster.TokenCache.Token = []byte("token-2")
+	cluster.TokenCache.Expiry = time.Now().Add(24 * time.Hour)
+
+	cluster, err = tester.repo.Cluster.UpdateClusterTokenCache(&cluster.TokenCache)
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+	cluster, err = tester.repo.Cluster.ReadCluster(cluster.Model.ID)
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if cluster.Model.ID != 1 {
+		t.Errorf("incorrect service account ID: expected %d, got %d\n", 1, cluster.Model.ID)
+	}
+
+	// make sure new token is correct and not expired
+	if cluster.TokenCache.ClusterID != 1 {
+		t.Fatalf("incorrect service account ID in token cache: expected %d, got %d\n", 1, cluster.TokenCache.ClusterID)
+	}
+
+	if isExpired := cluster.TokenCache.IsExpired(); isExpired {
+		t.Fatalf("token was expired\n")
+	}
+
+	if string(cluster.TokenCache.Token) != "token-2" {
+		t.Errorf("incorrect token in cache: expected %s, got %s\n", "token-2", cluster.TokenCache.Token)
+	}
+}

+ 64 - 0
internal/repository/gorm/gitrepo.go

@@ -0,0 +1,64 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// GitRepoRepository uses gorm.DB for querying the database
+type GitRepoRepository struct {
+	db  *gorm.DB
+	key *[32]byte
+}
+
+// NewGitRepoRepository returns a GitRepoRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewGitRepoRepository(db *gorm.DB, key *[32]byte) repository.GitRepoRepository {
+	return &GitRepoRepository{db, key}
+}
+
+// CreateGitRepo creates a new repo client and appends it to the in-memory list
+func (repo *GitRepoRepository) CreateGitRepo(gr *models.GitRepo) (*models.GitRepo, error) {
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", gr.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("GitRepos")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(gr); err != nil {
+		return nil, err
+	}
+
+	return gr, nil
+}
+
+// ReadGitRepo returns a repo client by id
+func (repo *GitRepoRepository) ReadGitRepo(id uint) (*models.GitRepo, error) {
+	gr := &models.GitRepo{}
+
+	// preload Clusters association
+	if err := repo.db.Where("id = ?", id).First(&gr).Error; err != nil {
+		return nil, err
+	}
+
+	return gr, nil
+}
+
+// ListGitReposByProjectID returns a list of repo clients that match a project id
+func (repo *GitRepoRepository) ListGitReposByProjectID(projectID uint) ([]*models.GitRepo, error) {
+	grs := []*models.GitRepo{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&grs).Error; err != nil {
+		return nil, err
+	}
+
+	return grs, nil
+}

+ 93 - 0
internal/repository/gorm/gitrepo_test.go

@@ -0,0 +1,93 @@
+package gorm_test
+
+import (
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	orm "gorm.io/gorm"
+)
+
+func TestCreateGitRepo(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_gr.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	initOAuthIntegration(tester, t)
+	defer cleanup(tester, t)
+
+	gr := &models.GitRepo{
+		ProjectID:          tester.initProjects[0].ID,
+		RepoEntity:         "porter-dev",
+		OAuthIntegrationID: tester.initOAuths[0].ID,
+	}
+
+	expGR := *gr
+
+	gr, err := tester.repo.GitRepo.CreateGitRepo(gr)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	gr, err = tester.repo.GitRepo.ReadGitRepo(gr.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if gr.Model.ID != 1 {
+		t.Errorf("incorrect git repo ID: expected %d, got %d\n", 1, gr.Model.ID)
+	}
+
+	// reset fields for reflect.DeepEqual
+	gr.Model = orm.Model{}
+
+	if diff := deep.Equal(expGR, *gr); diff != nil {
+		t.Errorf("incorrect git repo")
+		t.Error(diff)
+	}
+}
+
+func TestListGitReposByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_grs.db",
+	}
+
+	setupTestEnv(tester, t)
+	initGitRepo(tester, t)
+	defer cleanup(tester, t)
+
+	grs, err := tester.repo.GitRepo.ListGitReposByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(grs) != 1 {
+		t.Fatalf("length of oidc integrations incorrect: expected %d, got %d\n", 1, len(grs))
+	}
+
+	// make sure data is correct
+	expGR := models.GitRepo{
+		ProjectID:          tester.initProjects[0].ID,
+		RepoEntity:         "porter-dev",
+		OAuthIntegrationID: tester.initOAuths[0].ID,
+	}
+
+	gr := grs[0]
+
+	// reset fields for reflect.DeepEqual
+	gr.Model = orm.Model{}
+
+	if diff := deep.Equal(expGR, *gr); diff != nil {
+		t.Errorf("incorrect git repo")
+		t.Error(diff)
+	}
+}

+ 240 - 54
internal/repository/gorm/helpers_test.go

@@ -7,19 +7,26 @@ import (
 	"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
-	initSACandidates []*models.ServiceAccountCandidate
-	initSAs          []*models.ServiceAccount
-	initRCs          []*models.RepoClient
+	repo         *repository.Repository
+	key          *[32]byte
+	dbFileName   string
+	initUsers    []*models.User
+	initProjects []*models.Project
+	initGRs      []*models.GitRepo
+	initRegs     []*models.Registry
+	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) {
@@ -38,14 +45,19 @@ func setupTestEnv(tester *tester, t *testing.T) {
 	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{},
 	)
 
 	if err != nil {
@@ -119,77 +131,251 @@ func initProjectRole(tester *tester, t *testing.T) {
 	}
 }
 
-func initServiceAccountCandidate(tester *tester, t *testing.T) {
+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()
 
-	saCandidate := &models.ServiceAccountCandidate{
-		ProjectID:       1,
-		Kind:            "connector",
-		ClusterName:     "cluster-test",
-		ClusterEndpoint: "https://localhost",
-		AuthMechanism:   models.X509,
-		Kubeconfig:      []byte("current-context: testing\n"),
-		Actions: []models.ServiceAccountAction{
-			models.ServiceAccountAction{
-				Name:     models.TokenDataAction,
-				Resolved: false,
-			},
-		},
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
 	}
 
-	saCandidate, err := tester.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	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.initSACandidates = append(tester.initSACandidates, saCandidate)
+	tester.initOIDCs = append(tester.initOIDCs, oidc)
 }
 
-func initServiceAccount(tester *tester, t *testing.T) {
+func initOAuthIntegration(tester *tester, t *testing.T) {
 	t.Helper()
 
-	sa := &models.ServiceAccount{
-		ProjectID:             1,
-		Kind:                  "connector",
-		AuthMechanism:         models.X509,
-		ClientCertificateData: []byte("-----BEGIN"),
-		ClientKeyData:         []byte("-----BEGIN"),
-		Clusters: []models.Cluster{
-			models.Cluster{
-				Name:                     "cluster-test",
-				Server:                   "https://localhost",
-				CertificateAuthorityData: []byte("-----BEGIN"),
-			},
-		},
+	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"),
 	}
 
-	sa, err := tester.repo.ServiceAccount.CreateServiceAccount(sa)
+	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
 	}
 
-	tester.initSAs = append(tester.initSAs, sa)
+	tester.initOAuths = append(tester.initOAuths, oauth)
 }
 
-func initRepoClient(tester *tester, t *testing.T) {
+func initGCPIntegration(tester *tester, t *testing.T) {
 	t.Helper()
 
-	rc := &models.RepoClient{
+	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,
-		RepoUserID:   1,
-		Kind:         models.RepoClientGithub,
-		AccessToken:  []byte("accesstoken1234"),
-		RefreshToken: []byte("refreshtoken1234"),
+		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)
+}
+
+func initRegistry(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	reg := &models.Registry{
+		ProjectID: tester.initProjects[0].ID,
+		Name:      "registry-test",
 	}
 
-	rc, err := tester.repo.RepoClient.CreateRepoClient(rc)
+	reg, err := tester.repo.Registry.CreateRegistry(reg)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
 	}
 
-	tester.initRCs = append(tester.initRCs, rc)
+	tester.initRegs = append(tester.initRegs, reg)
 }

+ 64 - 0
internal/repository/gorm/registry.go

@@ -0,0 +1,64 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// RegistryRepository uses gorm.DB for querying the database
+type RegistryRepository struct {
+	db *gorm.DB
+}
+
+// NewRegistryRepository returns a RegistryRepository which uses
+// gorm.DB for querying the database
+func NewRegistryRepository(db *gorm.DB) repository.RegistryRepository {
+	return &RegistryRepository{db}
+}
+
+// CreateRegistry creates a new registry
+func (repo *RegistryRepository) CreateRegistry(reg *models.Registry) (*models.Registry, error) {
+	project := &models.Project{}
+
+	if err := repo.db.Where("id = ?", reg.ProjectID).First(&project).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&project).Association("Registries")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(reg); err != nil {
+		return nil, err
+	}
+
+	return reg, nil
+}
+
+// ReadRegistry gets a registry specified by a unique id
+func (repo *RegistryRepository) ReadRegistry(id uint) (*models.Registry, error) {
+	reg := &models.Registry{}
+
+	if err := repo.db.Where("id = ?", id).First(&reg).Error; err != nil {
+		return nil, err
+	}
+
+	return reg, nil
+}
+
+// ListRegistriesByProjectID finds all registries
+// for a given project id
+func (repo *RegistryRepository) ListRegistriesByProjectID(
+	projectID uint,
+) ([]*models.Registry, error) {
+	regs := []*models.Registry{}
+
+	if err := repo.db.Where("project_id = ?", projectID).Find(&regs).Error; err != nil {
+		return nil, err
+	}
+
+	return regs, nil
+}

+ 84 - 0
internal/repository/gorm/registry_test.go

@@ -0,0 +1,84 @@
+package gorm_test
+
+import (
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	"gorm.io/gorm"
+)
+
+func TestCreateRegistry(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_reg.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	defer cleanup(tester, t)
+
+	reg := &models.Registry{
+		Name:      "registry-test",
+		ProjectID: tester.initProjects[0].Model.ID,
+	}
+
+	reg, err := tester.repo.Registry.CreateRegistry(reg)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	reg, err = tester.repo.Registry.ReadRegistry(reg.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1 and name is "registry-test"
+	if reg.Model.ID != 1 {
+		t.Errorf("incorrect registry ID: expected %d, got %d\n", 1, reg.Model.ID)
+	}
+
+	if reg.Name != "registry-test" {
+		t.Errorf("incorrect project name: expected %s, got %s\n", "registry-test", reg.Name)
+	}
+}
+
+func TestListRegistriesByProjectID(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_list_regs.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initRegistry(tester, t)
+	defer cleanup(tester, t)
+
+	regs, err := tester.repo.Registry.ListRegistriesByProjectID(
+		tester.initProjects[0].Model.ID,
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(regs) != 1 {
+		t.Fatalf("length of registries incorrect: expected %d, got %d\n", 1, len(regs))
+	}
+
+	// make sure data is correct
+	expRegistry := models.Registry{
+		ProjectID: tester.initProjects[0].ID,
+		Name:      "registry-test",
+	}
+
+	reg := regs[0]
+
+	// reset fields for reflect.DeepEqual
+	reg.Model = gorm.Model{}
+
+	if diff := deep.Equal(expRegistry, *reg); diff != nil {
+		t.Errorf("incorrect registry")
+		t.Error(diff)
+	}
+}

+ 0 - 134
internal/repository/gorm/repoclient.go

@@ -1,134 +0,0 @@
-package gorm
-
-import (
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
-	"gorm.io/gorm"
-)
-
-// RepoClientRepository uses gorm.DB for querying the database
-type RepoClientRepository struct {
-	db  *gorm.DB
-	key *[32]byte
-}
-
-// NewRepoClientRepository returns a RepoClientRepository which uses
-// gorm.DB for querying the database. It accepts an encryption key to encrypt
-// sensitive data
-func NewRepoClientRepository(db *gorm.DB, key *[32]byte) repository.RepoClientRepository {
-	return &RepoClientRepository{db, key}
-}
-
-// CreateRepoClient creates a new repo client and appends it to the in-memory list
-func (repo *RepoClientRepository) CreateRepoClient(rc *models.RepoClient) (*models.RepoClient, error) {
-	err := repo.EncryptRepoClientData(rc, repo.key)
-
-	if err != nil {
-		return nil, err
-	}
-
-	project := &models.Project{}
-
-	if err := repo.db.Where("id = ?", rc.ProjectID).First(&project).Error; err != nil {
-		return nil, err
-	}
-
-	assoc := repo.db.Model(&project).Association("RepoClients")
-
-	if assoc.Error != nil {
-		return nil, assoc.Error
-	}
-
-	if err := assoc.Append(rc); err != nil {
-		return nil, err
-	}
-
-	return rc, nil
-}
-
-// ReadRepoClient returns a repo client by id
-func (repo *RepoClientRepository) ReadRepoClient(id uint) (*models.RepoClient, error) {
-	rc := &models.RepoClient{}
-
-	// preload Clusters association
-	if err := repo.db.Where("id = ?", id).First(&rc).Error; err != nil {
-		return nil, err
-	}
-
-	repo.DecryptRepoClientData(rc, repo.key)
-
-	return rc, nil
-}
-
-// ListRepoClientsByProjectID returns a list of repo clients that match a project id
-func (repo *RepoClientRepository) ListRepoClientsByProjectID(projectID uint) ([]*models.RepoClient, error) {
-	rcs := []*models.RepoClient{}
-
-	if err := repo.db.Where("project_id = ?", projectID).Find(&rcs).Error; err != nil {
-		return nil, err
-	}
-
-	for _, rc := range rcs {
-		repo.DecryptRepoClientData(rc, repo.key)
-	}
-
-	return rcs, nil
-}
-
-// EncryptRepoClientData will encrypt the repo client tokens before writing
-// to the DB
-func (repo *RepoClientRepository) EncryptRepoClientData(
-	rc *models.RepoClient,
-	key *[32]byte,
-) error {
-	if len(rc.AccessToken) > 0 {
-		cipherData, err := repository.Encrypt(rc.AccessToken, key)
-
-		if err != nil {
-			return err
-		}
-
-		rc.AccessToken = cipherData
-	}
-
-	if len(rc.RefreshToken) > 0 {
-		cipherData, err := repository.Encrypt(rc.RefreshToken, key)
-
-		if err != nil {
-			return err
-		}
-
-		rc.RefreshToken = cipherData
-	}
-
-	return nil
-}
-
-// DecryptRepoClientData will decrypt the repo client tokens before
-// returning it from the DB
-func (repo *RepoClientRepository) DecryptRepoClientData(
-	rc *models.RepoClient,
-	key *[32]byte,
-) error {
-	if len(rc.AccessToken) > 0 {
-		plaintext, err := repository.Decrypt(rc.AccessToken, key)
-
-		if err != nil {
-			return err
-		}
-
-		rc.AccessToken = plaintext
-	}
-
-	if len(rc.RefreshToken) > 0 {
-		plaintext, err := repository.Decrypt(rc.RefreshToken, key)
-
-		if err != nil {
-			return err
-		}
-
-		rc.RefreshToken = plaintext
-	}
-
-	return nil
-}

+ 0 - 112
internal/repository/gorm/repoclient_test.go

@@ -1,112 +0,0 @@
-package gorm_test
-
-import (
-	"testing"
-
-	"github.com/go-test/deep"
-	"github.com/porter-dev/porter/internal/models"
-	orm "gorm.io/gorm"
-)
-
-func TestCreateRepoClient(t *testing.T) {
-	tester := &tester{
-		dbFileName: "./porter_create_rc.db",
-	}
-
-	setupTestEnv(tester, t)
-	initUser(tester, t)
-	initProject(tester, t)
-	defer cleanup(tester, t)
-
-	repoClient := &models.RepoClient{
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		RepoUserID:   1,
-		Kind:         models.RepoClientGithub,
-		AccessToken:  []byte("accesstoken1234"),
-		RefreshToken: []byte("refreshtoken1234"),
-	}
-
-	repoClient, err := tester.repo.RepoClient.CreateRepoClient(repoClient)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	repoClient, err = tester.repo.RepoClient.ReadRepoClient(repoClient.Model.ID)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// make sure id is 1
-	if repoClient.Model.ID != 1 {
-		t.Errorf("incorrect repo client ID: expected %d, got %d\n", 1, repoClient.Model.ID)
-	}
-
-	// make sure data is correct
-	expRepoClient := &models.RepoClient{
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		RepoUserID:   1,
-		Kind:         models.RepoClientGithub,
-		AccessToken:  []byte("accesstoken1234"),
-		RefreshToken: []byte("refreshtoken1234"),
-	}
-
-	copyRepoClient := repoClient
-
-	// reset fields for reflect.DeepEqual
-	copyRepoClient.Model = orm.Model{}
-
-	if diff := deep.Equal(copyRepoClient, expRepoClient); diff != nil {
-		t.Errorf("incorrect repo client")
-		t.Error(diff)
-	}
-}
-
-func TestListRepoClientsByProjectID(t *testing.T) {
-	tester := &tester{
-		dbFileName: "./porter_list_rcs.db",
-	}
-
-	setupTestEnv(tester, t)
-	initUser(tester, t)
-	initProject(tester, t)
-	initServiceAccount(tester, t)
-	initRepoClient(tester, t)
-	defer cleanup(tester, t)
-
-	rcs, err := tester.repo.RepoClient.ListRepoClientsByProjectID(
-		tester.initProjects[0].Model.ID,
-	)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	if len(rcs) != 1 {
-		t.Fatalf("length of rcs incorrect: expected %d, got %d\n", 1, len(rcs))
-	}
-
-	// make sure data is correct
-	// make sure data is correct
-	expRepoClient := &models.RepoClient{
-		ProjectID:    tester.initProjects[0].ID,
-		UserID:       tester.initUsers[0].ID,
-		RepoUserID:   1,
-		Kind:         models.RepoClientGithub,
-		AccessToken:  []byte("accesstoken1234"),
-		RefreshToken: []byte("refreshtoken1234"),
-	}
-
-	copyRepoClient := rcs[0]
-
-	// reset fields for reflect.DeepEqual
-	copyRepoClient.Model = orm.Model{}
-
-	if diff := deep.Equal(copyRepoClient, expRepoClient); diff != nil {
-		t.Errorf("incorrect repo client")
-		t.Error(diff)
-	}
-}

+ 11 - 5
internal/repository/gorm/repository.go

@@ -9,10 +9,16 @@ import (
 // gorm.DB for querying the database
 func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 	return &repository.Repository{
-		User:           NewUserRepository(db),
-		Session:        NewSessionRepository(db),
-		Project:        NewProjectRepository(db),
-		ServiceAccount: NewServiceAccountRepository(db, key),
-		RepoClient:     NewRepoClientRepository(db, key),
+		User:             NewUserRepository(db),
+		Session:          NewSessionRepository(db),
+		Project:          NewProjectRepository(db),
+		GitRepo:          NewGitRepoRepository(db, key),
+		Cluster:          NewClusterRepository(db, key),
+		Registry:         NewRegistryRepository(db),
+		KubeIntegration:  NewKubeIntegrationRepository(db, key),
+		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),
+		OAuthIntegration: NewOAuthIntegrationRepository(db, key),
+		GCPIntegration:   NewGCPIntegrationRepository(db, key),
+		AWSIntegration:   NewAWSIntegrationRepository(db, key),
 	}
 }

+ 0 - 609
internal/repository/gorm/serviceaccount.go

@@ -1,609 +0,0 @@
-package gorm
-
-import (
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
-	"gorm.io/gorm"
-)
-
-// ServiceAccountRepository uses gorm.DB for querying the database
-type ServiceAccountRepository struct {
-	db  *gorm.DB
-	key *[32]byte
-}
-
-// NewServiceAccountRepository returns a ServiceAccountRepository which uses
-// gorm.DB for querying the database. It accepts an encryption key to encrypt
-// sensitive data
-func NewServiceAccountRepository(db *gorm.DB, key *[32]byte) repository.ServiceAccountRepository {
-	return &ServiceAccountRepository{db, key}
-}
-
-// CreateServiceAccountCandidate creates a new service account candidate
-func (repo *ServiceAccountRepository) CreateServiceAccountCandidate(
-	saCandidate *models.ServiceAccountCandidate,
-) (*models.ServiceAccountCandidate, error) {
-	err := repo.EncryptServiceAccountCandidateData(saCandidate, repo.key)
-
-	if err != nil {
-		return nil, err
-	}
-
-	project := &models.Project{}
-
-	if err := repo.db.Where("id = ?", saCandidate.ProjectID).First(&project).Error; err != nil {
-		return nil, err
-	}
-
-	assoc := repo.db.Model(&project).Association("ServiceAccountCandidates")
-
-	if assoc.Error != nil {
-		return nil, assoc.Error
-	}
-
-	if err := assoc.Append(saCandidate); err != nil {
-		return nil, err
-	}
-
-	return saCandidate, nil
-}
-
-// ReadServiceAccountCandidate finds a service account candidate by id
-func (repo *ServiceAccountRepository) ReadServiceAccountCandidate(
-	id uint,
-) (*models.ServiceAccountCandidate, error) {
-	saCandidate := &models.ServiceAccountCandidate{}
-
-	if err := repo.db.Preload("Actions").Where("id = ?", id).First(&saCandidate).Error; err != nil {
-		return nil, err
-	}
-
-	repo.DecryptServiceAccountCandidateData(saCandidate, repo.key)
-
-	return saCandidate, nil
-}
-
-// ListServiceAccountCandidatesByProjectID finds all service account candidates
-// for a given project id
-func (repo *ServiceAccountRepository) ListServiceAccountCandidatesByProjectID(
-	projectID uint,
-) ([]*models.ServiceAccountCandidate, error) {
-	saCandidates := []*models.ServiceAccountCandidate{}
-
-	if err := repo.db.Preload("Actions").Where("project_id = ?", projectID).Find(&saCandidates).Error; err != nil {
-		return nil, err
-	}
-
-	for _, saCandidate := range saCandidates {
-		repo.DecryptServiceAccountCandidateData(saCandidate, repo.key)
-	}
-
-	return saCandidates, nil
-}
-
-// UpdateServiceAccountCandidateCreatedSAID updates the CreatedServiceAccountID for
-// a candidate, after the candidate has been resolved.
-func (repo *ServiceAccountRepository) UpdateServiceAccountCandidateCreatedSAID(
-	id uint,
-	createdSAID uint,
-) (*models.ServiceAccountCandidate, error) {
-	saCandidate := &models.ServiceAccountCandidate{}
-
-	if err := repo.db.Where("id = ?", id).First(&saCandidate).Error; err != nil {
-		return nil, err
-	}
-
-	saCandidate.CreatedServiceAccountID = createdSAID
-
-	if err := repo.db.Save(saCandidate).Error; err != nil {
-		return nil, err
-	}
-
-	return saCandidate, nil
-}
-
-// CreateServiceAccount creates a new servicea account
-func (repo *ServiceAccountRepository) CreateServiceAccount(
-	sa *models.ServiceAccount,
-) (*models.ServiceAccount, error) {
-	err := repo.EncryptServiceAccountData(sa, repo.key)
-
-	if err != nil {
-		return nil, err
-	}
-
-	project := &models.Project{}
-
-	if err := repo.db.Where("id = ?", sa.ProjectID).First(&project).Error; err != nil {
-		return nil, err
-	}
-
-	assoc := repo.db.Model(&project).Association("ServiceAccounts")
-
-	if assoc.Error != nil {
-		return nil, assoc.Error
-	}
-
-	if err := assoc.Append(sa); err != nil {
-		return nil, err
-	}
-
-	// create a token cache by default
-	assoc = repo.db.Model(sa).Association("TokenCache")
-
-	if assoc.Error != nil {
-		return nil, assoc.Error
-	}
-
-	if err := assoc.Append(&sa.TokenCache); err != nil {
-		return nil, err
-	}
-
-	return sa, nil
-}
-
-// ReadServiceAccount finds a service account by id
-func (repo *ServiceAccountRepository) ReadServiceAccount(
-	id uint,
-) (*models.ServiceAccount, error) {
-	sa := &models.ServiceAccount{}
-
-	// preload Clusters association
-	if err := repo.db.Preload("Clusters").Preload("TokenCache").Where("id = ?", id).First(&sa).Error; err != nil {
-		return nil, err
-	}
-
-	repo.DecryptServiceAccountData(sa, repo.key)
-
-	return sa, nil
-}
-
-// ListServiceAccountsByProjectID finds all service accounts
-// for a given project id
-func (repo *ServiceAccountRepository) ListServiceAccountsByProjectID(
-	projectID uint,
-) ([]*models.ServiceAccount, error) {
-	sas := []*models.ServiceAccount{}
-
-	if err := repo.db.Preload("Clusters").Where("project_id = ?", projectID).Find(&sas).Error; err != nil {
-		return nil, err
-	}
-
-	for _, sa := range sas {
-		repo.DecryptServiceAccountData(sa, repo.key)
-	}
-
-	return sas, nil
-}
-
-// UpdateServiceAccountTokenCache updates the token cache for a service account
-func (repo *ServiceAccountRepository) UpdateServiceAccountTokenCache(
-	tokenCache *models.TokenCache,
-) (*models.ServiceAccount, error) {
-	if tok := tokenCache.Token; len(tok) > 0 {
-		cipherData, err := repository.Encrypt(tok, repo.key)
-
-		if err != nil {
-			return nil, err
-		}
-
-		tokenCache.Token = cipherData
-	}
-
-	sa := &models.ServiceAccount{}
-
-	if err := repo.db.Where("id = ?", tokenCache.ServiceAccountID).First(&sa).Error; err != nil {
-		return nil, err
-	}
-
-	sa.TokenCache.Token = tokenCache.Token
-	sa.TokenCache.Expiry = tokenCache.Expiry
-
-	if err := repo.db.Save(sa).Error; err != nil {
-		return nil, err
-	}
-
-	return sa, nil
-}
-
-// EncryptServiceAccountData will encrypt the user's service account data before writing
-// to the DB
-func (repo *ServiceAccountRepository) EncryptServiceAccountData(
-	sa *models.ServiceAccount,
-	key *[32]byte,
-) error {
-	if len(sa.ClientCertificateData) > 0 {
-		cipherData, err := repository.Encrypt(sa.ClientCertificateData, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.ClientCertificateData = cipherData
-	}
-
-	if len(sa.ClientKeyData) > 0 {
-		cipherData, err := repository.Encrypt(sa.ClientKeyData, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.ClientKeyData = cipherData
-	}
-
-	if len(sa.Token) > 0 {
-		cipherData, err := repository.Encrypt(sa.Token, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.Token = cipherData
-	}
-
-	if len(sa.Username) > 0 {
-		cipherData, err := repository.Encrypt(sa.Username, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.Username = cipherData
-	}
-
-	if len(sa.Password) > 0 {
-		cipherData, err := repository.Encrypt(sa.Password, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.Password = cipherData
-	}
-
-	if len(sa.GCPKeyData) > 0 {
-		cipherData, err := repository.Encrypt(sa.GCPKeyData, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.GCPKeyData = cipherData
-	}
-
-	if tok := sa.TokenCache.Token; len(tok) > 0 {
-		cipherData, err := repository.Encrypt(tok, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.TokenCache.Token = cipherData
-	}
-
-	if len(sa.AWSAccessKeyID) > 0 {
-		cipherData, err := repository.Encrypt(sa.AWSAccessKeyID, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.AWSAccessKeyID = cipherData
-	}
-
-	if len(sa.AWSSecretAccessKey) > 0 {
-		cipherData, err := repository.Encrypt(sa.AWSSecretAccessKey, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.AWSSecretAccessKey = cipherData
-	}
-
-	if len(sa.AWSClusterID) > 0 {
-		cipherData, err := repository.Encrypt(sa.AWSClusterID, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.AWSClusterID = cipherData
-	}
-
-	if len(sa.OIDCIssuerURL) > 0 {
-		cipherData, err := repository.Encrypt(sa.OIDCIssuerURL, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.OIDCIssuerURL = cipherData
-	}
-
-	if len(sa.OIDCClientID) > 0 {
-		cipherData, err := repository.Encrypt(sa.OIDCClientID, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.OIDCClientID = cipherData
-	}
-
-	if len(sa.OIDCClientSecret) > 0 {
-		cipherData, err := repository.Encrypt(sa.OIDCClientSecret, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.OIDCClientSecret = cipherData
-	}
-
-	if len(sa.OIDCCertificateAuthorityData) > 0 {
-		cipherData, err := repository.Encrypt(sa.OIDCCertificateAuthorityData, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.OIDCCertificateAuthorityData = cipherData
-	}
-
-	if len(sa.OIDCIDToken) > 0 {
-		cipherData, err := repository.Encrypt(sa.OIDCIDToken, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.OIDCIDToken = cipherData
-	}
-
-	if len(sa.OIDCRefreshToken) > 0 {
-		cipherData, err := repository.Encrypt(sa.OIDCRefreshToken, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.OIDCRefreshToken = cipherData
-	}
-
-	for i, cluster := range sa.Clusters {
-		if len(cluster.CertificateAuthorityData) > 0 {
-			cipherData, err := repository.Encrypt(cluster.CertificateAuthorityData, key)
-
-			if err != nil {
-				return err
-			}
-
-			cluster.CertificateAuthorityData = cipherData
-			sa.Clusters[i] = cluster
-		}
-	}
-
-	return nil
-}
-
-// EncryptServiceAccountCandidateData will encrypt the service account candidate data before
-// writing to the DB
-func (repo *ServiceAccountRepository) EncryptServiceAccountCandidateData(
-	saCandidate *models.ServiceAccountCandidate,
-	key *[32]byte,
-) error {
-	if len(saCandidate.Kubeconfig) > 0 {
-		cipherData, err := repository.Encrypt(saCandidate.Kubeconfig, key)
-
-		if err != nil {
-			return err
-		}
-
-		saCandidate.Kubeconfig = cipherData
-	}
-
-	return nil
-}
-
-// DecryptServiceAccountData will decrypt the user's service account data before
-// returning it from the DB
-func (repo *ServiceAccountRepository) DecryptServiceAccountData(
-	sa *models.ServiceAccount,
-	key *[32]byte,
-) error {
-	if len(sa.ClientCertificateData) > 0 {
-		plaintext, err := repository.Decrypt(sa.ClientCertificateData, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.ClientCertificateData = plaintext
-	}
-
-	if len(sa.ClientKeyData) > 0 {
-		plaintext, err := repository.Decrypt(sa.ClientKeyData, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.ClientKeyData = plaintext
-	}
-
-	if len(sa.Token) > 0 {
-		plaintext, err := repository.Decrypt(sa.Token, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.Token = plaintext
-	}
-
-	if len(sa.Username) > 0 {
-		plaintext, err := repository.Decrypt(sa.Username, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.Username = plaintext
-	}
-
-	if len(sa.Password) > 0 {
-		plaintext, err := repository.Decrypt(sa.Password, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.Password = plaintext
-	}
-
-	if len(sa.GCPKeyData) > 0 {
-		plaintext, err := repository.Decrypt(sa.GCPKeyData, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.GCPKeyData = plaintext
-	}
-
-	if tok := sa.TokenCache.Token; len(tok) > 0 {
-		plaintext, err := repository.Decrypt(tok, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.TokenCache.Token = plaintext
-	}
-
-	if len(sa.AWSAccessKeyID) > 0 {
-		plaintext, err := repository.Decrypt(sa.AWSAccessKeyID, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.AWSAccessKeyID = plaintext
-	}
-
-	if len(sa.AWSSecretAccessKey) > 0 {
-		plaintext, err := repository.Decrypt(sa.AWSSecretAccessKey, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.AWSSecretAccessKey = plaintext
-	}
-
-	if len(sa.AWSClusterID) > 0 {
-		plaintext, err := repository.Decrypt(sa.AWSClusterID, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.AWSClusterID = plaintext
-	}
-
-	if len(sa.OIDCIssuerURL) > 0 {
-		plaintext, err := repository.Decrypt(sa.OIDCIssuerURL, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.OIDCIssuerURL = plaintext
-	}
-
-	if len(sa.OIDCClientID) > 0 {
-		plaintext, err := repository.Decrypt(sa.OIDCClientID, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.OIDCClientID = plaintext
-	}
-
-	if len(sa.OIDCClientSecret) > 0 {
-		plaintext, err := repository.Decrypt(sa.OIDCClientSecret, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.OIDCClientSecret = plaintext
-	}
-
-	if len(sa.OIDCCertificateAuthorityData) > 0 {
-		plaintext, err := repository.Decrypt(sa.OIDCCertificateAuthorityData, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.OIDCCertificateAuthorityData = plaintext
-	}
-
-	if len(sa.OIDCIDToken) > 0 {
-		plaintext, err := repository.Decrypt(sa.OIDCIDToken, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.OIDCIDToken = plaintext
-	}
-
-	if len(sa.OIDCRefreshToken) > 0 {
-		plaintext, err := repository.Decrypt(sa.OIDCRefreshToken, key)
-
-		if err != nil {
-			return err
-		}
-
-		sa.OIDCRefreshToken = plaintext
-	}
-
-	for i, cluster := range sa.Clusters {
-		if len(cluster.CertificateAuthorityData) > 0 {
-			plaintext, err := repository.Decrypt(cluster.CertificateAuthorityData, key)
-
-			if err != nil {
-				return err
-			}
-
-			cluster.CertificateAuthorityData = plaintext
-			sa.Clusters[i] = cluster
-		}
-	}
-
-	return nil
-}
-
-// DecryptServiceAccountCandidateData will decrypt the service account candidate data before
-// returning it from the DB
-func (repo *ServiceAccountRepository) DecryptServiceAccountCandidateData(
-	saCandidate *models.ServiceAccountCandidate,
-	key *[32]byte,
-) error {
-	if len(saCandidate.Kubeconfig) > 0 {
-		plaintext, err := repository.Decrypt(saCandidate.Kubeconfig, key)
-
-		if err != nil {
-			return err
-		}
-
-		saCandidate.Kubeconfig = plaintext
-	}
-
-	return nil
-}

+ 0 - 418
internal/repository/gorm/serviceaccount_test.go

@@ -1,418 +0,0 @@
-package gorm_test
-
-import (
-	"testing"
-	"time"
-
-	"github.com/go-test/deep"
-	"github.com/porter-dev/porter/internal/models"
-	orm "gorm.io/gorm"
-)
-
-func TestCreateServiceAccountCandidate(t *testing.T) {
-	tester := &tester{
-		dbFileName: "./porter_create_sa_candidate.db",
-	}
-
-	setupTestEnv(tester, t)
-	initProject(tester, t)
-	defer cleanup(tester, t)
-
-	saCandidate := &models.ServiceAccountCandidate{
-		ProjectID:       1,
-		Kind:            "connector",
-		ClusterName:     "cluster-test",
-		ClusterEndpoint: "https://localhost",
-		AuthMechanism:   models.X509,
-		Kubeconfig:      []byte("current-context: testing\n"),
-	}
-
-	saCandidate, err := tester.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	saCandidate, err = tester.repo.ServiceAccount.ReadServiceAccountCandidate(saCandidate.Model.ID)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// make sure id is 1
-	if saCandidate.Model.ID != 1 {
-		t.Errorf("incorrect service accound candidate ID: expected %d, got %d\n", 1, saCandidate.Model.ID)
-	}
-
-	// make sure data is correct
-	expSACandidate := &models.ServiceAccountCandidate{
-		ProjectID:       1,
-		Kind:            "connector",
-		ClusterName:     "cluster-test",
-		ClusterEndpoint: "https://localhost",
-		AuthMechanism:   models.X509,
-		Kubeconfig:      []byte("current-context: testing\n"),
-		Actions:         []models.ServiceAccountAction{},
-	}
-
-	copySACandidate := saCandidate
-
-	// reset fields for reflect.DeepEqual
-	copySACandidate.Model = orm.Model{}
-
-	if diff := deep.Equal(copySACandidate, expSACandidate); diff != nil {
-		t.Errorf("incorrect sa candidate")
-		t.Error(diff)
-	}
-}
-
-func TestCreateServiceAccountCandidateWithAction(t *testing.T) {
-	tester := &tester{
-		dbFileName: "./porter_create_sa_candidate_w_action.db",
-	}
-
-	setupTestEnv(tester, t)
-	initProject(tester, t)
-	initServiceAccountCandidate(tester, t)
-	defer cleanup(tester, t)
-
-	saCandidate := tester.initSACandidates[0]
-
-	saCandidate, err := tester.repo.ServiceAccount.ReadServiceAccountCandidate(saCandidate.Model.ID)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// make sure IDs are correct
-	if saCandidate.Model.ID != 1 {
-		t.Errorf("incorrect service account candidate ID: expected %d, got %d\n", 1, saCandidate.Model.ID)
-	}
-
-	if len(saCandidate.Actions) != 1 {
-		t.Errorf("incorrect actions length: expected %d, got %d\n", 1, len(saCandidate.Actions))
-	}
-
-	if saCandidate.Actions[0].Model.ID != 1 {
-		t.Errorf("incorrect actions ID: expected %d, got %d\n", 1, saCandidate.Actions[0].Model.ID)
-	}
-
-	// make sure data is correct
-	expSACandidate := &models.ServiceAccountCandidate{
-		ProjectID:       1,
-		Kind:            "connector",
-		ClusterName:     "cluster-test",
-		ClusterEndpoint: "https://localhost",
-		AuthMechanism:   models.X509,
-		Kubeconfig:      []byte("current-context: testing\n"),
-		Actions: []models.ServiceAccountAction{
-			models.ServiceAccountAction{
-				ServiceAccountCandidateID: 1,
-				Name:                      models.TokenDataAction,
-				Resolved:                  false,
-			},
-		},
-	}
-
-	copySACandidate := saCandidate
-
-	// reset fields for reflect.DeepEqual
-	copySACandidate.Model = orm.Model{}
-
-	copySACandidate.Actions[0].Model = orm.Model{}
-
-	if diff := deep.Equal(copySACandidate, expSACandidate); diff != nil {
-		t.Errorf("incorrect sa candidate")
-		t.Error(diff)
-	}
-}
-
-func TestListServiceAccountCandidatesByProjectID(t *testing.T) {
-	tester := &tester{
-		dbFileName: "./porter_list_sa_candidates.db",
-	}
-
-	setupTestEnv(tester, t)
-	initProject(tester, t)
-	initServiceAccountCandidate(tester, t)
-	defer cleanup(tester, t)
-
-	saCandidates, err := tester.repo.ServiceAccount.ListServiceAccountCandidatesByProjectID(
-		tester.initProjects[0].Model.ID,
-	)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	if len(saCandidates) != 1 {
-		t.Fatalf("length of sa candidates incorrect: expected %d, got %d\n", 1, len(saCandidates))
-	}
-
-	// make sure data is correct
-	expSACandidate := &models.ServiceAccountCandidate{
-		ProjectID:       1,
-		Kind:            "connector",
-		ClusterName:     "cluster-test",
-		ClusterEndpoint: "https://localhost",
-		AuthMechanism:   models.X509,
-		Kubeconfig:      []byte("current-context: testing\n"),
-		Actions: []models.ServiceAccountAction{
-			models.ServiceAccountAction{
-				ServiceAccountCandidateID: 1,
-				Name:                      models.TokenDataAction,
-				Resolved:                  false,
-			},
-		},
-	}
-
-	copySACandidate := saCandidates[0]
-
-	// reset fields for reflect.DeepEqual
-	copySACandidate.Model = orm.Model{}
-	copySACandidate.Actions[0].Model = orm.Model{}
-
-	if diff := deep.Equal(copySACandidate, expSACandidate); diff != nil {
-		t.Errorf("incorrect sa candidate")
-		t.Error(diff)
-	}
-}
-
-func TestCreateServiceAccount(t *testing.T) {
-	tester := &tester{
-		dbFileName: "./porter_create_sa.db",
-	}
-
-	setupTestEnv(tester, t)
-	initProject(tester, t)
-	defer cleanup(tester, t)
-
-	sa := &models.ServiceAccount{
-		ProjectID:             1,
-		Kind:                  "connector",
-		AuthMechanism:         models.X509,
-		ClientCertificateData: []byte("-----BEGIN"),
-		ClientKeyData:         []byte("-----BEGIN"),
-	}
-
-	sa, err := tester.repo.ServiceAccount.CreateServiceAccount(sa)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	sa, err = tester.repo.ServiceAccount.ReadServiceAccount(sa.Model.ID)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// make sure id is 1
-	if sa.Model.ID != 1 {
-		t.Errorf("incorrect service account ID: expected %d, got %d\n", 1, sa.Model.ID)
-	}
-
-	// make sure data is correct
-	expSA := &models.ServiceAccount{
-		ProjectID:             1,
-		Kind:                  "connector",
-		AuthMechanism:         models.X509,
-		ClientCertificateData: []byte("-----BEGIN"),
-		ClientKeyData:         []byte("-----BEGIN"),
-		Clusters:              []models.Cluster{},
-	}
-
-	copySA := sa
-
-	// reset fields for reflect.DeepEqual
-	copySA.Model = orm.Model{}
-
-	if diff := deep.Equal(copySA, expSA); diff != nil {
-		t.Errorf("incorrect service account")
-		t.Error(diff)
-	}
-}
-
-func TestCreateServiceAccountWithCluster(t *testing.T) {
-	tester := &tester{
-		dbFileName: "./porter_create_sa_w_cluster.db",
-	}
-
-	setupTestEnv(tester, t)
-	initProject(tester, t)
-	initServiceAccount(tester, t)
-	defer cleanup(tester, t)
-
-	sa := tester.initSAs[0]
-
-	sa, err := tester.repo.ServiceAccount.ReadServiceAccount(sa.Model.ID)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// make sure id is 1
-	if sa.Model.ID != 1 {
-		t.Errorf("incorrect service account ID: expected %d, got %d\n", 1, sa.Model.ID)
-	}
-
-	if len(sa.Clusters) != 1 {
-		t.Errorf("incorrect clusters length: expected %d, got %d\n", 1, len(sa.Clusters))
-	}
-
-	if sa.Clusters[0].Model.ID != 1 {
-		t.Errorf("incorrect clusters ID: expected %d, got %d\n", 1, sa.Clusters[0].Model.ID)
-	}
-
-	// make sure data is correct
-	expSA := &models.ServiceAccount{
-		ProjectID:             1,
-		Kind:                  "connector",
-		AuthMechanism:         models.X509,
-		ClientCertificateData: []byte("-----BEGIN"),
-		ClientKeyData:         []byte("-----BEGIN"),
-		Clusters: []models.Cluster{
-			models.Cluster{
-				ServiceAccountID:         1,
-				Name:                     "cluster-test",
-				Server:                   "https://localhost",
-				CertificateAuthorityData: []byte("-----BEGIN"),
-			},
-		},
-	}
-
-	copySA := sa
-
-	// reset fields for reflect.DeepEqual
-	copySA.Model = orm.Model{}
-	copySA.Clusters[0].Model = orm.Model{}
-
-	if diff := deep.Equal(copySA, expSA); diff != nil {
-		t.Errorf("incorrect service account")
-		t.Error(diff)
-	}
-}
-
-func TestListServiceAccountsByProjectID(t *testing.T) {
-	tester := &tester{
-		dbFileName: "./porter_list_sas.db",
-	}
-
-	setupTestEnv(tester, t)
-	initProject(tester, t)
-	initServiceAccount(tester, t)
-	defer cleanup(tester, t)
-
-	sas, err := tester.repo.ServiceAccount.ListServiceAccountsByProjectID(
-		tester.initProjects[0].Model.ID,
-	)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	if len(sas) != 1 {
-		t.Fatalf("length of sas incorrect: expected %d, got %d\n", 1, len(sas))
-	}
-
-	// make sure data is correct
-	expSA := &models.ServiceAccount{
-		ProjectID:             1,
-		Kind:                  "connector",
-		AuthMechanism:         models.X509,
-		ClientCertificateData: []byte("-----BEGIN"),
-		ClientKeyData:         []byte("-----BEGIN"),
-		Clusters: []models.Cluster{
-			models.Cluster{
-				ServiceAccountID:         1,
-				Name:                     "cluster-test",
-				Server:                   "https://localhost",
-				CertificateAuthorityData: []byte("-----BEGIN"),
-			},
-		},
-	}
-
-	copySA := sas[0]
-
-	// reset fields for reflect.DeepEqual
-	copySA.Model = orm.Model{}
-	copySA.Clusters[0].Model = orm.Model{}
-
-	if diff := deep.Equal(copySA, expSA); diff != nil {
-		t.Errorf("incorrect service account")
-		t.Error(diff)
-	}
-}
-
-func TestUpdateServiceAccountToken(t *testing.T) {
-	tester := &tester{
-		dbFileName: "./porter_test_update_sa_token.db",
-	}
-
-	setupTestEnv(tester, t)
-	initProject(tester, t)
-	defer cleanup(tester, t)
-
-	sa := &models.ServiceAccount{
-		ProjectID:     1,
-		Kind:          "connector",
-		AuthMechanism: models.GCP,
-		GCPKeyData:    []byte(`{"key":"data"}`),
-		TokenCache: models.TokenCache{
-			Token:  []byte("token-1"),
-			Expiry: time.Now().Add(-1 * time.Hour),
-		},
-	}
-
-	sa, err := tester.repo.ServiceAccount.CreateServiceAccount(sa)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	sa, err = tester.repo.ServiceAccount.ReadServiceAccount(sa.Model.ID)
-
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// make sure service account id of token is 1
-	if sa.TokenCache.ServiceAccountID != 1 {
-		t.Fatalf("incorrect service account ID in token cache: expected %d, got %d\n", 1, sa.TokenCache.ServiceAccountID)
-	}
-
-	// make sure old token is expired
-	if isExpired := sa.TokenCache.IsExpired(); !isExpired {
-		t.Fatalf("token was not expired\n")
-	}
-
-	sa.TokenCache.Token = []byte("token-2")
-	sa.TokenCache.Expiry = time.Now().Add(24 * time.Hour)
-
-	sa, err = tester.repo.ServiceAccount.UpdateServiceAccountTokenCache(&sa.TokenCache)
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-	sa, err = tester.repo.ServiceAccount.ReadServiceAccount(sa.Model.ID)
-	if err != nil {
-		t.Fatalf("%v\n", err)
-	}
-
-	// make sure id is 1
-	if sa.Model.ID != 1 {
-		t.Errorf("incorrect service account ID: expected %d, got %d\n", 1, sa.Model.ID)
-	}
-
-	// make sure new token is correct and not expired
-	if sa.TokenCache.ServiceAccountID != 1 {
-		t.Fatalf("incorrect service account ID in token cache: expected %d, got %d\n", 1, sa.TokenCache.ServiceAccountID)
-	}
-
-	if isExpired := sa.TokenCache.IsExpired(); isExpired {
-		t.Fatalf("token was expired\n")
-	}
-
-	if string(sa.TokenCache.Token) != "token-2" {
-		t.Errorf("incorrect token in cache: expected %s, got %s\n", "token-2", sa.TokenCache.Token)
-	}
-}

+ 45 - 0
internal/repository/integrations.go

@@ -0,0 +1,45 @@
+package repository
+
+import (
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// KubeIntegrationRepository represents the set of queries on the OIDC auth
+// mechanism
+type KubeIntegrationRepository interface {
+	CreateKubeIntegration(am *ints.KubeIntegration) (*ints.KubeIntegration, error)
+	ReadKubeIntegration(id uint) (*ints.KubeIntegration, error)
+	ListKubeIntegrationsByProjectID(projectID uint) ([]*ints.KubeIntegration, error)
+}
+
+// OIDCIntegrationRepository represents the set of queries on the OIDC auth
+// mechanism
+type OIDCIntegrationRepository interface {
+	CreateOIDCIntegration(am *ints.OIDCIntegration) (*ints.OIDCIntegration, error)
+	ReadOIDCIntegration(id uint) (*ints.OIDCIntegration, error)
+	ListOIDCIntegrationsByProjectID(projectID uint) ([]*ints.OIDCIntegration, error)
+}
+
+// OAuthIntegrationRepository represents the set of queries on the oauth
+// mechanism
+type OAuthIntegrationRepository interface {
+	CreateOAuthIntegration(am *ints.OAuthIntegration) (*ints.OAuthIntegration, error)
+	ReadOAuthIntegration(id uint) (*ints.OAuthIntegration, error)
+	ListOAuthIntegrationsByProjectID(projectID uint) ([]*ints.OAuthIntegration, error)
+}
+
+// AWSIntegrationRepository represents the set of queries on the AWS auth
+// mechanism
+type AWSIntegrationRepository interface {
+	CreateAWSIntegration(am *ints.AWSIntegration) (*ints.AWSIntegration, error)
+	ReadAWSIntegration(id uint) (*ints.AWSIntegration, error)
+	ListAWSIntegrationsByProjectID(projectID uint) ([]*ints.AWSIntegration, error)
+}
+
+// GCPIntegrationRepository represents the set of queries on the GCP auth
+// mechanism
+type GCPIntegrationRepository interface {
+	CreateGCPIntegration(am *ints.GCPIntegration) (*ints.GCPIntegration, error)
+	ReadGCPIntegration(id uint) (*ints.GCPIntegration, error)
+	ListGCPIntegrationsByProjectID(projectID uint) ([]*ints.GCPIntegration, error)
+}

+ 10 - 0
internal/repository/registry.go

@@ -0,0 +1,10 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+// RegistryRepository represents the set of queries on the Registry model
+type RegistryRepository interface {
+	CreateRegistry(reg *models.Registry) (*models.Registry, error)
+	ReadRegistry(id uint) (*models.Registry, error)
+	ListRegistriesByProjectID(projectID uint) ([]*models.Registry, error)
+}

+ 0 - 11
internal/repository/repoclient.go

@@ -1,11 +0,0 @@
-package repository
-
-import "github.com/porter-dev/porter/internal/models"
-
-// RepoClientRepository represents the set of queries on the
-// RepoClient model
-type RepoClientRepository interface {
-	CreateRepoClient(rc *models.RepoClient) (*models.RepoClient, error)
-	ReadRepoClient(id uint) (*models.RepoClient, error)
-	ListRepoClientsByProjectID(projectID uint) ([]*models.RepoClient, error)
-}

+ 11 - 5
internal/repository/repository.go

@@ -2,9 +2,15 @@ package repository
 
 // Repository collects the repositories for each model
 type Repository struct {
-	User           UserRepository
-	Project        ProjectRepository
-	Session        SessionRepository
-	ServiceAccount ServiceAccountRepository
-	RepoClient     RepoClientRepository
+	User             UserRepository
+	Project          ProjectRepository
+	Session          SessionRepository
+	GitRepo          GitRepoRepository
+	Cluster          ClusterRepository
+	Registry         RegistryRepository
+	KubeIntegration  KubeIntegrationRepository
+	OIDCIntegration  OIDCIntegrationRepository
+	OAuthIntegration OAuthIntegrationRepository
+	GCPIntegration   GCPIntegrationRepository
+	AWSIntegration   AWSIntegrationRepository
 }

+ 0 - 18
internal/repository/serviceaccount.go

@@ -1,18 +0,0 @@
-package repository
-
-import (
-	"github.com/porter-dev/porter/internal/models"
-)
-
-// ServiceAccountRepository represents the set of queries on the
-// ServiceAccount model
-type ServiceAccountRepository interface {
-	CreateServiceAccountCandidate(saCandidate *models.ServiceAccountCandidate) (*models.ServiceAccountCandidate, error)
-	ReadServiceAccountCandidate(id uint) (*models.ServiceAccountCandidate, error)
-	ListServiceAccountCandidatesByProjectID(projectID uint) ([]*models.ServiceAccountCandidate, error)
-	UpdateServiceAccountCandidateCreatedSAID(id uint, createdSAID uint) (*models.ServiceAccountCandidate, error)
-	CreateServiceAccount(sa *models.ServiceAccount) (*models.ServiceAccount, error)
-	ReadServiceAccount(id uint) (*models.ServiceAccount, error)
-	ListServiceAccountsByProjectID(projectID uint) ([]*models.ServiceAccount, error)
-	UpdateServiceAccountTokenCache(tokenCache *models.TokenCache) (*models.ServiceAccount, error)
-}

+ 330 - 0
internal/repository/test/auth.go

@@ -0,0 +1,330 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// KubeIntegrationRepository implements repository.KubeIntegrationRepository
+type KubeIntegrationRepository struct {
+	canQuery         bool
+	kubeIntegrations []*ints.KubeIntegration
+}
+
+// NewKubeIntegrationRepository will return errors if canQuery is false
+func NewKubeIntegrationRepository(canQuery bool) repository.KubeIntegrationRepository {
+	return &KubeIntegrationRepository{
+		canQuery,
+		[]*ints.KubeIntegration{},
+	}
+}
+
+// CreateKubeIntegration creates a new kube auth mechanism
+func (repo *KubeIntegrationRepository) CreateKubeIntegration(
+	am *ints.KubeIntegration,
+) (*ints.KubeIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.kubeIntegrations = append(repo.kubeIntegrations, am)
+	am.ID = uint(len(repo.kubeIntegrations))
+
+	return am, nil
+}
+
+// ReadKubeIntegration finds a kube auth mechanism by id
+func (repo *KubeIntegrationRepository) ReadKubeIntegration(
+	id uint,
+) (*ints.KubeIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.kubeIntegrations) || repo.kubeIntegrations[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.kubeIntegrations[index], nil
+}
+
+// ListKubeIntegrationsByProjectID finds all kube auth mechanisms
+// for a given project id
+func (repo *KubeIntegrationRepository) ListKubeIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.KubeIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*ints.KubeIntegration, 0)
+
+	for _, kubeAM := range repo.kubeIntegrations {
+		if kubeAM.ProjectID == projectID {
+			res = append(res, kubeAM)
+		}
+	}
+
+	return res, nil
+}
+
+// OIDCIntegrationRepository implements repository.OIDCIntegrationRepository
+type OIDCIntegrationRepository struct {
+	canQuery         bool
+	oidcIntegrations []*ints.OIDCIntegration
+}
+
+// NewOIDCIntegrationRepository will return errors if canQuery is false
+func NewOIDCIntegrationRepository(canQuery bool) repository.OIDCIntegrationRepository {
+	return &OIDCIntegrationRepository{
+		canQuery,
+		[]*ints.OIDCIntegration{},
+	}
+}
+
+// CreateOIDCIntegration creates a new oidc auth mechanism
+func (repo *OIDCIntegrationRepository) CreateOIDCIntegration(
+	am *ints.OIDCIntegration,
+) (*ints.OIDCIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.oidcIntegrations = append(repo.oidcIntegrations, am)
+	am.ID = uint(len(repo.oidcIntegrations))
+
+	return am, nil
+}
+
+// ReadOIDCIntegration finds a oidc auth mechanism by id
+func (repo *OIDCIntegrationRepository) ReadOIDCIntegration(
+	id uint,
+) (*ints.OIDCIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.oidcIntegrations) || repo.oidcIntegrations[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.oidcIntegrations[index], nil
+}
+
+// ListOIDCIntegrationsByProjectID finds all oidc auth mechanisms
+// for a given project id
+func (repo *OIDCIntegrationRepository) ListOIDCIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.OIDCIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*ints.OIDCIntegration, 0)
+
+	for _, oidcAM := range repo.oidcIntegrations {
+		if oidcAM.ProjectID == projectID {
+			res = append(res, oidcAM)
+		}
+	}
+
+	return res, nil
+}
+
+// OAuthIntegrationRepository implements repository.OAuthIntegrationRepository
+type OAuthIntegrationRepository struct {
+	canQuery      bool
+	oIntegrations []*ints.OAuthIntegration
+}
+
+// NewOAuthIntegrationRepository will return errors if canQuery is false
+func NewOAuthIntegrationRepository(canQuery bool) repository.OAuthIntegrationRepository {
+	return &OAuthIntegrationRepository{
+		canQuery,
+		[]*ints.OAuthIntegration{},
+	}
+}
+
+// CreateOAuthIntegration creates a new o auth mechanism
+func (repo *OAuthIntegrationRepository) CreateOAuthIntegration(
+	am *ints.OAuthIntegration,
+) (*ints.OAuthIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.oIntegrations = append(repo.oIntegrations, am)
+	am.ID = uint(len(repo.oIntegrations))
+
+	return am, nil
+}
+
+// ReadOAuthIntegration finds a o auth mechanism by id
+func (repo *OAuthIntegrationRepository) ReadOAuthIntegration(
+	id uint,
+) (*ints.OAuthIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.oIntegrations) || repo.oIntegrations[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.oIntegrations[index], nil
+}
+
+// ListOAuthIntegrationsByProjectID finds all o auth mechanisms
+// for a given project id
+func (repo *OAuthIntegrationRepository) ListOAuthIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.OAuthIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*ints.OAuthIntegration, 0)
+
+	for _, oAM := range repo.oIntegrations {
+		if oAM.ProjectID == projectID {
+			res = append(res, oAM)
+		}
+	}
+
+	return res, nil
+}
+
+// AWSIntegrationRepository implements repository.AWSIntegrationRepository
+type AWSIntegrationRepository struct {
+	canQuery        bool
+	awsIntegrations []*ints.AWSIntegration
+}
+
+// NewAWSIntegrationRepository will return errors if canQuery is false
+func NewAWSIntegrationRepository(canQuery bool) repository.AWSIntegrationRepository {
+	return &AWSIntegrationRepository{
+		canQuery,
+		[]*ints.AWSIntegration{},
+	}
+}
+
+// CreateAWSIntegration creates a new aws auth mechanism
+func (repo *AWSIntegrationRepository) CreateAWSIntegration(
+	am *ints.AWSIntegration,
+) (*ints.AWSIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.awsIntegrations = append(repo.awsIntegrations, am)
+	am.ID = uint(len(repo.awsIntegrations))
+
+	return am, nil
+}
+
+// ReadAWSIntegration finds a aws auth mechanism by id
+func (repo *AWSIntegrationRepository) ReadAWSIntegration(
+	id uint,
+) (*ints.AWSIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.awsIntegrations) || repo.awsIntegrations[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.awsIntegrations[index], nil
+}
+
+// ListAWSIntegrationsByProjectID finds all aws auth mechanisms
+// for a given project id
+func (repo *AWSIntegrationRepository) ListAWSIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.AWSIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*ints.AWSIntegration, 0)
+
+	for _, awsAM := range repo.awsIntegrations {
+		if awsAM.ProjectID == projectID {
+			res = append(res, awsAM)
+		}
+	}
+
+	return res, nil
+}
+
+// GCPIntegrationRepository implements repository.GCPIntegrationRepository
+type GCPIntegrationRepository struct {
+	canQuery        bool
+	gcpIntegrations []*ints.GCPIntegration
+}
+
+// NewGCPIntegrationRepository will return errors if canQuery is false
+func NewGCPIntegrationRepository(canQuery bool) repository.GCPIntegrationRepository {
+	return &GCPIntegrationRepository{
+		canQuery,
+		[]*ints.GCPIntegration{},
+	}
+}
+
+// CreateGCPIntegration creates a new gcp auth mechanism
+func (repo *GCPIntegrationRepository) CreateGCPIntegration(
+	am *ints.GCPIntegration,
+) (*ints.GCPIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.gcpIntegrations = append(repo.gcpIntegrations, am)
+	am.ID = uint(len(repo.gcpIntegrations))
+
+	return am, nil
+}
+
+// ReadGCPIntegration finds a gcp auth mechanism by id
+func (repo *GCPIntegrationRepository) ReadGCPIntegration(
+	id uint,
+) (*ints.GCPIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.gcpIntegrations) || repo.gcpIntegrations[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.gcpIntegrations[index], nil
+}
+
+// ListGCPIntegrationsByProjectID finds all gcp auth mechanisms
+// for a given project id
+func (repo *GCPIntegrationRepository) ListGCPIntegrationsByProjectID(
+	projectID uint,
+) ([]*ints.GCPIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*ints.GCPIntegration, 0)
+
+	for _, gcpAM := range repo.gcpIntegrations {
+		if gcpAM.ProjectID == projectID {
+			res = append(res, gcpAM)
+		}
+	}
+
+	return res, nil
+}

+ 160 - 0
internal/repository/test/cluster.go

@@ -0,0 +1,160 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// ClusterRepository implements repository.ClusterRepository
+type ClusterRepository struct {
+	canQuery          bool
+	clusterCandidates []*models.ClusterCandidate
+	clusters          []*models.Cluster
+}
+
+// NewClusterRepository will return errors if canQuery is false
+func NewClusterRepository(canQuery bool) repository.ClusterRepository {
+	return &ClusterRepository{
+		canQuery,
+		[]*models.ClusterCandidate{},
+		[]*models.Cluster{},
+	}
+}
+
+// CreateClusterCandidate creates a new cluster candidate
+func (repo *ClusterRepository) CreateClusterCandidate(
+	cc *models.ClusterCandidate,
+) (*models.ClusterCandidate, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.clusterCandidates = append(repo.clusterCandidates, cc)
+	cc.ID = uint(len(repo.clusterCandidates))
+
+	return cc, nil
+}
+
+// ReadClusterCandidate finds a service account candidate by id
+func (repo *ClusterRepository) ReadClusterCandidate(id uint) (*models.ClusterCandidate, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.clusterCandidates) || repo.clusterCandidates[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.clusterCandidates[index], nil
+}
+
+// ListClusterCandidatesByProjectID finds all service account candidates
+// for a given project id
+func (repo *ClusterRepository) ListClusterCandidatesByProjectID(
+	projectID uint,
+) ([]*models.ClusterCandidate, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*models.ClusterCandidate, 0)
+
+	for _, saCandidate := range repo.clusterCandidates {
+		if saCandidate.ProjectID == projectID {
+			res = append(res, saCandidate)
+		}
+	}
+
+	return res, nil
+}
+
+// UpdateClusterCandidateCreatedClusterID updates the CreatedClusterID for
+// a candidate, after the candidate has been resolved.
+func (repo *ClusterRepository) UpdateClusterCandidateCreatedClusterID(
+	id uint,
+	createdClusterID uint,
+) (*models.ClusterCandidate, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	index := int(id - 1)
+	repo.clusterCandidates[index].CreatedClusterID = createdClusterID
+
+	return repo.clusterCandidates[index], nil
+}
+
+// CreateCluster creates a new servicea account
+func (repo *ClusterRepository) CreateCluster(
+	cluster *models.Cluster,
+) (*models.Cluster, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if cluster == nil {
+		return nil, nil
+	}
+
+	repo.clusters = append(repo.clusters, cluster)
+	cluster.ID = uint(len(repo.clusters))
+
+	return cluster, nil
+}
+
+// ReadCluster finds a service account by id
+func (repo *ClusterRepository) ReadCluster(
+	id uint,
+) (*models.Cluster, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.clusters) || repo.clusters[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.clusters[index], nil
+}
+
+// ListClustersByProjectID finds all service accounts
+// for a given project id
+func (repo *ClusterRepository) ListClustersByProjectID(
+	projectID uint,
+) ([]*models.Cluster, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*models.Cluster, 0)
+
+	for _, sa := range repo.clusters {
+		if sa.ProjectID == projectID {
+			res = append(res, sa)
+		}
+	}
+
+	return res, nil
+}
+
+// UpdateClusterTokenCache updates the token cache for a cluster
+func (repo *ClusterRepository) UpdateClusterTokenCache(
+	tokenCache *ints.TokenCache,
+) (*models.Cluster, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	index := int(tokenCache.ClusterID - 1)
+	repo.clusters[index].TokenCache.Token = tokenCache.Token
+	repo.clusters[index].TokenCache.Expiry = tokenCache.Expiry
+
+	return repo.clusters[index], nil
+}

+ 67 - 0
internal/repository/test/gitrepo.go

@@ -0,0 +1,67 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+
+	"gorm.io/gorm"
+)
+
+// GitRepoRepository implements repository.GitRepoRepository
+type GitRepoRepository struct {
+	canQuery bool
+	gitRepos []*models.GitRepo
+}
+
+// NewGitRepoRepository will return errors if canQuery is false
+func NewGitRepoRepository(canQuery bool) repository.GitRepoRepository {
+	return &GitRepoRepository{
+		canQuery,
+		[]*models.GitRepo{},
+	}
+}
+
+// CreateGitRepo creates a new repo client and appends it to the in-memory list
+func (repo *GitRepoRepository) CreateGitRepo(gr *models.GitRepo) (*models.GitRepo, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.gitRepos = append(repo.gitRepos, gr)
+	gr.ID = uint(len(repo.gitRepos))
+
+	return gr, nil
+}
+
+// ReadGitRepo returns a repo client by id
+func (repo *GitRepoRepository) ReadGitRepo(id uint) (*models.GitRepo, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.gitRepos) || repo.gitRepos[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.gitRepos[index], nil
+}
+
+// ListGitReposByProjectID returns a list of repo clients that match a project id
+func (repo *GitRepoRepository) ListGitReposByProjectID(projectID uint) ([]*models.GitRepo, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*models.GitRepo, 0)
+
+	for _, gr := range repo.gitRepos {
+		if gr.ProjectID == projectID {
+			res = append(res, gr)
+		}
+	}
+
+	return res, nil
+}

+ 73 - 0
internal/repository/test/registry.go

@@ -0,0 +1,73 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// RegistryRepository implements repository.RegistryRepository
+type RegistryRepository struct {
+	canQuery   bool
+	registries []*models.Registry
+}
+
+// NewRegistryRepository will return errors if canQuery is false
+func NewRegistryRepository(canQuery bool) repository.RegistryRepository {
+	return &RegistryRepository{
+		canQuery,
+		[]*models.Registry{},
+	}
+}
+
+// CreateRegistry creates a new registry
+func (repo *RegistryRepository) CreateRegistry(
+	reg *models.Registry,
+) (*models.Registry, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	repo.registries = append(repo.registries, reg)
+	reg.ID = uint(len(repo.registries))
+
+	return reg, nil
+}
+
+// ReadRegistry finds a registry by id
+func (repo *RegistryRepository) ReadRegistry(
+	id uint,
+) (*models.Registry, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	if int(id-1) >= len(repo.registries) || repo.registries[id-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	return repo.registries[index], nil
+}
+
+// ListRegistriesByProjectID finds all registries
+// for a given project id
+func (repo *RegistryRepository) ListRegistriesByProjectID(
+	projectID uint,
+) ([]*models.Registry, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	res := make([]*models.Registry, 0)
+
+	for _, reg := range repo.registries {
+		if reg.ProjectID == projectID {
+			res = append(res, reg)
+		}
+	}
+
+	return res, nil
+}

+ 0 - 66
internal/repository/test/repoclient.go

@@ -1,66 +0,0 @@
-package test
-
-import (
-	"errors"
-
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
-	"gorm.io/gorm"
-)
-
-// RepoClientRepository implements repository.RepoClientRepository
-type RepoClientRepository struct {
-	canQuery    bool
-	repoClients []*models.RepoClient
-}
-
-// NewRepoClientRepository will return errors if canQuery is false
-func NewRepoClientRepository(canQuery bool) repository.RepoClientRepository {
-	return &RepoClientRepository{
-		canQuery,
-		[]*models.RepoClient{},
-	}
-}
-
-// CreateRepoClient creates a new repo client and appends it to the in-memory list
-func (repo *RepoClientRepository) CreateRepoClient(rc *models.RepoClient) (*models.RepoClient, error) {
-	if !repo.canQuery {
-		return nil, errors.New("Cannot write database")
-	}
-
-	repo.repoClients = append(repo.repoClients, rc)
-	rc.ID = uint(len(repo.repoClients))
-
-	return rc, nil
-}
-
-// ReadRepoClient returns a repo client by id
-func (repo *RepoClientRepository) ReadRepoClient(id uint) (*models.RepoClient, error) {
-	if !repo.canQuery {
-		return nil, errors.New("Cannot read from database")
-	}
-
-	if int(id-1) >= len(repo.repoClients) || repo.repoClients[id-1] == nil {
-		return nil, gorm.ErrRecordNotFound
-	}
-
-	index := int(id - 1)
-	return repo.repoClients[index], nil
-}
-
-// ListRepoClientsByProjectID returns a list of repo clients that match a project id
-func (repo *RepoClientRepository) ListRepoClientsByProjectID(projectID uint) ([]*models.RepoClient, error) {
-	if !repo.canQuery {
-		return nil, errors.New("Cannot read from database")
-	}
-
-	res := make([]*models.RepoClient, 0)
-
-	for _, rc := range repo.repoClients {
-		if rc.ProjectID == projectID {
-			res = append(res, rc)
-		}
-	}
-
-	return res, nil
-}

+ 11 - 4
internal/repository/test/repository.go

@@ -8,9 +8,16 @@ import (
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool) *repository.Repository {
 	return &repository.Repository{
-		User:           NewUserRepository(canQuery),
-		Session:        NewSessionRepository(canQuery),
-		Project:        NewProjectRepository(canQuery),
-		ServiceAccount: NewServiceAccountRepository(canQuery),
+		User:             NewUserRepository(canQuery),
+		Session:          NewSessionRepository(canQuery),
+		Project:          NewProjectRepository(canQuery),
+		Cluster:          NewClusterRepository(canQuery),
+		Registry:         NewRegistryRepository(canQuery),
+		GitRepo:          NewGitRepoRepository(canQuery),
+		KubeIntegration:  NewKubeIntegrationRepository(canQuery),
+		OIDCIntegration:  NewOIDCIntegrationRepository(canQuery),
+		OAuthIntegration: NewOAuthIntegrationRepository(canQuery),
+		GCPIntegration:   NewGCPIntegrationRepository(canQuery),
+		AWSIntegration:   NewAWSIntegrationRepository(canQuery),
 	}
 }

+ 0 - 185
internal/repository/test/serviceaccount.go

@@ -1,185 +0,0 @@
-package test
-
-import (
-	"errors"
-
-	"github.com/porter-dev/porter/internal/models"
-	"github.com/porter-dev/porter/internal/repository"
-	"gorm.io/gorm"
-)
-
-// ServiceAccountRepository implements repository.ServiceAccountRepository
-type ServiceAccountRepository struct {
-	canQuery                 bool
-	serviceAccountCandidates []*models.ServiceAccountCandidate
-	serviceAccounts          []*models.ServiceAccount
-	clusters                 []*models.Cluster
-}
-
-// NewServiceAccountRepository will return errors if canQuery is false
-func NewServiceAccountRepository(canQuery bool) repository.ServiceAccountRepository {
-	return &ServiceAccountRepository{
-		canQuery,
-		[]*models.ServiceAccountCandidate{},
-		[]*models.ServiceAccount{},
-		[]*models.Cluster{},
-	}
-}
-
-// CreateServiceAccountCandidate creates a new service account candidate
-func (repo *ServiceAccountRepository) CreateServiceAccountCandidate(
-	saCandidate *models.ServiceAccountCandidate,
-) (*models.ServiceAccountCandidate, error) {
-	if !repo.canQuery {
-		return nil, errors.New("Cannot write database")
-	}
-
-	repo.serviceAccountCandidates = append(repo.serviceAccountCandidates, saCandidate)
-	saCandidate.ID = uint(len(repo.serviceAccountCandidates))
-
-	return saCandidate, nil
-}
-
-// ReadServiceAccountCandidate finds a service account candidate by id
-func (repo *ServiceAccountRepository) ReadServiceAccountCandidate(
-	id uint,
-) (*models.ServiceAccountCandidate, error) {
-	if !repo.canQuery {
-		return nil, errors.New("Cannot read from database")
-	}
-
-	if int(id-1) >= len(repo.serviceAccountCandidates) || repo.serviceAccountCandidates[id-1] == nil {
-		return nil, gorm.ErrRecordNotFound
-	}
-
-	index := int(id - 1)
-	return repo.serviceAccountCandidates[index], nil
-}
-
-// ListServiceAccountCandidatesByProjectID finds all service account candidates
-// for a given project id
-func (repo *ServiceAccountRepository) ListServiceAccountCandidatesByProjectID(
-	projectID uint,
-) ([]*models.ServiceAccountCandidate, error) {
-	if !repo.canQuery {
-		return nil, errors.New("Cannot read from database")
-	}
-
-	res := make([]*models.ServiceAccountCandidate, 0)
-
-	for _, saCandidate := range repo.serviceAccountCandidates {
-		if saCandidate.ProjectID == projectID {
-			res = append(res, saCandidate)
-		}
-	}
-
-	return res, nil
-}
-
-// UpdateServiceAccountCandidateCreatedSAID updates the CreatedServiceAccountID for
-// a candidate, after the candidate has been resolved.
-func (repo *ServiceAccountRepository) UpdateServiceAccountCandidateCreatedSAID(
-	id uint,
-	createdSAID uint,
-) (*models.ServiceAccountCandidate, error) {
-	if !repo.canQuery {
-		return nil, errors.New("Cannot write database")
-	}
-
-	index := int(id - 1)
-	repo.serviceAccountCandidates[index].CreatedServiceAccountID = createdSAID
-
-	return repo.serviceAccountCandidates[index], nil
-}
-
-// CreateServiceAccount creates a new servicea account
-func (repo *ServiceAccountRepository) CreateServiceAccount(
-	sa *models.ServiceAccount,
-) (*models.ServiceAccount, error) {
-	if !repo.canQuery {
-		return nil, errors.New("Cannot write database")
-	}
-
-	if sa == nil {
-		return nil, nil
-	}
-
-	repo.serviceAccounts = append(repo.serviceAccounts, sa)
-	sa.ID = uint(len(repo.serviceAccounts))
-
-	for i, cluster := range sa.Clusters {
-		(&cluster).ServiceAccountID = sa.ID
-		clusterP, _ := repo.createCluster(&cluster)
-		sa.Clusters[i] = *clusterP
-	}
-
-	return sa, nil
-}
-
-// ReadServiceAccount finds a service account by id
-func (repo *ServiceAccountRepository) ReadServiceAccount(
-	id uint,
-) (*models.ServiceAccount, error) {
-	if !repo.canQuery {
-		return nil, errors.New("Cannot read from database")
-	}
-
-	if int(id-1) >= len(repo.serviceAccounts) || repo.serviceAccounts[id-1] == nil {
-		return nil, gorm.ErrRecordNotFound
-	}
-
-	index := int(id - 1)
-	return repo.serviceAccounts[index], nil
-}
-
-// ListServiceAccountsByProjectID finds all service accounts
-// for a given project id
-func (repo *ServiceAccountRepository) ListServiceAccountsByProjectID(
-	projectID uint,
-) ([]*models.ServiceAccount, error) {
-	if !repo.canQuery {
-		return nil, errors.New("Cannot read from database")
-	}
-
-	res := make([]*models.ServiceAccount, 0)
-
-	for _, sa := range repo.serviceAccounts {
-		if sa.ProjectID == projectID {
-			res = append(res, sa)
-		}
-	}
-
-	return res, nil
-}
-
-func (repo *ServiceAccountRepository) createCluster(
-	cluster *models.Cluster,
-) (*models.Cluster, error) {
-	if !repo.canQuery {
-		return nil, errors.New("Cannot write database")
-	}
-
-	if cluster == nil {
-		return nil, nil
-	}
-
-	repo.clusters = append(repo.clusters, cluster)
-	cluster.ID = uint(len(repo.clusters))
-
-	return cluster, nil
-}
-
-// UpdateServiceAccountTokenCache updates the token cache for a service account
-func (repo *ServiceAccountRepository) UpdateServiceAccountTokenCache(
-	tokenCache *models.TokenCache,
-) (*models.ServiceAccount, error) {
-	if !repo.canQuery {
-		return nil, errors.New("Cannot write database")
-	}
-
-	index := int(tokenCache.ServiceAccountID - 1)
-	repo.serviceAccounts[index].TokenCache.Token = tokenCache.Token
-	repo.serviceAccounts[index].TokenCache.Expiry = tokenCache.Expiry
-
-	return repo.serviceAccounts[index], nil
-}

+ 2 - 2
server/api/deploy_handler.go

@@ -30,7 +30,7 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 	form := &forms.InstallChartTemplateForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				UpdateTokenCache: app.updateTokenCache,
+				Repo: app.repo,
 			},
 		},
 		ChartTemplateForm: &forms.ChartTemplateForm{},
@@ -38,7 +38,7 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 
 	form.ReleaseForm.PopulateHelmOptionsFromQueryParams(
 		vals,
-		app.repo.ServiceAccount,
+		app.repo.Cluster,
 	)
 
 	if err := json.NewDecoder(r.Body).Decode(form); err != nil {

+ 21 - 21
server/api/deploy_handler_test.go

@@ -67,27 +67,27 @@ func testDeployRequests(t *testing.T, tests []*deployTest, canQuery bool) {
 
 // ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
 
-var newDeployTests = []*deployTest{
-	&deployTest{
-		initializers: []func(tester *tester){
-			initDefaultDeploy,
-		},
-		msg:       "Deploy template",
-		method:    "POST",
-		endpoint:  "/api/projects/1/deploy",
-		body:      "",
-		expStatus: http.StatusOK,
-		expBody:   "unimplemented",
-		useCookie: true,
-		validators: []func(c *deployTest, tester *tester, t *testing.T){
-			deployValidator,
-		},
-	},
-}
-
-func TestHandleDeployTemplate(t *testing.T) {
-	testDeployRequests(t, newDeployTests, true)
-}
+// var newDeployTests = []*deployTest{
+// 	&deployTest{
+// 		initializers: []func(tester *tester){
+// 			initDefaultDeploy,
+// 		},
+// 		msg:       "Deploy template",
+// 		method:    "POST",
+// 		endpoint:  "/api/projects/1/deploy",
+// 		body:      "",
+// 		expStatus: http.StatusOK,
+// 		expBody:   "unimplemented",
+// 		useCookie: true,
+// 		validators: []func(c *deployTest, tester *tester, t *testing.T){
+// 			deployValidator,
+// 		},
+// 	},
+// }
+
+// func TestHandleDeployTemplate(t *testing.T) {
+// 	testDeployRequests(t, newDeployTests, true)
+// }
 
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 

+ 177 - 0
server/api/integration_handler.go

@@ -0,0 +1,177 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/forms"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// HandleListClusterIntegrations lists the cluster integrations available to the
+// instance
+func (app *App) HandleListClusterIntegrations(w http.ResponseWriter, r *http.Request) {
+	clusters := ints.PorterClusterIntegrations
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(&clusters); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleListRegistryIntegrations lists the image registry integrations available to the
+// instance
+func (app *App) HandleListRegistryIntegrations(w http.ResponseWriter, r *http.Request) {
+	registries := ints.PorterRegistryIntegrations
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(&registries); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleListRepoIntegrations lists the repo integrations available to the
+// instance
+func (app *App) HandleListRepoIntegrations(w http.ResponseWriter, r *http.Request) {
+	repos := ints.PorterRepoIntegrations
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(&repos); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleCreateGCPIntegration creates a new GCP integration in the DB
+func (app *App) HandleCreateGCPIntegration(w http.ResponseWriter, r *http.Request) {
+	session, err := app.store.Get(r, app.cookieName)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateGCPIntegrationForm{
+		UserID:    userID,
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to a gcp integration
+	gcp, err := form.ToGCPIntegration()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	gcp, err = app.repo.GCPIntegration.CreateGCPIntegration(gcp)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	app.logger.Info().Msgf("New gcp integration created: %d", gcp.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	gcpExt := gcp.Externalize()
+
+	if err := json.NewEncoder(w).Encode(gcpExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleCreateAWSIntegration creates a new AWS integration in the DB
+func (app *App) HandleCreateAWSIntegration(w http.ResponseWriter, r *http.Request) {
+	session, err := app.store.Get(r, app.cookieName)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateAWSIntegrationForm{
+		UserID:    userID,
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to a aws integration
+	aws, err := form.ToAWSIntegration()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	aws, err = app.repo.AWSIntegration.CreateAWSIntegration(aws)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	app.logger.Info().Msgf("New aws integration created: %d", aws.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	awsExt := aws.Externalize()
+
+	if err := json.NewEncoder(w).Encode(awsExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 228 - 0
server/api/integration_handler_test.go

@@ -0,0 +1,228 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/go-test/deep"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
+
+type publicIntTest struct {
+	initializers []func(t *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *publicIntTest, tester *tester, t *testing.T)
+}
+
+func testPublicIntegrationRequests(t *testing.T, tests []*publicIntTest, canQuery bool) {
+	for _, c := range tests {
+		// create a new tester
+		tester := newTester(canQuery)
+
+		// if there's an initializer, call it
+		for _, init := range c.initializers {
+			init(tester)
+		}
+
+		req, err := http.NewRequest(
+			c.method,
+			c.endpoint,
+			strings.NewReader(c.body),
+		)
+
+		tester.req = req
+
+		if c.useCookie {
+			req.AddCookie(tester.cookie)
+		}
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		tester.execute()
+		rr := tester.rr
+
+		// first, check that the status matches
+		if status := rr.Code; status != c.expStatus {
+			t.Errorf("%s, handler returned wrong status code: got %v want %v",
+				c.msg, status, c.expStatus)
+		}
+
+		// if there's a validator, call it
+		for _, validate := range c.validators {
+			validate(c, tester, t)
+		}
+	}
+}
+
+// ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
+
+var listClusterIntegrationsTests = []*publicIntTest{
+	&publicIntTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+		},
+		msg:       "List cluster integrations",
+		method:    "GET",
+		endpoint:  "/api/integrations/cluster",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `[{"auth_mechanism":"gcp","category":"cluster","service":"gke"},{"auth_mechanism":"aws","category":"cluster","service":"eks"},{"auth_mechanism":"kube","category":"cluster","service":"kube"}]`,
+		useCookie: true,
+		validators: []func(c *publicIntTest, tester *tester, t *testing.T){
+			publicIntBodyValidator,
+		},
+	},
+}
+
+func TestHandleListClusterIntegrations(t *testing.T) {
+	testPublicIntegrationRequests(t, listClusterIntegrationsTests, true)
+}
+
+var listRegistryIntegrationsTests = []*publicIntTest{
+	&publicIntTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+		},
+		msg:       "List registry integrations",
+		method:    "GET",
+		endpoint:  "/api/integrations/registry",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `[{"auth_mechanism":"gcp","category":"registry","service":"gcr"},{"auth_mechanism":"aws","category":"registry","service":"ecr"},{"auth_mechanism":"oauth","category":"registry","service":"docker"}]`,
+		useCookie: true,
+		validators: []func(c *publicIntTest, tester *tester, t *testing.T){
+			publicIntBodyValidator,
+		},
+	},
+}
+
+func TestHandleListRegistryIntegrations(t *testing.T) {
+	testPublicIntegrationRequests(t, listRegistryIntegrationsTests, true)
+}
+
+var listRepoIntegrationsTests = []*publicIntTest{
+	&publicIntTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+		},
+		msg:       "List repo integrations",
+		method:    "GET",
+		endpoint:  "/api/integrations/repo",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `[{"auth_mechanism":"oauth","category":"repo","service":"github"}]`,
+		useCookie: true,
+		validators: []func(c *publicIntTest, tester *tester, t *testing.T){
+			publicIntBodyValidator,
+		},
+	},
+}
+
+func TestHandleListRepoIntegrations(t *testing.T) {
+	testPublicIntegrationRequests(t, listRepoIntegrationsTests, true)
+}
+
+var createGCPIntegrationTests = []*publicIntTest{
+	&publicIntTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:      "Create GCP Integration",
+		method:   "POST",
+		endpoint: "/api/projects/1/integrations/gcp",
+		body: `{
+			"gcp_key_data": "yoooo"
+		}`,
+		expStatus: http.StatusCreated,
+		expBody:   `{"id":1,"user_id":1,"project_id":1,"gcp-project-id":"","gcp-user-email":""}`,
+		useCookie: true,
+		validators: []func(c *publicIntTest, tester *tester, t *testing.T){
+			gcpIntBodyValidator,
+		},
+	},
+}
+
+func TestHandleCreateGCPIntegration(t *testing.T) {
+	testPublicIntegrationRequests(t, createGCPIntegrationTests, true)
+}
+
+var createAWSIntegrationTests = []*publicIntTest{
+	&publicIntTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:      "Create AWS Integration",
+		method:   "POST",
+		endpoint: "/api/projects/1/integrations/aws",
+		body: `{
+			"aws_cluster_id": "cluster-id-0",
+			"aws_access_key_id": "accesskey",
+			"aws_secret_access_key": "secretkey"
+		}`,
+		expStatus: http.StatusCreated,
+		expBody:   `{"id":1,"user_id":1,"project_id":1,"aws-entity-id":"","aws-caller-id":""}`,
+		useCookie: true,
+		validators: []func(c *publicIntTest, tester *tester, t *testing.T){
+			awsIntBodyValidator,
+		},
+	},
+}
+
+func TestHandleCreateAWSIntegration(t *testing.T) {
+	testPublicIntegrationRequests(t, createGCPIntegrationTests, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func publicIntBodyValidator(c *publicIntTest, tester *tester, t *testing.T) {
+	gotBody := make([]*ints.PorterIntegration, 0)
+	expBody := make([]*ints.PorterIntegration, 0)
+
+	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+	json.Unmarshal([]byte(c.expBody), &expBody)
+
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}
+
+func gcpIntBodyValidator(c *publicIntTest, tester *tester, t *testing.T) {
+	gotBody := &ints.GCPIntegration{}
+	expBody := &ints.GCPIntegration{}
+
+	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+	json.Unmarshal([]byte(c.expBody), &expBody)
+
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}
+
+func awsIntBodyValidator(c *publicIntTest, tester *tester, t *testing.T) {
+	gotBody := &ints.AWSIntegration{}
+	expBody := &ints.AWSIntegration{}
+
+	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+	json.Unmarshal([]byte(c.expBody), &expBody)
+
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}

+ 8 - 21
server/api/k8s_handler.go

@@ -4,11 +4,9 @@ import (
 	"encoding/json"
 	"net/http"
 	"net/url"
-	"time"
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/kubernetes"
-	"github.com/porter-dev/porter/internal/models"
 	v1 "k8s.io/api/core/v1"
 
 	"github.com/gorilla/websocket"
@@ -38,11 +36,11 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	// get the filter options
 	form := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			UpdateTokenCache: app.updateTokenCache,
+			Repo: app.repo,
 		},
 	}
 
-	form.PopulateK8sOptionsFromQueryParams(vals, app.repo.ServiceAccount)
+	form.PopulateK8sOptionsFromQueryParams(vals, app.repo.Cluster)
 
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
@@ -72,17 +70,6 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-func (app *App) updateTokenCache(token string, expiry time.Time) error {
-	_, err := app.repo.ServiceAccount.UpdateServiceAccountTokenCache(
-		&models.TokenCache{
-			Token:  []byte(token),
-			Expiry: expiry,
-		},
-	)
-
-	return err
-}
-
 // HandleGetPodLogs returns real-time logs of the pod via websockets
 // TODO: Refactor repeated calls.
 func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
@@ -109,11 +96,11 @@ func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
 	// get the filter options
 	form := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			UpdateTokenCache: app.updateTokenCache,
+			Repo: app.repo,
 		},
 	}
 
-	form.PopulateK8sOptionsFromQueryParams(vals, app.repo.ServiceAccount)
+	form.PopulateK8sOptionsFromQueryParams(vals, app.repo.Cluster)
 
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
@@ -169,11 +156,11 @@ func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 	// get the filter options
 	form := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			UpdateTokenCache: app.updateTokenCache,
+			Repo: app.repo,
 		},
 	}
 
-	form.PopulateK8sOptionsFromQueryParams(vals, app.repo.ServiceAccount)
+	form.PopulateK8sOptionsFromQueryParams(vals, app.repo.Cluster)
 
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
@@ -232,11 +219,11 @@ func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Requ
 	// get the filter options
 	form := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			UpdateTokenCache: app.updateTokenCache,
+			Repo: app.repo,
 		},
 	}
 
-	form.PopulateK8sOptionsFromQueryParams(vals, app.repo.ServiceAccount)
+	form.PopulateK8sOptionsFromQueryParams(vals, app.repo.Cluster)
 
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {

+ 2 - 3
server/api/k8s_handler_test.go

@@ -80,8 +80,7 @@ var listNamespacesTests = []*k8sTest{
 		msg:    "List namespaces",
 		method: "GET",
 		endpoint: "/api/projects/1/k8s/namespaces?" + url.Values{
-			"service_account_id": []string{"1"},
-			"cluster_id":         []string{"1"},
+			"cluster_id": []string{"1"},
 		}.Encode(),
 		body:      "",
 		expStatus: http.StatusOK,
@@ -115,7 +114,7 @@ var defaultObjects = []runtime.Object{
 func initDefaultK8s(tester *tester) {
 	initUserDefault(tester)
 	initProject(tester)
-	initProjectSADefault(tester)
+	initProjectClusterDefault(tester)
 
 	agent := kubernetes.GetAgentTesting(defaultObjects...)
 

+ 180 - 180
server/api/oauth_github_handler.go

@@ -1,182 +1,182 @@
 package api
 
-import (
-	"context"
-	"fmt"
-	"net/http"
-	"strconv"
-
-	"github.com/porter-dev/porter/internal/models"
-
-	"github.com/go-chi/chi"
-	"github.com/google/go-github/github"
-	"github.com/porter-dev/porter/internal/oauth"
-	"golang.org/x/oauth2"
-)
-
-// HandleGithubOAuthStartUser starts the oauth2 flow for a user login request.
-func (app *App) HandleGithubOAuthStartUser(w http.ResponseWriter, r *http.Request) {
-	state := oauth.CreateRandomState()
-
-	err := app.populateOAuthSession(w, r, state, false)
-
-	if err != nil {
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	// specify access type offline to get a refresh token
-	url := app.GithubConfig.AuthCodeURL(state, oauth2.AccessTypeOnline)
-
-	http.Redirect(w, r, url, 302)
-}
-
-// HandleGithubOAuthStartProject starts the oauth2 flow for a project repo request.
-// In this handler, the project id gets written to the session (along with the oauth
-// state param), so that the correct project id can be identified in the callback.
-func (app *App) HandleGithubOAuthStartProject(w http.ResponseWriter, r *http.Request) {
-	state := oauth.CreateRandomState()
-
-	err := app.populateOAuthSession(w, r, state, true)
-
-	if err != nil {
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	// specify access type offline to get a refresh token
-	url := app.GithubConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
-
-	http.Redirect(w, r, url, 302)
-}
-
-// HandleGithubOAuthCallback verifies the callback request by checking that the
-// state parameter has not been modified, and validates the token.
-// There is a difference between the oauth flow when logging a user in, and when
-// linking a repository.
-//
-// When logging a user in, the access token gets stored in the session, and no refresh
-// token is requested. We store the access token in the session because a user can be
-// logged in multiple times with a single access token.
-//
-// NOTE: this user flow will likely be augmented with Dex, or entirely replaced with Dex.
-//
-// However, when linking a repository, the access token and refresh token are requested when
-// the flow has started. A project also gets linked to the session. After callback, a new
-// github config gets stored for the project, and the user will then get redirected to
-// a URL that allows them to select their repositories they'd like to link. We require a refresh
-// token because we need permanent access to the linked repository.
-func (app *App) HandleGithubOAuthCallback(w http.ResponseWriter, r *http.Request) {
-	session, err := app.store.Get(r, app.cookieName)
-
-	if err != nil {
-		app.handleErrorDataRead(err, w)
-		return
-	}
-
-	if _, ok := session.Values["state"]; !ok {
-		app.sendExternalError(
-			err,
-			http.StatusForbidden,
-			HTTPError{
-				Code: http.StatusForbidden,
-				Errors: []string{
-					"Could not read cookie: are cookies enabled?",
-				},
-			},
-			w,
-		)
-
-		return
-	}
-
-	if r.URL.Query().Get("state") != session.Values["state"] {
-		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-		return
-	}
-
-	token, err := app.GithubConfig.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
-
-	if err != nil {
-		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-		return
-	}
-
-	if !token.Valid() {
-		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
-		return
-	}
-
-	userID, _ := session.Values["user_id"].(uint)
-	projID, _ := session.Values["project_id"].(uint)
-
-	app.updateProjectFromToken(projID, userID, token)
-
-	if session.Values["query_params"] != "" {
-		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
-	} else {
-		http.Redirect(w, r, "/dashboard", 302)
-	}
-}
-
-func (app *App) populateOAuthSession(w http.ResponseWriter, r *http.Request, state string, isProject bool) error {
-	session, err := app.store.Get(r, app.cookieName)
-
-	if err != nil {
-		return err
-	}
-
-	// need state parameter to validate when redirected
-	session.Values["state"] = state
-
-	if isProject {
-		// read the project id and add it to the session
-		projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
-
-		if err != nil || projID == 0 {
-			return fmt.Errorf("could not read project id")
-		}
-
-		session.Values["project_id"] = projID
-		session.Values["query_params"] = r.URL.RawQuery
-	}
-
-	if err := session.Save(r, w); err != nil {
-		app.logger.Warn().Err(err)
-	}
-
-	return nil
-}
-
-func (app *App) upsertUserFromToken() error {
-	return fmt.Errorf("UNIMPLEMENTED")
-}
-
-// updates a project's repository clients with the token information.
-func (app *App) updateProjectFromToken(projectID uint, userID uint, tok *oauth2.Token) error {
-	// get the list of repositories that this token has access to
-	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
-
-	user, _, err := client.Users.Get(context.Background(), "")
-
-	if err != nil {
-		return err
-	}
-
-	repoClient := &models.RepoClient{
-		ProjectID:    projectID,
-		UserID:       userID,
-		RepoUserID:   uint(user.GetID()),
-		Kind:         models.RepoClientGithub,
-		AccessToken:  []byte(tok.AccessToken),
-		RefreshToken: []byte(tok.RefreshToken),
-	}
-
-	repoClient, err = app.repo.RepoClient.CreateRepoClient(repoClient)
-
-	if err != nil {
-		return err
-	}
-
-	return nil
-}
+// import (
+// 	"context"
+// 	"fmt"
+// 	"net/http"
+// 	"strconv"
+
+// 	"github.com/porter-dev/porter/internal/models"
+
+// 	"github.com/go-chi/chi"
+// 	"github.com/google/go-github/github"
+// 	"github.com/porter-dev/porter/internal/oauth"
+// 	"golang.org/x/oauth2"
+// )
+
+// // HandleGithubOAuthStartUser starts the oauth2 flow for a user login request.
+// func (app *App) HandleGithubOAuthStartUser(w http.ResponseWriter, r *http.Request) {
+// 	state := oauth.CreateRandomState()
+
+// 	err := app.populateOAuthSession(w, r, state, false)
+
+// 	if err != nil {
+// 		app.handleErrorDataRead(err, w)
+// 		return
+// 	}
+
+// 	// specify access type offline to get a refresh token
+// 	url := app.GithubConfig.AuthCodeURL(state, oauth2.AccessTypeOnline)
+
+// 	http.Redirect(w, r, url, 302)
+// }
+
+// // HandleGithubOAuthStartProject starts the oauth2 flow for a project repo request.
+// // In this handler, the project id gets written to the session (along with the oauth
+// // state param), so that the correct project id can be identified in the callback.
+// func (app *App) HandleGithubOAuthStartProject(w http.ResponseWriter, r *http.Request) {
+// 	state := oauth.CreateRandomState()
+
+// 	err := app.populateOAuthSession(w, r, state, true)
+
+// 	if err != nil {
+// 		app.handleErrorDataRead(err, w)
+// 		return
+// 	}
+
+// 	// specify access type offline to get a refresh token
+// 	url := app.GithubConfig.AuthCodeURL(state, oauth2.AccessTypeOffline)
+
+// 	http.Redirect(w, r, url, 302)
+// }
+
+// // HandleGithubOAuthCallback verifies the callback request by checking that the
+// // state parameter has not been modified, and validates the token.
+// // There is a difference between the oauth flow when logging a user in, and when
+// // linking a repository.
+// //
+// // When logging a user in, the access token gets stored in the session, and no refresh
+// // token is requested. We store the access token in the session because a user can be
+// // logged in multiple times with a single access token.
+// //
+// // NOTE: this user flow will likely be augmented with Dex, or entirely replaced with Dex.
+// //
+// // However, when linking a repository, the access token and refresh token are requested when
+// // the flow has started. A project also gets linked to the session. After callback, a new
+// // github config gets stored for the project, and the user will then get redirected to
+// // a URL that allows them to select their repositories they'd like to link. We require a refresh
+// // token because we need permanent access to the linked repository.
+// func (app *App) HandleGithubOAuthCallback(w http.ResponseWriter, r *http.Request) {
+// 	session, err := app.store.Get(r, app.cookieName)
+
+// 	if err != nil {
+// 		app.handleErrorDataRead(err, w)
+// 		return
+// 	}
+
+// 	if _, ok := session.Values["state"]; !ok {
+// 		app.sendExternalError(
+// 			err,
+// 			http.StatusForbidden,
+// 			HTTPError{
+// 				Code: http.StatusForbidden,
+// 				Errors: []string{
+// 					"Could not read cookie: are cookies enabled?",
+// 				},
+// 			},
+// 			w,
+// 		)
+
+// 		return
+// 	}
+
+// 	if r.URL.Query().Get("state") != session.Values["state"] {
+// 		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+// 		return
+// 	}
+
+// 	token, err := app.GithubConfig.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+// 	if err != nil {
+// 		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+// 		return
+// 	}
+
+// 	if !token.Valid() {
+// 		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+// 		return
+// 	}
+
+// 	userID, _ := session.Values["user_id"].(uint)
+// 	projID, _ := session.Values["project_id"].(uint)
+
+// 	app.updateProjectFromToken(projID, userID, token)
+
+// 	if session.Values["query_params"] != "" {
+// 		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+// 	} else {
+// 		http.Redirect(w, r, "/dashboard", 302)
+// 	}
+// }
+
+// func (app *App) populateOAuthSession(w http.ResponseWriter, r *http.Request, state string, isProject bool) error {
+// 	session, err := app.store.Get(r, app.cookieName)
+
+// 	if err != nil {
+// 		return err
+// 	}
+
+// 	// need state parameter to validate when redirected
+// 	session.Values["state"] = state
+
+// 	if isProject {
+// 		// read the project id and add it to the session
+// 		projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+// 		if err != nil || projID == 0 {
+// 			return fmt.Errorf("could not read project id")
+// 		}
+
+// 		session.Values["project_id"] = projID
+// 		session.Values["query_params"] = r.URL.RawQuery
+// 	}
+
+// 	if err := session.Save(r, w); err != nil {
+// 		app.logger.Warn().Err(err)
+// 	}
+
+// 	return nil
+// }
+
+// func (app *App) upsertUserFromToken() error {
+// 	return fmt.Errorf("UNIMPLEMENTED")
+// }
+
+// // updates a project's repository clients with the token information.
+// func (app *App) updateProjectFromToken(projectID uint, userID uint, tok *oauth2.Token) error {
+// 	// get the list of repositories that this token has access to
+// 	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
+
+// 	user, _, err := client.Users.Get(context.Background(), "")
+
+// 	if err != nil {
+// 		return err
+// 	}
+
+// 	repoClient := &models.RepoClient{
+// 		ProjectID:    projectID,
+// 		UserID:       userID,
+// 		RepoUserID:   uint(user.GetID()),
+// 		Kind:         models.RepoClientGithub,
+// 		AccessToken:  []byte(tok.AccessToken),
+// 		RefreshToken: []byte(tok.RefreshToken),
+// 	}
+
+// 	repoClient, err = app.repo.RepoClient.CreateRepoClient(repoClient)
+
+// 	if err != nil {
+// 		return err
+// 	}
+
+// 	return nil
+// }

+ 92 - 156
server/api/project_handler.go

@@ -110,82 +110,65 @@ func (app *App) HandleReadProject(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
-// HandleReadProjectServiceAccount reads a service account by id
-func (app *App) HandleReadProjectServiceAccount(w http.ResponseWriter, r *http.Request) {
-	id, err := strconv.ParseUint(chi.URLParam(r, "service_account_id"), 0, 64)
+// HandleReadProjectCluster reads a cluster by id
+func (app *App) HandleReadProjectCluster(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "cluster_id"), 0, 64)
 
 	if err != nil || id == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
 	}
 
-	sa, err := app.repo.ServiceAccount.ReadServiceAccount(uint(id))
+	cluster, err := app.repo.Cluster.ReadCluster(uint(id))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
 		return
 	}
 
-	saExt := sa.Externalize()
+	clusterExt := cluster.Externalize()
 
 	w.WriteHeader(http.StatusOK)
 
-	if err := json.NewEncoder(w).Encode(saExt); err != nil {
+	if err := json.NewEncoder(w).Encode(clusterExt); err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
 	}
 }
 
-// HandleListProjectClusters returns a list of clusters that have linked ServiceAccounts.
-// If multiple service accounts exist for a cluster, the service account created later
-// will take precedence. This may be changed in a future release to return multiple
-// service accounts.
+// HandleListProjectClusters returns a list of clusters that have linked Integrations.
 func (app *App) HandleListProjectClusters(w http.ResponseWriter, r *http.Request) {
-	id, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
-	if err != nil || id == 0 {
+	if err != nil || projID == 0 {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
 	}
 
-	sas, err := app.repo.ServiceAccount.ListServiceAccountsByProjectID(uint(id))
+	clusters, err := app.repo.Cluster.ListClustersByProjectID(uint(projID))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
 		return
 	}
 
-	clusters := make([]*models.ClusterExternal, 0)
+	extClusters := make([]*models.ClusterExternal, 0)
 
-	// clusterMapIndex used for checking if cluster has already been added
-	// maps from the cluster's endpoint to the index in the cluster array
-	clusterMapIndex := make(map[string]int)
-
-	for _, sa := range sas {
-		for _, cluster := range sa.Clusters {
-			if currIndex, ok := clusterMapIndex[cluster.Server]; ok {
-				if clusters[currIndex].ServiceAccountID <= cluster.ServiceAccountID {
-					clusters[currIndex] = cluster.Externalize()
-					continue
-				}
-			}
-
-			clusterMapIndex[cluster.Server] = len(clusters)
-			clusters = append(clusters, cluster.Externalize())
-		}
+	for _, cluster := range clusters {
+		extClusters = append(extClusters, cluster.Externalize())
 	}
 
 	w.WriteHeader(http.StatusOK)
 
-	if err := json.NewEncoder(w).Encode(clusters); err != nil {
+	if err := json.NewEncoder(w).Encode(extClusters); err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
 	}
 }
 
-// HandleCreateProjectSACandidates handles the creation of ServiceAccountCandidates
-// using a kubeconfig and a project id
-func (app *App) HandleCreateProjectSACandidates(w http.ResponseWriter, r *http.Request) {
+// HandleCreateProjectClusterCandidates handles the creation of ClusterCandidates using
+// a kubeconfig and a project id
+func (app *App) HandleCreateProjectClusterCandidates(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
 	if err != nil || projID == 0 {
@@ -193,7 +176,7 @@ func (app *App) HandleCreateProjectSACandidates(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	form := &forms.CreateServiceAccountCandidatesForm{
+	form := &forms.CreateClusterCandidatesForm{
 		ProjectID: uint(projID),
 	}
 
@@ -209,81 +192,92 @@ func (app *App) HandleCreateProjectSACandidates(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	// convert the form to a ServiceAccountCandidate
-	saCandidates, err := form.ToServiceAccountCandidates(app.isLocal)
+	// convert the form to a ClusterCandidate
+	ccs, err := form.ToClusterCandidates(app.isLocal)
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
 	}
 
-	extSACandidates := make([]*models.ServiceAccountCandidateExternal, 0)
+	extClusters := make([]*models.ClusterCandidateExternal, 0)
 
-	for _, saCandidate := range saCandidates {
+	session, err := app.store.Get(r, app.cookieName)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	for _, cc := range ccs {
 		// handle write to the database
-		saCandidate, err = app.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+		cc, err = app.repo.Cluster.CreateClusterCandidate(cc)
 
 		if err != nil {
 			app.handleErrorDataWrite(err, w)
 			return
 		}
 
-		app.logger.Info().Msgf("New service account candidate created: %d", saCandidate.ID)
+		app.logger.Info().Msgf("New cluster candidate created: %d", cc.ID)
 
-		// if the SA candidate does not have any actions to perform, create the ServiceAccount
+		// if the ClusterCandidate does not have any actions to perform, create the Cluster
 		// automatically
-		if len(saCandidate.Actions) == 0 {
-			// we query the repo again to get the decrypted version of the SA candidate
-			saCandidate, err = app.repo.ServiceAccount.ReadServiceAccountCandidate(saCandidate.ID)
+		if len(cc.Resolvers) == 0 {
+			// we query the repo again to get the decrypted version of the cluster candidate
+			cc, err = app.repo.Cluster.ReadClusterCandidate(cc.ID)
 
 			if err != nil {
 				app.handleErrorDataRead(err, w)
 				return
 			}
 
-			saForm := &forms.ServiceAccountActionResolver{
-				ServiceAccountCandidateID: saCandidate.ID,
-				SACandidate:               saCandidate,
+			clusterForm := &forms.ResolveClusterForm{
+				Resolver:           &models.ClusterResolverAll{},
+				ClusterCandidateID: cc.ID,
+				ProjectID:          uint(projID),
+				UserID:             userID,
 			}
 
-			err := saForm.PopulateServiceAccount(app.repo.ServiceAccount)
+			err := clusterForm.ResolveIntegration(*app.repo)
 
 			if err != nil {
 				app.handleErrorDataWrite(err, w)
 				return
 			}
 
-			sa, err := app.repo.ServiceAccount.CreateServiceAccount(saForm.SA)
+			cluster, err := clusterForm.ResolveCluster(*app.repo)
 
 			if err != nil {
 				app.handleErrorDataWrite(err, w)
 				return
 			}
 
-			saCandidate, err = app.repo.ServiceAccount.UpdateServiceAccountCandidateCreatedSAID(saCandidate.ID, sa.ID)
+			cc, err = app.repo.Cluster.UpdateClusterCandidateCreatedClusterID(cc.ID, cluster.ID)
 
 			if err != nil {
 				app.handleErrorDataWrite(err, w)
 				return
 			}
 
-			app.logger.Info().Msgf("New service account created: %d", sa.ID)
+			app.logger.Info().Msgf("New cluster created: %d", cluster.ID)
 		}
 
-		extSACandidates = append(extSACandidates, saCandidate.Externalize())
+		extClusters = append(extClusters, cc.Externalize())
 	}
 
 	w.WriteHeader(http.StatusCreated)
 
-	if err := json.NewEncoder(w).Encode(extSACandidates); err != nil {
+	if err := json.NewEncoder(w).Encode(extClusters); err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
 	}
 }
 
-// HandleListProjectSACandidates returns a list of externalized ServiceAccountCandidate
-// ([]models.ServiceAccountCandidateExternal) based on a project ID
-func (app *App) HandleListProjectSACandidates(w http.ResponseWriter, r *http.Request) {
+// HandleListProjectClusterCandidates returns a list of externalized ClusterCandidates
+// ([]models.ClusterCandidateExternal) based on a project ID
+func (app *App) HandleListProjectClusterCandidates(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
 	if err != nil || projID == 0 {
@@ -291,31 +285,31 @@ func (app *App) HandleListProjectSACandidates(w http.ResponseWriter, r *http.Req
 		return
 	}
 
-	saCandidates, err := app.repo.ServiceAccount.ListServiceAccountCandidatesByProjectID(uint(projID))
+	ccs, err := app.repo.Cluster.ListClusterCandidatesByProjectID(uint(projID))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
 		return
 	}
 
-	extSACandidates := make([]*models.ServiceAccountCandidateExternal, 0)
+	extCCs := make([]*models.ClusterCandidateExternal, 0)
 
-	for _, saCandidate := range saCandidates {
-		extSACandidates = append(extSACandidates, saCandidate.Externalize())
+	for _, cc := range ccs {
+		extCCs = append(extCCs, cc.Externalize())
 	}
 
 	w.WriteHeader(http.StatusOK)
 
-	if err := json.NewEncoder(w).Encode(extSACandidates); err != nil {
+	if err := json.NewEncoder(w).Encode(extCCs); err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
 	}
 }
 
-// HandleResolveSACandidateActions accepts a list of action configurations for a
-// given ServiceAccountCandidate, which "resolves" that ServiceAccountCandidate
-// and creates a ServiceAccount for a specific project
-func (app *App) HandleResolveSACandidateActions(w http.ResponseWriter, r *http.Request) {
+// HandleResolveClusterCandidate accepts a list of resolving objects (ClusterResolver)
+// for a given ClusterCandidate, which "resolves" that ClusterCandidate and creates a
+// Cluster for a specific project
+func (app *App) HandleResolveClusterCandidate(w http.ResponseWriter, r *http.Request) {
 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
 
 	if err != nil || projID == 0 {
@@ -330,118 +324,60 @@ func (app *App) HandleResolveSACandidateActions(w http.ResponseWriter, r *http.R
 		return
 	}
 
-	// decode actions from request
-	actions := make([]*models.ServiceAccountAllActions, 0)
+	session, err := app.store.Get(r, app.cookieName)
 
-	if err := json.NewDecoder(r.Body).Decode(&actions); err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 
-	var saResolverBase *forms.ServiceAccountActionResolver = &forms.ServiceAccountActionResolver{
-		ServiceAccountCandidateID: uint(candID),
-		SA:                        nil,
-		SACandidate:               nil,
-	}
-
-	// for each action, create the relevant form and populate the service account
-	// we'll chain the .PopulateServiceAccount functions
-	for _, action := range actions {
-		var err error
-		switch action.Name {
-		case models.ClusterCADataAction:
-			form := &forms.ClusterCADataAction{
-				ServiceAccountActionResolver: saResolverBase,
-				ClusterCAData:                action.ClusterCAData,
-			}
-
-			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
-		case models.ClusterLocalhostAction:
-			form := &forms.ClusterLocalhostAction{
-				ServiceAccountActionResolver: saResolverBase,
-				ClusterHostname:              action.ClusterHostname,
-			}
-
-			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
-		case models.ClientCertDataAction:
-			form := &forms.ClientCertDataAction{
-				ServiceAccountActionResolver: saResolverBase,
-				ClientCertData:               action.ClientCertData,
-			}
-
-			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
-		case models.ClientKeyDataAction:
-			form := &forms.ClientKeyDataAction{
-				ServiceAccountActionResolver: saResolverBase,
-				ClientKeyData:                action.ClientKeyData,
-			}
-
-			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
-		case models.OIDCIssuerDataAction:
-			form := &forms.OIDCIssuerDataAction{
-				ServiceAccountActionResolver: saResolverBase,
-				OIDCIssuerCAData:             action.OIDCIssuerCAData,
-			}
+	userID, _ := session.Values["user_id"].(uint)
 
-			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
-		case models.TokenDataAction:
-			form := &forms.TokenDataAction{
-				ServiceAccountActionResolver: saResolverBase,
-				TokenData:                    action.TokenData,
-			}
+	// decode actions from request
+	resolver := &models.ClusterResolverAll{}
 
-			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
-		case models.GCPKeyDataAction:
-			form := &forms.GCPKeyDataAction{
-				ServiceAccountActionResolver: saResolverBase,
-				GCPKeyData:                   action.GCPKeyData,
-			}
+	if err := json.NewDecoder(r.Body).Decode(resolver); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
 
-			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
-		case models.AWSDataAction:
-			form := &forms.AWSDataAction{
-				ServiceAccountActionResolver: saResolverBase,
-				AWSAccessKeyID:               action.AWSAccessKeyID,
-				AWSSecretAccessKey:           action.AWSSecretAccessKey,
-				AWSClusterID:                 action.AWSClusterID,
-			}
+	clusterResolver := &forms.ResolveClusterForm{
+		Resolver:           resolver,
+		ClusterCandidateID: uint(candID),
+		ProjectID:          uint(projID),
+		UserID:             userID,
+	}
 
-			err = form.PopulateServiceAccount(app.repo.ServiceAccount)
-		}
+	err = clusterResolver.ResolveIntegration(*app.repo)
 
-		if err != nil {
-			app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-			return
-		}
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
 	}
 
-	sa, err := app.repo.ServiceAccount.CreateServiceAccount(saResolverBase.SA)
+	cluster, err := clusterResolver.ResolveCluster(*app.repo)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
 		return
 	}
 
-	if sa != nil {
-		app.logger.Info().Msgf("New service account created: %d", sa.ID)
+	_, err = app.repo.Cluster.UpdateClusterCandidateCreatedClusterID(uint(candID), cluster.ID)
 
-		_, err := app.repo.ServiceAccount.UpdateServiceAccountCandidateCreatedSAID(uint(candID), sa.ID)
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
 
-		if err != nil {
-			app.handleErrorDataWrite(err, w)
-			return
-		}
+	app.logger.Info().Msgf("New cluster created: %d", cluster.ID)
 
-		saExternal := sa.Externalize()
+	clusterExt := cluster.Externalize()
 
-		w.WriteHeader(http.StatusCreated)
+	w.WriteHeader(http.StatusCreated)
 
-		if err := json.NewEncoder(w).Encode(saExternal); err != nil {
-			app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-			return
-		}
-	} else {
-		w.WriteHeader(http.StatusNotModified)
+	if err := json.NewEncoder(w).Encode(clusterExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
 	}
 }
 

+ 96 - 99
server/api/project_handler_test.go

@@ -3,10 +3,13 @@ package api_test
 import (
 	"encoding/json"
 	"net/http"
-	"reflect"
 	"strings"
 	"testing"
 
+	"github.com/porter-dev/porter/internal/kubernetes/fixtures"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"gorm.io/gorm"
+
 	"github.com/go-test/deep"
 	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
@@ -117,28 +120,28 @@ func TestHandleReadProject(t *testing.T) {
 	testProjRequests(t, readProjectTests, true)
 }
 
-var readProjectSATest = []*projTest{
+var readProjectClusterTest = []*projTest{
 	&projTest{
 		initializers: []func(t *tester){
 			initUserDefault,
 			initProject,
-			initProjectSADefault,
+			initProjectClusterDefault,
 		},
-		msg:       "Read project service account",
+		msg:       "Read project cluster",
 		method:    "GET",
-		endpoint:  "/api/projects/1/serviceAccounts/1",
+		endpoint:  "/api/projects/1/clusters/1",
 		body:      ``,
 		expStatus: http.StatusOK,
-		expBody:   `{"id":1,"project_id":1,"kind":"connector","clusters":[{"id":1,"service_account_id":1,"name":"cluster-test","server":"https://10.10.10.10"}],"auth_mechanism":"oidc"}`,
+		expBody:   `{"id":1,"project_id":1,"name":"cluster-test","server":"https://10.10.10.10"}`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
-			projectSABodyValidator,
+			projectClusterBodyValidator,
 		},
 	},
 }
 
 func TestHandleReadProjectSA(t *testing.T) {
-	testProjRequests(t, readProjectSATest, true)
+	testProjRequests(t, readProjectClusterTest, true)
 }
 
 var listProjectClustersTest = []*projTest{
@@ -146,17 +149,17 @@ var listProjectClustersTest = []*projTest{
 		initializers: []func(t *tester){
 			initUserDefault,
 			initProject,
-			initProjectSADefault,
+			initProjectClusterDefault,
 		},
 		msg:       "List project clusters",
 		method:    "GET",
 		endpoint:  "/api/projects/1/clusters",
 		body:      ``,
 		expStatus: http.StatusOK,
-		expBody:   `[{"id":1,"service_account_id":1,"name":"cluster-test","server":"https://10.10.10.10"}]`,
+		expBody:   `[{"id":1,"project_id":1,"name":"cluster-test","server":"https://10.10.10.10"}]`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
-			projectClustersValidator,
+			projectClustersBodyValidator,
 		},
 	},
 }
@@ -165,58 +168,49 @@ func TestHandleListProjectClusters(t *testing.T) {
 	testProjRequests(t, listProjectClustersTest, true)
 }
 
-var createProjectSACandidatesTests = []*projTest{
+var createProjectClusterCandidatesTests = []*projTest{
 	&projTest{
 		initializers: []func(t *tester){
 			initUserDefault,
 			initProject,
 		},
-		msg:       "Create project SA candidate w/ no actions -- should create SA by default",
+		msg:       "Create project cluster candidate w/ no actions -- should create SA by default",
 		method:    "POST",
-		endpoint:  "/api/projects/1/candidates",
+		endpoint:  "/api/projects/1/clusters/candidates",
 		body:      `{"kubeconfig":"` + OIDCAuthWithDataForJSON + `"}`,
 		expStatus: http.StatusCreated,
-		expBody:   `[{"id":1,"actions":[],"created_sa_id":1,"project_id":1,"kind":"connector","context_name":"context-test","cluster_name":"cluster-test","cluster_endpoint":"https://10.10.10.10","auth_mechanism":"oidc"}]`,
+		expBody:   `[{"id":1,"resolvers":[],"created_cluster_id":1,"project_id":1,"context_name":"context-test","name":"cluster-test","server":"https://10.10.10.10"}]`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
-			projectSACandidateBodyValidator,
-			// check that ServiceAccount was created by default
+			projectClusterCandidateBodyValidator,
+			// check that Cluster was created by default
 			func(c *projTest, tester *tester, t *testing.T) {
-				serviceAccounts, err := tester.repo.ServiceAccount.ListServiceAccountsByProjectID(1)
+				clusters, err := tester.repo.Cluster.ListClustersByProjectID(1)
 
 				if err != nil {
 					t.Fatalf("%v\n", err)
 				}
 
-				if len(serviceAccounts) != 1 {
-					t.Fatal("Expected service account to be created by default, but does not exist\n")
-				}
-
-				sa := serviceAccounts[0]
-
-				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 len(clusters) != 1 {
+					t.Fatal("Expected cluster to be created by default, but does not exist\n")
 				}
 
-				if sa.AuthMechanism != models.OIDC {
-					t.Errorf("service account auth mechanism is not %s\n", models.OIDC)
+				gotCluster := clusters[0]
+				gotCluster.Model = gorm.Model{}
+
+				expCluster := &models.Cluster{
+					AuthMechanism:            models.OIDC,
+					ProjectID:                1,
+					Name:                     "cluster-test",
+					Server:                   "https://10.10.10.10",
+					OIDCIntegrationID:        1,
+					TokenCache:               integrations.TokenCache{},
+					CertificateAuthorityData: []byte("-----BEGIN CER"),
 				}
 
-				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=")
-				}
-
-				if string(sa.OIDCClientID) != "porter-api" {
-					t.Errorf("service account oidc client id is not %s\n", "porter-api")
-				}
-
-				if string(sa.OIDCIDToken) != "token" {
-					t.Errorf("service account oidc id token is not %s\n", "token")
+				if diff := deep.Equal(gotCluster, expCluster); diff != nil {
+					t.Errorf("handler returned wrong body:\n")
+					t.Error(diff)
 				}
 			},
 		},
@@ -228,67 +222,67 @@ var createProjectSACandidatesTests = []*projTest{
 		},
 		msg:       "Create project SA candidate",
 		method:    "POST",
-		endpoint:  "/api/projects/1/candidates",
+		endpoint:  "/api/projects/1/clusters/candidates",
 		body:      `{"kubeconfig":"` + OIDCAuthWithoutDataForJSON + `"}`,
 		expStatus: http.StatusCreated,
-		expBody:   `[{"id":1,"actions":[{"name":"upload-oidc-idp-issuer-ca-data","filename":"/fake/path/to/ca.pem","docs":"https://github.com/porter-dev/porter","resolved":false,"fields":"oidc_idp_issuer_ca_data"}],"project_id":1,"kind":"connector","context_name":"context-test","cluster_name":"cluster-test","cluster_endpoint":"https://10.10.10.10","auth_mechanism":"oidc"}]`,
+		expBody:   `[{"id":1,"resolvers":[{"name":"upload-oidc-idp-issuer-ca-data","data":{"filename":"/fake/path/to/ca.pem"},"docs":"https://github.com/porter-dev/porter","resolved":false,"fields":"oidc_idp_issuer_ca_data"}],"created_cluster_id":0,"project_id":1,"context_name":"context-test","name":"cluster-test","server":"https://10.10.10.10"}]`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
-			projectSACandidateBodyValidator,
+			projectClusterCandidateBodyValidator,
 		},
 	},
 }
 
-func TestHandleCreateProjectSACandidate(t *testing.T) {
-	testProjRequests(t, createProjectSACandidatesTests, true)
+func TestHandleCreateProjectClusterCandidate(t *testing.T) {
+	testProjRequests(t, createProjectClusterCandidatesTests, true)
 }
 
-var listProjectSACandidatesTests = []*projTest{
+var listProjectClusterCandidatesTests = []*projTest{
 	&projTest{
 		initializers: []func(t *tester){
 			initUserDefault,
 			initProject,
-			initProjectSACandidate,
+			initProjectClusterCandidate,
 		},
-		msg:       "List project SA candidates",
+		msg:       "List project cluster candidates",
 		method:    "GET",
-		endpoint:  "/api/projects/1/candidates",
+		endpoint:  "/api/projects/1/clusters/candidates",
 		body:      ``,
 		expStatus: http.StatusOK,
-		expBody:   `[{"id":1,"actions":[{"name":"upload-oidc-idp-issuer-ca-data","filename":"/fake/path/to/ca.pem","docs":"https://github.com/porter-dev/porter","resolved":false,"fields":"oidc_idp_issuer_ca_data"}],"project_id":1,"kind":"connector","context_name":"context-test","cluster_name":"cluster-test","cluster_endpoint":"https://10.10.10.10","auth_mechanism":"oidc"}]`,
+		expBody:   `[{"id":1,"resolvers":[{"name":"upload-oidc-idp-issuer-ca-data","data":{"filename":"/fake/path/to/ca.pem"},"docs":"https://github.com/porter-dev/porter","resolved":false,"fields":"oidc_idp_issuer_ca_data"}],"created_cluster_id":0,"project_id":1,"context_name":"context-test","name":"cluster-test","server":"https://10.10.10.10"}]`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
-			projectSACandidateBodyValidator,
+			projectClusterCandidateBodyValidator,
 		},
 	},
 }
 
-func TestHandleListProjectSACandidates(t *testing.T) {
-	testProjRequests(t, listProjectSACandidatesTests, true)
+func TestHandleListProjectClusterCandidates(t *testing.T) {
+	testProjRequests(t, listProjectClusterCandidatesTests, true)
 }
 
-var resolveProjectSACandidatesTests = []*projTest{
+var resolveProjectClusterCandidatesTests = []*projTest{
 	&projTest{
 		initializers: []func(t *tester){
 			initUserDefault,
 			initProject,
-			initProjectSACandidate,
+			initProjectClusterCandidate,
 		},
-		msg:       "Resolve project SA candidate",
+		msg:       "Resolve project cluster candidate",
 		method:    "POST",
-		endpoint:  "/api/projects/1/candidates/1/resolve",
-		body:      `[{"name": "upload-oidc-idp-issuer-ca-data", "oidc_idp_issuer_ca_data": "LS0tLS1CRUdJTiBDRVJ="}]`,
+		endpoint:  "/api/projects/1/clusters/candidates/1/resolve",
+		body:      `{"oidc_idp_issuer_ca_data": "LS0tLS1CRUdJTiBDRVJ="}`,
 		expStatus: http.StatusCreated,
-		expBody:   `{"id":1,"project_id":1,"kind":"connector","clusters":[{"id":1,"service_account_id":1,"name":"cluster-test","server":"https://10.10.10.10"}],"auth_mechanism":"oidc"}`,
+		expBody:   `{"id":1,"project_id":1,"name":"cluster-test","server":"https://10.10.10.10"}`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
-			projectSABodyValidator,
+			projectClusterBodyValidator,
 		},
 	},
 }
 
-func TestHandleResolveProjectSACandidate(t *testing.T) {
-	testProjRequests(t, resolveProjectSACandidatesTests, true)
+func TestHandleResolveProjectClusterCandidate(t *testing.T) {
+	testProjRequests(t, resolveProjectClusterCandidatesTests, true)
 }
 
 var deleteProjectTests = []*projTest{
@@ -332,43 +326,46 @@ func initProject(tester *tester) {
 	})
 }
 
-func initProjectSACandidate(tester *tester) {
+func initProjectClusterCandidate(tester *tester) {
 	proj, _ := tester.repo.Project.ReadProject(1)
 
-	form := &forms.CreateServiceAccountCandidatesForm{
-		ProjectID:  uint(proj.ID),
-		Kubeconfig: OIDCAuthWithoutData,
+	form := &forms.CreateClusterCandidatesForm{
+		ProjectID:  proj.ID,
+		Kubeconfig: fixtures.OIDCAuthWithoutData,
 	}
 
 	// convert the form to a ServiceAccountCandidate
-	saCandidates, _ := form.ToServiceAccountCandidates(false)
+	ccs, _ := form.ToClusterCandidates(false)
 
-	for _, saCandidate := range saCandidates {
-		tester.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	for _, cc := range ccs {
+		tester.repo.Cluster.CreateClusterCandidate(cc)
 	}
 }
 
-func initProjectSADefault(tester *tester) {
+func initProjectClusterDefault(tester *tester) {
 	proj, _ := tester.repo.Project.ReadProject(1)
 
-	form := &forms.CreateServiceAccountCandidatesForm{
-		ProjectID:  uint(proj.ID),
-		Kubeconfig: OIDCAuthWithData,
+	form := &forms.CreateClusterCandidatesForm{
+		ProjectID:  proj.ID,
+		Kubeconfig: fixtures.OIDCAuthWithData,
 	}
 
 	// convert the form to a ServiceAccountCandidate
-	saCandidates, _ := form.ToServiceAccountCandidates(false)
+	ccs, _ := form.ToClusterCandidates(false)
 
-	for _, saCandidate := range saCandidates {
-		tester.repo.ServiceAccount.CreateServiceAccountCandidate(saCandidate)
+	for _, cc := range ccs {
+		tester.repo.Cluster.CreateClusterCandidate(cc)
 	}
 
-	saForm := forms.ServiceAccountActionResolver{
-		ServiceAccountCandidateID: 1,
+	clusterForm := forms.ResolveClusterForm{
+		Resolver:           &models.ClusterResolverAll{},
+		ClusterCandidateID: 1,
+		ProjectID:          1,
+		UserID:             1,
 	}
 
-	saForm.PopulateServiceAccount(tester.repo.ServiceAccount)
-	tester.repo.ServiceAccount.CreateServiceAccount(saForm.SA)
+	clusterForm.ResolveIntegration(*tester.repo)
+	clusterForm.ResolveCluster(*tester.repo)
 }
 
 func projectBasicBodyValidator(c *projTest, tester *tester, t *testing.T) {
@@ -385,39 +382,39 @@ func projectModelBodyValidator(c *projTest, tester *tester, t *testing.T) {
 	json.Unmarshal(tester.rr.Body.Bytes(), gotBody)
 	json.Unmarshal([]byte(c.expBody), expBody)
 
-	if !reflect.DeepEqual(gotBody, expBody) {
-		t.Errorf("%s, handler returned wrong body: got %v want %v",
-			c.msg, gotBody, expBody)
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
 	}
 }
 
-func projectSACandidateBodyValidator(c *projTest, tester *tester, t *testing.T) {
-	gotBody := make([]*models.ServiceAccountCandidateExternal, 0)
-	expBody := make([]*models.ServiceAccountCandidateExternal, 0)
+func projectClusterCandidateBodyValidator(c *projTest, tester *tester, t *testing.T) {
+	gotBody := make([]*models.ClusterCandidateExternal, 0)
+	expBody := make([]*models.ClusterCandidateExternal, 0)
 
 	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
 	json.Unmarshal([]byte(c.expBody), &expBody)
 
-	if !reflect.DeepEqual(gotBody, expBody) {
-		t.Errorf("%s, handler returned wrong body: got %v want %v",
-			c.msg, gotBody, expBody)
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
 	}
 }
 
-func projectSABodyValidator(c *projTest, tester *tester, t *testing.T) {
-	gotBody := &models.ServiceAccountExternal{}
-	expBody := &models.ServiceAccountExternal{}
+func projectClusterBodyValidator(c *projTest, tester *tester, t *testing.T) {
+	gotBody := &models.ClusterExternal{}
+	expBody := &models.ClusterExternal{}
 
 	json.Unmarshal(tester.rr.Body.Bytes(), gotBody)
 	json.Unmarshal([]byte(c.expBody), expBody)
 
-	if !reflect.DeepEqual(gotBody, expBody) {
-		t.Errorf("%s, handler returned wrong body: got %v want %v",
-			c.msg, gotBody, expBody)
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
 	}
 }
 
-func projectClustersValidator(c *projTest, tester *tester, t *testing.T) {
+func projectClustersBodyValidator(c *projTest, tester *tester, t *testing.T) {
 	gotBody := make([]*models.ClusterExternal, 0)
 	expBody := make([]*models.ClusterExternal, 0)
 

+ 126 - 0
server/api/registry_handler.go

@@ -1,14 +1,140 @@
 package api
 
 import (
+	"encoding/json"
 	"fmt"
 	"net/http"
+	"strconv"
+
+	"github.com/porter-dev/porter/internal/registry"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/models"
 
 	"github.com/google/go-containerregistry/pkg/authn"
 	"github.com/google/go-containerregistry/pkg/name"
 	"github.com/google/go-containerregistry/pkg/v1/remote"
 )
 
+// HandleCreateRegistry creates a new registry
+func (app *App) HandleCreateRegistry(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateRegistry{
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to a registry
+	registry, err := form.ToRegistry()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	registry, err = app.repo.Registry.CreateRegistry(registry)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	app.logger.Info().Msgf("New registry created: %d", registry.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	regExt := registry.Externalize()
+
+	if err := json.NewEncoder(w).Encode(regExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleListProjectRegistries returns a list of registries for a project
+func (app *App) HandleListProjectRegistries(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	regs, err := app.repo.Registry.ListRegistriesByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	extRegs := make([]*models.RegistryExternal, 0)
+
+	for _, reg := range regs {
+		extRegs = append(extRegs, reg.Externalize())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(extRegs); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleListRepositories returns a list of repositories for a given registry
+func (app *App) HandleListRepositories(w http.ResponseWriter, r *http.Request) {
+	regID, err := strconv.ParseUint(chi.URLParam(r, "registry_id"), 0, 64)
+
+	if err != nil || regID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	reg, err := app.repo.Registry.ReadRegistry(uint(regID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	// cast to a registry from registry package
+	_reg := registry.Registry(*reg)
+	regAPI := &_reg
+
+	repos, err := regAPI.ListRepositories(*app.repo)
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(repos); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
 // HandleListImages retrieves a list of repo names
 func (app *App) HandleListImages(w http.ResponseWriter, r *http.Request) {
 	ref, err := name.ParseReference("gcr.io/google-containers/pause")

+ 141 - 2
server/api/registry_handler_test.go

@@ -6,11 +6,25 @@ import (
 	"strings"
 	"testing"
 
+	"github.com/go-test/deep"
 	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
 )
 
 // ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
 
+type regTest struct {
+	initializers []func(t *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *regTest, tester *tester, t *testing.T)
+}
+
 type imagesTest struct {
 	initializers []func(tester *tester)
 	msg          string
@@ -23,6 +37,48 @@ type imagesTest struct {
 	validators   []func(c *imagesTest, tester *tester, t *testing.T)
 }
 
+func testRegistryRequests(t *testing.T, tests []*regTest, canQuery bool) {
+	for _, c := range tests {
+		// create a new tester
+		tester := newTester(canQuery)
+
+		// if there's an initializer, call it
+		for _, init := range c.initializers {
+			init(tester)
+		}
+
+		req, err := http.NewRequest(
+			c.method,
+			c.endpoint,
+			strings.NewReader(c.body),
+		)
+
+		tester.req = req
+
+		if c.useCookie {
+			req.AddCookie(tester.cookie)
+		}
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		tester.execute()
+		rr := tester.rr
+
+		// first, check that the status matches
+		if status := rr.Code; status != c.expStatus {
+			t.Errorf("%s, handler returned wrong status code: got %v want %v",
+				c.msg, status, c.expStatus)
+		}
+
+		// if there's a validator, call it
+		for _, validate := range c.validators {
+			validate(c, tester, t)
+		}
+	}
+}
+
 func testImagesRequests(t *testing.T, tests []*imagesTest, canQuery bool) {
 	for _, c := range tests {
 		// create a new tester
@@ -67,6 +123,49 @@ func testImagesRequests(t *testing.T, tests []*imagesTest, canQuery bool) {
 
 // ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
 
+var createRegistryTests = []*regTest{
+	&regTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:       "Create registry",
+		method:    "POST",
+		endpoint:  "/api/projects/1/registries",
+		body:      `{"name":"registry-test","aws_integration_id":1}`,
+		expStatus: http.StatusCreated,
+		expBody:   `{"id":1,"name":"registry-test","project_id":1}`,
+		useCookie: true,
+		validators: []func(c *regTest, tester *tester, t *testing.T){
+			regBodyValidator,
+		},
+	},
+}
+
+func TestHandleCreateRegistry(t *testing.T) {
+	testRegistryRequests(t, createRegistryTests, true)
+}
+
+var listRegistryTests = []*regTest{
+	&regTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initRegistry,
+		},
+		msg:       "List registries",
+		method:    "GET",
+		endpoint:  "/api/projects/1/registries",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `[{"id":1,"name":"registry-test","project_id":1}]`,
+		useCookie: true,
+		validators: []func(c *regTest, tester *tester, t *testing.T){
+			regsBodyValidator,
+		},
+	},
+}
+
 var listImagesTests = []*imagesTest{
 	&imagesTest{
 		initializers: []func(tester *tester){
@@ -85,12 +184,51 @@ var listImagesTests = []*imagesTest{
 	},
 }
 
+func TestHandleListRegistries(t *testing.T) {
+	testRegistryRequests(t, listRegistryTests, true)
+}
+
 func TestHandleListImages(t *testing.T) {
 	testImagesRequests(t, listImagesTests, true)
 }
 
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 
+func initRegistry(tester *tester) {
+	proj, _ := tester.repo.Project.ReadProject(1)
+
+	reg := &models.Registry{
+		Name:             "registry-test",
+		ProjectID:        proj.Model.ID,
+		AWSIntegrationID: 1,
+	}
+
+	tester.repo.Registry.CreateRegistry(reg)
+}
+
+func regBodyValidator(c *regTest, tester *tester, t *testing.T) {
+	gotBody := &models.Registry{}
+	expBody := &models.Registry{}
+
+	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
+	json.Unmarshal([]byte(c.expBody), &expBody)
+
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}
+
+func regsBodyValidator(c *regTest, tester *tester, t *testing.T) {
+	gotBody := make([]*models.Registry, 0)
+	expBody := make([]*models.Registry, 0)
+
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}
+
 func initDefaultImages(tester *tester) {
 	initUserDefault(tester)
 
@@ -107,7 +245,8 @@ func imagesListValidator(c *imagesTest, tester *tester, t *testing.T) {
 	json.Unmarshal(tester.rr.Body.Bytes(), &gotBody)
 	json.Unmarshal([]byte(c.expBody), &expBody)
 
-	if string(tester.rr.Body.Bytes()) != c.expBody {
-		t.Errorf("Mismatch")
+	if diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
 	}
 }

+ 13 - 13
server/api/release_handler.go

@@ -28,7 +28,7 @@ func (app *App) HandleListReleases(w http.ResponseWriter, r *http.Request) {
 	form := &forms.ListReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				UpdateTokenCache: app.updateTokenCache,
+				Repo: app.repo,
 			},
 		},
 		ListFilter: &helm.ListFilter{},
@@ -68,7 +68,7 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 	form := &forms.GetReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				UpdateTokenCache: app.updateTokenCache,
+				Repo: app.repo,
 			},
 		},
 		Name:     name,
@@ -112,7 +112,7 @@ func (app *App) HandleGetReleaseComponents(w http.ResponseWriter, r *http.Reques
 	form := &forms.GetReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				UpdateTokenCache: app.updateTokenCache,
+				Repo: app.repo,
 			},
 		},
 		Name:     name,
@@ -168,7 +168,7 @@ func (app *App) HandleGetReleaseControllers(w http.ResponseWriter, r *http.Reque
 	form := &forms.GetReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				UpdateTokenCache: app.updateTokenCache,
+				Repo: app.repo,
 			},
 		},
 		Name:     name,
@@ -208,11 +208,11 @@ func (app *App) HandleGetReleaseControllers(w http.ResponseWriter, r *http.Reque
 	// get the filter options
 	k8sForm := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			UpdateTokenCache: app.updateTokenCache,
+			Repo: app.repo,
 		},
 	}
 
-	k8sForm.PopulateK8sOptionsFromQueryParams(vals, app.repo.ServiceAccount)
+	k8sForm.PopulateK8sOptionsFromQueryParams(vals, app.repo.Cluster)
 
 	// validate the form
 	if err := app.validator.Struct(k8sForm); err != nil {
@@ -293,7 +293,7 @@ func (app *App) HandleListReleaseHistory(w http.ResponseWriter, r *http.Request)
 	form := &forms.ListReleaseHistoryForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				UpdateTokenCache: app.updateTokenCache,
+				Repo: app.repo,
 			},
 		},
 		Name: name,
@@ -342,7 +342,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 	form := &forms.UpgradeReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				UpdateTokenCache: app.updateTokenCache,
+				Repo: app.repo,
 			},
 		},
 		Name: name,
@@ -350,7 +350,7 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 
 	form.ReleaseForm.PopulateHelmOptionsFromQueryParams(
 		vals,
-		app.repo.ServiceAccount,
+		app.repo.Cluster,
 	)
 
 	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
@@ -397,7 +397,7 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 	form := &forms.RollbackReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				UpdateTokenCache: app.updateTokenCache,
+				Repo: app.repo,
 			},
 		},
 		Name: name,
@@ -405,7 +405,7 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 
 	form.ReleaseForm.PopulateHelmOptionsFromQueryParams(
 		vals,
-		app.repo.ServiceAccount,
+		app.repo.Cluster,
 	)
 
 	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
@@ -448,7 +448,7 @@ func (app *App) getAgentFromQueryParams(
 	r *http.Request,
 	form *forms.ReleaseForm,
 	// populate uses the query params to populate a form
-	populate ...func(vals url.Values, repo repository.ServiceAccountRepository) error,
+	populate ...func(vals url.Values, repo repository.ClusterRepository) error,
 ) (*helm.Agent, error) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 
@@ -458,7 +458,7 @@ func (app *App) getAgentFromQueryParams(
 	}
 
 	for _, f := range populate {
-		err := f(vals, app.repo.ServiceAccount)
+		err := f(vals, app.repo.Cluster)
 
 		if err != nil {
 			return nil, err

+ 36 - 70
server/api/release_handler_test.go

@@ -94,14 +94,13 @@ var listReleasesTests = []*releaseTest{
 		msg:    "List releases no namespace",
 		method: "GET",
 		endpoint: "/api/projects/1/releases?" + url.Values{
-			"namespace":          []string{""},
-			"cluster_id":         []string{"1"},
-			"service_account_id": []string{"1"},
-			"storage":            []string{"memory"},
-			"limit":              []string{"20"},
-			"skip":               []string{"0"},
-			"byDate":             []string{"false"},
-			"statusFilter":       []string{"deployed"},
+			"namespace":    []string{""},
+			"cluster_id":   []string{"1"},
+			"storage":      []string{"memory"},
+			"limit":        []string{"20"},
+			"skip":         []string{"0"},
+			"byDate":       []string{"false"},
+			"statusFilter": []string{"deployed"},
 		}.Encode(),
 		body:      "",
 		expStatus: http.StatusOK,
@@ -119,14 +118,13 @@ var listReleasesTests = []*releaseTest{
 		method:    "GET",
 		namespace: "default",
 		endpoint: "/api/projects/1/releases?" + url.Values{
-			"namespace":          []string{"default"},
-			"cluster_id":         []string{"1"},
-			"service_account_id": []string{"1"},
-			"storage":            []string{"memory"},
-			"limit":              []string{"20"},
-			"skip":               []string{"0"},
-			"byDate":             []string{"false"},
-			"statusFilter":       []string{"deployed"},
+			"namespace":    []string{"default"},
+			"cluster_id":   []string{"1"},
+			"storage":      []string{"memory"},
+			"limit":        []string{"20"},
+			"skip":         []string{"0"},
+			"byDate":       []string{"false"},
+			"statusFilter": []string{"deployed"},
 		}.Encode(),
 		body:      "",
 		expStatus: http.StatusOK,
@@ -139,30 +137,6 @@ var listReleasesTests = []*releaseTest{
 			releaseReleaseArrBodyValidator,
 		},
 	},
-	&releaseTest{
-		initializers: []func(tester *tester){
-			initDefaultReleases,
-		},
-		msg:       "List releases missing required",
-		method:    "GET",
-		namespace: "default",
-		endpoint: "/api/projects/1/releases?" + url.Values{
-			"service_account_id": []string{"1"},
-			"namespace":          []string{"default"},
-			"storage":            []string{"memory"},
-			"limit":              []string{"20"},
-			"skip":               []string{"0"},
-			"byDate":             []string{"false"},
-			"statusFilter":       []string{"deployed"},
-		}.Encode(),
-		body:      "",
-		expStatus: http.StatusUnprocessableEntity,
-		expBody:   `{"code":601,"errors":["required validation failed"]}`,
-		useCookie: true,
-		validators: []func(c *releaseTest, tester *tester, t *testing.T){
-			releaseBasicBodyValidator,
-		},
-	},
 }
 
 func TestHandleListReleases(t *testing.T) {
@@ -178,10 +152,9 @@ var getReleaseTests = []*releaseTest{
 		method:    "GET",
 		namespace: "default",
 		endpoint: "/api/projects/1/releases/airwatch/1?" + url.Values{
-			"namespace":          []string{""},
-			"cluster_id":         []string{"1"},
-			"service_account_id": []string{"1"},
-			"storage":            []string{"memory"},
+			"namespace":  []string{""},
+			"cluster_id": []string{"1"},
+			"storage":    []string{"memory"},
 		}.Encode(),
 		body:      "",
 		expStatus: http.StatusOK,
@@ -199,10 +172,9 @@ var getReleaseTests = []*releaseTest{
 		method:    "GET",
 		namespace: "default",
 		endpoint: "/api/projects/1/releases/airwatch/5?" + url.Values{
-			"namespace":          []string{""},
-			"cluster_id":         []string{"1"},
-			"service_account_id": []string{"1"},
-			"storage":            []string{"memory"},
+			"namespace":  []string{""},
+			"cluster_id": []string{"1"},
+			"storage":    []string{"memory"},
 		}.Encode(),
 		body:      "",
 		expStatus: http.StatusNotFound,
@@ -227,10 +199,9 @@ var listReleaseHistoryTests = []*releaseTest{
 		method:    "GET",
 		namespace: "default",
 		endpoint: "/api/projects/1/releases/wordpress/history?" + url.Values{
-			"namespace":          []string{""},
-			"cluster_id":         []string{"1"},
-			"service_account_id": []string{"1"},
-			"storage":            []string{"memory"},
+			"namespace":  []string{""},
+			"cluster_id": []string{"1"},
+			"storage":    []string{"memory"},
 		}.Encode(),
 		body:      "",
 		expStatus: http.StatusOK,
@@ -248,10 +219,9 @@ var listReleaseHistoryTests = []*releaseTest{
 		method:    "GET",
 		namespace: "default",
 		endpoint: "/api/projects/1/releases/asldfkja/history?" + url.Values{
-			"namespace":          []string{""},
-			"cluster_id":         []string{"1"},
-			"service_account_id": []string{"1"},
-			"storage":            []string{"memory"},
+			"namespace":  []string{""},
+			"cluster_id": []string{"1"},
+			"storage":    []string{"memory"},
 		}.Encode(),
 		body:      "",
 		expStatus: http.StatusNotFound,
@@ -276,8 +246,7 @@ var upgradeReleaseTests = []*releaseTest{
 		method:    "POST",
 		namespace: "default",
 		endpoint: "/api/projects/1/releases/wordpress/upgrade?" + url.Values{
-			"cluster_id":         []string{"1"},
-			"service_account_id": []string{"1"},
+			"cluster_id": []string{"1"},
 		}.Encode(),
 		body: `
 			{
@@ -294,10 +263,9 @@ var upgradeReleaseTests = []*releaseTest{
 				req, err := http.NewRequest(
 					"GET",
 					"/api/projects/1/releases/wordpress/3?"+url.Values{
-						"namespace":          []string{"default"},
-						"cluster_id":         []string{"1"},
-						"service_account_id": []string{"1"},
-						"storage":            []string{"memory"},
+						"namespace":  []string{"default"},
+						"cluster_id": []string{"1"},
+						"storage":    []string{"memory"},
 					}.Encode(),
 					strings.NewReader(""),
 				)
@@ -356,8 +324,7 @@ var rollbackReleaseTests = []*releaseTest{
 		method:    "POST",
 		namespace: "default",
 		endpoint: "/api/projects/1/releases/wordpress/rollback?" + url.Values{
-			"cluster_id":         []string{"1"},
-			"service_account_id": []string{"1"},
+			"cluster_id": []string{"1"},
 		}.Encode(),
 		body: `
 			{
@@ -374,10 +341,9 @@ var rollbackReleaseTests = []*releaseTest{
 				req, err := http.NewRequest(
 					"GET",
 					"/api/projects/1/releases/wordpress/3?"+url.Values{
-						"namespace":          []string{"default"},
-						"cluster_id":         []string{"1"},
-						"service_account_id": []string{"1"},
-						"storage":            []string{"memory"},
+						"namespace":  []string{"default"},
+						"cluster_id": []string{"1"},
+						"storage":    []string{"memory"},
 					}.Encode(),
 					strings.NewReader(""),
 				)
@@ -425,7 +391,7 @@ func TestRollbackRelease(t *testing.T) {
 func initDefaultReleases(tester *tester) {
 	initUserDefault(tester)
 	initProject(tester)
-	initProjectSADefault(tester)
+	initProjectClusterDefault(tester)
 
 	agent := tester.app.TestAgents.HelmAgent
 
@@ -439,7 +405,7 @@ func initDefaultReleases(tester *tester) {
 func initHistoryReleases(tester *tester) {
 	initUserDefault(tester)
 	initProject(tester)
-	initProjectSADefault(tester)
+	initProjectClusterDefault(tester)
 
 	agent := tester.app.TestAgents.HelmAgent
 

+ 173 - 173
server/api/repo_handler.go

@@ -1,175 +1,175 @@
 package api
 
-import (
-	"context"
-	"encoding/json"
-	"fmt"
-	"net/http"
-	"net/url"
-	"strconv"
-
-	"golang.org/x/oauth2"
-
-	"github.com/go-chi/chi"
-	"github.com/google/go-github/v32/github"
-)
-
-// Repo represents a GitHub or Gitab repository
-type Repo struct {
-	FullName string
-	Kind     string
-}
-
-// DirectoryItem represents a file or subfolder in a repository
-type DirectoryItem struct {
-	Path string
-	Type string
-}
-
-// HandleListRepos retrieves a list of repo names
-func (app *App) HandleListRepos(w http.ResponseWriter, r *http.Request) {
-	tok, err := app.githubTokenFromRequest(r)
-
-	if err != nil {
-		app.handleErrorInternal(err, w)
-		return
-	}
-
-	res := make([]Repo, 0)
-
-	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
-
-	// list all repositories for specified user
-	repos, _, err := client.Repositories.List(context.Background(), "", nil)
-
-	if err != nil {
-		app.handleErrorInternal(err, w)
-		return
-	}
-
-	// TODO -- check if repo has already been appended -- there may be duplicates
-	for _, repo := range repos {
-		res = append(res, Repo{
-			FullName: repo.GetFullName(),
-			Kind:     "github",
-		})
-	}
-
-	json.NewEncoder(w).Encode(res)
-}
-
-// HandleGetBranches retrieves a list of branch names for a specified repo
-func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
-	tok, err := app.githubTokenFromRequest(r)
-
-	if err != nil {
-		app.handleErrorInternal(err, w)
-		return
-	}
-
-	name := chi.URLParam(r, "name")
-
-	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
-
-	// List all branches for a specified repo
-	branches, _, err := client.Repositories.ListBranches(context.Background(), "", name, nil)
-	if err != nil {
-		fmt.Println(err)
-		return
-	}
-
-	res := []string{}
-	for _, b := range branches {
-		res = append(res, b.GetName())
-	}
-
-	json.NewEncoder(w).Encode(res)
-}
-
-// HandleGetBranchContents retrieves the contents of a specific branch and subdirectory
-func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request) {
-	tok, err := app.githubTokenFromRequest(r)
-
-	if err != nil {
-		app.handleErrorInternal(err, w)
-		return
-	}
-
-	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
-
-	queryParams, err := url.ParseQuery(r.URL.RawQuery)
-	if err != nil {
-		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
-		return
-	}
-
-	name := chi.URLParam(r, "name")
-	branch := chi.URLParam(r, "branch")
-
-	repoContentOptions := github.RepositoryContentGetOptions{}
-	repoContentOptions.Ref = branch
-	_, directoryContents, _, err := client.Repositories.GetContents(context.Background(), "", name, queryParams["dir"][0], &repoContentOptions)
-	if err != nil {
-		app.handleErrorInternal(err, w)
-		return
-	}
-
-	res := []DirectoryItem{}
-	for i := range directoryContents {
-		d := DirectoryItem{}
-		d.Path = *directoryContents[i].Path
-		d.Type = *directoryContents[i].Type
-		res = append(res, d)
-	}
-
-	// Ret2: recursively traverse all dirs to create config bundle (case on type == dir)
-	// https://api.github.com/repos/porter-dev/porter/contents?ref=frontend-graph
-	// fmt.Println(res)
-	json.NewEncoder(w).Encode(res)
-}
-
-func (app *App) githubTokenFromRequest(
-	r *http.Request,
-) (*oauth2.Token, error) {
-	// read project id
-	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
-
-	if err != nil || projID == 0 {
-		return nil, fmt.Errorf("could not read project id")
-	}
-
-	// read user id
-	session, err := app.store.Get(r, app.cookieName)
-
-	if err != nil {
-		return nil, fmt.Errorf("could not read user id")
-	}
-
-	userID, ok := session.Values["user_id"].(uint)
-
-	if !ok {
-		return nil, fmt.Errorf("could not read user id")
-	}
-
-	// query for repo client
-	repoClients, err := app.repo.RepoClient.ListRepoClientsByProjectID(uint(projID))
-
-	if err != nil {
-		return nil, err
-	}
-
-	for _, rc := range repoClients {
-		// find the RepoClient that matches the user id in the request
-		if rc.UserID == userID {
-			// TODO -- refresh token is irrelevant at the moment, because the access token
-			// doesn't expire.
-			return &oauth2.Token{
-				AccessToken:  string(rc.AccessToken),
-				RefreshToken: string(rc.RefreshToken),
-				TokenType:    "Bearer",
-			}, nil
-		}
-	}
-
-	return nil, fmt.Errorf("could not find matching token")
-}
+// import (
+// 	"context"
+// 	"encoding/json"
+// 	"fmt"
+// 	"net/http"
+// 	"net/url"
+// 	"strconv"
+
+// 	"golang.org/x/oauth2"
+
+// 	"github.com/go-chi/chi"
+// 	"github.com/google/go-github/v32/github"
+// )
+
+// // Repo represents a GitHub or Gitab repository
+// type Repo struct {
+// 	FullName string
+// 	Kind     string
+// }
+
+// // DirectoryItem represents a file or subfolder in a repository
+// type DirectoryItem struct {
+// 	Path string
+// 	Type string
+// }
+
+// // HandleListRepos retrieves a list of repo names
+// func (app *App) HandleListRepos(w http.ResponseWriter, r *http.Request) {
+// 	tok, err := app.githubTokenFromRequest(r)
+
+// 	if err != nil {
+// 		app.handleErrorInternal(err, w)
+// 		return
+// 	}
+
+// 	res := make([]Repo, 0)
+
+// 	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
+
+// 	// list all repositories for specified user
+// 	repos, _, err := client.Repositories.List(context.Background(), "", nil)
+
+// 	if err != nil {
+// 		app.handleErrorInternal(err, w)
+// 		return
+// 	}
+
+// 	// TODO -- check if repo has already been appended -- there may be duplicates
+// 	for _, repo := range repos {
+// 		res = append(res, Repo{
+// 			FullName: repo.GetFullName(),
+// 			Kind:     "github",
+// 		})
+// 	}
+
+// 	json.NewEncoder(w).Encode(res)
+// }
+
+// // HandleGetBranches retrieves a list of branch names for a specified repo
+// func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
+// 	tok, err := app.githubTokenFromRequest(r)
+
+// 	if err != nil {
+// 		app.handleErrorInternal(err, w)
+// 		return
+// 	}
+
+// 	name := chi.URLParam(r, "name")
+
+// 	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
+
+// 	// List all branches for a specified repo
+// 	branches, _, err := client.Repositories.ListBranches(context.Background(), "", name, nil)
+// 	if err != nil {
+// 		fmt.Println(err)
+// 		return
+// 	}
+
+// 	res := []string{}
+// 	for _, b := range branches {
+// 		res = append(res, b.GetName())
+// 	}
+
+// 	json.NewEncoder(w).Encode(res)
+// }
+
+// // HandleGetBranchContents retrieves the contents of a specific branch and subdirectory
+// func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request) {
+// 	tok, err := app.githubTokenFromRequest(r)
+
+// 	if err != nil {
+// 		app.handleErrorInternal(err, w)
+// 		return
+// 	}
+
+// 	client := github.NewClient(app.GithubConfig.Client(oauth2.NoContext, tok))
+
+// 	queryParams, err := url.ParseQuery(r.URL.RawQuery)
+// 	if err != nil {
+// 		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+// 		return
+// 	}
+
+// 	name := chi.URLParam(r, "name")
+// 	branch := chi.URLParam(r, "branch")
+
+// 	repoContentOptions := github.RepositoryContentGetOptions{}
+// 	repoContentOptions.Ref = branch
+// 	_, directoryContents, _, err := client.Repositories.GetContents(context.Background(), "", name, queryParams["dir"][0], &repoContentOptions)
+// 	if err != nil {
+// 		app.handleErrorInternal(err, w)
+// 		return
+// 	}
+
+// 	res := []DirectoryItem{}
+// 	for i := range directoryContents {
+// 		d := DirectoryItem{}
+// 		d.Path = *directoryContents[i].Path
+// 		d.Type = *directoryContents[i].Type
+// 		res = append(res, d)
+// 	}
+
+// 	// Ret2: recursively traverse all dirs to create config bundle (case on type == dir)
+// 	// https://api.github.com/repos/porter-dev/porter/contents?ref=frontend-graph
+// 	// fmt.Println(res)
+// 	json.NewEncoder(w).Encode(res)
+// }
+
+// func (app *App) githubTokenFromRequest(
+// 	r *http.Request,
+// ) (*oauth2.Token, error) {
+// 	// read project id
+// 	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+// 	if err != nil || projID == 0 {
+// 		return nil, fmt.Errorf("could not read project id")
+// 	}
+
+// 	// read user id
+// 	session, err := app.store.Get(r, app.cookieName)
+
+// 	if err != nil {
+// 		return nil, fmt.Errorf("could not read user id")
+// 	}
+
+// 	userID, ok := session.Values["user_id"].(uint)
+
+// 	if !ok {
+// 		return nil, fmt.Errorf("could not read user id")
+// 	}
+
+// 	// query for repo client
+// 	gitRepos, err := app.repo.GitRepo.ListGitReposByProjectID(uint(projID))
+
+// 	if err != nil {
+// 		return nil, err
+// 	}
+
+// 	for _, rc := range repoClients {
+// 		// find the RepoClient that matches the user id in the request
+// 		if rc.UserID == userID {
+// 			// TODO -- refresh token is irrelevant at the moment, because the access token
+// 			// doesn't expire.
+// 			return &oauth2.Token{
+// 				AccessToken:  string(rc.AccessToken),
+// 				RefreshToken: string(rc.RefreshToken),
+// 				TokenType:    "Bearer",
+// 			}, nil
+// 		}
+// 	}
+
+// 	return nil, fmt.Errorf("could not find matching token")
+// }

+ 21 - 22
server/api/repo_handler_test.go

@@ -3,7 +3,6 @@ package api_test
 import (
 	"encoding/json"
 	"net/http"
-	"net/url"
 	"strings"
 	"testing"
 
@@ -68,27 +67,27 @@ func testReposRequests(t *testing.T, tests []*reposTest, canQuery bool) {
 
 // ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
 
-var listReposTests = []*reposTest{
-	&reposTest{
-		initializers: []func(tester *tester){
-			initDefaultRepos,
-		},
-		msg:       "List repos",
-		method:    "GET",
-		endpoint:  "/api/repos/github/porter/master/contents?dir=" + url.QueryEscape("./"),
-		body:      "",
-		expStatus: http.StatusOK,
-		expBody:   "unimplemented",
-		useCookie: true,
-		validators: []func(c *reposTest, tester *tester, t *testing.T){
-			reposListValidator,
-		},
-	},
-}
-
-func TestHandleListRepos(t *testing.T) {
-	testReposRequests(t, listReposTests, true)
-}
+// var listReposTests = []*reposTest{
+// 	&reposTest{
+// 		initializers: []func(tester *tester){
+// 			initDefaultRepos,
+// 		},
+// 		msg:       "List repos",
+// 		method:    "GET",
+// 		endpoint:  "/api/repos/github/porter/master/contents?dir=" + url.QueryEscape("./"),
+// 		body:      "",
+// 		expStatus: http.StatusOK,
+// 		expBody:   "unimplemented",
+// 		useCookie: true,
+// 		validators: []func(c *reposTest, tester *tester, t *testing.T){
+// 			reposListValidator,
+// 		},
+// 	},
+// }
+
+// func TestHandleListRepos(t *testing.T) {
+// 	testReposRequests(t, listReposTests, true)
+// }
 
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 

+ 21 - 21
server/api/template_handler_test.go

@@ -67,27 +67,27 @@ func testTemplatesRequests(t *testing.T, tests []*templatesTest, canQuery bool)
 
 // ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
 
-var listTemplatesTests = []*templatesTest{
-	&templatesTest{
-		initializers: []func(tester *tester){
-			initDefaultTemplates,
-		},
-		msg:       "List templates",
-		method:    "GET",
-		endpoint:  "/api/templates",
-		body:      "",
-		expStatus: http.StatusOK,
-		expBody:   "unimplemented",
-		useCookie: true,
-		validators: []func(c *templatesTest, tester *tester, t *testing.T){
-			templatesListValidator,
-		},
-	},
-}
-
-func TestHandleListTemplates(t *testing.T) {
-	testTemplatesRequests(t, listTemplatesTests, true)
-}
+// var listTemplatesTests = []*templatesTest{
+// 	&templatesTest{
+// 		initializers: []func(tester *tester){
+// 			initDefaultTemplates,
+// 		},
+// 		msg:       "List templates",
+// 		method:    "GET",
+// 		endpoint:  "/api/templates",
+// 		body:      "",
+// 		expStatus: http.StatusOK,
+// 		expBody:   "unimplemented",
+// 		useCookie: true,
+// 		validators: []func(c *templatesTest, tester *tester, t *testing.T){
+// 			templatesListValidator,
+// 		},
+// 	},
+// }
+
+// func TestHandleListTemplates(t *testing.T) {
+// 	testTemplatesRequests(t, listTemplatesTests, true)
+// }
 
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 

Некоторые файлы не были показаны из-за большого количества измененных файлов