فهرست منبع

projects endpoints for client

Alexander Belanger 5 سال پیش
والد
کامیت
c9f1782059

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

@@ -5,7 +5,10 @@ import (
 	"fmt"
 	"io/ioutil"
 	"net/http"
+	"path/filepath"
 	"time"
+
+	"k8s.io/client-go/util/homedir"
 )
 
 // Client represents the client for the Porter API
@@ -13,7 +16,7 @@ type Client struct {
 	BaseURL        string
 	HTTPClient     *http.Client
 	Cookie         *http.Cookie
-	CookieFilename string
+	CookieFilePath string
 }
 
 // HTTPError is the Porter error response returned if a request fails
@@ -24,9 +27,12 @@ type HTTPError struct {
 
 // NewClient constructs a new client based on a set of options
 func NewClient(baseURL string, cookieFileName string) *Client {
+	home := homedir.HomeDir()
+	cookieFilePath := filepath.Join(home, ".porter", cookieFileName)
+
 	client := &Client{
 		BaseURL:        baseURL,
-		CookieFilename: cookieFileName,
+		CookieFilePath: cookieFilePath,
 		HTTPClient: &http.Client{
 			Timeout: time.Minute,
 		},
@@ -41,11 +47,11 @@ func NewClient(baseURL string, cookieFileName string) *Client {
 	return client
 }
 
-func (c *Client) sendRequest(req *http.Request, v interface{}) (*HTTPError, error) {
+func (c *Client) sendRequest(req *http.Request, v interface{}, useCookie bool) (*HTTPError, error) {
 	req.Header.Set("Content-Type", "application/json; charset=utf-8")
 	req.Header.Set("Accept", "application/json; charset=utf-8")
 
-	if cookie, _ := c.getCookie(); cookie != nil {
+	if cookie, _ := c.getCookie(); useCookie && cookie != nil {
 		c.Cookie = cookie
 		req.AddCookie(c.Cookie)
 	}
@@ -95,12 +101,12 @@ func (c *Client) saveCookie(cookie *http.Cookie) error {
 		return err
 	}
 
-	return ioutil.WriteFile(c.CookieFilename, data, 0644)
+	return ioutil.WriteFile(c.CookieFilePath, data, 0644)
 }
 
 // retrieves single cookie from file
 func (c *Client) getCookie() (*http.Cookie, error) {
-	data, err := ioutil.ReadFile(c.CookieFilename)
+	data, err := ioutil.ReadFile(c.CookieFilePath)
 
 	if err != nil {
 		return nil, err

+ 23 - 2
cli/cmd/api/helper_test.go

@@ -1,9 +1,29 @@
 package api_test
 
 import (
+	"fmt"
+	"os"
+	"testing"
+
 	"github.com/porter-dev/porter/cli/cmd/docker"
 )
 
+const baseURL string = "http://localhost:10000/api"
+
+func TestMain(m *testing.M) {
+	err := startPorterServerWithDocker("user", 10000, docker.SQLite)
+
+	if err != nil {
+		fmt.Printf("%v\n", err)
+		os.Exit(1)
+	}
+
+	code := m.Run()
+	stopPorterServerWithDocker("user")
+
+	os.Exit(code)
+}
+
 type db int
 
 const (
@@ -43,13 +63,14 @@ func stopPorterServerWithDocker(processID string) error {
 		return err
 	}
 
-	err = agent.StopPorterContainersWithProcessID(processID)
+	err = agent.StopPorterContainersWithProcessID(processID, true)
 
 	if err != nil {
 		return err
 	}
 
-	// remove stopped containers and volumes
+	// remove volumes
+	err = agent.RemoveLocalVolume("porter_sqlite_" + processID)
 
 	return nil
 }

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

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

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

@@ -0,0 +1,304 @@
+package api_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/models"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+)
+
+func initProject(name string, client *api.Client, t *testing.T) *api.CreateProjectResponse {
+	t.Helper()
+
+	resp, err := client.CreateProject(context.Background(), &api.CreateProjectRequest{
+		Name: name,
+	})
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	return resp
+}
+
+func initProjectCandidate(
+	projectID uint,
+	kubeconfig string,
+	client *api.Client,
+	t *testing.T,
+) *models.ServiceAccountCandidateExternal {
+	t.Helper()
+
+	resp, err := client.CreateProjectCandidates(
+		context.Background(),
+		projectID,
+		&api.CreateProjectCandidatesRequest{
+			Kubeconfig: kubeconfig,
+		},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	return resp[0]
+}
+
+func TestCreateProject(t *testing.T) {
+	email := "create_project_test@example.com"
+	client := api.NewClient(baseURL, "cookie_create_project_test.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+
+	resp, err := client.CreateProject(context.Background(), &api.CreateProjectRequest{
+		Name: "project-test",
+	})
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure user is admin and project name is correct
+	if resp.Name != "project-test" {
+		t.Errorf("project name incorrect: expected %s, got %s\n", "project-test", resp.Name)
+	}
+
+	if len(resp.Roles) != 1 {
+		t.Fatalf("project role length is not 1")
+	}
+
+	if resp.Roles[0].Kind != models.RoleAdmin {
+		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
+	}
+
+	if resp.Roles[0].UserID != user.ID {
+		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
+	}
+}
+
+func TestGetProject(t *testing.T) {
+	email := "get_project_test@example.com"
+	client := api.NewClient(baseURL, "cookie_get_project_test.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+	project := initProject("project-test", client, t)
+
+	resp, err := client.GetProject(context.Background(), project.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure user is admin and project name is correct
+	if resp.Name != "project-test" {
+		t.Errorf("project name incorrect: expected %s, got %s\n", "project-test", resp.Name)
+	}
+
+	if len(resp.Roles) != 1 {
+		t.Fatalf("project role length is not 1")
+	}
+
+	if resp.Roles[0].Kind != models.RoleAdmin {
+		t.Errorf("project role kind is incorrect: expected %s, got %s\n", models.RoleAdmin, resp.Roles[0].Kind)
+	}
+
+	if resp.Roles[0].UserID != user.ID {
+		t.Errorf("project role user_id is incorrect: expected %d, got %d\n", user.ID, resp.Roles[0].UserID)
+	}
+}
+
+func TestCreateProjectCandidates(t *testing.T) {
+	email := "create_project_candidates_test@example.com"
+	client := api.NewClient(baseURL, "cookie_create_project_candidates_test.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+	project := initProject("project-test", client, t)
+
+	resp, err := client.CreateProjectCandidates(
+		context.Background(),
+		project.ID,
+		&api.CreateProjectCandidatesRequest{
+			Kubeconfig: OIDCAuthWithoutData,
+		},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure length is 1
+	if len(resp) != 1 {
+		t.Fatalf("candidates length is not 1\n")
+	}
+
+	// make sure auth mechanism is OIDC, project id is correct, and cluster info is correct
+	if resp[0].AuthMechanism != models.OIDC {
+		t.Errorf("oidc auth mechanism incorrect: expected %s, got %s\n", models.OIDC, resp[0].AuthMechanism)
+	}
+
+	if resp[0].ProjectID != project.ID {
+		t.Errorf("project id incorrect: expected %d, got %d\n", project.ID, resp[0].ProjectID)
+	}
+
+	if resp[0].ClusterName != "cluster-test" {
+		t.Errorf("cluster name incorrect: expected %s, got %s\n", "cluster-test", resp[0].ClusterName)
+	}
+
+	if resp[0].ClusterEndpoint != "https://localhost" {
+		t.Errorf("cluster endpoint incorrect: expected %s, got %s\n", "https://localhost", resp[0].ClusterEndpoint)
+	}
+
+	// make sure correct actions need to be performed
+	if len(resp[0].Actions) != 1 {
+		t.Fatalf("actions length is not 1\n")
+	}
+
+	if resp[0].Actions[0].Name != models.OIDCIssuerDataAction {
+		t.Errorf("action name incorrect: expected %s, got %s\n", models.OIDCIssuerDataAction, resp[0].Actions[0].Name)
+	}
+}
+
+func TestGetProjectCandidates(t *testing.T) {
+	email := "get_project_candidates_test@example.com"
+	client := api.NewClient(baseURL, "cookie_get_project_candidates_test.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+	project := initProject("project-test", client, t)
+	initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
+
+	resp, err := client.GetProjectCandidates(context.Background(), project.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure length is 1
+	if len(resp) != 1 {
+		t.Fatalf("candidates length is not 1\n")
+	}
+
+	// make sure auth mechanism is OIDC, project id is correct, and cluster info is correct
+	if resp[0].AuthMechanism != models.OIDC {
+		t.Errorf("oidc auth mechanism incorrect: expected %s, got %s\n", models.OIDC, resp[0].AuthMechanism)
+	}
+
+	if resp[0].ProjectID != project.ID {
+		t.Errorf("project id incorrect: expected %d, got %d\n", project.ID, resp[0].ProjectID)
+	}
+
+	if resp[0].ClusterName != "cluster-test" {
+		t.Errorf("cluster name incorrect: expected %s, got %s\n", "cluster-test", resp[0].ClusterName)
+	}
+
+	if resp[0].ClusterEndpoint != "https://localhost" {
+		t.Errorf("cluster endpoint incorrect: expected %s, got %s\n", "https://localhost", resp[0].ClusterEndpoint)
+	}
+
+	// make sure correct actions need to be performed
+	if len(resp[0].Actions) != 1 {
+		t.Fatalf("actions length is not 1\n")
+	}
+
+	if resp[0].Actions[0].Name != models.OIDCIssuerDataAction {
+		t.Errorf("action name incorrect: expected %s, got %s\n", models.OIDCIssuerDataAction, resp[0].Actions[0].Name)
+	}
+}
+
+func TestCreateProjectServiceAccount(t *testing.T) {
+	email := "create_project_sa_test@example.com"
+	client := api.NewClient(baseURL, "cookie_create_project_sa_test.json")
+	user := initUser(email, client, t)
+	client.Login(context.Background(), &api.LoginRequest{
+		Email:    user.Email,
+		Password: "hello1234",
+	})
+	project := initProject("project-test", client, t)
+	saCandidate := initProjectCandidate(project.ID, OIDCAuthWithoutData, client, t)
+
+	resp, err := client.CreateProjectServiceAccount(
+		context.Background(),
+		project.ID,
+		saCandidate.ID,
+		api.CreateProjectServiceAccountRequest{
+			&models.ServiceAccountAllActions{
+				Name:             models.OIDCIssuerDataAction,
+				OIDCIssuerCAData: "LS0tLS1CRUdJTiBDRVJ=",
+			},
+		},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// ensure project id and metadata is correct
+	if resp.ProjectID != project.ID {
+		t.Errorf("project id incorrect: expected %d, got %d\n", project.ID, resp.ProjectID)
+	}
+
+	if resp.Kind != "connector" {
+		t.Errorf("service account kind incorrect: expected %s, got %s\n", "connector", resp.Kind)
+	}
+
+	if resp.AuthMechanism != models.OIDC {
+		t.Errorf("service account auth mechanism incorrect: expected %s, got %s\n", models.OIDC, resp.AuthMechanism)
+	}
+
+	// verify clusters
+	if len(resp.Clusters) != 1 {
+		t.Fatalf("length of clusters is not 1")
+	}
+
+	if resp.Clusters[0].ServiceAccountID != resp.ID {
+		t.Errorf("cluster's sa id is incorrect: expected %d, got %d\n", resp.ID, resp.Clusters[0].ServiceAccountID)
+	}
+
+	if resp.Clusters[0].Name != "cluster-test" {
+		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "cluster-test", resp.Clusters[0].Name)
+	}
+
+	if resp.Clusters[0].Server != "https://localhost" {
+		t.Errorf("cluster's name is incorrect: expected %s, got %s\n", "https://localhost", resp.Clusters[0].Server)
+	}
+}
+
+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
+`

+ 6 - 6
cli/cmd/api/user.go

@@ -30,7 +30,7 @@ func (c *Client) AuthCheck(ctx context.Context) (*AuthCheckResponse, error) {
 
 	bodyResp := &AuthCheckResponse{}
 
-	if httpErr, err := c.sendRequest(req, bodyResp); httpErr != nil || err != nil {
+	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)
 		}
@@ -72,7 +72,7 @@ func (c *Client) Login(ctx context.Context, loginRequest *LoginRequest) (*LoginR
 	req = req.WithContext(ctx)
 	bodyResp := &LoginResponse{}
 
-	if httpErr, err := c.sendRequest(req, bodyResp); httpErr != nil || err != nil {
+	if httpErr, err := c.sendRequest(req, bodyResp, false); httpErr != nil || err != nil {
 		if httpErr != nil {
 			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
 		}
@@ -97,7 +97,7 @@ func (c *Client) Logout(ctx context.Context) error {
 
 	req = req.WithContext(ctx)
 
-	if httpErr, err := c.sendRequest(req, nil); httpErr != nil || err != nil {
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
 		if httpErr != nil {
 			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
 		}
@@ -142,7 +142,7 @@ func (c *Client) CreateUser(
 	req = req.WithContext(ctx)
 	bodyResp := &CreateUserResponse{}
 
-	if httpErr, err := c.sendRequest(req, bodyResp); httpErr != nil || err != nil {
+	if httpErr, err := c.sendRequest(req, bodyResp, false); httpErr != nil || err != nil {
 		if httpErr != nil {
 			return nil, fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
 		}
@@ -173,7 +173,7 @@ func (c *Client) GetUser(ctx context.Context, userID uint) (*GetUserResponse, er
 
 	bodyResp := &GetUserResponse{}
 
-	if httpErr, err := c.sendRequest(req, bodyResp); httpErr != nil || err != nil {
+	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)
 		}
@@ -213,7 +213,7 @@ func (c *Client) DeleteUser(
 
 	req = req.WithContext(ctx)
 
-	if httpErr, err := c.sendRequest(req, nil); httpErr != nil || err != nil {
+	if httpErr, err := c.sendRequest(req, nil, true); httpErr != nil || err != nil {
 		if httpErr != nil {
 			return fmt.Errorf("code %d, errors %v", httpErr.Code, httpErr.Errors)
 		}

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

@@ -2,32 +2,12 @@ package api_test
 
 import (
 	"context"
-	"fmt"
-	"os"
 	"strings"
 	"testing"
 
-	"github.com/porter-dev/porter/cli/cmd/docker"
-
 	"github.com/porter-dev/porter/cli/cmd/api"
 )
 
-const baseURL string = "http://localhost:10000/api"
-
-func TestMain(m *testing.M) {
-	err := startPorterServerWithDocker("user", 10000, docker.SQLite)
-
-	if err != nil {
-		fmt.Printf("%v\n", err)
-		os.Exit(1)
-	}
-
-	code := m.Run()
-	stopPorterServerWithDocker("user")
-
-	os.Exit(code)
-}
-
 func initUser(email string, client *api.Client, t *testing.T) *api.CreateUserResponse {
 	t.Helper()
 

+ 5 - 0
cli/cmd/docker/agent.go

@@ -68,6 +68,11 @@ func (a *Agent) CreateLocalVolume(name string) (*types.Volume, error) {
 	return &vol, nil
 }
 
+// RemoveLocalVolume removes a volume by name
+func (a *Agent) RemoveLocalVolume(name string) error {
+	return a.client.VolumeRemove(a.ctx, name, true)
+}
+
 // CreateBridgeNetworkIfNotExist creates a volume using driver type "local" with the
 // given name if it does not exist. If the volume does exist but does not contain
 // the required label (a.label), an error is thrown.

+ 21 - 4
cli/cmd/docker/porter.go

@@ -389,8 +389,8 @@ func (a *Agent) startPostgresContainer(id string) error {
 }
 
 // StopPorterContainers finds all containers that were started via the CLI and stops them
-// without removal.
-func (a *Agent) StopPorterContainers() error {
+// -- removes the container if remove is set to true
+func (a *Agent) StopPorterContainers(remove bool) error {
 	fmt.Println("Stopping containers...")
 
 	containers, err := a.getContainersCreatedByStart()
@@ -408,14 +408,23 @@ func (a *Agent) StopPorterContainers() error {
 		if err != nil {
 			return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
 		}
+
+		if remove {
+			err = a.client.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
+
+			if err != nil {
+				return a.handleDockerClientErr(err, "Could not remove container "+container.ID)
+			}
+		}
 	}
 
 	return nil
 }
 
 // StopPorterContainersWithProcessID finds all containers that were started via the CLI
-// and have a given process id and stops them without removal.
-func (a *Agent) StopPorterContainersWithProcessID(processID string) error {
+// and have a given process id and stops them -- removes the container if remove is set
+// to true
+func (a *Agent) StopPorterContainersWithProcessID(processID string, remove bool) error {
 	fmt.Println("Stopping containers...")
 
 	containers, err := a.getContainersCreatedByStart()
@@ -434,6 +443,14 @@ func (a *Agent) StopPorterContainersWithProcessID(processID string) error {
 			if err != nil {
 				return a.handleDockerClientErr(err, "Could not stop container "+container.ID)
 			}
+
+			if remove {
+				err = a.client.ContainerRemove(a.ctx, container.ID, types.ContainerRemoveOptions{})
+
+				if err != nil {
+					return a.handleDockerClientErr(err, "Could not remove container "+container.ID)
+				}
+			}
 		}
 	}
 

+ 1 - 1
cli/cmd/start.go

@@ -111,7 +111,7 @@ func stop() error {
 		return err
 	}
 
-	err = agent.StopPorterContainersWithProcessID("main")
+	err = agent.StopPorterContainersWithProcessID("main", false)
 
 	if err != nil {
 		return err

+ 2 - 0
internal/kubernetes/kubeconfig.go

@@ -2,6 +2,7 @@ package kubernetes
 
 import (
 	"errors"
+	"fmt"
 	"strings"
 
 	"github.com/porter-dev/porter/internal/models"
@@ -72,6 +73,7 @@ func GetRawConfigFromBytes(kubeconfig []byte) (*api.Config, error) {
 	config, err := clientcmd.NewClientConfigFromBytes(kubeconfig)
 
 	if err != nil {
+		fmt.Println("ERROR IS HERE")
 		return nil, err
 	}
 

+ 7 - 7
internal/models/action.go

@@ -52,13 +52,13 @@ type ServiceAccountActionExternal struct {
 type ServiceAccountAllActions struct {
 	Name string `json:"name"`
 
-	ClusterCAData    string `json:"cluster_ca_data" form:"required"`
-	ClientCertData   string `json:"client_cert_data" form:"required"`
-	ClientKeyData    string `json:"client_key_data" form:"required"`
-	OIDCIssuerCAData string `json:"oidc_idp_issuer_ca_data" form:"required"`
-	TokenData        string `json:"token_data" form:"required"`
-	GCPKeyData       string `json:"gcp_key_data" form:"required"`
-	AWSKeyData       string `json:"aws_key_data" form:"required"`
+	ClusterCAData    string `json:"cluster_ca_data,omitempty"`
+	ClientCertData   string `json:"client_cert_data,omitempty"`
+	ClientKeyData    string `json:"client_key_data,omitempty"`
+	OIDCIssuerCAData string `json:"oidc_idp_issuer_ca_data,omitempty"`
+	TokenData        string `json:"token_data,omitempty"`
+	GCPKeyData       string `json:"gcp_key_data,omitempty"`
+	AWSKeyData       string `json:"aws_key_data,omitempty"`
 }
 
 // ServiceAccountActionInfo contains the information for actions to be

+ 2 - 0
internal/models/cluster.go

@@ -21,6 +21,7 @@ type Cluster struct {
 // ClusterExternal is the external cluster type to be sent over REST
 type ClusterExternal struct {
 	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"`
@@ -31,6 +32,7 @@ type ClusterExternal struct {
 func (c *Cluster) Externalize() *ClusterExternal {
 	return &ClusterExternal{
 		ServiceAccountID:      c.ServiceAccountID,
+		Name:                  c.Name,
 		Server:                c.Server,
 		TLSServerName:         c.TLSServerName,
 		InsecureSkipTLSVerify: c.InsecureSkipTLSVerify,

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

@@ -58,6 +58,8 @@ func (repo *ServiceAccountRepository) ReadServiceAccountCandidate(
 		return nil, err
 	}
 
+	repo.DecryptServiceAccountCandidateData(saCandidate, repo.key)
+
 	return saCandidate, nil
 }
 
@@ -72,6 +74,10 @@ func (repo *ServiceAccountRepository) ListServiceAccountCandidatesByProjectID(
 		return nil, err
 	}
 
+	for _, saCandidate := range saCandidates {
+		repo.DecryptServiceAccountCandidateData(saCandidate, repo.key)
+	}
+
 	return saCandidates, nil
 }
 
@@ -115,6 +121,8 @@ func (repo *ServiceAccountRepository) ReadServiceAccount(
 		return nil, err
 	}
 
+	repo.DecryptServiceAccountData(sa, repo.key)
+
 	return sa, nil
 }
 
@@ -129,6 +137,10 @@ func (repo *ServiceAccountRepository) ListServiceAccountsByProjectID(
 		return nil, err
 	}
 
+	for _, sa := range sas {
+		repo.DecryptServiceAccountData(sa, repo.key)
+	}
+
 	return sas, nil
 }
 
@@ -302,3 +314,174 @@ func (repo *ServiceAccountRepository) EncryptServiceAccountCandidateData(
 
 	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 sa.Token != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.Token), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.Token = string(plaintext)
+	}
+
+	if sa.Username != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.Username), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.Username = string(plaintext)
+	}
+
+	if sa.Password != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.Password), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.Password = string(plaintext)
+	}
+
+	if len(sa.KeyData) > 0 {
+		plaintext, err := repository.Decrypt(sa.KeyData, key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.KeyData = plaintext
+	}
+
+	if sa.PrevToken != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.PrevToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.PrevToken = string(plaintext)
+	}
+
+	if sa.OIDCIssuerURL != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.OIDCIssuerURL), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCIssuerURL = string(plaintext)
+	}
+
+	if sa.OIDCClientID != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.OIDCClientID), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCClientID = string(plaintext)
+	}
+
+	if sa.OIDCClientSecret != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.OIDCClientSecret), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCClientSecret = string(plaintext)
+	}
+
+	if sa.OIDCCertificateAuthorityData != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.OIDCCertificateAuthorityData), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCCertificateAuthorityData = string(plaintext)
+	}
+
+	if sa.OIDCIDToken != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.OIDCIDToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCIDToken = string(plaintext)
+	}
+
+	if sa.OIDCRefreshToken != "" {
+		plaintext, err := repository.Decrypt([]byte(sa.OIDCRefreshToken), key)
+
+		if err != nil {
+			return err
+		}
+
+		sa.OIDCRefreshToken = string(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
+}

+ 1 - 28
internal/repository/gorm/serviceaccount_test.go

@@ -3,8 +3,6 @@ package gorm_test
 import (
 	"testing"
 
-	"github.com/porter-dev/porter/internal/repository"
-
 	"github.com/go-test/deep"
 	"github.com/porter-dev/porter/internal/models"
 	orm "gorm.io/gorm"
@@ -114,10 +112,6 @@ func TestCreateServiceAccountCandidate(t *testing.T) {
 
 	// reset fields for reflect.DeepEqual
 	copySACandidate.Model = orm.Model{}
-	copySACandidate.Kubeconfig, _ = repository.Decrypt(
-		copySACandidate.Kubeconfig,
-		tester.key,
-	)
 
 	if diff := deep.Equal(copySACandidate, expSACandidate); diff != nil {
 		t.Errorf("incorrect sa candidate")
@@ -177,10 +171,7 @@ func TestCreateServiceAccountCandidateWithAction(t *testing.T) {
 
 	// reset fields for reflect.DeepEqual
 	copySACandidate.Model = orm.Model{}
-	copySACandidate.Kubeconfig, _ = repository.Decrypt(
-		copySACandidate.Kubeconfig,
-		tester.key,
-	)
+
 	copySACandidate.Actions[0].Model = orm.Model{}
 
 	if diff := deep.Equal(copySACandidate, expSACandidate); diff != nil {
@@ -232,10 +223,6 @@ func TestListServiceAccountCandidatesByProjectID(t *testing.T) {
 
 	// reset fields for reflect.DeepEqual
 	copySACandidate.Model = orm.Model{}
-	copySACandidate.Kubeconfig, _ = repository.Decrypt(
-		copySACandidate.Kubeconfig,
-		tester.key,
-	)
 	copySACandidate.Actions[0].Model = orm.Model{}
 
 	if diff := deep.Equal(copySACandidate, expSACandidate); diff != nil {
@@ -292,8 +279,6 @@ func TestCreateServiceAccount(t *testing.T) {
 
 	// reset fields for reflect.DeepEqual
 	copySA.Model = orm.Model{}
-	copySA.ClientCertificateData, _ = repository.Decrypt(copySA.ClientCertificateData, tester.key)
-	copySA.ClientKeyData, _ = repository.Decrypt(copySA.ClientKeyData, tester.key)
 
 	if diff := deep.Equal(copySA, expSA); diff != nil {
 		t.Errorf("incorrect service account")
@@ -353,13 +338,7 @@ func TestCreateServiceAccountWithCluster(t *testing.T) {
 
 	// reset fields for reflect.DeepEqual
 	copySA.Model = orm.Model{}
-	copySA.ClientCertificateData, _ = repository.Decrypt(copySA.ClientCertificateData, tester.key)
-	copySA.ClientKeyData, _ = repository.Decrypt(copySA.ClientKeyData, tester.key)
 	copySA.Clusters[0].Model = orm.Model{}
-	copySA.Clusters[0].CertificateAuthorityData, _ = repository.Decrypt(
-		copySA.Clusters[0].CertificateAuthorityData,
-		tester.key,
-	)
 
 	if diff := deep.Equal(copySA, expSA); diff != nil {
 		t.Errorf("incorrect service account")
@@ -410,13 +389,7 @@ func TestListServiceAccountsByProjectID(t *testing.T) {
 
 	// reset fields for reflect.DeepEqual
 	copySA.Model = orm.Model{}
-	copySA.ClientCertificateData, _ = repository.Decrypt(copySA.ClientCertificateData, tester.key)
-	copySA.ClientKeyData, _ = repository.Decrypt(copySA.ClientKeyData, tester.key)
 	copySA.Clusters[0].Model = orm.Model{}
-	copySA.Clusters[0].CertificateAuthorityData, _ = repository.Decrypt(
-		copySA.Clusters[0].CertificateAuthorityData,
-		tester.key,
-	)
 
 	if diff := deep.Equal(copySA, expSA); diff != nil {
 		t.Errorf("incorrect service account")

+ 7 - 0
server/api/project_handler.go

@@ -74,6 +74,13 @@ func (app *App) HandleCreateProject(w http.ResponseWriter, r *http.Request) {
 	app.logger.Info().Msgf("New project created: %d", projModel.ID)
 
 	w.WriteHeader(http.StatusCreated)
+
+	projExt := projModel.Externalize()
+
+	if err := json.NewEncoder(w).Encode(projExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
 }
 
 // HandleReadProject returns an externalized Project (models.ProjectExternal)

+ 3 - 3
server/api/project_handler_test.go

@@ -81,10 +81,10 @@ var createProjectTests = []*projTest{
 			"name": "project-test"
 		}`,
 		expStatus: http.StatusCreated,
-		expBody:   ``,
+		expBody:   `{"id":1,"name":"project-test","roles":[{"id":0,"kind":"admin","user_id":1,"project_id":1}]}`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
-			projectBasicBodyValidator,
+			projectModelBodyValidator,
 		},
 	},
 }
@@ -230,7 +230,7 @@ var resolveProjectSACandidatesTests = []*projTest{
 		endpoint:  "/api/projects/1/candidates/1/resolve",
 		body:      `[{"name": "upload-oidc-idp-issuer-ca-data", "oidc_idp_issuer_ca_data": "LS0tLS1CRUdJTiBDRVJ="}]`,
 		expStatus: http.StatusCreated,
-		expBody:   `{"id":1,"project_id":1,"kind":"connector","clusters":[{"service_account_id":1,"server":"https://localhost"}],"auth_mechanism":"oidc"}`,
+		expBody:   `{"id":1,"project_id":1,"kind":"connector","clusters":[{"service_account_id":1,"name":"cluster-test","server":"https://localhost"}],"auth_mechanism":"oidc"}`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
 			projectSABodyValidator,