Browse Source

implements #91: gke support

Alexander Belanger 5 years ago
parent
commit
a4e247f636

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

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

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

@@ -167,6 +167,10 @@ func TestCreateProjectCandidates(t *testing.T) {
 	if resp[0].Actions[0].Name != models.OIDCIssuerDataAction {
 		t.Errorf("action name incorrect: expected %s, got %s\n", models.OIDCIssuerDataAction, resp[0].Actions[0].Name)
 	}
+
+	if resp[0].Actions[0].Filename != "/fake/path/to/ca.pem" {
+		t.Errorf("action filename incorrect: expected %s, got %s\n", "/fake/path/to/ca.pem", resp[0].Actions[0].Filename)
+	}
 }
 
 func TestGetProjectCandidates(t *testing.T) {
@@ -216,6 +220,10 @@ func TestGetProjectCandidates(t *testing.T) {
 	if resp[0].Actions[0].Name != models.OIDCIssuerDataAction {
 		t.Errorf("action name incorrect: expected %s, got %s\n", models.OIDCIssuerDataAction, resp[0].Actions[0].Name)
 	}
+
+	if resp[0].Actions[0].Filename != "/fake/path/to/ca.pem" {
+		t.Errorf("action filename incorrect: expected %s, got %s\n", "/fake/path/to/ca.pem", resp[0].Actions[0].Filename)
+	}
 }
 
 func TestCreateProjectServiceAccount(t *testing.T) {

+ 0 - 24
cli/cmd/generate.go

@@ -5,9 +5,6 @@ import (
 	"path/filepath"
 
 	"github.com/porter-dev/porter/internal/kubernetes/local"
-	"github.com/porter-dev/porter/internal/utils"
-
-	gcpLocal "github.com/porter-dev/porter/internal/providers/gcp/local"
 
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/util/homedir"
@@ -99,24 +96,3 @@ func generate(kubeconfigPath string, output string, print bool, contexts []strin
 
 	return nil
 }
-
-// TODO -- error handling, stop hard-coding, ask for permissions
-func gcpHelper() {
-	agent, _ := gcpLocal.NewDefaultAgent()
-
-	agent.ProjectID = "PROJECT_ID"
-	name := "porter-dashboard-" + utils.StringWithCharset(6, "abcdefghijklmnopqrstuvwxyz1234567890")
-	resp, err := agent.CreateServiceAccount(name)
-
-	if err != nil {
-		fmt.Printf("Error was %v\n", err)
-		return
-	}
-
-	err = agent.SetServiceAccountIAMPolicy(resp)
-
-	if err != nil {
-		fmt.Printf("Error was %v\n", err)
-		return
-	}
-}

+ 72 - 0
cli/cmd/login.go

@@ -0,0 +1,72 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/spf13/cobra"
+)
+
+var (
+	host string
+)
+
+// loginCmd represents the login command
+var loginCmd = &cobra.Command{
+	Use:   "login",
+	Short: "Authorizes a user for a given Porter server",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := login(host)
+
+		if err != nil {
+			fmt.Println("Error logging in:", err.Error())
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(loginCmd)
+
+	loginCmd.PersistentFlags().StringVar(
+		&host,
+		"host",
+		"http://localhost:10000",
+		"host url of Porter instance",
+	)
+}
+
+func login(host string) error {
+	var username, pw string
+
+	fmt.Println("Please log in with an email and password:")
+
+	username, err := promptPlaintext("Email: ")
+
+	if err != nil {
+		return err
+	}
+
+	pw, err = promptPasswordWithConfirmation()
+
+	if err != nil {
+		return err
+	}
+
+	client := api.NewClient(host+"/api", "cookie.json")
+
+	_, err = client.Login(context.Background(), &api.LoginRequest{
+		Email:    username,
+		Password: pw,
+	})
+
+	if err != nil {
+		return err
+	}
+
+	fmt.Println("Successfully logged in!")
+
+	return nil
+}

+ 92 - 0
cli/cmd/project.go

@@ -0,0 +1,92 @@
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strconv"
+
+	"github.com/spf13/viper"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/spf13/cobra"
+)
+
+// projectCmd represents the "porter project" base command when called
+// without any subcommands
+var projectCmd = &cobra.Command{
+	Use:   "project",
+	Short: "The commands that can be run for a project",
+}
+
+var createProjectCmd = &cobra.Command{
+	Use:   "create [name]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Creates a project with the authorized user as admin",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := createProject(host, args[0])
+
+		if err != nil {
+			fmt.Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
+var setProjectCmd = &cobra.Command{
+	Use:   "set [id]",
+	Args:  cobra.ExactArgs(1),
+	Short: "Sets the current project as the project id.",
+	Run: func(cmd *cobra.Command, args []string) {
+		projID, err := strconv.ParseUint(args[0], 10, 64)
+
+		if err != nil {
+			fmt.Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+
+		err = setProject(uint(projID))
+
+		if err != nil {
+			fmt.Printf("An error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(projectCmd)
+
+	projectCmd.AddCommand(createProjectCmd)
+
+	createProjectCmd.PersistentFlags().StringVar(
+		&host,
+		"host",
+		"http://localhost:10000",
+		"host url of Porter instance",
+	)
+
+	projectCmd.AddCommand(setProjectCmd)
+}
+
+func createProject(host string, name string) error {
+	client := api.NewClient(host+"/api", "cookie.json")
+
+	resp, err := client.CreateProject(context.Background(), &api.CreateProjectRequest{
+		Name: name,
+	})
+
+	if err != nil {
+		return err
+	}
+
+	fmt.Printf("Created project with name %s and id %d\n", name, resp.ID)
+
+	return setProject(resp.ID)
+}
+
+func setProject(id uint) error {
+	fmt.Printf("Set the current project id as %d\n", id)
+	viper.Set("project", id)
+	return viper.WriteConfig()
+}

+ 28 - 0
cli/cmd/root.go

@@ -2,9 +2,13 @@ package cmd
 
 import (
 	"fmt"
+	"io/ioutil"
 	"os"
+	"path/filepath"
 
 	"github.com/spf13/cobra"
+	"github.com/spf13/viper"
+	"k8s.io/client-go/util/homedir"
 )
 
 // rootCmd represents the base command when called without any subcommands
@@ -14,9 +18,33 @@ var rootCmd = &cobra.Command{
 	Long:  `Porter is a tool for creating, versioning, and updating Kubernetes deployments using a visual dashboard. For more information, visit github.com/porter-dev/porter`,
 }
 
+var home = homedir.HomeDir()
+
 // Execute adds all child commands to the root command and sets flags appropriately.
 // This is called by main.main(). It only needs to happen once to the rootCmd.
 func Execute() {
+	viper.SetConfigName("porter")
+	viper.SetConfigType("yaml")
+	viper.AddConfigPath(filepath.Join(home, ".porter"))
+
+	err := viper.ReadInConfig()
+
+	if err != nil {
+		if _, ok := err.(viper.ConfigFileNotFoundError); ok {
+			// create blank config file
+			err := ioutil.WriteFile(filepath.Join(home, ".porter", "porter.yaml"), []byte{}, 0644)
+
+			if err != nil {
+				fmt.Printf("%v\n", err)
+				os.Exit(1)
+			}
+		} else {
+			// Config file was found but another error was produced
+			fmt.Printf("%v\n", err)
+			os.Exit(1)
+		}
+	}
+
 	if err := rootCmd.Execute(); err != nil {
 		fmt.Println(err)
 		os.Exit(1)

+ 303 - 0
cli/cmd/setconfig.go

@@ -0,0 +1,303 @@
+package cmd
+
+import (
+	"context"
+	"encoding/base64"
+	"fmt"
+	"io/ioutil"
+	"os"
+
+	"github.com/porter-dev/porter/internal/kubernetes/local"
+	gcpLocal "github.com/porter-dev/porter/internal/providers/gcp/local"
+	"github.com/porter-dev/porter/internal/utils"
+
+	"github.com/spf13/viper"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/spf13/cobra"
+)
+
+var setConfigCmd = &cobra.Command{
+	Use:   "set-config",
+	Short: "Uses the local kubeconfig to set the configuration for a cluster",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := setConfig()
+
+		if err != nil {
+			fmt.Printf("Error occurred: %v\n", err)
+			os.Exit(1)
+		}
+	},
+}
+
+func init() {
+	rootCmd.AddCommand(setConfigCmd)
+
+	setConfigCmd.PersistentFlags().StringVarP(
+		&kubeconfigPath,
+		"kubeconfig",
+		"k",
+		"",
+		"path to kubeconfig",
+	)
+
+	setConfigCmd.PersistentFlags().StringVar(
+		&host,
+		"host",
+		"http://localhost:10000",
+		"host url of Porter instance",
+	)
+
+	contexts = setConfigCmd.PersistentFlags().StringArray(
+		"contexts",
+		nil,
+		"the list of contexts to use (defaults to the current context)",
+	)
+}
+
+func setConfig() error {
+	// TODO: construct the kubeconfig based on the passed contexts
+
+	// get the current project ID
+	projectID := viper.GetUint("project")
+
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	// get the kubeconfig
+	rawBytes, err := local.GetKubeconfigFromHost(kubeconfigPath, *contexts)
+
+	if err != nil {
+		return err
+	}
+
+	// send kubeconfig to client
+	client := api.NewClient(host+"/api", "cookie.json")
+
+	saCandidates, err := client.CreateProjectCandidates(
+		context.Background(),
+		projectID,
+		&api.CreateProjectCandidatesRequest{
+			Kubeconfig: string(rawBytes),
+		},
+	)
+
+	if err != nil {
+		return err
+	}
+
+	for _, saCandidate := range saCandidates {
+		resolvers := make(api.CreateProjectServiceAccountRequest, 0)
+
+		for _, action := range saCandidate.Actions {
+			switch action.Name {
+			case models.ClusterCADataAction:
+				resolveAction, err := resolveClusterCAAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.ClientCertDataAction:
+				resolveAction, err := resolveClientCertAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.ClientKeyDataAction:
+				resolveAction, err := resolveClientKeyAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.OIDCIssuerDataAction:
+				resolveAction, err := resolveOIDCIssuerAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.TokenDataAction:
+				resolveAction, err := resolveTokenDataAction(action.Filename)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.GCPKeyDataAction:
+				resolveAction, err := resolveGCPKeyAction(saCandidate.ClusterEndpoint)
+
+				if err != nil {
+					return err
+				}
+
+				resolvers = append(resolvers, resolveAction)
+			case models.AWSKeyDataAction:
+			}
+		}
+
+		sa, err := client.CreateProjectServiceAccount(
+			context.Background(),
+			projectID,
+			saCandidate.ID,
+			resolvers,
+		)
+
+		if err != nil {
+			return err
+		}
+
+		for _, cluster := range sa.Clusters {
+			fmt.Printf("created service account for cluster %s with id %d\n", cluster.Name, sa.ID)
+
+			// sanity check to ensure it's working
+			// namespaces, err := client.GetK8sNamespaces(
+			// 	context.Background(),
+			// 	projectID,
+			// 	saCandidate.ID,
+			// 	cluster.ID,
+			// )
+
+			// if err != nil {
+			// 	return err
+			// }
+
+			// for _, ns := range namespaces.Items {
+			// 	fmt.Println(ns.ObjectMeta.GetName())
+			// }
+		}
+	}
+
+	return nil
+}
+
+// resolves a cluster ca data action
+func resolveClusterCAAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:          models.ClusterCADataAction,
+		ClusterCAData: base64.StdEncoding.EncodeToString(fileBytes),
+	}, nil
+}
+
+// resolves a client cert data action
+func resolveClientCertAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:           models.ClientCertDataAction,
+		ClientCertData: base64.StdEncoding.EncodeToString(fileBytes),
+	}, nil
+}
+
+// resolves a client key data action
+func resolveClientKeyAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:          models.ClientKeyDataAction,
+		ClientKeyData: base64.StdEncoding.EncodeToString(fileBytes),
+	}, nil
+}
+
+// resolves an oidc issuer data action
+func resolveOIDCIssuerAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:             models.OIDCIssuerDataAction,
+		OIDCIssuerCAData: base64.StdEncoding.EncodeToString(fileBytes),
+	}, nil
+}
+
+// resolves a token data action
+func resolveTokenDataAction(
+	filename string,
+) (*models.ServiceAccountAllActions, error) {
+	fileBytes, err := ioutil.ReadFile(filename)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:      models.TokenDataAction,
+		TokenData: string(fileBytes),
+	}, nil
+}
+
+// resolves a gcp key data action
+func resolveGCPKeyAction(endpoint string) (*models.ServiceAccountAllActions, error) {
+	agent, _ := gcpLocal.NewDefaultAgent()
+	projID, err := agent.GetProjectIDForGKECluster(endpoint)
+
+	if err != nil {
+		return nil, err
+	}
+
+	agent.ProjectID = projID
+
+	name := "porter-dashboard-" + utils.StringWithCharset(6, "abcdefghijklmnopqrstuvwxyz1234567890")
+
+	// create the service account and give it the correct iam permissions
+	resp, err := agent.CreateServiceAccount(name)
+
+	if err != nil {
+		return nil, err
+	}
+
+	err = agent.SetServiceAccountIAMPolicy(resp)
+
+	if err != nil {
+		return nil, err
+	}
+
+	// get the service account key data to send to the server
+	bytes, err := agent.CreateServiceAccountKey(resp)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.ServiceAccountAllActions{
+		Name:       models.GCPKeyDataAction,
+		GCPKeyData: string(bytes),
+	}, nil
+}
+
+// resolves an aws key data action

+ 1 - 5
cli/cmd/start.go

@@ -35,8 +35,6 @@ var startCmd = &cobra.Command{
 	Use:   "start",
 	Short: "Starts a Porter instance using the Docker engine.",
 	Run: func(cmd *cobra.Command, args []string) {
-		closeHandler(stop)
-
 		err := start(
 			opts.imageTag,
 			opts.kubeconfigPath,
@@ -194,15 +192,13 @@ func start(
 		Env:            env,
 	}
 
-	agent, id, err := docker.StartPorter(startOpts)
+	_, _, err = docker.StartPorter(startOpts)
 
 	fmt.Println("Spinning up the server...")
 	time.Sleep(7 * time.Second)
 	openBrowser(fmt.Sprintf("http://localhost:%d/login?email=%s", port, username))
 	fmt.Printf("Server ready: listening on localhost:%d\n", port)
 
-	agent.WaitForContainerStop(id)
-
 	return nil
 }
 

+ 1 - 0
go.mod

@@ -37,6 +37,7 @@ require (
 	github.com/rs/zerolog v1.20.0
 	github.com/sirupsen/logrus v1.7.0
 	github.com/spf13/cobra v1.0.0
+	github.com/spf13/viper v1.4.0
 	github.com/stretchr/testify v1.6.1
 	golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
 	golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43

+ 7 - 0
go.sum

@@ -469,6 +469,7 @@ github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA
 github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
 github.com/hashicorp/golang-lru v0.5.1 h1:0hERBMJE1eitiLkihrMvRVBYAkpHzc/J3QdDN+dAcgU=
 github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
+github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
 github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
 github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
@@ -605,6 +606,7 @@ github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-b
 github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
 github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc=
 github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
+github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
 github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
 github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
 github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -656,6 +658,7 @@ github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUb
 github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
 github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
 github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
+github.com/mitchellh/mapstructure v1.1.2 h1:fmNYVwqnSfB9mZU6OS2O6GsXM+wcskZDuKQzvN1EDeE=
 github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A=
 github.com/mitchellh/reflectwalk v1.0.0 h1:9D+8oIskB4VJBN5SFlmc27fSlIBZaov1Wpk/IfikLNY=
@@ -721,6 +724,7 @@ github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FI
 github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
 github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys=
+github.com/pelletier/go-toml v1.8.1 h1:1Nf83orprkJyknT6h7zbuEGUEjcyVlCxSUGTENmNCRM=
 github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc=
 github.com/performancecopilot/speed v3.0.0+incompatible/go.mod h1:/CLtqpZ5gBg1M9iaPbIdPPGyKcA8hKdoy6hAWba7Yac=
 github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
@@ -808,6 +812,7 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k
 github.com/sony/gobreaker v0.4.1/go.mod h1:ZKptC7FHNvhBz7dN2LGjPVBz2sZJmc0/PkyDJOjmxWY=
 github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
 github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
+github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc=
 github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
@@ -817,6 +822,7 @@ github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3
 github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
 github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
 github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
+github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 github.com/spf13/pflag v0.0.0-20170130214245-9ff6c6923cff/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
@@ -825,6 +831,7 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
+github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=
 github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
 github.com/streadway/amqp v0.0.0-20190404075320-75d898a42a94/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=
 github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3DKn8O/DFsRAY58/XVQiIPMTMB1SddzLXVw=

+ 19 - 2
internal/kubernetes/config.go

@@ -64,8 +64,9 @@ type OutOfClusterConfig struct {
 	ClusterID      uint                   `json:"cluster_id" form:"required"`
 }
 
-// ToRESTConfig creates a kubernetes REST client factory -- it simply calls ClientConfig on
-// the result of ToRawKubeConfigLoader
+// ToRESTConfig creates a kubernetes REST client factory -- it calls ClientConfig on
+// the result of ToRawKubeConfigLoader, and also adds a custom http transport layer
+// if necessary (required for GCP auth)
 func (conf *OutOfClusterConfig) ToRESTConfig() (*rest.Config, error) {
 	restConf, err := conf.ToRawKubeConfigLoader().ClientConfig()
 
@@ -73,6 +74,22 @@ func (conf *OutOfClusterConfig) ToRESTConfig() (*rest.Config, error) {
 		return nil, err
 	}
 
+	// if conf.ServiceAccount.AuthMechanism == models.GCP {
+	// 	creds, err := google.CredentialsFromJSON(
+	// 		context.Background(),
+	// 		conf.ServiceAccount.KeyData,
+	// 		"https://www.googleapis.com/auth/cloud-platform",
+	// 	)
+
+	// 	if err != nil {
+	// 		return nil, err
+	// 	}
+
+	// 	restConf.Transport = &oauth2.Transport{
+	// 		Source: creds.TokenSource,
+	// 	}
+	// }
+
 	rest.SetKubernetesDefaults(restConf)
 	return restConf, nil
 }

+ 30 - 6
internal/kubernetes/kubeconfig.go

@@ -1,11 +1,13 @@
 package kubernetes
 
 import (
+	"context"
 	"errors"
 	"fmt"
 	"strings"
 
 	"github.com/porter-dev/porter/internal/models"
+	"golang.org/x/oauth2/google"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/client-go/tools/clientcmd/api"
 )
@@ -103,6 +105,7 @@ func parseAuthInfoForActions(authInfo *api.AuthInfo) (authMechanism string, acti
 			actions = append(actions, models.ServiceAccountAction{
 				Name:     models.ClientCertDataAction,
 				Resolved: false,
+				Filename: authInfo.ClientCertificate,
 			})
 		}
 
@@ -110,6 +113,7 @@ func parseAuthInfoForActions(authInfo *api.AuthInfo) (authMechanism string, acti
 			actions = append(actions, models.ServiceAccountAction{
 				Name:     models.ClientKeyDataAction,
 				Resolved: false,
+				Filename: authInfo.ClientKey,
 			})
 		}
 
@@ -119,7 +123,7 @@ func parseAuthInfoForActions(authInfo *api.AuthInfo) (authMechanism string, acti
 	if authInfo.AuthProvider != nil {
 		switch authInfo.AuthProvider.Name {
 		case "oidc":
-			_, isFile := authInfo.AuthProvider.Config["idp-certificate-authority"]
+			filename, isFile := authInfo.AuthProvider.Config["idp-certificate-authority"]
 			data, isData := authInfo.AuthProvider.Config["idp-certificate-authority-data"]
 
 			if isFile && (!isData || data == "") {
@@ -127,6 +131,7 @@ func parseAuthInfoForActions(authInfo *api.AuthInfo) (authMechanism string, acti
 					models.ServiceAccountAction{
 						Name:     models.OIDCIssuerDataAction,
 						Resolved: false,
+						Filename: filename,
 					},
 				}
 			}
@@ -159,6 +164,7 @@ func parseAuthInfoForActions(authInfo *api.AuthInfo) (authMechanism string, acti
 				models.ServiceAccountAction{
 					Name:     models.TokenDataAction,
 					Resolved: false,
+					Filename: authInfo.TokenFile,
 				},
 			}
 		}
@@ -183,6 +189,7 @@ func parseClusterForActions(cluster *api.Cluster) (actions []models.ServiceAccou
 			models.ServiceAccountAction{
 				Name:     models.ClusterCADataAction,
 				Resolved: false,
+				Filename: cluster.CertificateAuthority,
 			},
 		}
 	}
@@ -277,9 +284,12 @@ func createRawConfigFromServiceAccount(
 	authInfoMap := make(map[string]*api.AuthInfo)
 
 	authInfoMap[authInfoName] = &api.AuthInfo{
-		LocationOfOrigin:  sa.LocationOfOrigin,
-		Impersonate:       sa.Impersonate,
-		ImpersonateGroups: strings.Split(sa.ImpersonateGroups, ","),
+		LocationOfOrigin: sa.LocationOfOrigin,
+		Impersonate:      sa.Impersonate,
+	}
+
+	if groups := strings.Split(sa.ImpersonateGroups, ","); len(groups) > 0 && groups[0] != "" {
+		authInfoMap[authInfoName].ImpersonateGroups = groups
 	}
 
 	switch sa.AuthMechanism {
@@ -303,10 +313,24 @@ func createRawConfigFromServiceAccount(
 				"refresh-token":                  sa.OIDCRefreshToken,
 			},
 		}
+	// we'll add a bearer token here for now
 	case models.GCP:
-		return nil, errors.New("gcp unimplemented")
+		creds, err := google.CredentialsFromJSON(
+			context.Background(),
+			sa.KeyData,
+			"https://www.googleapis.com/auth/cloud-platform",
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		tok, err := creds.TokenSource.Token()
+
+		authInfoMap[authInfoName].Token = tok.AccessToken
 	case models.AWS:
-		return nil, errors.New("gcp unimplemented")
+	default:
+		return nil, errors.New("not a supported auth mechanism")
 	}
 
 	// create a context of the cluster name

+ 12 - 0
internal/kubernetes/kubeconfig_test.go

@@ -182,6 +182,7 @@ var SACandidatesTests = []saCandidatesTest{
 					models.ServiceAccountAction{
 						Name:     "upload-cluster-ca-data",
 						Resolved: false,
+						Filename: "/fake/path/to/ca.pem",
 					},
 				},
 				Kind:            "connector",
@@ -215,6 +216,7 @@ var SACandidatesTests = []saCandidatesTest{
 					models.ServiceAccountAction{
 						Name:     "upload-client-cert-data",
 						Resolved: false,
+						Filename: "/fake/path/to/cert.pem",
 					},
 				},
 				Kind:            "connector",
@@ -234,6 +236,7 @@ var SACandidatesTests = []saCandidatesTest{
 					models.ServiceAccountAction{
 						Name:     "upload-client-key-data",
 						Resolved: false,
+						Filename: "/fake/path/to/key.pem",
 					},
 				},
 				Kind:            "connector",
@@ -253,10 +256,12 @@ var SACandidatesTests = []saCandidatesTest{
 					models.ServiceAccountAction{
 						Name:     "upload-client-cert-data",
 						Resolved: false,
+						Filename: "/fake/path/to/cert.pem",
 					},
 					models.ServiceAccountAction{
 						Name:     "upload-client-key-data",
 						Resolved: false,
+						Filename: "/fake/path/to/key.pem",
 					},
 				},
 				Kind:            "connector",
@@ -290,6 +295,7 @@ var SACandidatesTests = []saCandidatesTest{
 					models.ServiceAccountAction{
 						Name:     "upload-token-data",
 						Resolved: false,
+						Filename: "/path/to/token/file.txt",
 					},
 				},
 				Kind:            "connector",
@@ -366,6 +372,7 @@ var SACandidatesTests = []saCandidatesTest{
 					models.ServiceAccountAction{
 						Name:     "upload-oidc-idp-issuer-ca-data",
 						Resolved: false,
+						Filename: "/fake/path/to/ca.pem",
 					},
 				},
 				Kind:            "connector",
@@ -454,6 +461,11 @@ func TestGetServiceAccountCandidates(t *testing.T) {
 						t.Errorf("%s failed on action names: expected res to contain %s, got %s\n",
 							c.name, action.Name, res.Actions[i].Name)
 					}
+
+					if res.Actions[i].Filename != action.Filename {
+						t.Errorf("%s failed on action file names: expected res to contain %s, got %s\n",
+							c.name, action.Filename, res.Actions[i].Filename)
+					}
 				}
 			}
 

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

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

+ 6 - 0
internal/models/action.go

@@ -23,6 +23,10 @@ type ServiceAccountAction struct {
 	// One of the constant action names
 	Name     string `json:"name"`
 	Resolved bool   `json:"resolved"`
+
+	// Filename is an optional filename, if the action requires
+	// data populated from a local file
+	Filename string `json:"filename,omitempty"`
 }
 
 // Externalize generates an external ServiceAccount to be shared over REST
@@ -32,6 +36,7 @@ func (u *ServiceAccountAction) Externalize() *ServiceAccountActionExternal {
 	return &ServiceAccountActionExternal{
 		Name:     u.Name,
 		Resolved: u.Resolved,
+		Filename: u.Filename,
 		Docs:     info.Docs,
 		Fields:   info.Fields,
 	}
@@ -44,6 +49,7 @@ type ServiceAccountActionExternal struct {
 	Docs     string `json:"docs"`
 	Resolved bool   `json:"resolved"`
 	Fields   string `json:"fields"`
+	Filename string `json:"filename,omitempty"`
 }
 
 // ServiceAccountAllActions is a helper type that contains the fields for

+ 2 - 0
internal/models/cluster.go

@@ -20,6 +20,7 @@ type Cluster struct {
 
 // ClusterExternal is the external cluster type to be sent over REST
 type ClusterExternal struct {
+	ID                    uint   `json:"id"`
 	ServiceAccountID      uint   `json:"service_account_id"`
 	Name                  string `json:"name"`
 	Server                string `json:"server"`
@@ -31,6 +32,7 @@ type ClusterExternal struct {
 // Externalize generates an external Cluster to be shared over REST
 func (c *Cluster) Externalize() *ClusterExternal {
 	return &ClusterExternal{
+		ID:                    c.Model.ID,
 		ServiceAccountID:      c.ServiceAccountID,
 		Name:                  c.Name,
 		Server:                c.Server,

+ 84 - 0
internal/providers/gcp/agent.go

@@ -2,11 +2,14 @@ package gcp
 
 import (
 	"context"
+	"fmt"
+	"net/url"
 
 	admin "cloud.google.com/go/iam/admin/apiv1"
 	adminpb "google.golang.org/genproto/googleapis/iam/admin/v1"
 
 	crm "google.golang.org/api/cloudresourcemanager/v1"
+	gke "google.golang.org/api/container/v1"
 )
 
 type Agent struct {
@@ -15,6 +18,7 @@ type Agent struct {
 
 	IAMClient                   *admin.IamClient
 	CloudResourceManagerService *crm.Service
+	GKEService                  *gke.Service
 }
 
 func (a *Agent) CreateServiceAccount(name string) (*adminpb.ServiceAccount, error) {
@@ -72,3 +76,83 @@ func (a *Agent) SetServiceAccountIAMPolicy(sa *adminpb.ServiceAccount) error {
 
 	return nil
 }
+
+type ServiceAccountKey struct {
+	// set to service_account
+	Type         string `json:"type"`
+	ProjectID    string `json:"project_id"`
+	PrivateKeyID string `json:"private_key_id"`
+	// the private key, not base64 encoded
+	PrivateKey              string `json:"private_key"`
+	ClientEmail             string `json:"client_email"`
+	ClientID                string `json:"client_id"`
+	AuthURI                 string `json:"auth_uri"`
+	TokenURI                string `json:"token_uri"`
+	AuthProviderX509CertURL string `json:"auth_provider_x509_cert_url"`
+	ClientX509CertURL       string `json:"client_x509_cert_url"`
+}
+
+// CreateServiceAccountKey will create a new key for the specified service account
+func (a *Agent) CreateServiceAccountKey(sa *adminpb.ServiceAccount) ([]byte, error) {
+	req := &adminpb.CreateServiceAccountKeyRequest{
+		Name: "projects/" + a.ProjectID + "/serviceAccounts/" + sa.Email,
+	}
+
+	resp, err := a.IAMClient.CreateServiceAccountKey(a.Ctx, req)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return resp.GetPrivateKeyData(), nil
+}
+
+// GetProjectIDForGKECluster automatically determines the project ID for a cluster
+// that a user has access to
+func (a *Agent) GetProjectIDForGKECluster(endpoint string) (string, error) {
+	// get a list of project IDs
+	projectSvc := a.CloudResourceManagerService.Projects
+
+	resp, err := projectSvc.List().Do()
+
+	if err != nil {
+		return "", err
+	}
+
+	projectIDs := make([]string, 0)
+
+	for _, project := range resp.Projects {
+		projectIDs = append(projectIDs, project.ProjectId)
+	}
+
+	// parse endpoint for ip address
+	u, err := url.Parse(endpoint)
+
+	if err != nil {
+		return "", err
+	}
+
+	ipAddr := u.Hostname()
+
+	// iterate through the projects, and get the GKE endpoints for each project
+	// if there's a match, return that project id
+	for _, projectID := range projectIDs {
+		projectsLocsService := a.GKEService.Projects.Locations
+
+		// this should be all zones
+		resp, err := projectsLocsService.Clusters.List("projects/" + projectID + "/locations/-").Do()
+
+		// we'll just continue -- if nothing is found, we'll return an error
+		if err != nil {
+			continue
+		}
+
+		for _, cluster := range resp.Clusters {
+			if cluster.Endpoint == ipAddr {
+				return projectID, nil
+			}
+		}
+	}
+
+	return "", fmt.Errorf("cluster not found")
+}

+ 4 - 0
internal/providers/gcp/local/config.go

@@ -8,6 +8,7 @@ import (
 
 	"github.com/porter-dev/porter/internal/providers/gcp"
 	"google.golang.org/api/cloudresourcemanager/v1"
+	gke "google.golang.org/api/container/v1"
 
 	admin "cloud.google.com/go/iam/admin/apiv1"
 
@@ -37,11 +38,14 @@ func NewDefaultAgent() (*gcp.Agent, error) {
 		return nil, err
 	}
 
+	gkeService, err := gke.NewService(ctx)
+
 	return &gcp.Agent{
 		Ctx:                         ctx,
 		ProjectID:                   creds.ProjectID,
 		IAMClient:                   c,
 		CloudResourceManagerService: cloudresourcemanagerService,
+		GKEService:                  gkeService,
 	}, nil
 }
 

+ 3 - 3
server/api/project_handler_test.go

@@ -182,7 +182,7 @@ var createProjectSACandidatesTests = []*projTest{
 		endpoint:  "/api/projects/1/candidates",
 		body:      `{"kubeconfig":"` + OIDCAuthWithoutDataForJSON + `"}`,
 		expStatus: http.StatusCreated,
-		expBody:   `[{"id":1,"actions":[{"name":"upload-oidc-idp-issuer-ca-data","docs":"https://github.com/porter-dev/porter","resolved":false,"fields":"oidc_idp_issuer_ca_data"}],"project_id":1,"kind":"connector","cluster_name":"cluster-test","cluster_endpoint":"https://localhost","auth_mechanism":"oidc"}]`,
+		expBody:   `[{"id":1,"actions":[{"name":"upload-oidc-idp-issuer-ca-data","filename":"/fake/path/to/ca.pem","docs":"https://github.com/porter-dev/porter","resolved":false,"fields":"oidc_idp_issuer_ca_data"}],"project_id":1,"kind":"connector","cluster_name":"cluster-test","cluster_endpoint":"https://localhost","auth_mechanism":"oidc"}]`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
 			projectSACandidateBodyValidator,
@@ -206,7 +206,7 @@ var listProjectSACandidatesTests = []*projTest{
 		endpoint:  "/api/projects/1/candidates",
 		body:      ``,
 		expStatus: http.StatusOK,
-		expBody:   `[{"id":1,"actions":[{"name":"upload-oidc-idp-issuer-ca-data","docs":"https://github.com/porter-dev/porter","resolved":false,"fields":"oidc_idp_issuer_ca_data"}],"project_id":1,"kind":"connector","cluster_name":"cluster-test","cluster_endpoint":"https://localhost","auth_mechanism":"oidc"}]`,
+		expBody:   `[{"id":1,"actions":[{"name":"upload-oidc-idp-issuer-ca-data","filename":"/fake/path/to/ca.pem","docs":"https://github.com/porter-dev/porter","resolved":false,"fields":"oidc_idp_issuer_ca_data"}],"project_id":1,"kind":"connector","cluster_name":"cluster-test","cluster_endpoint":"https://localhost","auth_mechanism":"oidc"}]`,
 		useCookie: true,
 		validators: []func(c *projTest, tester *tester, t *testing.T){
 			projectSACandidateBodyValidator,
@@ -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,"name":"cluster-test","server":"https://localhost"}],"auth_mechanism":"oidc"}`,
+		expBody:   `{"id":1,"project_id":1,"kind":"connector","clusters":[{"id":1,"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,