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

Merge branch 'beta.2.integration-backend'

sunguroku 5 лет назад
Родитель
Сommit
17513521ca

+ 67 - 7
cli/cmd/connect/ecr.go

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

+ 1 - 1
cli/cmd/connect/kubeconfig.go

@@ -448,7 +448,7 @@ Would you like to proceed? %s `,
 	}
 	}
 
 
 	if userResp := strings.ToLower(userResp); userResp == "y" || userResp == "yes" {
 	if userResp := strings.ToLower(userResp); userResp == "y" || userResp == "yes" {
-		agent, err := awsLocal.NewDefaultAgent(kubeconfigPath, contextName)
+		agent, err := awsLocal.NewDefaultKubernetesAgent(kubeconfigPath, contextName)
 
 
 		if err != nil {
 		if err != nil {
 			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)
 			color.New(color.FgRed).Printf("Automatic creation failed, manual input required. Error was: %v\n", err)

+ 98 - 9
cli/cmd/providers/aws/agent.go

@@ -20,19 +20,31 @@ type PorterAWSCredentials struct {
 }
 }
 
 
 func (a *Agent) CreateIAMKubernetesMapping(clusterIDGuess string) (*PorterAWSCredentials, error) {
 func (a *Agent) CreateIAMKubernetesMapping(clusterIDGuess string) (*PorterAWSCredentials, error) {
-	// (1) Create a new IAM user called porter-dashboard-[random_string], and attach the policy:
-	//
-	// arn:aws:iam::aws:policy/AmazonEKSClusterPolicy
-	name := "porter-dashboard-" + utils.StringWithCharset(6, "abcdefghijklmnopqrstuvwxyz1234567890")
-
-	user, err := a.IAMService.CreateUser(&iam.CreateUserInput{
-		UserName: &name,
-	})
+	user, err := a.getIAMUserIfExists()
 
 
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
 
 
+	var name string
+
+	if user == nil {
+		// (1) Create a new IAM user called porter-dashboard-[random_string], and attach the policy:
+		name = "porter-dashboard-" + utils.StringWithCharset(6, "abcdefghijklmnopqrstuvwxyz1234567890")
+
+		resp, err := a.IAMService.CreateUser(&iam.CreateUserInput{
+			UserName: &name,
+		})
+
+		if err != nil {
+			return nil, err
+		}
+
+		user = resp.User
+	} else {
+		name = *user.UserName
+	}
+
 	policyArn := "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
 	policyArn := "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
 
 
 	_, err = a.IAMService.AttachUserPolicy(&iam.AttachUserPolicyInput{
 	_, err = a.IAMService.AttachUserPolicy(&iam.AttachUserPolicyInput{
@@ -68,7 +80,7 @@ func (a *Agent) CreateIAMKubernetesMapping(clusterIDGuess string) (*PorterAWSCre
 	}
 	}
 
 
 	identity, err := NewIdentity(
 	identity, err := NewIdentity(
-		*user.User.Arn,
+		*user.Arn,
 		"admin",
 		"admin",
 		[]string{"system:masters"},
 		[]string{"system:masters"},
 	)
 	)
@@ -91,3 +103,80 @@ func (a *Agent) CreateIAMKubernetesMapping(clusterIDGuess string) (*PorterAWSCre
 
 
 	return porterCreds, nil
 	return porterCreds, nil
 }
 }
+
+// CreateIAMECRUser creates an IAM user if it does not exist, and attaches a ECR-read policy
+// to the user
+func (a *Agent) CreateIAMECRUser(region string) (*PorterAWSCredentials, error) {
+	user, err := a.getIAMUserIfExists()
+
+	if err != nil {
+		return nil, err
+	}
+
+	var name string
+
+	if user == nil {
+		// (1) Create a new IAM user called porter-dashboard-[random_string], and attach the policy:
+		//
+		// arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly
+		name = "porter-dashboard-" + utils.StringWithCharset(6, "abcdefghijklmnopqrstuvwxyz1234567890")
+
+		resp, err := a.IAMService.CreateUser(&iam.CreateUserInput{
+			UserName: &name,
+		})
+
+		if err != nil {
+			return nil, err
+		}
+
+		user = resp.User
+	} else {
+		name = *user.UserName
+	}
+
+	policyArn := "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
+
+	_, err = a.IAMService.AttachUserPolicy(&iam.AttachUserPolicyInput{
+		PolicyArn: &policyArn,
+		UserName:  &name,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	// (2) Create an access key for the porter-dashboard-[random_string] user and return the
+	// access key and secret. Use the guessed cluster ID.
+	resp, err := a.IAMService.CreateAccessKey(&iam.CreateAccessKeyInput{
+		UserName: &name,
+	})
+
+	if err != nil {
+		return nil, err
+	}
+
+	porterCreds := &PorterAWSCredentials{
+		AWSAccessKeyID:     *resp.AccessKey.AccessKeyId,
+		AWSSecretAccessKey: *resp.AccessKey.SecretAccessKey,
+	}
+
+	return porterCreds, nil
+}
+
+func (a *Agent) getIAMUserIfExists() (*iam.User, error) {
+	// resp, err := a.IAMService.ListUsers(&iam.ListUsersInput{})
+
+	// if err != nil {
+	// 	return nil, err
+	// }
+
+	// re := regexp.MustCompile(`porter-dashboard-[a-z1-9]{6}`)
+
+	// for _, user := range resp.Users {
+	// 	if re.MatchString(*user.UserName) {
+	// 		return user, nil
+	// 	}
+	// }
+
+	return nil, nil
+}

+ 16 - 4
cli/cmd/providers/aws/local/config.go

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

+ 55 - 0
internal/forms/cluster.go

@@ -3,7 +3,9 @@ package forms
 import (
 import (
 	"encoding/base64"
 	"encoding/base64"
 	"errors"
 	"errors"
+	"fmt"
 	"net/url"
 	"net/url"
+	"regexp"
 	"strings"
 	"strings"
 
 
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/kubernetes"
@@ -14,6 +16,59 @@ import (
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 )
 
 
+// CreateClusterForm represents the accepted values for creating a
+// cluster through manual configuration (not through a kubeconfig)
+type CreateClusterForm struct {
+	Name      string `json:"name" form:"required"`
+	ProjectID uint   `json:"project_id" form:"required"`
+	Server    string `json:"server" form:"required"`
+
+	GCPIntegrationID uint `json:"gcp_integration_id"`
+	AWSIntegrationID uint `json:"aws_integration_id"`
+
+	CertificateAuthorityData string `json:"certificate_authority_data,omitempty"`
+}
+
+// ToCluster converts the form to a cluster
+func (ccf *CreateClusterForm) ToCluster() (*models.Cluster, error) {
+	var authMechanism models.ClusterAuth
+
+	if ccf.GCPIntegrationID != 0 {
+		authMechanism = models.GCP
+	} else if ccf.AWSIntegrationID != 0 {
+		authMechanism = models.AWS
+	} else {
+		return nil, fmt.Errorf("must include aws or gcp integration id")
+	}
+
+	cert := make([]byte, 0)
+
+	if ccf.CertificateAuthorityData != "" {
+		// determine if data is base64 decoded using regex
+		re := regexp.MustCompile(`^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$`)
+
+		// if it matches the base64 regex, decode it
+		if re.MatchString(ccf.CertificateAuthorityData) {
+			decoded, err := base64.StdEncoding.DecodeString(ccf.CertificateAuthorityData)
+
+			if err != nil {
+				return nil, err
+			}
+
+			cert = []byte(decoded)
+		}
+	}
+
+	return &models.Cluster{
+		AuthMechanism:            authMechanism,
+		Name:                     ccf.Name,
+		Server:                   ccf.Server,
+		GCPIntegrationID:         ccf.GCPIntegrationID,
+		AWSIntegrationID:         ccf.AWSIntegrationID,
+		CertificateAuthorityData: cert,
+	}, nil
+}
+
 // ResolveClusterForm will resolve a cluster candidate and create a new cluster
 // ResolveClusterForm will resolve a cluster candidate and create a new cluster
 type ResolveClusterForm struct {
 type ResolveClusterForm struct {
 	Resolver *models.ClusterResolverAll `form:"required"`
 	Resolver *models.ClusterResolverAll `form:"required"`

+ 6 - 6
internal/models/cluster.go

@@ -13,12 +13,12 @@ type ClusterAuth string
 // The support cluster candidate auth mechanisms
 // The support cluster candidate auth mechanisms
 const (
 const (
 	X509   ClusterAuth = "x509"
 	X509   ClusterAuth = "x509"
-	Basic              = "basic"
-	Bearer             = "bearerToken"
-	OIDC               = "oidc"
-	GCP                = "gcp-sa"
-	AWS                = "aws-sa"
-	Local              = "local"
+	Basic  ClusterAuth = "basic"
+	Bearer ClusterAuth = "bearerToken"
+	OIDC   ClusterAuth = "oidc"
+	GCP    ClusterAuth = "gcp-sa"
+	AWS    ClusterAuth = "aws-sa"
+	Local  ClusterAuth = "local"
 )
 )
 
 
 // Cluster is an integration that can connect to a Kubernetes cluster via
 // Cluster is an integration that can connect to a Kubernetes cluster via

+ 4 - 2
internal/models/integrations/aws.go

@@ -119,8 +119,10 @@ func (a *AWSIntegration) GetBearerToken(
 	cache, err := getTokenCache()
 	cache, err := getTokenCache()
 
 
 	// check the token cache for a non-expired token
 	// check the token cache for a non-expired token
-	if tok := cache.Token; err == nil && !cache.IsExpired() && len(tok) > 0 {
-		return string(tok), nil
+	if cache != nil {
+		if tok := cache.Token; err == nil && !cache.IsExpired() && len(tok) > 0 {
+			return string(tok), nil
+		}
 	}
 	}
 
 
 	generator, err := token.NewGenerator(false, false)
 	generator, err := token.NewGenerator(false, false)

+ 1 - 0
internal/repository/cluster.go

@@ -17,4 +17,5 @@ type ClusterRepository interface {
 	ReadCluster(id uint) (*models.Cluster, error)
 	ReadCluster(id uint) (*models.Cluster, error)
 	ListClustersByProjectID(projectID uint) ([]*models.Cluster, error)
 	ListClustersByProjectID(projectID uint) ([]*models.Cluster, error)
 	UpdateClusterTokenCache(tokenCache *ints.TokenCache) (*models.Cluster, error)
 	UpdateClusterTokenCache(tokenCache *ints.TokenCache) (*models.Cluster, error)
+	DeleteCluster(cluster *models.Cluster) error
 }
 }

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

@@ -223,6 +223,28 @@ func (repo *ClusterRepository) UpdateClusterTokenCache(
 	return cluster, nil
 	return cluster, nil
 }
 }
 
 
+// DeleteCluster removes a cluster from the db
+func (repo *ClusterRepository) DeleteCluster(
+	cluster *models.Cluster,
+) error {
+	// clear TokenCache association
+	assoc := repo.db.Model(cluster).Association("TokenCache")
+
+	if assoc.Error != nil {
+		return assoc.Error
+	}
+
+	if err := assoc.Clear(); err != nil {
+		return err
+	}
+
+	if err := repo.db.Where("id = ?", cluster.ID).Delete(&models.Cluster{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}
+
 // EncryptClusterData will encrypt the user's service account data before writing
 // EncryptClusterData will encrypt the user's service account data before writing
 // to the DB
 // to the DB
 func (repo *ClusterRepository) EncryptClusterData(
 func (repo *ClusterRepository) EncryptClusterData(

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

@@ -373,3 +373,42 @@ func TestUpdateClusterToken(t *testing.T) {
 		t.Errorf("incorrect token in cache: expected %s, got %s\n", "token-2", cluster.TokenCache.Token)
 		t.Errorf("incorrect token in cache: expected %s, got %s\n", "token-2", cluster.TokenCache.Token)
 	}
 	}
 }
 }
+
+func TestDeleteCluster(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_delete_cluster.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initCluster(tester, t)
+	defer cleanup(tester, t)
+
+	cluster, err := tester.repo.Cluster.ReadCluster(tester.initClusters[0].Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	err = tester.repo.Cluster.DeleteCluster(cluster)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	_, err = tester.repo.Cluster.ReadCluster(tester.initClusters[0].Model.ID)
+
+	if err != orm.ErrRecordNotFound {
+		t.Fatalf("incorrect error: expected %v, got %v\n", orm.ErrRecordNotFound, err)
+	}
+
+	clusters, err := tester.repo.Cluster.ListClustersByProjectID(tester.initProjects[0].Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(clusters) != 0 {
+		t.Fatalf("length of clusters was not 0")
+	}
+}

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

@@ -124,6 +124,28 @@ func (repo *RegistryRepository) UpdateRegistryTokenCache(
 	return registry, nil
 	return registry, nil
 }
 }
 
 
+// DeleteRegistry removes a registry from the db
+func (repo *RegistryRepository) DeleteRegistry(
+	reg *models.Registry,
+) error {
+	// clear TokenCache association
+	assoc := repo.db.Model(reg).Association("TokenCache")
+
+	if assoc.Error != nil {
+		return assoc.Error
+	}
+
+	if err := assoc.Clear(); err != nil {
+		return err
+	}
+
+	if err := repo.db.Where("id = ?", reg.ID).Delete(&models.Registry{}).Error; err != nil {
+		return err
+	}
+
+	return nil
+}
+
 // EncryptRegistryData will encrypt the user's registry data before writing
 // EncryptRegistryData will encrypt the user's registry data before writing
 // to the DB
 // to the DB
 func (repo *RegistryRepository) EncryptRegistryData(
 func (repo *RegistryRepository) EncryptRegistryData(

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

@@ -8,6 +8,7 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 	"gorm.io/gorm"
 	"gorm.io/gorm"
+	orm "gorm.io/gorm"
 )
 )
 
 
 func TestCreateRegistry(t *testing.T) {
 func TestCreateRegistry(t *testing.T) {
@@ -159,3 +160,42 @@ func TestUpdateRegistryToken(t *testing.T) {
 		t.Errorf("incorrect token in cache: expected %s, got %s\n", "token-2", reg.TokenCache.Token)
 		t.Errorf("incorrect token in cache: expected %s, got %s\n", "token-2", reg.TokenCache.Token)
 	}
 	}
 }
 }
+
+func TestDeleteRegistry(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_delete_registry.db",
+	}
+
+	setupTestEnv(tester, t)
+	initProject(tester, t)
+	initRegistry(tester, t)
+	defer cleanup(tester, t)
+
+	reg, err := tester.repo.Registry.ReadRegistry(tester.initRegs[0].Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	err = tester.repo.Registry.DeleteRegistry(reg)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	_, err = tester.repo.Registry.ReadRegistry(tester.initRegs[0].Model.ID)
+
+	if err != orm.ErrRecordNotFound {
+		t.Fatalf("incorrect error: expected %v, got %v\n", orm.ErrRecordNotFound, err)
+	}
+
+	regs, err := tester.repo.Registry.ListRegistriesByProjectID(tester.initProjects[0].Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if len(regs) != 0 {
+		t.Fatalf("length of clusters was not 0")
+	}
+}

+ 1 - 0
internal/repository/registry.go

@@ -11,4 +11,5 @@ type RegistryRepository interface {
 	ReadRegistry(id uint) (*models.Registry, error)
 	ReadRegistry(id uint) (*models.Registry, error)
 	ListRegistriesByProjectID(projectID uint) ([]*models.Registry, error)
 	ListRegistriesByProjectID(projectID uint) ([]*models.Registry, error)
 	UpdateRegistryTokenCache(tokenCache *ints.RegTokenCache) (*models.Registry, error)
 	UpdateRegistryTokenCache(tokenCache *ints.RegTokenCache) (*models.Registry, error)
+	DeleteRegistry(reg *models.Registry) error
 }
 }

+ 21 - 3
internal/repository/test/cluster.go

@@ -135,9 +135,9 @@ func (repo *ClusterRepository) ListClustersByProjectID(
 
 
 	res := make([]*models.Cluster, 0)
 	res := make([]*models.Cluster, 0)
 
 
-	for _, sa := range repo.clusters {
-		if sa.ProjectID == projectID {
-			res = append(res, sa)
+	for _, cluster := range repo.clusters {
+		if cluster != nil && cluster.ProjectID == projectID {
+			res = append(res, cluster)
 		}
 		}
 	}
 	}
 
 
@@ -158,3 +158,21 @@ func (repo *ClusterRepository) UpdateClusterTokenCache(
 
 
 	return repo.clusters[index], nil
 	return repo.clusters[index], nil
 }
 }
+
+// DeleteCluster removes a cluster from the array by setting it to nil
+func (repo *ClusterRepository) DeleteCluster(
+	cluster *models.Cluster,
+) error {
+	if !repo.canQuery {
+		return errors.New("Cannot write database")
+	}
+
+	if int(cluster.ID-1) >= len(repo.clusters) || repo.clusters[cluster.ID-1] == nil {
+		return gorm.ErrRecordNotFound
+	}
+
+	index := int(cluster.ID - 1)
+	repo.clusters[index] = nil
+
+	return nil
+}

+ 1 - 1
internal/repository/test/gitrepo.go

@@ -58,7 +58,7 @@ func (repo *GitRepoRepository) ListGitReposByProjectID(projectID uint) ([]*model
 	res := make([]*models.GitRepo, 0)
 	res := make([]*models.GitRepo, 0)
 
 
 	for _, gr := range repo.gitRepos {
 	for _, gr := range repo.gitRepos {
-		if gr.ProjectID == projectID {
+		if gr != nil && gr.ProjectID == projectID {
 			res = append(res, gr)
 			res = append(res, gr)
 		}
 		}
 	}
 	}

+ 19 - 1
internal/repository/test/registry.go

@@ -65,7 +65,7 @@ func (repo *RegistryRepository) ListRegistriesByProjectID(
 	res := make([]*models.Registry, 0)
 	res := make([]*models.Registry, 0)
 
 
 	for _, reg := range repo.registries {
 	for _, reg := range repo.registries {
-		if reg.ProjectID == projectID {
+		if reg != nil && reg.ProjectID == projectID {
 			res = append(res, reg)
 			res = append(res, reg)
 		}
 		}
 	}
 	}
@@ -87,3 +87,21 @@ func (repo *RegistryRepository) UpdateRegistryTokenCache(
 
 
 	return repo.registries[index], nil
 	return repo.registries[index], nil
 }
 }
+
+// DeleteRegistry removes a registry from the array by setting it to nil
+func (repo *RegistryRepository) DeleteRegistry(
+	reg *models.Registry,
+) error {
+	if !repo.canQuery {
+		return errors.New("Cannot write database")
+	}
+
+	if int(reg.ID-1) >= len(repo.registries) || repo.registries[reg.ID-1] == nil {
+		return gorm.ErrRecordNotFound
+	}
+
+	index := int(reg.ID - 1)
+	repo.registries[index] = nil
+
+	return nil
+}

+ 361 - 0
server/api/cluster_handler.go

@@ -0,0 +1,361 @@
+package api
+
+import (
+	"encoding/json"
+	"net/http"
+	"strconv"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// HandleCreateProjectCluster creates a new cluster
+func (app *App) HandleCreateProjectCluster(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.CreateClusterForm{
+		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
+	cluster, err := form.ToCluster()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	cluster, err = app.repo.Cluster.CreateCluster(cluster)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	app.logger.Info().Msgf("New cluster created: %d", cluster.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	clusterExt := cluster.Externalize()
+
+	if err := json.NewEncoder(w).Encode(clusterExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// 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
+	}
+
+	cluster, err := app.repo.Cluster.ReadCluster(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	clusterExt := cluster.Externalize()
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(clusterExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleListProjectClusters returns a list of clusters that have linked Integrations.
+func (app *App) HandleListProjectClusters(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
+	}
+
+	clusters, err := app.repo.Cluster.ListClustersByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	extClusters := make([]*models.ClusterExternal, 0)
+
+	for _, cluster := range clusters {
+		extClusters = append(extClusters, cluster.Externalize())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(extClusters); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleDeleteProjectCluster handles the deletion of a Cluster via the cluster ID
+func (app *App) HandleDeleteProjectCluster(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
+	}
+
+	cluster, err := app.repo.Cluster.ReadCluster(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	err = app.repo.Cluster.DeleteCluster(cluster)
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
+// 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 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateClusterCandidatesForm{
+		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 ClusterCandidate
+	ccs, err := form.ToClusterCandidates(app.isLocal)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	extClusters := make([]*models.ClusterCandidateExternal, 0)
+
+	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
+		cc, err = app.repo.Cluster.CreateClusterCandidate(cc)
+
+		if err != nil {
+			app.handleErrorDataWrite(err, w)
+			return
+		}
+
+		app.logger.Info().Msgf("New cluster candidate created: %d", cc.ID)
+
+		// if the ClusterCandidate does not have any actions to perform, create the Cluster
+		// automatically
+		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
+			}
+
+			clusterForm := &forms.ResolveClusterForm{
+				Resolver:           &models.ClusterResolverAll{},
+				ClusterCandidateID: cc.ID,
+				ProjectID:          uint(projID),
+				UserID:             userID,
+			}
+
+			err := clusterForm.ResolveIntegration(*app.repo)
+
+			if err != nil {
+				app.handleErrorDataWrite(err, w)
+				return
+			}
+
+			cluster, err := clusterForm.ResolveCluster(*app.repo)
+
+			if err != nil {
+				app.handleErrorDataWrite(err, w)
+				return
+			}
+
+			cc, err = app.repo.Cluster.UpdateClusterCandidateCreatedClusterID(cc.ID, cluster.ID)
+
+			if err != nil {
+				app.handleErrorDataWrite(err, w)
+				return
+			}
+
+			app.logger.Info().Msgf("New cluster created: %d", cluster.ID)
+		}
+
+		extClusters = append(extClusters, cc.Externalize())
+	}
+
+	w.WriteHeader(http.StatusCreated)
+
+	if err := json.NewEncoder(w).Encode(extClusters); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// 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 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	ccs, err := app.repo.Cluster.ListClusterCandidatesByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	extCCs := make([]*models.ClusterCandidateExternal, 0)
+
+	for _, cc := range ccs {
+		extCCs = append(extCCs, cc.Externalize())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(extCCs); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// 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 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	candID, err := strconv.ParseUint(chi.URLParam(r, "candidate_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	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)
+
+	// decode actions from request
+	resolver := &models.ClusterResolverAll{}
+
+	if err := json.NewDecoder(r.Body).Decode(resolver); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	clusterResolver := &forms.ResolveClusterForm{
+		Resolver:           resolver,
+		ClusterCandidateID: uint(candID),
+		ProjectID:          uint(projID),
+		UserID:             userID,
+	}
+
+	err = clusterResolver.ResolveIntegration(*app.repo)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	cluster, err := clusterResolver.ResolveCluster(*app.repo)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	_, err = app.repo.Cluster.UpdateClusterCandidateCreatedClusterID(uint(candID), cluster.ID)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	app.logger.Info().Msgf("New cluster created: %d", cluster.ID)
+
+	clusterExt := cluster.Externalize()
+
+	w.WriteHeader(http.StatusCreated)
+
+	if err := json.NewEncoder(w).Encode(clusterExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 449 - 0
server/api/cluster_handler_test.go

@@ -0,0 +1,449 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"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"
+)
+
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
+
+type clusterTest struct {
+	initializers []func(t *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *clusterTest, tester *tester, t *testing.T)
+}
+
+func testClusterRequests(t *testing.T, tests []*clusterTest, 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 createClusterTests = []*clusterTest{
+	&clusterTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:       "Create cluster",
+		method:    "POST",
+		endpoint:  "/api/projects/1/clusters",
+		body:      `{"name":"cluster-test","server":"https://10.10.10.10:6443","aws_integration_id":1}`,
+		expStatus: http.StatusCreated,
+		expBody:   `{"id":1,"project_id":1,"name":"cluster-test","server":"https://10.10.10.10:6443","service":"eks"}`,
+		useCookie: true,
+		validators: []func(c *clusterTest, tester *tester, t *testing.T){
+			projectClusterBodyValidator,
+		},
+	},
+}
+
+func TestHandleCreateCluster(t *testing.T) {
+	testRegistryRequests(t, createRegistryTests, true)
+}
+
+var readProjectClusterTest = []*clusterTest{
+	&clusterTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initProjectClusterDefault,
+		},
+		msg:       "Read project cluster",
+		method:    "GET",
+		endpoint:  "/api/projects/1/clusters/1",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `{"id":1,"project_id":1,"name":"cluster-test","server":"https://10.10.10.10","service":"kube"}`,
+		useCookie: true,
+		validators: []func(c *clusterTest, tester *tester, t *testing.T){
+			projectClusterBodyValidator,
+		},
+	},
+}
+
+func TestHandleReadProjectCluster(t *testing.T) {
+	testClusterRequests(t, readProjectClusterTest, true)
+}
+
+var listProjectClustersTest = []*clusterTest{
+	&clusterTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initProjectClusterDefault,
+		},
+		msg:       "List project clusters",
+		method:    "GET",
+		endpoint:  "/api/projects/1/clusters",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   `[{"id":1,"project_id":1,"name":"cluster-test","server":"https://10.10.10.10","service":"kube"}]`,
+		useCookie: true,
+		validators: []func(c *clusterTest, tester *tester, t *testing.T){
+			projectClustersBodyValidator,
+		},
+	},
+}
+
+func TestHandleListProjectClusters(t *testing.T) {
+	testClusterRequests(t, listProjectClustersTest, true)
+}
+
+var deleteClusterTests = []*clusterTest{
+	&clusterTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initProjectClusterDefault,
+		},
+		msg:       "Delete cluster",
+		method:    "DELETE",
+		endpoint:  "/api/projects/1/clusters/1",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   ``,
+		useCookie: true,
+		validators: []func(c *clusterTest, tester *tester, t *testing.T){
+			func(c *clusterTest, tester *tester, t *testing.T) {
+				req, err := http.NewRequest(
+					"GET",
+					"/api/projects/1/clusters/1",
+					strings.NewReader(""),
+				)
+
+				req.AddCookie(tester.cookie)
+
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				rr2 := httptest.NewRecorder()
+
+				tester.router.ServeHTTP(rr2, req)
+
+				if status := rr2.Code; status != 403 {
+					t.Errorf("DELETE cluster validation, handler returned wrong status code: got %v want %v",
+						status, 403)
+				}
+			},
+		},
+	},
+}
+
+func TestHandleDeleteCluster(t *testing.T) {
+	testClusterRequests(t, deleteClusterTests, true)
+}
+
+var createProjectClusterCandidatesTests = []*clusterTest{
+	&clusterTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:       "Create project cluster candidate w/ no actions -- should create SA by default",
+		method:    "POST",
+		endpoint:  "/api/projects/1/clusters/candidates",
+		body:      `{"kubeconfig":"` + OIDCAuthWithDataForJSON + `"}`,
+		expStatus: http.StatusCreated,
+		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 *clusterTest, tester *tester, t *testing.T){
+			projectClusterCandidateBodyValidator,
+			// check that Cluster was created by default
+			func(c *clusterTest, tester *tester, t *testing.T) {
+				clusters, err := tester.repo.Cluster.ListClustersByProjectID(1)
+
+				if err != nil {
+					t.Fatalf("%v\n", err)
+				}
+
+				if len(clusters) != 1 {
+					t.Fatal("Expected cluster to be created by default, but does not exist\n")
+				}
+
+				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 diff := deep.Equal(gotCluster, expCluster); diff != nil {
+					t.Errorf("handler returned wrong body:\n")
+					t.Error(diff)
+				}
+			},
+		},
+	},
+	&clusterTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+		},
+		msg:       "Create project SA candidate",
+		method:    "POST",
+		endpoint:  "/api/projects/1/clusters/candidates",
+		body:      `{"kubeconfig":"` + OIDCAuthWithoutDataForJSON + `"}`,
+		expStatus: http.StatusCreated,
+		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 *clusterTest, tester *tester, t *testing.T){
+			projectClusterCandidateBodyValidator,
+		},
+	},
+}
+
+func TestHandleCreateProjectClusterCandidate(t *testing.T) {
+	testClusterRequests(t, createProjectClusterCandidatesTests, true)
+}
+
+var listProjectClusterCandidatesTests = []*clusterTest{
+	&clusterTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initProjectClusterCandidate,
+		},
+		msg:       "List project cluster candidates",
+		method:    "GET",
+		endpoint:  "/api/projects/1/clusters/candidates",
+		body:      ``,
+		expStatus: http.StatusOK,
+		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 *clusterTest, tester *tester, t *testing.T){
+			projectClusterCandidateBodyValidator,
+		},
+	},
+}
+
+func TestHandleListProjectClusterCandidates(t *testing.T) {
+	testClusterRequests(t, listProjectClusterCandidatesTests, true)
+}
+
+var resolveProjectClusterCandidatesTests = []*clusterTest{
+	&clusterTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initProjectClusterCandidate,
+		},
+		msg:       "Resolve project cluster candidate",
+		method:    "POST",
+		endpoint:  "/api/projects/1/clusters/candidates/1/resolve",
+		body:      `{"oidc_idp_issuer_ca_data": "LS0tLS1CRUdJTiBDRVJ="}`,
+		expStatus: http.StatusCreated,
+		expBody:   `{"id":1,"project_id":1,"name":"cluster-test","server":"https://10.10.10.10","service":"kube"}`,
+		useCookie: true,
+		validators: []func(c *clusterTest, tester *tester, t *testing.T){
+			projectClusterBodyValidator,
+		},
+	},
+}
+
+func TestHandleResolveProjectClusterCandidate(t *testing.T) {
+	testClusterRequests(t, resolveProjectClusterCandidatesTests, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initProjectClusterCandidate(tester *tester) {
+	proj, _ := tester.repo.Project.ReadProject(1)
+
+	form := &forms.CreateClusterCandidatesForm{
+		ProjectID:  proj.ID,
+		Kubeconfig: fixtures.OIDCAuthWithoutData,
+	}
+
+	// convert the form to a ServiceAccountCandidate
+	ccs, _ := form.ToClusterCandidates(false)
+
+	for _, cc := range ccs {
+		tester.repo.Cluster.CreateClusterCandidate(cc)
+	}
+}
+
+func initProjectClusterDefault(tester *tester) {
+	proj, _ := tester.repo.Project.ReadProject(1)
+
+	form := &forms.CreateClusterCandidatesForm{
+		ProjectID:  proj.ID,
+		Kubeconfig: fixtures.OIDCAuthWithData,
+	}
+
+	// convert the form to a ServiceAccountCandidate
+	ccs, _ := form.ToClusterCandidates(false)
+
+	for _, cc := range ccs {
+		tester.repo.Cluster.CreateClusterCandidate(cc)
+	}
+
+	clusterForm := forms.ResolveClusterForm{
+		Resolver:           &models.ClusterResolverAll{},
+		ClusterCandidateID: 1,
+		ProjectID:          1,
+		UserID:             1,
+	}
+
+	clusterForm.ResolveIntegration(*tester.repo)
+	clusterForm.ResolveCluster(*tester.repo)
+}
+
+func projectClusterCandidateBodyValidator(c *clusterTest, 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 diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}
+
+func projectClusterBodyValidator(c *clusterTest, 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 diff := deep.Equal(gotBody, expBody); diff != nil {
+		t.Errorf("handler returned wrong body:\n")
+		t.Error(diff)
+	}
+}
+
+func projectClustersBodyValidator(c *clusterTest, tester *tester, t *testing.T) {
+	gotBody := make([]*models.ClusterExternal, 0)
+	expBody := make([]*models.ClusterExternal, 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)
+	}
+}
+
+const OIDCAuthWithDataForJSON string = `apiVersion: v1\nclusters:\n- cluster:\n    server: https://10.10.10.10\n    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\ncurrent-context: context-test\nkind: Config\npreferences: {}\nusers:\n- name: test-admin\n  user:\n    auth-provider:\n      config:\n        client-id: porter-api\n        id-token: token\n        idp-issuer-url: https://10.10.10.10\n        idp-certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n      name: oidc`
+
+const OIDCAuthWithoutDataForJSON string = `apiVersion: v1\nclusters:\n- cluster:\n    server: https://10.10.10.10\n    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\ncurrent-context: context-test\nkind: Config\npreferences: {}\nusers:\n- name: test-admin\n  user:\n    auth-provider:\n      config:\n        client-id: porter-api\n        id-token: token\n        idp-issuer-url: https://10.10.10.10\n        idp-certificate-authority: /fake/path/to/ca.pem\n      name: oidc`
+
+const OIDCAuthWithoutData string = `
+apiVersion: v1
+clusters:
+- cluster:
+    server: https://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 string = `
+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
+`

+ 0 - 271
server/api/project_handler.go

@@ -110,277 +110,6 @@ func (app *App) HandleReadProject(w http.ResponseWriter, r *http.Request) {
 	}
 	}
 }
 }
 
 
-// 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
-	}
-
-	cluster, err := app.repo.Cluster.ReadCluster(uint(id))
-
-	if err != nil {
-		app.handleErrorRead(err, ErrProjectDataRead, w)
-		return
-	}
-
-	clusterExt := cluster.Externalize()
-
-	w.WriteHeader(http.StatusOK)
-
-	if err := json.NewEncoder(w).Encode(clusterExt); err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
-}
-
-// HandleListProjectClusters returns a list of clusters that have linked Integrations.
-func (app *App) HandleListProjectClusters(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
-	}
-
-	clusters, err := app.repo.Cluster.ListClustersByProjectID(uint(projID))
-
-	if err != nil {
-		app.handleErrorRead(err, ErrProjectDataRead, w)
-		return
-	}
-
-	extClusters := make([]*models.ClusterExternal, 0)
-
-	for _, cluster := range clusters {
-		extClusters = append(extClusters, cluster.Externalize())
-	}
-
-	w.WriteHeader(http.StatusOK)
-
-	if err := json.NewEncoder(w).Encode(extClusters); err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
-}
-
-// 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 {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
-
-	form := &forms.CreateClusterCandidatesForm{
-		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 ClusterCandidate
-	ccs, err := form.ToClusterCandidates(app.isLocal)
-
-	if err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
-
-	extClusters := make([]*models.ClusterCandidateExternal, 0)
-
-	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
-		cc, err = app.repo.Cluster.CreateClusterCandidate(cc)
-
-		if err != nil {
-			app.handleErrorDataWrite(err, w)
-			return
-		}
-
-		app.logger.Info().Msgf("New cluster candidate created: %d", cc.ID)
-
-		// if the ClusterCandidate does not have any actions to perform, create the Cluster
-		// automatically
-		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
-			}
-
-			clusterForm := &forms.ResolveClusterForm{
-				Resolver:           &models.ClusterResolverAll{},
-				ClusterCandidateID: cc.ID,
-				ProjectID:          uint(projID),
-				UserID:             userID,
-			}
-
-			err := clusterForm.ResolveIntegration(*app.repo)
-
-			if err != nil {
-				app.handleErrorDataWrite(err, w)
-				return
-			}
-
-			cluster, err := clusterForm.ResolveCluster(*app.repo)
-
-			if err != nil {
-				app.handleErrorDataWrite(err, w)
-				return
-			}
-
-			cc, err = app.repo.Cluster.UpdateClusterCandidateCreatedClusterID(cc.ID, cluster.ID)
-
-			if err != nil {
-				app.handleErrorDataWrite(err, w)
-				return
-			}
-
-			app.logger.Info().Msgf("New cluster created: %d", cluster.ID)
-		}
-
-		extClusters = append(extClusters, cc.Externalize())
-	}
-
-	w.WriteHeader(http.StatusCreated)
-
-	if err := json.NewEncoder(w).Encode(extClusters); err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
-}
-
-// 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 {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
-
-	ccs, err := app.repo.Cluster.ListClusterCandidatesByProjectID(uint(projID))
-
-	if err != nil {
-		app.handleErrorRead(err, ErrProjectDataRead, w)
-		return
-	}
-
-	extCCs := make([]*models.ClusterCandidateExternal, 0)
-
-	for _, cc := range ccs {
-		extCCs = append(extCCs, cc.Externalize())
-	}
-
-	w.WriteHeader(http.StatusOK)
-
-	if err := json.NewEncoder(w).Encode(extCCs); err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
-}
-
-// 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 {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
-
-	candID, err := strconv.ParseUint(chi.URLParam(r, "candidate_id"), 0, 64)
-
-	if err != nil || projID == 0 {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
-
-	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)
-
-	// decode actions from request
-	resolver := &models.ClusterResolverAll{}
-
-	if err := json.NewDecoder(r.Body).Decode(resolver); err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
-
-	clusterResolver := &forms.ResolveClusterForm{
-		Resolver:           resolver,
-		ClusterCandidateID: uint(candID),
-		ProjectID:          uint(projID),
-		UserID:             userID,
-	}
-
-	err = clusterResolver.ResolveIntegration(*app.repo)
-
-	if err != nil {
-		app.handleErrorDataWrite(err, w)
-		return
-	}
-
-	cluster, err := clusterResolver.ResolveCluster(*app.repo)
-
-	if err != nil {
-		app.handleErrorDataWrite(err, w)
-		return
-	}
-
-	_, err = app.repo.Cluster.UpdateClusterCandidateCreatedClusterID(uint(candID), cluster.ID)
-
-	if err != nil {
-		app.handleErrorDataWrite(err, w)
-		return
-	}
-
-	app.logger.Info().Msgf("New cluster created: %d", cluster.ID)
-
-	clusterExt := cluster.Externalize()
-
-	w.WriteHeader(http.StatusCreated)
-
-	if err := json.NewEncoder(w).Encode(clusterExt); err != nil {
-		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
-		return
-	}
-}
-
 // HandleDeleteProject deletes a project from the db, reading from the project_id
 // HandleDeleteProject deletes a project from the db, reading from the project_id
 // in the URL param
 // in the URL param
 func (app *App) HandleDeleteProject(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleDeleteProject(w http.ResponseWriter, r *http.Request) {

+ 0 - 309
server/api/project_handler_test.go

@@ -6,12 +6,7 @@ import (
 	"strings"
 	"strings"
 	"testing"
 	"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/go-test/deep"
-	"github.com/porter-dev/porter/internal/forms"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 )
 )
 
 
@@ -120,171 +115,6 @@ func TestHandleReadProject(t *testing.T) {
 	testProjRequests(t, readProjectTests, true)
 	testProjRequests(t, readProjectTests, true)
 }
 }
 
 
-var readProjectClusterTest = []*projTest{
-	&projTest{
-		initializers: []func(t *tester){
-			initUserDefault,
-			initProject,
-			initProjectClusterDefault,
-		},
-		msg:       "Read project cluster",
-		method:    "GET",
-		endpoint:  "/api/projects/1/clusters/1",
-		body:      ``,
-		expStatus: http.StatusOK,
-		expBody:   `{"id":1,"project_id":1,"name":"cluster-test","server":"https://10.10.10.10","service":"kube"}`,
-		useCookie: true,
-		validators: []func(c *projTest, tester *tester, t *testing.T){
-			projectClusterBodyValidator,
-		},
-	},
-}
-
-func TestHandleReadProjectSA(t *testing.T) {
-	testProjRequests(t, readProjectClusterTest, true)
-}
-
-var listProjectClustersTest = []*projTest{
-	&projTest{
-		initializers: []func(t *tester){
-			initUserDefault,
-			initProject,
-			initProjectClusterDefault,
-		},
-		msg:       "List project clusters",
-		method:    "GET",
-		endpoint:  "/api/projects/1/clusters",
-		body:      ``,
-		expStatus: http.StatusOK,
-		expBody:   `[{"id":1,"project_id":1,"name":"cluster-test","server":"https://10.10.10.10","service":"kube"}]`,
-		useCookie: true,
-		validators: []func(c *projTest, tester *tester, t *testing.T){
-			projectClustersBodyValidator,
-		},
-	},
-}
-
-func TestHandleListProjectClusters(t *testing.T) {
-	testProjRequests(t, listProjectClustersTest, true)
-}
-
-var createProjectClusterCandidatesTests = []*projTest{
-	&projTest{
-		initializers: []func(t *tester){
-			initUserDefault,
-			initProject,
-		},
-		msg:       "Create project cluster candidate w/ no actions -- should create SA by default",
-		method:    "POST",
-		endpoint:  "/api/projects/1/clusters/candidates",
-		body:      `{"kubeconfig":"` + OIDCAuthWithDataForJSON + `"}`,
-		expStatus: http.StatusCreated,
-		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){
-			projectClusterCandidateBodyValidator,
-			// check that Cluster was created by default
-			func(c *projTest, tester *tester, t *testing.T) {
-				clusters, err := tester.repo.Cluster.ListClustersByProjectID(1)
-
-				if err != nil {
-					t.Fatalf("%v\n", err)
-				}
-
-				if len(clusters) != 1 {
-					t.Fatal("Expected cluster to be created by default, but does not exist\n")
-				}
-
-				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 diff := deep.Equal(gotCluster, expCluster); diff != nil {
-					t.Errorf("handler returned wrong body:\n")
-					t.Error(diff)
-				}
-			},
-		},
-	},
-	&projTest{
-		initializers: []func(t *tester){
-			initUserDefault,
-			initProject,
-		},
-		msg:       "Create project SA candidate",
-		method:    "POST",
-		endpoint:  "/api/projects/1/clusters/candidates",
-		body:      `{"kubeconfig":"` + OIDCAuthWithoutDataForJSON + `"}`,
-		expStatus: http.StatusCreated,
-		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){
-			projectClusterCandidateBodyValidator,
-		},
-	},
-}
-
-func TestHandleCreateProjectClusterCandidate(t *testing.T) {
-	testProjRequests(t, createProjectClusterCandidatesTests, true)
-}
-
-var listProjectClusterCandidatesTests = []*projTest{
-	&projTest{
-		initializers: []func(t *tester){
-			initUserDefault,
-			initProject,
-			initProjectClusterCandidate,
-		},
-		msg:       "List project cluster candidates",
-		method:    "GET",
-		endpoint:  "/api/projects/1/clusters/candidates",
-		body:      ``,
-		expStatus: http.StatusOK,
-		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){
-			projectClusterCandidateBodyValidator,
-		},
-	},
-}
-
-func TestHandleListProjectClusterCandidates(t *testing.T) {
-	testProjRequests(t, listProjectClusterCandidatesTests, true)
-}
-
-var resolveProjectClusterCandidatesTests = []*projTest{
-	&projTest{
-		initializers: []func(t *tester){
-			initUserDefault,
-			initProject,
-			initProjectClusterCandidate,
-		},
-		msg:       "Resolve project cluster candidate",
-		method:    "POST",
-		endpoint:  "/api/projects/1/clusters/candidates/1/resolve",
-		body:      `{"oidc_idp_issuer_ca_data": "LS0tLS1CRUdJTiBDRVJ="}`,
-		expStatus: http.StatusCreated,
-		expBody:   `{"id":1,"project_id":1,"name":"cluster-test","server":"https://10.10.10.10","service":"kube"}`,
-		useCookie: true,
-		validators: []func(c *projTest, tester *tester, t *testing.T){
-			projectClusterBodyValidator,
-		},
-	},
-}
-
-func TestHandleResolveProjectClusterCandidate(t *testing.T) {
-	testProjRequests(t, resolveProjectClusterCandidatesTests, true)
-}
-
 var deleteProjectTests = []*projTest{
 var deleteProjectTests = []*projTest{
 	&projTest{
 	&projTest{
 		initializers: []func(t *tester){
 		initializers: []func(t *tester){
@@ -326,48 +156,6 @@ func initProject(tester *tester) {
 	})
 	})
 }
 }
 
 
-func initProjectClusterCandidate(tester *tester) {
-	proj, _ := tester.repo.Project.ReadProject(1)
-
-	form := &forms.CreateClusterCandidatesForm{
-		ProjectID:  proj.ID,
-		Kubeconfig: fixtures.OIDCAuthWithoutData,
-	}
-
-	// convert the form to a ServiceAccountCandidate
-	ccs, _ := form.ToClusterCandidates(false)
-
-	for _, cc := range ccs {
-		tester.repo.Cluster.CreateClusterCandidate(cc)
-	}
-}
-
-func initProjectClusterDefault(tester *tester) {
-	proj, _ := tester.repo.Project.ReadProject(1)
-
-	form := &forms.CreateClusterCandidatesForm{
-		ProjectID:  proj.ID,
-		Kubeconfig: fixtures.OIDCAuthWithData,
-	}
-
-	// convert the form to a ServiceAccountCandidate
-	ccs, _ := form.ToClusterCandidates(false)
-
-	for _, cc := range ccs {
-		tester.repo.Cluster.CreateClusterCandidate(cc)
-	}
-
-	clusterForm := forms.ResolveClusterForm{
-		Resolver:           &models.ClusterResolverAll{},
-		ClusterCandidateID: 1,
-		ProjectID:          1,
-		UserID:             1,
-	}
-
-	clusterForm.ResolveIntegration(*tester.repo)
-	clusterForm.ResolveCluster(*tester.repo)
-}
-
 func projectBasicBodyValidator(c *projTest, tester *tester, t *testing.T) {
 func projectBasicBodyValidator(c *projTest, tester *tester, t *testing.T) {
 	if body := tester.rr.Body.String(); strings.TrimSpace(body) != strings.TrimSpace(c.expBody) {
 	if body := tester.rr.Body.String(); strings.TrimSpace(body) != strings.TrimSpace(c.expBody) {
 		t.Errorf("%s, handler returned wrong body: got %v want %v",
 		t.Errorf("%s, handler returned wrong body: got %v want %v",
@@ -387,100 +175,3 @@ func projectModelBodyValidator(c *projTest, tester *tester, t *testing.T) {
 		t.Error(diff)
 		t.Error(diff)
 	}
 	}
 }
 }
-
-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 diff := deep.Equal(gotBody, expBody); diff != nil {
-		t.Errorf("handler returned wrong body:\n")
-		t.Error(diff)
-	}
-}
-
-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 diff := deep.Equal(gotBody, expBody); diff != nil {
-		t.Errorf("handler returned wrong body:\n")
-		t.Error(diff)
-	}
-}
-
-func projectClustersBodyValidator(c *projTest, tester *tester, t *testing.T) {
-	gotBody := make([]*models.ClusterExternal, 0)
-	expBody := make([]*models.ClusterExternal, 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)
-	}
-}
-
-const OIDCAuthWithDataForJSON string = `apiVersion: v1\nclusters:\n- cluster:\n    server: https://10.10.10.10\n    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\ncurrent-context: context-test\nkind: Config\npreferences: {}\nusers:\n- name: test-admin\n  user:\n    auth-provider:\n      config:\n        client-id: porter-api\n        id-token: token\n        idp-issuer-url: https://10.10.10.10\n        idp-certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n      name: oidc`
-
-const OIDCAuthWithoutDataForJSON string = `apiVersion: v1\nclusters:\n- cluster:\n    server: https://10.10.10.10\n    certificate-authority-data: LS0tLS1CRUdJTiBDRVJ=\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\ncurrent-context: context-test\nkind: Config\npreferences: {}\nusers:\n- name: test-admin\n  user:\n    auth-provider:\n      config:\n        client-id: porter-api\n        id-token: token\n        idp-issuer-url: https://10.10.10.10\n        idp-certificate-authority: /fake/path/to/ca.pem\n      name: oidc`
-
-const OIDCAuthWithoutData string = `
-apiVersion: v1
-clusters:
-- cluster:
-    server: https://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 string = `
-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
-`

+ 26 - 0
server/api/registry_handler.go

@@ -95,6 +95,32 @@ func (app *App) HandleListProjectRegistries(w http.ResponseWriter, r *http.Reque
 	}
 	}
 }
 }
 
 
+// HandleDeleteProjectRegistry handles the deletion of a Registry via the registry ID
+func (app *App) HandleDeleteProjectRegistry(w http.ResponseWriter, r *http.Request) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "registry_id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	reg, err := app.repo.Registry.ReadRegistry(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	err = app.repo.Registry.DeleteRegistry(reg)
+
+	if err != nil {
+		app.handleErrorRead(err, ErrProjectDataRead, w)
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+}
+
 // HandleListRepositories returns a list of repositories for a given registry
 // HandleListRepositories returns a list of repositories for a given registry
 func (app *App) HandleListRepositories(w http.ResponseWriter, r *http.Request) {
 func (app *App) HandleListRepositories(w http.ResponseWriter, r *http.Request) {
 	regID, err := strconv.ParseUint(chi.URLParam(r, "registry_id"), 0, 64)
 	regID, err := strconv.ParseUint(chi.URLParam(r, "registry_id"), 0, 64)

+ 56 - 0
server/api/registry_handler_test.go

@@ -3,6 +3,7 @@ package api_test
 import (
 import (
 	"encoding/json"
 	"encoding/json"
 	"net/http"
 	"net/http"
+	"net/http/httptest"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
 
 
@@ -170,6 +171,61 @@ func TestHandleListRegistries(t *testing.T) {
 	testRegistryRequests(t, listRegistryTests, true)
 	testRegistryRequests(t, listRegistryTests, true)
 }
 }
 
 
+var deleteRegTests = []*regTest{
+	&regTest{
+		initializers: []func(t *tester){
+			initUserDefault,
+			initProject,
+			initRegistry,
+		},
+		msg:       "Delete registry",
+		method:    "DELETE",
+		endpoint:  "/api/projects/1/registries/1",
+		body:      ``,
+		expStatus: http.StatusOK,
+		expBody:   ``,
+		useCookie: true,
+		validators: []func(c *regTest, tester *tester, t *testing.T){
+			func(c *regTest, tester *tester, t *testing.T) {
+				req, err := http.NewRequest(
+					"GET",
+					"/api/projects/1/registries",
+					strings.NewReader(""),
+				)
+
+				req.AddCookie(tester.cookie)
+
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				rr2 := httptest.NewRecorder()
+
+				tester.router.ServeHTTP(rr2, req)
+
+				if status := rr2.Code; status != 200 {
+					t.Errorf("DELETE registry validation, handler returned wrong status code: got %v want %v",
+						status, 200)
+				}
+
+				gotBody := make([]*models.RegistryExternal, 0)
+				expBody := make([]*models.RegistryExternal, 0)
+
+				json.Unmarshal(rr2.Body.Bytes(), &gotBody)
+
+				if diff := deep.Equal(gotBody, expBody); diff != nil {
+					t.Errorf("handler returned wrong body:\n")
+					t.Error(diff)
+				}
+			},
+		},
+	},
+}
+
+func TestHandleDeleteRegistry(t *testing.T) {
+	testRegistryRequests(t, deleteRegTests, true)
+}
+
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 
 
 func initRegistry(tester *tester) {
 func initRegistry(tester *tester) {

+ 128 - 34
server/router/router.go

@@ -32,15 +32,62 @@ func New(
 		r.Method("GET", "/readyz", http.HandlerFunc(a.HandleReady))
 		r.Method("GET", "/readyz", http.HandlerFunc(a.HandleReady))
 
 
 		// /api/users routes
 		// /api/users routes
-		r.Method("GET", "/users/{user_id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUser, l), mw.URLParam))
-		r.Method("GET", "/users/{user_id}/projects", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleListUserProjects, l), mw.URLParam))
-		r.Method("POST", "/users", requestlog.NewHandler(a.HandleCreateUser, l))
-		r.Method("DELETE", "/users/{user_id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleDeleteUser, l), mw.URLParam))
-		r.Method("POST", "/login", requestlog.NewHandler(a.HandleLoginUser, l))
-		r.Method("GET", "/auth/check", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleAuthCheck, l)))
-		r.Method("POST", "/logout", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleLogoutUser, l)))
+		r.Method(
+			"GET",
+			"/users/{user_id}",
+			auth.DoesUserIDMatch(
+				requestlog.NewHandler(a.HandleReadUser, l),
+				mw.URLParam,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/users/{user_id}/projects",
+			auth.DoesUserIDMatch(
+				requestlog.NewHandler(a.HandleListUserProjects, l),
+				mw.URLParam,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/users",
+			requestlog.NewHandler(a.HandleCreateUser, l),
+		)
+
+		r.Method(
+			"DELETE",
+			"/users/{user_id}",
+			auth.DoesUserIDMatch(
+				requestlog.NewHandler(a.HandleDeleteUser, l),
+				mw.URLParam,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/login",
+			requestlog.NewHandler(a.HandleLoginUser, l),
+		)
+
+		r.Method(
+			"GET",
+			"/auth/check",
+			auth.BasicAuthenticate(
+				requestlog.NewHandler(a.HandleAuthCheck, l),
+			),
+		)
 
 
-		// /integrations routes
+		r.Method(
+			"POST",
+			"/logout",
+			auth.BasicAuthenticate(
+				requestlog.NewHandler(a.HandleLogoutUser, l),
+			),
+		)
+
+		// /api/integrations routes
 		r.Method(
 		r.Method(
 			"GET",
 			"GET",
 			"/integrations/cluster",
 			"/integrations/cluster",
@@ -65,6 +112,15 @@ func New(
 			),
 			),
 		)
 		)
 
 
+		// /api/templates routes
+		r.Method(
+			"GET",
+			"/templates",
+			auth.BasicAuthenticate(
+				requestlog.NewHandler(a.HandleListTemplates, l),
+			),
+		)
+
 		// /api/oauth routes
 		// /api/oauth routes
 		// r.Method(
 		// r.Method(
 		// 	"GET",
 		// 	"GET",
@@ -93,6 +149,45 @@ func New(
 			),
 			),
 		)
 		)
 
 
+		r.Method(
+			"POST",
+			"/projects",
+			auth.BasicAuthenticate(
+				requestlog.NewHandler(a.HandleCreateProject, l),
+			),
+		)
+
+		r.Method(
+			"DELETE",
+			"/projects/{project_id}",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleDeleteProject, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		// /api/projects/{project_id}/clusters routes
+		r.Method(
+			"GET",
+			"/projects/{project_id}/clusters",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleListProjectClusters, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/clusters",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleCreateProjectCluster, l),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		r.Method(
 		r.Method(
 			"GET",
 			"GET",
 			"/projects/{project_id}/clusters/{cluster_id}",
 			"/projects/{project_id}/clusters/{cluster_id}",
@@ -108,17 +203,20 @@ func New(
 		)
 		)
 
 
 		r.Method(
 		r.Method(
-			"GET",
-			"/projects/{project_id}/clusters",
+			"DELETE",
+			"/projects/{project_id}/clusters/{cluster_id}",
 			auth.DoesUserHaveProjectAccess(
 			auth.DoesUserHaveProjectAccess(
-				requestlog.NewHandler(a.HandleListProjectClusters, l),
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleDeleteProjectCluster, l),
+					mw.URLParam,
+					mw.URLParam,
+				),
 				mw.URLParam,
 				mw.URLParam,
-				mw.ReadAccess,
+				mw.WriteAccess,
 			),
 			),
 		)
 		)
 
 
-		r.Method("POST", "/projects", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleCreateProject, l)))
-
+		// /api/projects/{project_id}/clusters/candidates routes
 		r.Method(
 		r.Method(
 			"POST",
 			"POST",
 			"/projects/{project_id}/clusters/candidates",
 			"/projects/{project_id}/clusters/candidates",
@@ -149,16 +247,6 @@ func New(
 			),
 			),
 		)
 		)
 
 
-		r.Method(
-			"DELETE",
-			"/projects/{project_id}",
-			auth.DoesUserHaveProjectAccess(
-				requestlog.NewHandler(a.HandleDeleteProject, l),
-				mw.URLParam,
-				mw.WriteAccess,
-			),
-		)
-
 		// /api/projects/{project_id}/integrations routes
 		// /api/projects/{project_id}/integrations routes
 		r.Method(
 		r.Method(
 			"POST",
 			"POST",
@@ -201,6 +289,21 @@ func New(
 			),
 			),
 		)
 		)
 
 
+		r.Method(
+			"DELETE",
+			"/projects/{project_id}/registries/{registry_id}",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveRegistryAccess(
+					requestlog.NewHandler(a.HandleDeleteProjectRegistry, l),
+					mw.URLParam,
+					mw.URLParam,
+				),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		// /api/projects/{project_id}/registries/{registry_id}/repositories routes
 		r.Method(
 		r.Method(
 			"GET",
 			"GET",
 			"/projects/{project_id}/registries/{registry_id}/repositories",
 			"/projects/{project_id}/registries/{registry_id}/repositories",
@@ -362,7 +465,7 @@ func New(
 		// 	),
 		// 	),
 		// )
 		// )
 
 
-		// /api/projects/{project_id}/images routes
+		// /api/projects/{project_id}/deploy route
 		r.Method(
 		r.Method(
 			"POST",
 			"POST",
 			"/projects/{project_id}/deploy",
 			"/projects/{project_id}/deploy",
@@ -377,15 +480,6 @@ func New(
 			),
 			),
 		)
 		)
 
 
-		// /api/templates routes
-		r.Method(
-			"GET",
-			"/templates",
-			auth.BasicAuthenticate(
-				requestlog.NewHandler(a.HandleListTemplates, l),
-			),
-		)
-
 		// /api/projects/{project_id}/k8s routes
 		// /api/projects/{project_id}/k8s routes
 		r.Method(
 		r.Method(
 			"GET",
 			"GET",