Przeglądaj źródła

Merge pull request #255 from porter-dev/beta.3.integration-frontend

Beta.3.integration frontend
jusrhee 5 lat temu
rodzic
commit
0a446b2eff
77 zmienionych plików z 2891 dodań i 581 usunięć
  1. 48 1
      cli/cmd/api/api.go
  2. 41 0
      cli/cmd/api/git_repo.go
  3. 61 0
      cli/cmd/api/github_action.go
  4. 44 8
      cli/cmd/auth.go
  5. 10 0
      cli/cmd/config.go
  6. 20 0
      cli/cmd/connect.go
  7. 125 0
      cli/cmd/connect/actions.go
  8. 1 1
      cli/cmd/errors.go
  9. 1 2
      cli/cmd/open.go
  10. 9 0
      cli/cmd/root.go
  11. 1 0
      cmd/app/main.go
  12. 3 3
      cmd/docker-credential-porter/helper/helper.go
  13. 499 0
      cmd/migrate/keyrotate/helpers_test.go
  14. 66 0
      cmd/migrate/keyrotate/rotate.go
  15. 64 0
      cmd/migrate/keyrotate/rotate_test.go
  16. 1 0
      cmd/migrate/keyrotate/temp.go
  17. 1 0
      cmd/migrate/main.go
  18. BIN
      dashboard/src/assets/github-icon.png
  19. 30 11
      dashboard/src/components/Selector.tsx
  20. 11 1
      dashboard/src/components/repo-selector/BranchList.tsx
  21. 28 4
      dashboard/src/components/repo-selector/ContentsList.tsx
  22. 99 0
      dashboard/src/components/repo-selector/NewGHAction.tsx
  23. 121 19
      dashboard/src/components/repo-selector/RepoSelector.tsx
  24. 15 21
      dashboard/src/components/values-form/ValuesForm.tsx
  25. 13 0
      dashboard/src/components/values-form/ValuesWrapper.tsx
  26. 14 1
      dashboard/src/main/home/Home.tsx
  27. 80 26
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  28. 29 12
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  29. 1 1
      dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx
  30. 4 2
      dashboard/src/main/home/integrations/IntegrationList.tsx
  31. 2 1
      dashboard/src/main/home/integrations/Integrations.tsx
  32. 37 1
      dashboard/src/main/home/integrations/integration-form/GCRForm.tsx
  33. 1 1
      dashboard/src/main/home/modals/Modal.tsx
  34. 38 72
      dashboard/src/main/home/project-settings/InviteList.tsx
  35. 1 46
      dashboard/src/main/home/project-settings/ProjectSettings.tsx
  36. 7 12
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  37. 3 0
      dashboard/src/main/home/sidebar/Sidebar.tsx
  38. 2 7
      dashboard/src/main/home/templates/Templates.tsx
  39. 27 6
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  40. 18 5
      dashboard/src/main/home/templates/expanded-template/TemplateInfo.tsx
  41. 12 0
      dashboard/src/main/home/templates/hardcodedNameDict.tsx
  42. 3 0
      dashboard/src/shared/Context.tsx
  43. 277 233
      dashboard/src/shared/api.tsx
  44. 15 25
      dashboard/src/shared/feedback.tsx
  45. 9 1
      dashboard/src/shared/types.tsx
  46. 3 0
      go.mod
  47. 5 0
      go.sum
  48. 4 1
      internal/adapter/gorm.go
  49. 0 0
      internal/auth/sessionstore/sessionstore.go
  50. 1 1
      internal/auth/sessionstore/sessionstore_test.go
  51. 119 0
      internal/auth/token/token.go
  52. 52 0
      internal/auth/token/token_test.go
  53. 12 11
      internal/config/config.go
  54. 26 0
      internal/forms/git_action.go
  55. 15 15
      internal/helm/agent_test.go
  56. 235 0
      internal/integrations/ci/actions/actions.go
  57. 59 0
      internal/integrations/ci/actions/steps.go
  58. 46 0
      internal/models/gitrepo.go
  59. 11 8
      internal/models/release.go
  60. 4 2
      internal/models/templates.go
  61. 10 0
      internal/repository/git_action_config.go
  62. 6 1
      internal/repository/gorm/cluster.go
  63. 5 0
      internal/repository/gorm/cluster_test.go
  64. 51 0
      internal/repository/gorm/git_action_config.go
  65. 70 0
      internal/repository/gorm/git_action_config_test.go
  66. 26 0
      internal/repository/gorm/helpers_test.go
  67. 3 3
      internal/repository/gorm/release.go
  68. 3 3
      internal/repository/gorm/release_test.go
  69. 1 0
      internal/repository/gorm/repository.go
  70. 1 1
      internal/repository/release.go
  71. 1 0
      internal/repository/repository.go
  72. 2 2
      server/api/api.go
  73. 151 0
      server/api/git_action_handler.go
  74. 4 2
      server/api/git_repo_handler.go
  75. 10 1
      server/api/release_handler.go
  76. 39 1
      server/router/middleware/auth.go
  77. 24 6
      server/router/router.go

+ 48 - 1
cli/cmd/api/api.go

@@ -1,11 +1,13 @@
 package api
 
 import (
+	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"io/ioutil"
 	"net/http"
 	"path/filepath"
+	"strings"
 	"time"
 
 	"k8s.io/client-go/util/homedir"
@@ -17,6 +19,7 @@ type Client struct {
 	HTTPClient     *http.Client
 	Cookie         *http.Cookie
 	CookieFilePath string
+	Token          string
 }
 
 // HTTPError is the Porter error response returned if a request fails
@@ -47,11 +50,25 @@ func NewClient(baseURL string, cookieFileName string) *Client {
 	return client
 }
 
+func NewClientWithToken(baseURL, token string) *Client {
+	client := &Client{
+		BaseURL: baseURL,
+		Token:   token,
+		HTTPClient: &http.Client{
+			Timeout: time.Minute,
+		},
+	}
+
+	return client
+}
+
 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(); useCookie && cookie != nil {
+	if c.Token != "" {
+		req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.Token))
+	} else if cookie, _ := c.getCookie(); useCookie && cookie != nil {
 		c.Cookie = cookie
 		req.AddCookie(c.Cookie)
 	}
@@ -122,3 +139,33 @@ func (c *Client) getCookie() (*http.Cookie, error) {
 
 	return cookie.Cookie, nil
 }
+
+type TokenProjectID struct {
+	ProjectID uint `json:"project_id"`
+}
+
+func GetProjectIDFromToken(token string) (uint, error) {
+	var encoded string
+
+	if tokenSplit := strings.Split(token, "."); len(tokenSplit) != 3 {
+		return 0, fmt.Errorf("invalid jwt token format")
+	} else {
+		encoded = tokenSplit[1]
+	}
+
+	decodedBytes, err := base64.RawStdEncoding.DecodeString(encoded)
+
+	if err != nil {
+		return 0, fmt.Errorf("could not decode jwt token from base64: %v", err)
+	}
+
+	res := &TokenProjectID{}
+
+	err = json.Unmarshal(decodedBytes, res)
+
+	if err != nil {
+		return 0, fmt.Errorf("could not get token project id: %v", err)
+	}
+
+	return res.ProjectID, nil
+}

+ 41 - 0
cli/cmd/api/git_repo.go

@@ -0,0 +1,41 @@
+package api
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// ListGitRepoResponse is the list of Git repo integrations for a project
+type ListGitRepoResponse []models.GitRepoExternal
+
+// ListGitRepos returns a list of Git repos for a project
+func (c *Client) ListGitRepos(
+	ctx context.Context,
+	projectID uint,
+) (ListGitRepoResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/gitrepos", c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &ListGitRepoResponse{}
+
+	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
+}

+ 61 - 0
cli/cmd/api/github_action.go

@@ -0,0 +1,61 @@
+package api
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"strings"
+)
+
+// CreateGithubActionRequest represents the accepted fields for creating
+// a Github action
+type CreateGithubActionRequest struct {
+	GitRepo        string `json:"git_repo"`
+	ImageRepoURI   string `json:"image_repo_uri"`
+	DockerfilePath string `json:"dockerfile_path"`
+	GitRepoID      uint   `json:"git_repo_id"`
+}
+
+// CreateGithubAction creates a Github action with basic authentication
+func (c *Client) CreateGithubAction(
+	ctx context.Context,
+	projectID, clusterID uint,
+	releaseName, releaseNamespace string,
+	createGH *CreateGithubActionRequest,
+) error {
+	data, err := json.Marshal(createGH)
+
+	if err != nil {
+		return err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf(
+			"%s/projects/%d/ci/actions?cluster_id=%d&name=%s&namespace=%s",
+			c.BaseURL,
+			projectID,
+			clusterID,
+			releaseName,
+			releaseNamespace,
+		),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	req = req.WithContext(ctx)
+
+	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)
+		}
+
+		return err
+	}
+
+	return nil
+}

+ 44 - 8
cli/cmd/auth.go

@@ -55,6 +55,8 @@ var logoutCmd = &cobra.Command{
 	},
 }
 
+var token string = ""
+
 func init() {
 	rootCmd.AddCommand(authCmd)
 
@@ -68,10 +70,31 @@ func init() {
 		getHost(),
 		"host url of Porter instance",
 	)
+
+	loginCmd.PersistentFlags().StringVar(
+		&token,
+		"token",
+		"",
+		"bearer token for authentication",
+	)
 }
 
 func login() error {
-	client := api.NewClient(getHost()+"/api", "cookie.json")
+	var client *api.Client
+
+	if token != "" {
+		// set the token in config
+		err := setToken(token)
+
+		if err != nil {
+			return err
+		}
+
+		client = api.NewClientWithToken(getHost()+"/api", token)
+	} else {
+		client = api.NewClient(getHost()+"/api", "cookie.json")
+	}
+
 	user, _ := client.AuthCheck(context.Background())
 
 	if user != nil {
@@ -106,19 +129,32 @@ func login() error {
 
 	color.New(color.FgGreen).Println("Successfully logged in!")
 
-	// get a list of projects, and set the current project
-	projects, err := client.ListUserProjects(context.Background(), _user.ID)
+	// if the login was token-based, decode the claims to get the token
+	if token != "" {
+		projID, err := api.GetProjectIDFromToken(token)
 
-	if len(projects) > 0 {
-		setProject(projects[0].ID)
+		if err != nil {
+			return err
+		}
+
+		setProject(projID)
+	} else {
+		// get a list of projects, and set the current project
+		projects, err := client.ListUserProjects(context.Background(), _user.ID)
+
+		if err != nil {
+			return err
+		}
+
+		if len(projects) > 0 {
+			setProject(projects[0].ID)
+		}
 	}
 
 	return nil
 }
 
 func register() error {
-	host := getHost()
-
 	fmt.Println("Please register your admin account with an email and password:")
 
 	username, err := utils.PromptPlaintext("Email: ")
@@ -133,7 +169,7 @@ func register() error {
 		return err
 	}
 
-	client := api.NewClient(host+"/api", "cookie.json")
+	client := GetAPIClient()
 
 	resp, err := client.CreateUser(context.Background(), &api.CreateUserRequest{
 		Email:    username,

+ 10 - 0
cli/cmd/config.go

@@ -203,6 +203,12 @@ func setHost(host string) error {
 	return err
 }
 
+func setToken(token string) error {
+	viper.Set("token", token)
+	err := viper.WriteConfig()
+	return err
+}
+
 func getHost() string {
 	if host != "" {
 		return host
@@ -211,6 +217,10 @@ func getHost() string {
 	return viper.GetString("host")
 }
 
+func getToken() string {
+	return viper.GetString("token")
+}
+
 func getClusterID() uint {
 	if clusterID != 0 {
 		return clusterID

+ 20 - 0
cli/cmd/connect.go

@@ -43,6 +43,18 @@ var connectECRCmd = &cobra.Command{
 	},
 }
 
+var connectActionsCmd = &cobra.Command{
+	Use:   "actions",
+	Short: "Adds Github Actions to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnectActions)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 var connectGCRCmd = &cobra.Command{
 	Use:   "gcr",
 	Short: "Adds a GCR instance to a project",
@@ -113,6 +125,7 @@ func init() {
 		"the context to connect (defaults to the current context)",
 	)
 
+	connectCmd.AddCommand(connectActionsCmd)
 	connectCmd.AddCommand(connectECRCmd)
 	connectCmd.AddCommand(connectGCRCmd)
 	connectCmd.AddCommand(connectDOCRCmd)
@@ -192,3 +205,10 @@ func runConnectHelmRepoBasic(_ *api.AuthCheckResponse, client *api.Client, _ []s
 
 	return setHelmRepo(hrID)
 }
+
+func runConnectActions(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	return connect.Actions(
+		client,
+		getProjectID(),
+	)
+}

+ 125 - 0
cli/cmd/connect/actions.go

@@ -0,0 +1,125 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+	"strconv"
+	"time"
+
+	"github.com/porter-dev/porter/cli/cmd/api"
+	"github.com/porter-dev/porter/cli/cmd/utils"
+
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// Actions creates a github actions integration
+func Actions(
+	client *api.Client,
+	projectID uint,
+) error {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	// list oauth integrations and make sure Github exists
+	oauthInts, err := client.ListOAuthIntegrations(context.TODO(), projectID)
+
+	if err != nil {
+		return err
+	}
+
+	linkedGH := false
+
+	// iterate through oauth integrations to find do
+	for _, oauthInt := range oauthInts {
+		if oauthInt.Client == ints.OAuthGithub {
+			linkedGH = true
+			break
+		}
+	}
+
+	if !linkedGH {
+		_, err = triggerGithubOAuth(client, projectID)
+
+		if err != nil {
+			return err
+		}
+	}
+
+	gitRepos, err := client.ListGitRepos(context.TODO(), projectID)
+
+	gitRepoID := gitRepos[0].ID
+
+	// prompts (unfortunately a lot)
+	clusterIDStr, _ := utils.PromptPlaintext(fmt.Sprintf(`Please provide the cluster id (can be found with "porter clusters list").
+Cluster ID: `))
+	clusterID, err := strconv.ParseUint(clusterIDStr, 10, 64)
+
+	if err != nil {
+		return err
+	}
+
+	releaseName, _ := utils.PromptPlaintext(fmt.Sprintf(`Release name:`))
+	releaseNamespace, _ := utils.PromptPlaintext(fmt.Sprintf(`Release namespace:`))
+	gitRepo, _ := utils.PromptPlaintext(fmt.Sprintf(`Please enter the Github repo, in the form ${owner}/${repo_name}. For example, porter-dev/porter.
+Github repo:`))
+
+	imageRepo, _ := utils.PromptPlaintext(fmt.Sprintf(`Please enter the image repo url.
+Image repo:`))
+
+	dockerfilePath, _ := utils.PromptPlaintext(fmt.Sprintf(`Please enter the path in the repo to your dockerfile.
+Dockerfile path:`))
+
+	err = client.CreateGithubAction(
+		context.Background(),
+		projectID,
+		uint(clusterID),
+		releaseName,
+		releaseNamespace,
+		&api.CreateGithubActionRequest{
+			GitRepo:        gitRepo,
+			ImageRepoURI:   imageRepo,
+			DockerfilePath: dockerfilePath,
+			GitRepoID:      gitRepoID,
+		},
+	)
+
+	return err
+}
+
+func triggerGithubOAuth(client *api.Client, projectID uint) (ints.OAuthIntegrationExternal, error) {
+	var ghAuth ints.OAuthIntegrationExternal
+
+	oauthURL := fmt.Sprintf("%s/oauth/projects/%d/github", client.BaseURL, projectID)
+
+	fmt.Printf("Please visit %s in your browser to connect to Github (it should open automatically).", oauthURL)
+	utils.OpenBrowser(oauthURL)
+
+	for {
+		oauthInts, err := client.ListOAuthIntegrations(context.TODO(), projectID)
+
+		if err != nil {
+			return ghAuth, err
+		}
+
+		linkedGH := false
+
+		// iterate through oauth integrations to find do
+		for _, oauthInt := range oauthInts {
+			if oauthInt.Client == ints.OAuthGithub {
+				linkedGH = true
+				ghAuth = oauthInt
+				break
+			}
+		}
+
+		if linkedGH {
+			break
+		}
+
+		time.Sleep(2 * time.Second)
+	}
+
+	return ghAuth, nil
+}

+ 1 - 1
cli/cmd/errors.go

@@ -9,7 +9,7 @@ import (
 )
 
 func checkLoginAndRun(args []string, runner func(user *api.AuthCheckResponse, client *api.Client, args []string) error) error {
-	client := api.NewClient(getHost()+"/api", "cookie.json")
+	client := GetAPIClient()
 
 	user, err := client.AuthCheck(context.Background())
 

+ 1 - 2
cli/cmd/open.go

@@ -4,7 +4,6 @@ import (
 	"context"
 	"fmt"
 
-	"github.com/porter-dev/porter/cli/cmd/api"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 
 	"github.com/spf13/cobra"
@@ -14,7 +13,7 @@ var openCmd = &cobra.Command{
 	Use:   "open",
 	Short: "Opens the browser at the currently set Porter instance",
 	Run: func(cmd *cobra.Command, args []string) {
-		client := api.NewClient(getHost()+"/api", "cookie.json")
+		client := GetAPIClient()
 
 		user, err := client.AuthCheck(context.Background())
 

+ 9 - 0
cli/cmd/root.go

@@ -6,6 +6,7 @@ import (
 	"path/filepath"
 
 	"github.com/fatih/color"
+	"github.com/porter-dev/porter/cli/cmd/api"
 	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 	"k8s.io/client-go/util/homedir"
@@ -70,3 +71,11 @@ func Setup() {
 		viper.WriteConfig()
 	}
 }
+
+func GetAPIClient() *api.Client {
+	if token := viper.GetString("token"); token != "" {
+		return api.NewClientWithToken(getHost()+"/api", token)
+	}
+
+	return api.NewClient(getHost()+"/api", "cookie.json")
+}

+ 1 - 0
cmd/app/main.go

@@ -57,6 +57,7 @@ func main() {
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.Infra{},
+		&models.GitActionConfig{},
 		&models.Invite{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},

+ 3 - 3
cmd/docker-credential-porter/helper/helper.go

@@ -73,7 +73,7 @@ func (p *PorterHelper) getGCR(serverURL string) (user string, secret string, err
 		host := viper.GetString("host")
 		projID := viper.GetUint("project")
 
-		client := api.NewClient(host+"/api", "cookie.json")
+		client := cmd.GetAPIClient()
 
 		// get a token from the server
 		tokenResp, err := client.GetGCRAuthorizationToken(context.Background(), projID, &api.GetGCRTokenRequest{
@@ -128,7 +128,7 @@ func (p *PorterHelper) getDOCR(serverURL string) (user string, secret string, er
 		host := viper.GetString("host")
 		projID := viper.GetUint("project")
 
-		client := api.NewClient(host+"/api", "cookie.json")
+		client := cmd.GetAPIClient()
 
 		if p.Debug {
 			log.Printf("MAKING REQUEST", host, projID)
@@ -199,7 +199,7 @@ func (p *PorterHelper) getECR(serverURL string) (user string, secret string, err
 		host := viper.GetString("host")
 		projID := viper.GetUint("project")
 
-		client := api.NewClient(host+"/api", "cookie.json")
+		client := cmd.GetAPIClient()
 
 		// get a token from the server
 		tokenResp, err := client.GetECRAuthorizationToken(context.Background(), projID, matches[3])

+ 499 - 0
cmd/migrate/keyrotate/helpers_test.go

@@ -0,0 +1,499 @@
+package keyrotate_test
+
+import (
+	"os"
+	"testing"
+	"time"
+
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/repository/gorm"
+
+	_gorm "gorm.io/gorm"
+)
+
+type tester struct {
+	Key *[32]byte
+	DB  *_gorm.DB
+
+	repo         *repository.Repository
+	dbFileName   string
+	key          *[32]byte
+	initUsers    []*models.User
+	initProjects []*models.Project
+	initGRs      []*models.GitRepo
+	initRegs     []*models.Registry
+	initClusters []*models.Cluster
+	initHRs      []*models.HelmRepo
+	initInfras   []*models.Infra
+	initReleases []*models.Release
+	initCCs      []*models.ClusterCandidate
+	initKIs      []*ints.KubeIntegration
+	initBasics   []*ints.BasicIntegration
+	initOIDCs    []*ints.OIDCIntegration
+	initOAuths   []*ints.OAuthIntegration
+	initGCPs     []*ints.GCPIntegration
+	initAWSs     []*ints.AWSIntegration
+}
+
+func setupTestEnv(tester *tester, t *testing.T) {
+	t.Helper()
+
+	db, err := adapter.New(&config.DBConf{
+		EncryptionKey: "__random_strong_encryption_key__",
+		SQLLite:       true,
+		SQLLitePath:   tester.dbFileName,
+	})
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	err = db.AutoMigrate(
+		&models.Project{},
+		&models.Role{},
+		&models.User{},
+		&models.Session{},
+		&models.GitRepo{},
+		&models.Registry{},
+		&models.Release{},
+		&models.HelmRepo{},
+		&models.Cluster{},
+		&models.ClusterCandidate{},
+		&models.ClusterResolver{},
+		&models.Infra{},
+		&models.GitActionConfig{},
+		&ints.KubeIntegration{},
+		&ints.BasicIntegration{},
+		&ints.OIDCIntegration{},
+		&ints.OAuthIntegration{},
+		&ints.GCPIntegration{},
+		&ints.AWSIntegration{},
+		&ints.ClusterTokenCache{},
+		&ints.RegTokenCache{},
+		&ints.HelmRepoTokenCache{},
+	)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	var key [32]byte
+
+	for i, b := range []byte("__random_strong_encryption_key__") {
+		key[i] = b
+	}
+
+	tester.key = &key
+	tester.Key = &key
+	tester.DB = db
+
+	tester.repo = gorm.NewRepository(db, &key)
+}
+
+func cleanup(tester *tester, t *testing.T) {
+	t.Helper()
+
+	// remove the created file file
+	os.Remove(tester.dbFileName)
+}
+
+func initUser(tester *tester, t *testing.T) {
+	t.Helper()
+
+	user := &models.User{
+		Email:    "example@example.com",
+		Password: "hello1234",
+	}
+
+	user, err := tester.repo.User.CreateUser(user)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initUsers = append(tester.initUsers, user)
+}
+
+func initProject(tester *tester, t *testing.T) {
+	t.Helper()
+
+	proj := &models.Project{
+		Name: "project-test",
+	}
+
+	proj, err := tester.repo.Project.CreateProject(proj)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initProjects = append(tester.initProjects, proj)
+}
+
+func initProjectRole(tester *tester, t *testing.T) {
+	t.Helper()
+
+	role := &models.Role{
+		Kind:      models.RoleAdmin,
+		UserID:    tester.initUsers[0].Model.ID,
+		ProjectID: tester.initProjects[0].Model.ID,
+	}
+
+	role, err := tester.repo.Project.CreateProjectRole(tester.initProjects[0], role)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+}
+
+func initKubeIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	ki := &ints.KubeIntegration{
+		Mechanism:  ints.KubeLocal,
+		ProjectID:  tester.initProjects[0].ID,
+		UserID:     tester.initUsers[0].ID,
+		Kubeconfig: []byte("current-context: testing\n"),
+	}
+
+	ki, err := tester.repo.KubeIntegration.CreateKubeIntegration(ki)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initKIs = append(tester.initKIs, ki)
+}
+
+func initBasicIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	basic := &ints.BasicIntegration{
+		ProjectID: tester.initProjects[0].ID,
+		UserID:    tester.initUsers[0].ID,
+		Username:  []byte("username"),
+		Password:  []byte("password"),
+	}
+
+	basic, err := tester.repo.BasicIntegration.CreateBasicIntegration(basic)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initBasics = append(tester.initBasics, basic)
+}
+
+func initOIDCIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	oidc := &ints.OIDCIntegration{
+		Client:       ints.OIDCKube,
+		ProjectID:    tester.initProjects[0].ID,
+		UserID:       tester.initUsers[0].ID,
+		IssuerURL:    []byte("https://oidc.example.com"),
+		ClientID:     []byte("exampleclientid"),
+		ClientSecret: []byte("exampleclientsecret"),
+		IDToken:      []byte("idtoken"),
+		RefreshToken: []byte("refreshtoken"),
+	}
+
+	oidc, err := tester.repo.OIDCIntegration.CreateOIDCIntegration(oidc)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initOIDCs = append(tester.initOIDCs, oidc)
+}
+
+func initOAuthIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	oauth := &ints.OAuthIntegration{
+		Client:       ints.OAuthGithub,
+		ProjectID:    tester.initProjects[0].ID,
+		UserID:       tester.initUsers[0].ID,
+		ClientID:     []byte("exampleclientid"),
+		AccessToken:  []byte("idtoken"),
+		RefreshToken: []byte("refreshtoken"),
+	}
+
+	oauth, err := tester.repo.OAuthIntegration.CreateOAuthIntegration(oauth)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initOAuths = append(tester.initOAuths, oauth)
+}
+
+func initGCPIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	gcp := &ints.GCPIntegration{
+		ProjectID:    tester.initProjects[0].ID,
+		UserID:       tester.initUsers[0].ID,
+		GCPProjectID: "test-proj-123456",
+		GCPUserEmail: "test@test.it",
+		GCPKeyData:   []byte("{\"test\":\"key\"}"),
+	}
+
+	gcp, err := tester.repo.GCPIntegration.CreateGCPIntegration(gcp)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initGCPs = append(tester.initGCPs, gcp)
+}
+
+func initAWSIntegration(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initUsers) == 0 {
+		initUser(tester, t)
+	}
+
+	aws := &ints.AWSIntegration{
+		ProjectID:          tester.initProjects[0].ID,
+		UserID:             tester.initUsers[0].ID,
+		AWSClusterID:       []byte("example-cluster-0"),
+		AWSAccessKeyID:     []byte("accesskey"),
+		AWSSecretAccessKey: []byte("secret"),
+		AWSSessionToken:    []byte("optional"),
+	}
+
+	aws, err := tester.repo.AWSIntegration.CreateAWSIntegration(aws)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initAWSs = append(tester.initAWSs, aws)
+}
+
+func initClusterCandidate(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	cc := &models.ClusterCandidate{
+		AuthMechanism:     models.AWS,
+		ProjectID:         tester.initProjects[0].ID,
+		CreatedClusterID:  0,
+		Resolvers:         []models.ClusterResolver{},
+		Name:              "cluster-test",
+		Server:            "https://localhost",
+		ContextName:       "context-test",
+		AWSClusterIDGuess: []byte("example-cluster-0"),
+		Kubeconfig:        []byte("current-context: testing\n"),
+	}
+
+	cc, err := tester.repo.Cluster.CreateClusterCandidate(cc)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initCCs = append(tester.initCCs, cc)
+}
+
+func initCluster(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initKIs) == 0 {
+		initKubeIntegration(tester, t)
+	}
+
+	cluster := &models.Cluster{
+		ProjectID:                tester.initProjects[0].ID,
+		Name:                     "cluster-test",
+		Server:                   "https://localhost",
+		KubeIntegrationID:        tester.initKIs[0].ID,
+		CertificateAuthorityData: []byte("-----BEGIN"),
+		TokenCache: ints.ClusterTokenCache{
+			TokenCache: ints.TokenCache{
+				Token:  []byte("token-1"),
+				Expiry: time.Now().Add(-1 * time.Hour),
+			},
+		},
+	}
+
+	cluster, err := tester.repo.Cluster.CreateCluster(cluster)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initClusters = append(tester.initClusters, cluster)
+}
+
+func initGitRepo(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	if len(tester.initOAuths) == 0 {
+		initOAuthIntegration(tester, t)
+	}
+
+	gr := &models.GitRepo{
+		ProjectID:          tester.initProjects[0].ID,
+		RepoEntity:         "porter-dev",
+		OAuthIntegrationID: tester.initOAuths[0].ID,
+	}
+
+	gr, err := tester.repo.GitRepo.CreateGitRepo(gr)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initGRs = append(tester.initGRs, gr)
+}
+
+func initRegistry(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	reg := &models.Registry{
+		ProjectID: tester.initProjects[0].ID,
+		Name:      "registry-test",
+	}
+
+	reg, err := tester.repo.Registry.CreateRegistry(reg)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initRegs = append(tester.initRegs, reg)
+}
+
+func initHelmRepo(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	hr := &models.HelmRepo{
+		Name:      "helm-repo-test",
+		RepoURL:   "https://example-repo.com",
+		ProjectID: tester.initProjects[0].Model.ID,
+	}
+
+	hr, err := tester.repo.HelmRepo.CreateHelmRepo(hr)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initHRs = append(tester.initHRs, hr)
+}
+
+func initInfra(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	infra := &models.Infra{
+		Kind:      models.InfraECR,
+		ProjectID: tester.initProjects[0].Model.ID,
+		Status:    models.StatusCreated,
+	}
+
+	infra, err := tester.repo.Infra.CreateInfra(infra)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initInfras = append(tester.initInfras, infra)
+}
+
+func initRelease(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	release := &models.Release{
+		Name:         "denver-meister-dakota",
+		Namespace:    "default",
+		ProjectID:    1,
+		ClusterID:    1,
+		WebhookToken: "abcdefgh",
+	}
+
+	release, err := tester.repo.Release.CreateRelease(release)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initReleases = append(tester.initReleases, release)
+}

+ 66 - 0
cmd/migrate/keyrotate/rotate.go

@@ -0,0 +1,66 @@
+package keyrotate
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	gorm "github.com/porter-dev/porter/internal/repository/gorm"
+
+	_gorm "gorm.io/gorm"
+)
+
+// process 100 records at a time
+const stepSize = 100
+
+func Rotate(db *_gorm.DB, oldKey, newKey *[32]byte) error {
+	err := rotateClusterModel(db, oldKey, newKey)
+
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func rotateClusterModel(db *_gorm.DB, oldKey, newKey *[32]byte) error {
+	// get count of model
+	var count int64
+
+	if err := db.Model(&models.Cluster{}).Count(&count).Error; err != nil {
+		return err
+	}
+
+	// cluster-scoped repository
+	repo := gorm.NewClusterRepository(db, oldKey).(*gorm.ClusterRepository)
+
+	// iterate (count / stepSize) + 1 times using Limit and Offset
+	for i := 0; i < (int(count)/stepSize)+1; i++ {
+		clusters := []*models.Cluster{}
+
+		if err := db.Offset(i * stepSize).Limit(stepSize).Preload("TokenCache").Find(&clusters).Error; err != nil {
+			return err
+		}
+
+		// decrypt with the old key
+		for _, cluster := range clusters {
+			err := repo.DecryptClusterData(cluster, oldKey)
+
+			if err != nil {
+				return err
+			}
+		}
+
+		// encrypt with the new key and re-insert
+		for _, cluster := range clusters {
+			err := repo.EncryptClusterData(cluster, newKey)
+
+			if err != nil {
+				return err
+			}
+
+			if err := db.Save(cluster).Error; err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}

+ 64 - 0
cmd/migrate/keyrotate/rotate_test.go

@@ -0,0 +1,64 @@
+package keyrotate_test
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/porter-dev/porter/cmd/migrate/keyrotate"
+	"github.com/porter-dev/porter/internal/models"
+	gorm "github.com/porter-dev/porter/internal/repository/gorm"
+)
+
+func TestClusterModelRotation(t *testing.T) {
+	var newKey [32]byte
+
+	for i, b := range []byte("__r3n3o3_s3r3n3_3n3r3p3i3n_k3y__") {
+		newKey[i] = b
+	}
+
+	tester := &tester{
+		dbFileName: "./porter_cluster_rotate.db",
+	}
+
+	setupTestEnv(tester, t)
+
+	for i := 0; i < 1; i++ {
+		initCluster(tester, t)
+	}
+
+	defer cleanup(tester, t)
+
+	err := keyrotate.Rotate(tester.DB, tester.Key, &newKey)
+
+	if err != nil {
+		t.Fatalf("error rotating: %v\n", err)
+	}
+
+	// very all clusters decoded properly
+	repo := gorm.NewClusterRepository(tester.DB, &newKey).(*gorm.ClusterRepository)
+
+	clusters := []*models.Cluster{}
+
+	if err := tester.DB.Preload("TokenCache").Find(&clusters).Error; err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// decrypt with the old key
+	for _, c := range clusters {
+		fmt.Println("GOT TOKEN", string(c.TokenCache.Token))
+
+		cluster, err := repo.ReadCluster(c.ID)
+
+		if err != nil {
+			t.Fatalf("error reading cluster: %v\n", err)
+		}
+
+		if string(cluster.CertificateAuthorityData) != "-----BEGIN" {
+			t.Errorf("%s\n", string(cluster.CertificateAuthorityData))
+		}
+
+		if string(cluster.TokenCache.Token) != "token-1" {
+			t.Errorf("%s\n", string(cluster.TokenCache.Token))
+		}
+	}
+}

+ 1 - 0
cmd/migrate/keyrotate/temp.go

@@ -0,0 +1 @@
+package keyrotate

+ 1 - 0
cmd/migrate/main.go

@@ -37,6 +37,7 @@ func main() {
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.Infra{},
+		&models.GitActionConfig{},
 		&models.Invite{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},

BIN
dashboard/src/assets/github-icon.png


+ 30 - 11
dashboard/src/components/Selector.tsx

@@ -21,6 +21,26 @@ export default class Selector extends Component<PropsType, StateType> {
     expanded: false
   }
 
+  wrapperRef: any = React.createRef();
+  parentRef: any = React.createRef();
+
+  componentDidMount() {
+    document.addEventListener('mousedown', this.handleClickOutside.bind(this));
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener('mousedown', this.handleClickOutside.bind(this));
+  }
+
+  handleClickOutside = (event: any) => {
+    if (
+      (this.wrapperRef && this.wrapperRef.current && !this.wrapperRef.current.contains(event.target)) &&
+      (this.parentRef && this.parentRef.current && !this.parentRef.current.contains(event.target))
+    ) {
+      this.setState({ expanded: false })
+    }
+  }
+
   handleOptionClick = (option: { value: string, label: string }) => {
     this.props.setActiveValue(option.value);
     this.props.closeOverlay ? null : this.setState({ expanded: false });
@@ -53,17 +73,15 @@ export default class Selector extends Component<PropsType, StateType> {
   renderDropdown = () => {
     if (this.state.expanded) {
       return (
-        <div>
-          {this.props.closeOverlay ? <CloseOverlay onClick={() => this.setState({ expanded: false })} /> : null}
-          <Dropdown
-            dropdownWidth={this.props.dropdownWidth ? this.props.dropdownWidth : this.props.width}
-            dropdownMaxHeight={this.props.dropdownMaxHeight}
-            onClick={() => this.setState({ expanded: false })}
-          >
-            {this.renderDropdownLabel()}
-            {this.renderOptionList()}
-          </Dropdown>
-        </div>
+        <Dropdown
+          ref={this.wrapperRef}
+          dropdownWidth={this.props.dropdownWidth ? this.props.dropdownWidth : this.props.width}
+          dropdownMaxHeight={this.props.dropdownMaxHeight}
+          onClick={() => this.setState({ expanded: false })}
+        >
+          {this.renderDropdownLabel()}
+          {this.renderOptionList()}
+        </Dropdown>
       )
     }
   }
@@ -80,6 +98,7 @@ export default class Selector extends Component<PropsType, StateType> {
     return (
       <StyledSelector width={this.props.width}>
         <MainSelector
+          ref={this.parentRef}
           onClick={() => this.setState({ expanded: !this.state.expanded })}
           expanded={this.state.expanded}
           width={this.props.width}

+ 11 - 1
dashboard/src/components/repo-selector/BranchList.tsx

@@ -3,11 +3,14 @@ import styled from 'styled-components';
 import branch_icon from '../../assets/branch.png';
 
 import api from '../../shared/api';
+import { Context } from '../../shared/Context';
 
 import Loading from '../Loading';
 
 type PropsType = {
+  grid: number,
   repoName: string,
+  owner: string,
   setSelectedBranch: (x: string) => void,
   selectedBranch: string
 };
@@ -26,13 +29,18 @@ export default class BranchList extends Component<PropsType, StateType> {
   }
 
   componentDidMount() {
+    let { currentProject } = this.context;
 
     // Get branches
     api.getBranches('<token>', {}, {
+      project_id: currentProject.id,
+      git_repo_id: this.props.grid,
       kind: 'github',
-      repo: this.props.repoName
+      owner: this.props.owner,
+      name: this.props.repoName,
     }, (err: any, res: any) => {
       if (err) {
+        console.log(err);
         this.setState({ loading: false, error: true });
       } else {
         this.setState({ branches: res.data, loading: false, error: false });
@@ -71,6 +79,8 @@ export default class BranchList extends Component<PropsType, StateType> {
   }
 }
 
+BranchList.contextType = Context;
+
 const BranchName = styled.div`
   display: flex;
   width: 100%;

+ 28 - 4
dashboard/src/components/repo-selector/ContentsList.tsx

@@ -6,15 +6,19 @@ import folder from '../../assets/folder.svg';
 import info from '../../assets/info.svg';
 
 import api from '../../shared/api';
+import { Context } from '../../shared/Context';
 import { FileType } from '../../shared/types';
 
 import Loading from '../Loading';
 
 type PropsType = {
+  grid: number,
   repoName: string,
+  owner: string,
   selectedBranch: string,
   subdirectory: string,
   setSubdirectory: (x: string) => void,
+  setDockerfile: () => void,
 };
 
 type StateType = {
@@ -31,10 +35,15 @@ export default class ContentsList extends Component<PropsType, StateType> {
   }
 
   updateContents = () => {
+    let { currentProject } = this.context;
+
     // Get branch contents
     api.getBranchContents('<token>', { dir: this.props.subdirectory }, {
+      project_id: currentProject.id,
+      git_repo_id: this.props.grid,
       kind: 'github',
-      repo: this.props.repoName,
+      owner: this.props.owner,
+      name: this.props.repoName,
       branch: this.props.selectedBranch
     }, (err: any, res: any) => {
       if (err) {
@@ -91,6 +100,19 @@ export default class ContentsList extends Component<PropsType, StateType> {
         );
       }
 
+      if (fileName === 'Dockerfile') {
+        return (
+          <FileItem
+            key={i}
+            lastItem={i === contents.length - 1}
+            isADocker
+            onClick={() => this.props.setDockerfile()}
+          >
+            <img src={file} />
+            {fileName}
+          </FileItem>
+        );
+      }
       return (
         <FileItem
           key={i}
@@ -145,6 +167,8 @@ export default class ContentsList extends Component<PropsType, StateType> {
   }
 }
 
+ContentsList.contextType = Context;
+
 const BackLabel = styled.div`
   font-size: 16px;
   padding-left: 16px;
@@ -180,10 +204,10 @@ const Item = styled.div`
 `;
 
 const FileItem = styled(Item)`
-  cursor: default;
-  color: #ffffff55;
+  cursor: ${(props: {isADocker?: boolean}) => props.isADocker ? 'pointer' : 'default'};
+  color: ${(props: {isADocker?: boolean}) => props.isADocker ? '#fff' : '#ffffff55'};
   :hover {
-    background: #ffffff11;
+    background: ${(props: {isADocker?: boolean}) => props.isADocker ? '#ffffff22' : '#ffffff11'};
   }
 `;
 

+ 99 - 0
dashboard/src/components/repo-selector/NewGHAction.tsx

@@ -0,0 +1,99 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { ChartType } from '../../shared/types';
+import api from '../../shared/api';
+import { Context } from '../../shared/Context';
+import InputRow from '../../components/values-form/InputRow';
+
+import Loading from '../Loading';
+
+type PropsType = {
+  repoName: string,
+  dockerPath: string,
+  grid: number,
+  chart: ChartType,
+  imgURL: string,
+  setURL: (x: string) => void,
+};
+
+type StateType = {
+  trueDockerPath: string,
+  loading: boolean,
+  error: boolean,
+};
+
+export default class NewGHAction extends Component<PropsType, StateType> {
+  state = {
+    dockerRepo: '',
+    trueDockerPath: this.props.dockerPath,
+    loading: false,
+    error: false,
+  }
+
+  componentDidMount() {
+    if (this.props.dockerPath[0] === '/') {
+      this.setState({ trueDockerPath: this.props.dockerPath.substring(1, this.props.dockerPath.length) });
+    }
+  }
+
+  renderConfirmation = () => {
+    let { loading } = this.state;
+    if (loading) {
+      return <LoadingWrapper><Loading /></LoadingWrapper>
+    }
+
+    return (
+      <Holder>
+        <InputRow
+          disabled={true}
+          label='Git Repository'
+          type='text'
+          width='100%'
+          value={this.props.repoName}
+          setValue={(x: string) => console.log(x)}
+        />
+        <InputRow
+          disabled={true}
+          label='Dockerfile Path'
+          type='text'
+          width='100%'
+          value={this.state.trueDockerPath}
+          setValue={(x: string) => console.log(x)}
+        />
+        <InputRow
+          label='Docker Image Repository'
+          placeholder='Image Repo URL (ex. gcr.io/porter/mr-p)'
+          type='text'
+          width='100%'
+          value={this.props.imgURL}
+          setValue={(x: string) => this.props.setURL(x)}
+        />
+      </Holder>
+    )
+  }
+
+  render() {
+    return (
+      <div>
+        {this.renderConfirmation()}
+      </div>
+    );
+  }
+}
+
+NewGHAction.contextType = Context;
+
+const Holder = styled.div`
+  padding: 0px 12px;
+`;
+
+const LoadingWrapper = styled.div`
+  padding: 30px 0px;
+  background: #ffffff11;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 13px;
+  color: #ffffff44;
+`;

+ 121 - 19
dashboard/src/components/repo-selector/RepoSelector.tsx

@@ -4,14 +4,16 @@ import github from '../../assets/github.png';
 import info from '../../assets/info.svg';
 
 import api from '../../shared/api';
-import { RepoType } from '../../shared/types';
+import { RepoType, ChartType } from '../../shared/types';
 import { Context } from '../../shared/Context';
 
 import Loading from '../../components/Loading';
 import BranchList from './BranchList';
 import ContentsList from './ContentsList';
+import NewGHAction from './NewGHAction';
 
 type PropsType = {
+  chart: ChartType | null,
   forceExpanded?: boolean,
   selectedRepo: RepoType | null,
   selectedBranch: string,
@@ -26,6 +28,9 @@ type StateType = {
   loading: boolean,
   error: boolean,
   repos: RepoType[]
+  branchGrID: number,
+  dockerfileSelected: boolean,
+  imageURL: string,
 };
 
 export default class RepoSelector extends Component<PropsType, StateType> {
@@ -33,11 +38,14 @@ export default class RepoSelector extends Component<PropsType, StateType> {
     isExpanded: this.props.forceExpanded,
     loading: true,
     error: false,
-    repos: [] as RepoType[]
+    repos: [] as RepoType[],
+    branchGrID: null as number,
+    dockerfileSelected: false,
+    imageURL: null as string,
   }
 
   componentDidMount() {
-    let { currentProject, currentCluster } = this.context;
+    let { currentProject } = this.context;
 
     // Get repos
     api.getGitRepos('<token>', {
@@ -45,7 +53,50 @@ export default class RepoSelector extends Component<PropsType, StateType> {
       if (err) {
         this.setState({ loading: false, error: true });
       } else {
-        this.setState({ repos: res.data, loading: false, error: false });
+        var allRepos: any = [];
+        let counter = 0;
+        for (let i = 0; i < res.data.length; i++) {
+          var grid = res.data[i].id;
+          api.getGitRepoList('<token>', {}, { project_id: currentProject.id, git_repo_id: grid }, (err: any, res: any) => {
+            if (err) {
+              console.log(err);
+              this.setState({ loading: false, error: true });
+            } else {
+              res.data.forEach((repo: any, id: number) => {
+                repo.GHRepoID = grid;
+              })
+              allRepos = allRepos.concat(res.data);
+              this.setState({ repos: allRepos, loading: false, error: false });
+            }
+          })
+        }
+      }
+    });
+  }
+
+  createGHAction = () => {
+    let { currentProject, currentCluster } = this.context;
+    let path = this.props.subdirectory + '/Dockerfile';
+    if (path[0] === '/') {
+      path = path.substring(1, path.length);
+    }
+
+    api.createGHAction('<token>', {
+      git_repo: this.props.selectedRepo.FullName,
+      image_repo_uri: this.state.imageURL,
+      dockerfile_path: path,
+      git_repo_id: this.props.selectedRepo.GHRepoID,
+    }, {
+      project_id: currentProject.id,
+      CLUSTER_ID: currentCluster.id,
+      RELEASE_NAME: this.props.chart.name,
+      RELEASE_NAMESPACE: this.props.chart.namespace,
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+        this.setState({ error: true });
+      } else {
+        console.log(res.data);
       }
     });
   }
@@ -95,38 +146,81 @@ export default class RepoSelector extends Component<PropsType, StateType> {
         <div>
           <ExpandedWrapperAlt>
             <BranchList
-              setSelectedBranch={(branch: string) => setSelectedBranch(branch)}
+              grid={selectedRepo.GHRepoID}
+              setSelectedBranch={(branch: string) => {
+                this.setState({ branchGrID: selectedRepo.GHRepoID });
+                setSelectedBranch(branch);
+              }}
               repoName={selectedRepo.FullName.split('/')[1]}
+              owner={selectedRepo.FullName.split('/')[0]}
               selectedBranch={selectedBranch}
             />
           </ExpandedWrapperAlt>
-          <BackButton
-            width='130px'
-            onClick={() => setSelectedRepo(null)}
-          >
-            <i className="material-icons">keyboard_backspace</i>
-            Select Repo
-          </BackButton>
+          <ButtonTray>
+            <BackButton
+              width='130px'
+              onClick={() => setSelectedRepo(null)}
+            >
+              <i className="material-icons">keyboard_backspace</i>
+              Select Repo
+            </BackButton>
+          </ButtonTray>
         </div>
       );
+    } else if (this.state.dockerfileSelected) {
+      return (
+        <div>
+          <ExpandedWrapperAlt>
+            <NewGHAction
+              repoName={selectedRepo.FullName}
+              dockerPath={subdirectory + '/Dockerfile'}
+              grid={this.state.branchGrID}
+              chart={this.props.chart}
+              imgURL={this.state.imageURL}
+              setURL={(x: string) => this.setState({ imageURL: x })}
+            />
+          </ExpandedWrapperAlt>
+          <ButtonTray>
+            <BackButton
+              width='130px'
+              onClick={() => this.setState({ dockerfileSelected: false })}
+            >
+              <i className='material-icons'>keyboard_backspace</i>
+              Select Dockerfile
+            </BackButton>
+            <BackButton
+              width='146px'
+              onClick={() => this.createGHAction()}
+            >
+              <i className='material-icons'>play_circle_outline</i>
+              Create Github Action
+            </BackButton>
+          </ButtonTray>
+        </div>
+      )
     }
     return (
       <div>
         <ExpandedWrapperAlt>
           <ContentsList
+            grid={this.state.branchGrID}
             setSubdirectory={(subdirectory: string) => setSubdirectory(subdirectory)}
             repoName={selectedRepo.FullName.split('/')[1]}
+            owner={selectedRepo.FullName.split('/')[0]}
             selectedBranch={selectedBranch}
             subdirectory={subdirectory}
+            setDockerfile={() => this.setState({ dockerfileSelected: true })}
           />
         </ExpandedWrapperAlt>
-        <BackButton
-          onClick={() => setSelectedBranch('')}
-          width='140px'
-        >
-          <i className="material-icons">keyboard_backspace</i>
-          Select Branch
-        </BackButton>
+        <ButtonTray>
+          <BackButton
+            onClick={() => {setSelectedBranch(''); setSubdirectory(''); this.setState({ imageURL: '' })}}
+            width='140px'
+          >
+            <i className="material-icons">keyboard_backspace</i>
+            Select Branch
+          </BackButton>
+        </ButtonTray>
       </div>
     );
   }
@@ -208,6 +302,14 @@ const BackButton = styled.div`
   }
 `;
 
+const ButtonTray = styled.div`
+  margin-top: 10px;
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  align-items: center;
+`;
+
 const RepoName = styled.div`
   display: flex;
   width: 100%;

+ 15 - 21
dashboard/src/components/values-form/ValuesForm.tsx

@@ -15,27 +15,6 @@ import Heading from './Heading';
 import ExpandableResource from '../ExpandableResource';
 import VeleroForm from '../forms/VeleroForm';
 
- let dummySections = [
-   {
-    "name":"section_one",
-    "show_if":"",
-    "contents":[
-      {
-        "type":"heading",
-        "label":"Polyphia",
-      },
-      {
-        "type":"subtitle",
-        "label":"Tim Hendrix",
-      },
-      {
-        "type":"velero-create-backup",
-        "label":"Tim Hendrix",
-      },
-    ]
-  }
-];
-
 type PropsType = {
   sections?: Section[],
   metaState?: any,
@@ -159,6 +138,21 @@ export default class ValuesForm extends Component<PropsType, StateType> {
               label={item.label}
             />
           );
+        case 'provider-select':
+          return (
+            <SelectRow
+              key={i}
+              value={this.props.metaState[key]}
+              setActiveValue={(val) => this.props.setMetaState({ [key]: val })}
+              options={[
+                { value: 'gcp', label: 'Google Cloud Platform (GCP)' },
+                { value: 'aws', label: 'Amazon Web Services (AWS)' },
+                { value: 'do', label: 'DigitalOcean' },
+              ]}
+              dropdownLabel=''
+              label={item.label}
+            />
+          );
         case 'velero-create-backup':
           return (
             <VeleroForm

+ 13 - 0
dashboard/src/components/values-form/ValuesWrapper.tsx

@@ -2,6 +2,7 @@ import React, { Component } from 'react';
 import styled from 'styled-components';
 
 import { Section, FormElement } from '../../shared/types';
+import { Context } from '../../shared/Context';
 
 import SaveButton from '../SaveButton';
 
@@ -16,6 +17,12 @@ type PropsType = {
 
 type StateType = any;
 
+const providerMap: any = {
+  'gke': 'gcp',
+  'eks': 'aws',
+  'doks': 'do',
+};
+
 // Manages the consolidated state of all form tabs ("metastate")
 export default class ValuesWrapper extends Component<PropsType, StateType> {
 
@@ -56,6 +63,10 @@ export default class ValuesWrapper extends Component<PropsType, StateType> {
               case 'select':
                 metaState[key] = def ? def : item.settings.options[0].value;
                 break;
+              case 'provider-select':
+                def = providerMap[this.context.currentCluster.service];
+                metaState[key] = def ? def : 'aws';
+                break;
               case 'base-64':
                 metaState[key] = def ? def : '';
               case 'base-64-password':
@@ -128,6 +139,8 @@ export default class ValuesWrapper extends Component<PropsType, StateType> {
   }
 }
 
+ValuesWrapper.contextType = Context;
+
 const StyledValuesWrapper = styled.div`
   width: 100%;
   padding: 0;

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

@@ -36,6 +36,7 @@ type StateType = {
   showWelcome: boolean,
   currentView: string,
   handleDO: boolean, // Trigger DO infra calls after oauth flow if needed
+  ghRedirect: boolean,
   forceRefreshClusters: boolean, // For updating ClusterSection from modal on deletion
 
   // Track last project id for refreshing clusters on project change
@@ -53,6 +54,7 @@ export default class Home extends Component<PropsType, StateType> {
     forceRefreshClusters: false,
     sidebarReady: false,
     handleDO: false,
+    ghRedirect: false,
   }
 
   // TODO: Refactor and prevent flash + multiple reload
@@ -74,6 +76,8 @@ export default class Home extends Component<PropsType, StateType> {
         this.setState({ currentView: 'dashboard'}, () => {
           this.setState({ currentView: 'provisioner', sidebarReady: true, });
         });
+      } else if (this.state.ghRedirect) {
+        this.setState({ currentView: 'integrations', sidebarReady: true, ghRedirect: false });
       } else {
         this.setState({ currentView: 'provisioner'}, () => {
           this.setState({ currentView: 'dashboard', sidebarReady: true });
@@ -106,7 +110,12 @@ export default class Home extends Component<PropsType, StateType> {
           }
 
           if (!foundProject) {
-            this.context.setCurrentProject(res.data[0]);
+            res.data.forEach((project: ProjectType, i: number) => {
+              if (project.id.toString() === localStorage.getItem('currentProject')) {
+                foundProject = project;
+              }
+            })
+            this.context.setCurrentProject(foundProject ? foundProject : res.data[0]);
             this.initializeView();
           }
         }
@@ -204,6 +213,9 @@ export default class Home extends Component<PropsType, StateType> {
     }
 
     // initialize posthog on non-localhosts. Gracefully fail when env vars are not set.
+    this.setState({ ghRedirect: urlParams.get('gh_oauth') !== null });
+    urlParams.delete('gh_oauth');
+    
     window.location.href.indexOf('127.0.0.1') === -1 && posthog.init(process.env.POSTHOG_API_KEY || 'placeholder', {
       api_host: process.env.POSTHOG_HOST || 'placeholder',
       loaded: function(posthog: any) { 
@@ -356,6 +368,7 @@ export default class Home extends Component<PropsType, StateType> {
 
   handleDelete = () => {
     let { setCurrentModal, currentProject } = this.context;
+    localStorage.removeItem(currentProject.id + '-cluster');
     api.deleteProject('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
       if (err) {
         console.log(err)

+ 80 - 26
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -3,7 +3,7 @@ import styled from 'styled-components';
 import api from '../../../../shared/api';
 import yaml from 'js-yaml';
 
-import { ChartType, RepoType, StorageType } from '../../../../shared/types';
+import { ChartType, RepoType, StorageType, ActionConfigType } from '../../../../shared/types';
 import { Context } from '../../../../shared/Context';
 
 import ImageSelector from '../../../../components/image-selector/ImageSelector';
@@ -31,6 +31,7 @@ type StateType = {
   subdirectory: string,
   webhookToken: string,
   highlightCopyButton: boolean,
+  action: ActionConfigType;
 };
 
 export default class SettingsSection extends Component<PropsType, StateType> {
@@ -45,6 +46,12 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     subdirectory: '',
     webhookToken: '',
     highlightCopyButton: false,
+    action: {
+      git_repo: '',
+      image_repo_uri: '',
+      git_repo_id: 0,
+      dockerfile_path: '',
+    } as ActionConfigType,
   }
 
   // TODO: read in set image from form context instead of config
@@ -65,7 +72,7 @@ export default class SettingsSection extends Component<PropsType, StateType> {
       if (err) {
         console.log(err)
       } else {
-        this.setState({ webhookToken: res.data.webhook_token })
+        this.setState({ action: res.data.git_action_config, webhookToken: res.data.webhook_token });
       }
     });
   }
@@ -109,15 +116,21 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     });
   }
 
+  /*
+    <Helper>
+      Specify a container image and tag or
+      <Highlight onClick={() => this.setState({ sourceType: 'repo' })}>
+        link a repo
+      </Highlight>.
+    </Helper>
+  */
   renderSourceSection = () => {
     if (this.state.sourceType === 'registry') {
       return (
         <>
+          <Heading>Connected Source</Heading>
           <Helper>
-            Specify a container image and tag or
-            <Highlight onClick={() => this.setState({ sourceType: 'repo' })}>
-              link a repo
-            </Highlight>.
+            Specify a container image and tag.
           </Helper>
           <ImageSelector
             selectedImageUrl={this.state.selectedImageUrl}
@@ -134,24 +147,61 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     let { currentProject } = this.context;
     return (
       <>
-        <Helper>
-          Select a repo to connect to. You can 
-          <A padRight={true} href={`/api/oauth/projects/${currentProject.id}/github?redirected=true`}>
-            log in with GitHub
-          </A> or
-          <Highlight onClick={() => this.setState({ sourceType: 'registry' })}>
-            link an image registry
-          </Highlight>.
-        </Helper>
-        <RepoSelector
-          forceExpanded={true}
-          selectedRepo={this.state.selectedRepo}
-          selectedBranch={this.state.selectedBranch}
-          subdirectory={this.state.subdirectory}
-          setSelectedRepo={(x: RepoType) => this.setState({ selectedRepo: x })}
-          setSelectedBranch={(x: string) => this.setState({ selectedBranch: x })}
-          setSubdirectory={(x: string) => this.setState({ subdirectory: x })}
-        />
+        {this.state.action.git_repo.length > 0
+          ?
+          <>
+            <Heading>Connected Source</Heading>
+            <Holder>
+              <InputRow
+                disabled={true}
+                label='Git Repository'
+                type='text'
+                width='100%'
+                value={this.state.action.git_repo}
+                setValue={(x: string) => console.log(x)}
+              />
+              <InputRow
+                disabled={true}
+                label='Dockerfile Path'
+                type='text'
+                width='100%'
+                value={this.state.action.dockerfile_path}
+                setValue={(x: string) => console.log(x)}
+              />
+              <InputRow
+                disabled={true}
+                label='Docker Image Repository'
+                type='text'
+                width='100%'
+                value={this.state.action.image_repo_uri}
+                setValue={(x: string) => console.log(x)}
+              />
+            </Holder>
+          </>
+          :
+          <>
+            <Heading>Connect a Source</Heading>
+            <Helper>
+              Select a repo to connect to. You can 
+              <A padRight={true} href={`/api/oauth/projects/${currentProject.id}/github?redirected=true`}>
+                log in with GitHub
+              </A> or
+              <Highlight onClick={() => this.setState({ sourceType: 'registry' })}>
+                link an image registry
+              </Highlight>.
+            </Helper>
+            <RepoSelector
+              chart={this.props.currentChart}
+              forceExpanded={true}
+              selectedRepo={this.state.selectedRepo}
+              selectedBranch={this.state.selectedBranch}
+              subdirectory={this.state.subdirectory}
+              setSelectedRepo={(x: RepoType) => this.setState({ selectedRepo: x })}
+              setSelectedBranch={(x: string) => this.setState({ selectedBranch: x })}
+              setSubdirectory={(x: string) => this.setState({ subdirectory: x })}
+            />
+          </>
+        }
       </>
     );
   }
@@ -185,7 +235,6 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     return (
       <Wrapper>
         <StyledSettingsSection>
-          <Heading>Connected Source</Heading>
           {this.renderSourceSection()}
           {this.renderWebhookSection()}
           <Heading>Additional Settings</Heading>
@@ -208,9 +257,10 @@ export default class SettingsSection extends Component<PropsType, StateType> {
 SettingsSection.contextType = Context;
 
 const Button = styled.button`
-  height: 40px;
+  height: 35px;
   font-size: 13px;
   margin-top: 20px;
+  margin-bottom: 30px;
   font-weight: 500;
   font-family: 'Work Sans', sans-serif;
   color: white;
@@ -292,4 +342,8 @@ const StyledSettingsSection = styled.div`
   position: relative;
   border-radius: 5px;
   overflow: auto;
+`;
+
+const Holder = styled.div`
+  padding: 0px 12px;
 `;

+ 29 - 12
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -16,7 +16,7 @@ type PropsType = {
 type StateType = {
   pods: any[],
   raw: any[],
-  showTooltip: boolean,
+  showTooltip: boolean[],
 };
 
 // Controller tab in log section that displays list of pods on click.
@@ -24,7 +24,7 @@ export default class ControllerTab extends Component<PropsType, StateType> {
   state = {
     pods: [] as any[],
     raw: [] as any[],
-    showTooltip: false,
+    showTooltip: [] as boolean[],
   }
 
   componentDidMount() {
@@ -62,8 +62,12 @@ export default class ControllerTab extends Component<PropsType, StateType> {
           phase: pod?.status?.phase,
         }
       });
+      let showTooltip = new Array(pods.length);
+      for (let j = 0; j < pods.length; j ++) {
+        showTooltip[j] = false;
+      }
       
-      this.setState({ pods, raw: res.data });
+      this.setState({ pods, raw: res.data, showTooltip });
       
       if (isFirst) {
         selectPod(res.data[0])
@@ -110,8 +114,8 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     }
   }
 
-  renderTooltip = (x: string): JSX.Element | undefined => {
-    if (this.state.showTooltip) {
+  renderTooltip = (x: string, ind: number): JSX.Element | undefined => {
+    if (this.state.showTooltip[ind]) {
       return <Tooltip>{x}</Tooltip>;
     }
   }
@@ -143,12 +147,20 @@ export default class ControllerTab extends Component<PropsType, StateType> {
                   <Rail lastTab={i === this.state.raw.length - 1} />
                 </Gutter>
                 <Name
-                  onMouseOver={() => { this.setState({ showTooltip: true }) }}
-                  onMouseOut={() => { this.setState({ showTooltip: false }) }}
+                  onMouseOver={() => {
+                    let showTooltip = this.state.showTooltip;
+                    showTooltip[i] = true;
+                    this.setState({ showTooltip });
+                  }}
+                  onMouseOut={() => {
+                    let showTooltip = this.state.showTooltip;
+                    showTooltip[i] = false;
+                    this.setState({ showTooltip });
+                  }}
                 >
                   {pod.metadata?.name}
                 </Name>
-                {this.renderTooltip(pod.metadata?.name)}
+                {this.renderTooltip(pod.metadata?.name, i)}
                 <Status>
                   <StatusColor status={status} />
                   {status}
@@ -218,20 +230,25 @@ const StatusColor = styled.div`
 const Name = styled.div`
   max-width: calc(100% - 75px);
   overflow: hidden;
-  white-space: nowrap;
   text-overflow: ellipsis;
+  line-height: 16px;
+  word-wrap: break-word;
+  max-height: 32px;
+  display: -webkit-box;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
 `;
 
 const Tooltip = styled.div`
   position: absolute;
   left: 35px;
+  word-wrap: break-word;
   top: 38px;
-  white-space: nowrap;
-  height: 18px;
+  min-height: 18px;
+  max-width: calc(100% - 75px);
   padding: 2px 5px;
   background: #383842dd;
   display: flex;
-  align-items: center;
   justify-content: center;
   flex: 1;
   color: white;

+ 1 - 1
dashboard/src/main/home/dashboard/ClusterPlaceholder.tsx

@@ -52,7 +52,7 @@ export default class ClusterPlaceholder extends Component<PropsType, StateType>
             <Highlight onClick={() => {
               this.context.setCurrentModal('ClusterInstructionsModal', {});
             }}>
-              + Add a Cluster
+              + Connect a Cluster
             </Highlight>
           </StyledStatusPlaceholder>
         </>

+ 4 - 2
dashboard/src/main/home/integrations/IntegrationList.tsx

@@ -18,12 +18,14 @@ type StateType = {
 export default class IntegrationList extends Component<PropsType, StateType> {
   renderContents = () => {
     let { integrations, titles, setCurrent, isCategory } = this.props;
+    console.log(`titles: ${titles}`);
+    console.log(`integrations: ${integrations}`);
     if (titles && titles.length > 0) {
       return integrations.map((integration: string, i: number) => {
         let icon = integrationList[integration] && integrationList[integration].icon;
         let subtitle = integrationList[integration] && integrationList[integration].label;
         let label = titles[i];
-        let disabled = integration === 'repo' || integration === 'kubernetes';
+        let disabled = integration === 'kubernetes' || integration === 'repo';
         return (
           <Integration
             key={i}
@@ -46,7 +48,7 @@ export default class IntegrationList extends Component<PropsType, StateType> {
       return integrations.map((integration: string, i: number) => {
         let icon = integrationList[integration] && integrationList[integration].icon;
         let label = integrationList[integration] && integrationList[integration].label;
-        let disabled = integration === 'repo' || integration === 'kubernetes';
+        let disabled = integration === 'kubernetes' || integration === 'repo';
         return (
           <Integration
             key={i}

+ 2 - 1
dashboard/src/main/home/integrations/Integrations.tsx

@@ -32,6 +32,7 @@ export default class Integrations extends Component<PropsType, StateType> {
   // TODO: implement once backend is restructured
   getIntegrations = (categoryType: string) => {
     let { currentProject } = this.context;
+    this.setState({ currentOptions: [], currentTitles: [], currentIntegrationData: [] });
     switch (categoryType) {
       case 'kubernetes':
         api.getProjectClusters('<token>', {}, { id: currentProject.id }, (err: any, res: any) => {
@@ -99,7 +100,7 @@ export default class Integrations extends Component<PropsType, StateType> {
             {
               items.map((item: any, i: number) => {
                 return (
-                  <Credential>
+                  <Credential key={i}>
                     <i className="material-icons">admin_panel_settings</i> {item.name}
                   </Credential>
                 );

+ 37 - 1
dashboard/src/main/home/integrations/integration-form/GCRForm.tsx

@@ -16,13 +16,17 @@ type PropsType = {
 
 type StateType = {
   credentialsName: string,
+  gcpRegion: string,
   serviceAccountKey: string,
+  gcpProjectID: string,
 };
 
 export default class GCRForm extends Component<PropsType, StateType> {
   state = {
     credentialsName: '',
+    gcpRegion: '',
     serviceAccountKey: '',
+    gcpProjectID: '',
   }
 
   isDisabled = (): boolean => {
@@ -34,7 +38,21 @@ export default class GCRForm extends Component<PropsType, StateType> {
   }
   
   handleSubmit = () => {
-    // TODO: implement once api is restructured
+    let { currentProject } = this.context;
+
+    api.createGCPIntegration('<token>', {
+      gcp_region: this.state.gcpRegion,
+      gcp_key_data: this.state.serviceAccountKey,
+      gcp_project_id: this.state.gcpProjectID,
+    }, {
+      project_id: currentProject.id,
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else {
+        console.log(res.data);
+      }
+    })
   }
 
   render() {
@@ -53,6 +71,14 @@ export default class GCRForm extends Component<PropsType, StateType> {
           />
           <Heading>GCP Settings</Heading>
           <Helper>Service account credentials for GCP permissions.</Helper>
+          <InputRow
+            type='text'
+            value={this.state.gcpRegion}
+            setValue={(x: string) => this.setState({ gcpRegion: x })}
+            label='📍 GCP Region'
+            placeholder='ex: uranus-north-12'
+            width='100%'
+          />
           <TextArea
             value={this.state.serviceAccountKey}
             setValue={(x: string) => this.setState({ serviceAccountKey: x })}
@@ -60,6 +86,14 @@ export default class GCRForm extends Component<PropsType, StateType> {
             placeholder='(Paste your JSON service account key here)'
             width='100%'
           />
+          <InputRow
+            type='text'
+            value={this.state.gcpProjectID}
+            setValue={(x: string) => this.setState({ gcpProjectID: x })}
+            label='GCP Project ID'
+            placeholder='ex: porter-dev-273614'
+            width='100%'
+          />
         </CredentialWrapper>
         <SaveButton
           text='Save Settings'
@@ -72,6 +106,8 @@ export default class GCRForm extends Component<PropsType, StateType> {
   }
 }
 
+GCRForm.contextType = Context;
+
 const CredentialWrapper = styled.div`
   padding: 5px 40px 25px;
   background: #ffffff11;

+ 1 - 1
dashboard/src/main/home/modals/Modal.tsx

@@ -22,7 +22,7 @@ export default class Modal extends Component<PropsType, StateType> {
   }
 
   handleClickOutside = (event: any) => {
-    if (this.wrapperRef && !this.wrapperRef.current.contains(event.target)) {
+    if (this.wrapperRef && this.wrapperRef.current && !this.wrapperRef.current.contains(event.target)) {
       this.props.onRequestClose();
     }
   }

+ 38 - 72
dashboard/src/main/home/project-settings/InviteList.tsx

@@ -18,6 +18,7 @@ type StateType = {
   invites: InviteType[],
   email: string,
   invalidEmail: boolean,
+  isHTTPS: boolean,
 }
 
 const dummyInvites = [];
@@ -28,6 +29,7 @@ export default class InviteList extends Component<PropsType, StateType> {
     invites: [] as InviteType[],
     email: '',
     invalidEmail: false,
+    isHTTPS: (process.env.API_SERVER === 'dashboard.getporter.dev'),
   }
 
   componentDidMount() {
@@ -44,21 +46,7 @@ export default class InviteList extends Component<PropsType, StateType> {
       if (err) {
         console.log(err);
       } else {
-        this.setState({ invites: res.data, loading: false }, () => {
-          for (let i = this.state.invites.length - 1; i >= 0; i--) {
-            if (this.state.invites[i].expired && !this.state.invites[i].accepted) {
-              api.deleteInvite('<token>', {}, {
-                id: currentProject.id, invId: this.state.invites[i].id
-              }, (err: any, res: any) => {
-                if (err) {
-                  console.log(`Error deleting invite: ${err}`);
-                } else {
-                  this.state.invites.splice(i, 1);
-                }
-              })
-            }
-          }
-        });
+        this.setState({ invites: res.data, loading: false });
       }
     });
   }
@@ -120,7 +108,7 @@ export default class InviteList extends Component<PropsType, StateType> {
   copyToClip = (index: number) => {
     let { currentProject } = this.context;
     navigator.clipboard.writeText(
-      `${process.env.API_SERVER}/api/projects/${currentProject.id}/invites/${this.state.invites[index].token}`
+      `${this.state.isHTTPS ? 'https://' : ''}${process.env.API_SERVER}/api/projects/${currentProject.id}/invites/${this.state.invites[index].token}`
     ).then(function() {
     }, function() {
       console.log("couldn't copy link to clipboard");
@@ -133,18 +121,21 @@ export default class InviteList extends Component<PropsType, StateType> {
       return <Loading />;
     } else {
       var invContent: any[] = [];
+      var collabList: any[] = [];
+      this.state.invites.sort((a: any, b: any) => (a.email > b.email) ? 1 : -1);
+      this.state.invites.sort((a: any, b: any) => (a.accepted > b.accepted) ? 1 : -1);
       for (let i = 0; i < this.state.invites.length; i++) {
         if (this.state.invites[i].accepted) {
-          invContent.push(
+          collabList.push(
             <Tr key={i}>
               <MailTd isTop={i === 0}>
                 {this.state.invites[i].email}
               </MailTd>
               <LinkTd isTop={i === 0}>
               </LinkTd>
-              <Td isTop={i === 0} invis={true}>
+              <Td isTop={i === 0}>
                 <CopyButton
-                  onClick={() => this.deleteInvite(i)}
+                  invis={true}
                 >
                   Remove
                 </CopyButton>
@@ -159,16 +150,12 @@ export default class InviteList extends Component<PropsType, StateType> {
               </MailTd>
               <LinkTd isTop={i === 0}>
                 <Rower>
-                  <ShareLink
-                    disabled={true}
-                    type='string'
-                    placeholder='Link expired'
-                  />
-                  <CopyButton
+                  Link Expired.
+                  <NewLinkButton
                     onClick={() => this.replaceInvite(i)}
                   >
-                    Get New Link
-                  </CopyButton>
+                    <u>Generate a new link</u>
+                  </NewLinkButton>
                 </Rower>
               </LinkTd>
               <Td isTop={i === 0}>
@@ -191,7 +178,7 @@ export default class InviteList extends Component<PropsType, StateType> {
                   <ShareLink
                     disabled={true}
                     type='string'
-                    value={`${process.env.API_SERVER}/api/projects/${currentProject.id}/invites/${this.state.invites[i].token}`}
+                    value={`${this.state.isHTTPS ? 'https://' : ''}${process.env.API_SERVER}/api/projects/${currentProject.id}/invites/${this.state.invites[i].token}`}
                     placeholder='Unable to retrieve link'
                   />
                   <CopyButton
@@ -212,12 +199,13 @@ export default class InviteList extends Component<PropsType, StateType> {
           )
         }
       }
+
       return (
         <>
           <Heading>Invites & Collaborators</Heading>
           <Helper>Manage pending invites and view collaborators.</Helper>
-          {invContent.length > 0
-            ? <Table><tbody>{invContent}</tbody></Table>
+          {((invContent.length > 0) || (collabList.length > 0))
+            ? <Table><tbody>{invContent}{collabList}</tbody></Table>
             : <Placeholder>This project currently has no invites or collaborators.</Placeholder>
           }
         </>
@@ -282,35 +270,8 @@ const DarkMatter = styled.div`
   margin-top: -10px;
 `;
 
-const Subtitle = styled.div`
-  font-size: 18px;
-  font-weight: 700;
-  font-family: 'Work Sans', sans-serif;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  margin-bottom: 24px;
-  margin-top: 32px;
-`;
-
-const Subsubtitle = styled.div`
-  font-size: 13px;
-  font-family: 'Work Sans', sans-serif;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  margin-bottom: 12px;
-`;
-
-const BodyText = styled.div`
-  color: #ffffff66;
-  font-weight: 400;
-  font-size: 13px;
-`;
-
 const CopyButton = styled.div`
+  visibility: ${(props: { invis?: boolean }) => props.invis ? 'hidden' : 'visible'};
   color: #ffffff;
   font-weight: 400;
   font-size: 13px;
@@ -332,6 +293,16 @@ const CopyButton = styled.div`
   }
 `;
 
+const NewLinkButton = styled(CopyButton)`
+  border: none;
+  width: auto;
+  background-color: transparent;
+  :hover {
+    border: none;
+    background-color: transparent;
+  }
+`;
+
 const InviteButton = styled.div<{ disabled: boolean }>`
   height: 35px;
   font-size: 13px;
@@ -364,11 +335,6 @@ const Rower = styled.div`
   display: flex;
   flex-direction: row;
   align-items: center;
-  justify-content: center;
-`;
-
-const CreateInvite = styled.div`
-  margin-top: -10px;
 `;
 
 const ShareLink = styled.input`
@@ -378,7 +344,7 @@ const ShareLink = styled.input`
   background: none;
   width: 60%;
   color: #74a5f7;
-  margin-left: -30px;
+  margin-left: -10px;
   padding: 5px 10px;
   height: 30px;
   text-overflow: ellipsis;
@@ -397,13 +363,15 @@ const Table = styled.table`
   margin-top: 22px;
   border-radius: 5px;
   background: #ffffff11;
+  color: #ffffff;
+  font-weight: 400;
+  font-size: 13px;
 `;
 
 const Td = styled.td`
-  visibility: ${(props: { isTop: boolean, invis?: boolean }) => props.invis ? 'hidden' : 'visible'};
   white-space: nowrap;
   padding: 6px 0px;
-  border-top: ${(props: { isTop: boolean, invis?: boolean }) => (props.isTop ? 'none' : '1px solid #ffffff55')};
+  border-top: ${(props: { isTop: boolean }) => (props.isTop ? 'none' : '1px solid #ffffff55')};
   &:last-child {
     padding-right: 16px;
   }
@@ -414,17 +382,15 @@ const Tr = styled.tr`
 
 const MailTd = styled(Td)`
   padding: 0 12px;
-  max-width: 300px;
-  min-width: 300px;
+  max-width: 186px;
+  min-width: 186px;
   overflow: hidden;
   text-overflow: ellipsis;
-  color: #ffffff;
-  font-weight: 400;
-  font-size: 13px;
 `;
 
 const LinkTd = styled(Td)`
-  width: 100%;
+  width: calc(100% - 40px);
+  padding-left: 40px;
 `;
 
 const Invalid = styled.div`

+ 1 - 46
dashboard/src/main/home/project-settings/ProjectSettings.tsx

@@ -110,52 +110,7 @@ const StyledProjectSettings = styled.div`
   width: calc(90% - 130px);
   min-width: 300px;
   padding-top: 70px;
-`;
-
-const LineBreak = styled.div`
-  width: calc(100% - 0px);
-  height: 2px;
-  background: #ffffff20;
-  margin: 10px 0px -20px;
-`;
-
-const Subtitle = styled.div`
-  font-size: 18px;
-  font-weight: 700;
-  font-family: 'Work Sans', sans-serif;
-  color: #ffffff;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  margin-bottom: 24px;
-  margin-top: 32px;
-`;
-
-const BodyText = styled.div`
-  color: #ffffff;
-  font-weight: 400;
-  font-size: 13px;
-`;
-
-const CopyButton = styled.div`
-  color: #ffffff;
-  font-weight: 400;
-  font-size: 13px;
-  margin-left: 12px;
-  float: right;
-  width: 128px;
-  padding-top: 8px;
-  padding-bottom: 8px;
-  border-radius: 5px;
-  border: 1px solid #ffffff20;
-  background-color: #ffffff10;
-  text-align: center;
-  overflow: hidden;
-  transition: all 0.1s ease-out;
-  :hover {
-    border: 1px solid #ffffff66;
-    background-color: #ffffff20;
-  }
+  height: 100vh;
 `;
 
 const DeleteButton = styled.div`

+ 7 - 12
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -55,13 +55,14 @@ export default class ClusterSection extends Component<PropsType, StateType> {
           clusters.sort((a: any, b: any) => a.id - b.id);
           if (clusters.length > 0) {
             this.setState({ clusters });
-            let saved = JSON.parse(localStorage.getItem('currentCluster'));
-            if (localStorage.getItem('currentCluster') !== 'null') {
+            let saved = JSON.parse(localStorage.getItem(currentProject.id + '-cluster'));
+            if (saved !== 'null') {
               setCurrentCluster(clusters[0]);
               for (let i = 0; i < clusters.length; i++) {
-                if (clusters[i].id = saved.id 
-                  && clusters[i].project_id === saved.project_id 
-                  && clusters[i].name === saved.name
+                if (
+                  clusters[i].id === saved.id &&
+                  clusters[i].project_id === saved.project_id && 
+                  clusters[i].name === saved.name
                 ) {
                   setCurrentCluster(clusters[i]);
                   break;
@@ -150,19 +151,13 @@ export default class ClusterSection extends Component<PropsType, StateType> {
           </DrawerButton>
         </ClusterSelector>
       );
-    } else if (false) {
-      return (
-        <InitializeButton onClick={this.showClusterConfigModal}>
-          <Plus>+</Plus> Add a Cluster
-        </InitializeButton>
-      );
     }
 
     return (
       <InitializeButton
         onClick={() => this.context.setCurrentModal('ClusterInstructionsModal', {})}
       >
-        <Plus>+</Plus> Add a Cluster
+        <Plus>+</Plus> Connect a Cluster
       </InitializeButton>
     )
   };

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

@@ -116,6 +116,9 @@ export default class Sidebar extends Component<PropsType, StateType> {
           </NavButton>
           <NavButton
             selected={currentView === 'integrations'}
+            //onClick={() => {
+            //  setCurrentView('integrations')
+           // }}
             onClick={() => {
               setCurrentModal('IntegrationsInstructionsModal', {})
             }}

+ 2 - 7
dashboard/src/main/home/templates/Templates.tsx

@@ -9,17 +9,12 @@ import TabSelector from '../../../components/TabSelector';
 import ExpandedTemplate from './expanded-template/ExpandedTemplate';
 import Loading from '../../../components/Loading';
 
+import hardcodedNames from './hardcodedNameDict';
+
 const tabOptions = [
   { label: 'Community Templates', value: 'community' }
 ];
 
-// TODO: read in from metadata
-const hardcodedNames: any = {
-  'postgresql': 'PostgreSQL',
-  'docker': 'Docker',
-  'https-issuer': 'HTTPS Issuer'
-};
-
 type PropsType = {
   setCurrentView: (x: string) => void, // Link to add integration from source selector
 };

+ 27 - 6
dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx

@@ -10,7 +10,7 @@ import { PorterTemplate, ChoiceType, ClusterType, StorageType } from '../../../.
 import Selector from '../../../../components/Selector';
 import ImageSelector from '../../../../components/image-selector/ImageSelector';
 import TabRegion from '../../../../components/TabRegion';
-import Heading from '../../../../components/values-form/Heading';
+import InputRow from '../../../../components/values-form/InputRow';
 import SaveButton from '../../../../components/SaveButton';
 import ValuesWrapper from '../../../../components/values-form/ValuesWrapper';
 import ValuesForm from '../../../../components/values-form/ValuesForm';
@@ -32,6 +32,7 @@ type StateType = {
   selectedCluster: string,
   selectedImageUrl: string | null,
   selectedTag: string | null,
+  templateName: string,
   tabOptions: ChoiceType[],
   currentTab: string | null,
   tabContents: any
@@ -46,6 +47,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     selectedCluster: this.context.currentCluster.name,
     selectedNamespace: "default",
     selectedImageUrl: '' as string | null,
+    templateName: '',
     selectedTag: '' as string | null,
     tabOptions: [] as ChoiceType[],
     currentTab: null as string | null,
@@ -55,7 +57,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
   onSubmitAddon = (wildcard?: any) => {
     let { currentCluster, currentProject } = this.context;
-    let name = randomWords({ exactly: 3, join: '-' });
+    let name = this.state.templateName || randomWords({ exactly: 3, join: '-' });
     this.setState({ saveValuesStatus: 'loading' });
 
     let values = {};
@@ -97,7 +99,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
   onSubmit = (rawValues: any) => {
     let { currentCluster, currentProject } = this.context;
-    let name = randomWords({ exactly: 3, join: '-' });
+    let name = this.state.templateName || randomWords({ exactly: 3, join: '-' });
     this.setState({ saveValuesStatus: 'loading' });
 
     // Convert dotted keys to nested objects
@@ -285,7 +287,10 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     if (this.props.form?.hasSource) {
       return (
         <>
-          <Subtitle>Select the container image you would like to connect to this template.</Subtitle>
+          <Subtitle>
+            Select the container image you would like to connect to this template.
+            <Required>*</Required>
+          </Subtitle>
           <DarkMatter />
           <ImageSelector
             selectedTag={this.state.selectedTag}
@@ -345,6 +350,15 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
             closeOverlay={true}
           />
         </ClusterSection>
+        <Subtitle>Give a unique name to this template (optional).</Subtitle>
+        <DarkMatter antiHeight='-27px' />
+        <InputRow
+          type='text'
+          value={this.state.templateName}
+          setValue={(x: string) => this.setState({ templateName: x })}
+          placeholder='ex: doctor-scientist'
+          width='100%'
+        />
         {this.renderSourceSelector()}
         {this.renderTabRegion()}
       </StyledLaunchTemplate>
@@ -354,6 +368,11 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
 LaunchTemplate.contextType = Context;
 
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+`;
+
 const Link = styled.a`
   margin-left: 5px;
 `;
@@ -385,9 +404,9 @@ const Placeholder = styled.div`
   justify-content: center;
 `;
 
-const DarkMatter = styled.div`
+const DarkMatter = styled.div<{ antiHeight?: string }>`
   width: 100%;
-  margin-top: -15px;
+  margin-top: ${props => props.antiHeight || '-15px'};
 `;
 
 const Subtitle = styled.div`
@@ -396,6 +415,8 @@ const Subtitle = styled.div`
   font-size: 13px;
   color: #aaaabb;
   line-height: 1.6em;
+  display: flex;
+  align-items: center;
 `;
 
 const ClusterLabel = styled.div`

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

@@ -9,11 +9,7 @@ import Loading from '../../../../components/Loading';
 import { PorterTemplate } from '../../../../shared/types';
 import Helper from '../../../../components/values-form/Helper';
 
-// TODO: read in from metadata
-const hardcodedNames: any = {
-  'postgresql': 'PostgreSQL',
-  'docker': 'Docker',
-};
+import hardcodedNames from '../hardcodedNameDict';
 
 type PropsType = {
   currentTemplate: any,
@@ -99,6 +95,22 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
           </Banner>
         </>
       );
+    } else if (this.props.currentTemplate.name.toLowerCase() === 'https-issuer') {
+      return (
+        <>
+          <Br />
+          <Banner>
+            <i className="material-icons-outlined">info</i>
+            To use this template you must first follow
+            <Link 
+              target="_blank"
+              href="https://docs.getporter.dev/docs/https-and-custom-domains"
+            >
+              Porter's HTTPS setup guide
+            </Link> (5 minutes).
+          </Banner>
+        </>
+      );
     }
   }
 
@@ -145,6 +157,7 @@ TemplateInfo.contextType = Context;
 
 const Link = styled.a`
   color: #8590ff;
+  margin-right: 5px;
   cursor: pointer;
   margin-left: 5px;
 `;

+ 12 - 0
dashboard/src/main/home/templates/hardcodedNameDict.tsx

@@ -0,0 +1,12 @@
+const hardcodedNames: any = {
+  'docker': 'Docker',
+  'https-issuer': 'HTTPS Issuer',
+  'metabase': 'Metabase',
+  'mongodb': 'MongoDB',
+  'mysql': 'MySQL',
+  'postgresql': 'PostgreSQL',
+  'redis': 'Redis',
+  'ubuntu': 'Ubuntu',
+};
+
+export default hardcodedNames;

+ 3 - 0
dashboard/src/shared/Context.tsx

@@ -37,18 +37,21 @@ class ContextProvider extends Component {
     },
     currentCluster: null as ClusterType | null,
     setCurrentCluster: (currentCluster: ClusterType, callback?: any) => {
+      localStorage.setItem(this.state.currentProject.id + '-cluster', JSON.stringify(currentCluster));
       this.setState({ currentCluster }, () => {
         callback && callback();
       });
     },
     currentProject: null as ProjectType | null,
     setCurrentProject: (currentProject: ProjectType, callback?: any) => {
+      localStorage.setItem('currentProject', currentProject.id.toString());
       this.setState({ currentProject }, () => {
         callback && callback();
       });
     },
     projects: [] as ProjectType[],
     setProjects: (projects: ProjectType[]) => {
+      projects.sort((a: any, b: any) => (a.name > b.name) ? 1 : -1);
       this.setState({ projects });
     },
     user: null as any,

+ 277 - 233
dashboard/src/shared/api.tsx

@@ -13,174 +13,112 @@ import { StorageType } from './types';
 
 const checkAuth = baseApi('GET', '/api/auth/check');
 
-const registerUser = baseApi<{ 
-  email: string,
-  password: string
-}>('POST', '/api/users');
-
-const logInUser = baseApi<{
-  email: string,
-  password: string
-}>('POST', '/api/login');
-
-const logOutUser = baseApi('POST', '/api/logout');
-
-const getUser = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/users/${pathParams.id}`;
-});
-
-const updateUser = baseApi<{
-  rawKubeConfig?: string,
-  allowedContexts?: string[]
-}, { id: number }>('PUT', pathParams => {
-  return `/api/users/${pathParams.id}`;
-});
-
-const getClusters = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/clusters`;
-});
-
-const getCharts = baseApi<{
-  namespace: string,
-  cluster_id: number,
-  storage: StorageType,
-  limit: number,
-  skip: number,
-  byDate: boolean,
-  statusFilter: string[]
-}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/releases`;
-});
-
-const getChart = baseApi<{
-  namespace: string,
-  cluster_id: number,
-  storage: StorageType
-}, { id: number, name: string, revision: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}`;
-});
-
-const getChartComponents = baseApi<{
-  namespace: string,
-  cluster_id: number,
-  storage: StorageType
-}, { id: number, name: string, revision: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/components`;
-});
-
-const getChartControllers = baseApi<{
-  namespace: string,
-  cluster_id: number,
-  storage: StorageType
-}, { id: number, name: string, revision: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/controllers`;
-});
-
-const getNamespaces = baseApi<{
-  cluster_id: number,
-}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/k8s/namespaces`;
-});
-
-const getMatchingPods = baseApi<{
-  cluster_id: number,
-  selectors: string[]
-}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/k8s/pods`;
+const createAWSIntegration = baseApi<{
+  aws_region: string,
+  aws_cluster_id?: string,
+  aws_access_key_id: string,
+  aws_secret_access_key: string,
+}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/integrations/aws`;
 });
 
-const getIngress = baseApi<{
-  cluster_id: number,
-}, { name: string, namespace: string, id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/ingress/${pathParams.name}`;
+const createDOCR = baseApi<{
+  do_integration_id: number,
+  docr_name: string,
+  docr_subscription_tier: string,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/provision/docr`;
 });
 
-const getInvites = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/invites`;
+const createDOKS = baseApi<{
+  do_integration_id: number,
+  doks_name: string,
+  do_region: string,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/provision/doks`;
 });
 
-const getRevisions = baseApi<{
-  namespace: string,
-  cluster_id: number,
-  storage: StorageType
-}, { id: number, name: string }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/history`;
+const createECR = baseApi<{
+  name: string,
+  aws_integration_id: string,
+}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/registries`;
 });
 
-const rollbackChart = baseApi<{
-  namespace: string,
-  storage: StorageType,
-  revision: number
+const createGCPIntegration = baseApi<{
+  gcp_region: string,
+  gcp_key_data: string,
+  gcp_project_id: string,
 }, {
-  id: number,
-  name: string,
-  cluster_id: number,
+  project_id: number,
 }>('POST', pathParams => {
-  let { id, name, cluster_id } = pathParams;
-  return `/api/projects/${id}/releases/${name}/rollback?cluster_id=${cluster_id}`;
+  return `/api/projects/${pathParams.project_id}/integrations/gcp`;
 });
 
-const upgradeChartValues = baseApi<{
-  namespace: string,
-  storage: StorageType,
-  values: string
+const createGCR = baseApi<{
+  gcp_integration_id: number,
 }, {
-  id: number,
-  name: string,
-  cluster_id: number,
+  project_id: number,
 }>('POST', pathParams => {
-  let { id, name, cluster_id } = pathParams;
-  return `/api/projects/${id}/releases/${name}/upgrade?cluster_id=${cluster_id}`;
-});
-
-const getTemplates = baseApi('GET', '/api/templates');
-
-const getTemplateInfo = baseApi<{}, { name: string, version: string }>('GET', pathParams => {
-  return `/api/templates/${pathParams.name}/${pathParams.version}`;
+  return `/api/projects/${pathParams.project_id}/provision/gcr`;
 });
 
-const getRepos = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/repos`;
-});
+const createGHAction = baseApi<{
+  git_repo: string,
+  image_repo_uri: string,
+  dockerfile_path: string,
+  git_repo_id: number,
+}, {
+  project_id: number,
+  CLUSTER_ID: number,
+  RELEASE_NAME: string,
+  RELEASE_NAMESPACE: string,
+}>('POST', pathParams => {
+  let { project_id, CLUSTER_ID, RELEASE_NAME, RELEASE_NAMESPACE } = pathParams;
+  return `/api/projects/${project_id}/ci/actions?cluster_id=${CLUSTER_ID}&name=${RELEASE_NAME}&namespace=${RELEASE_NAMESPACE}`;
+})
 
-const getBranches = baseApi<{}, { kind: string, repo: string }>('GET', pathParams => {
-  return `/api/repos/${pathParams.kind}/${pathParams.repo}/branches`;
+const createGKE = baseApi<{
+  gcp_integration_id: number,
+  gke_name: string,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/provision/gke`;
 });
 
-const getBranchContents = baseApi<{ 
-  dir: string 
+const createInvite = baseApi<{
+  email: string
 }, {
-  kind: string,
-  repo: string,
-  branch: string
-}>('GET', pathParams => {
-  return `/api/repos/github/${pathParams.repo}/${pathParams.branch}/contents`;
+  id: number
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/invites`;
 });
 
-const getProjects = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/users/${pathParams.id}/projects`;
+const createProject = baseApi<{ name: string }, {}>('POST', pathParams => {
+  return `/api/projects`;
 });
 
-const getReleaseToken = baseApi<{ 
-  namespace: string,
+const deleteCluster = baseApi<{
+}, {
+  project_id: number,
   cluster_id: number,
-  storage: StorageType,
-}, { name: string, id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/webhook_token`;
+}>('DELETE', pathParams => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
 });
 
-const createProject = baseApi<{ name: string }, {}>('POST', pathParams => {
-  return `/api/projects`;
+const deleteInvite = baseApi<{}, { id: number, invId: number }>('DELETE', pathParams => {
+  return `/api/projects/${pathParams.id}/invites/${pathParams.invId}`;
 });
 
 const deleteProject = baseApi<{}, { id: number }>('DELETE', pathParams => {
   return `/api/projects/${pathParams.id}`;
 });
 
-const deleteInvite = baseApi<{}, { id: number, invId: number }>('DELETE', pathParams => {
-  return `/api/projects/${pathParams.id}/invites/${pathParams.invId}`;
-});
-
 const deployTemplate = baseApi<{
   templateName: string,
   imageURL?: string,
@@ -198,64 +136,94 @@ const deployTemplate = baseApi<{
   return `/api/projects/${id}/deploy/${name}/${version}?cluster_id=${cluster_id}`;
 });
 
-const uninstallTemplate = baseApi<{
+const destroyCluster = baseApi<{
+  eks_name: string,
 }, {
-  id: number,
-  name: string, 
-  cluster_id: number,
-  namespace: string,
-  storage: StorageType,
+  project_id: number,
+  infra_id: number,
 }>('POST', pathParams => {
-  let { id, name, cluster_id, storage, namespace } = pathParams;
-  return `/api/projects/${id}/deploy/${name}?cluster_id=${cluster_id}&namespace=${namespace}&storage=${storage}`;
+  return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/eks/destroy`;
 });
 
-const getClusterIntegrations = baseApi('GET', '/api/integrations/cluster');
-
-const getRegistryIntegrations = baseApi('GET', '/api/integrations/registry');
+const getBranchContents = baseApi<{ 
+  dir: string 
+}, {
+  project_id: number,
+  git_repo_id: number
+  kind: string,
+  owner: string,
+  name: string,
+  branch: string
+}>('GET', pathParams => {
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/${pathParams.branch}/contents`;
+});
 
-const getRepoIntegrations = baseApi('GET', '/api/integrations/repo');
+const getBranches = baseApi<{
+}, {
+  project_id: number,
+  git_repo_id: number,
+  kind: string,
+  owner: string,
+  name: string
+}>('GET', pathParams => {
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name}/branches`;
+});
 
-const getProjectClusters = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/clusters`;
+const getChart = baseApi<{
+  namespace: string,
+  cluster_id: number,
+  storage: StorageType
+}, { id: number, name: string, revision: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}`;
 });
 
-const getProjectRegistries = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/registries`;
+const getCharts = baseApi<{
+  namespace: string,
+  cluster_id: number,
+  storage: StorageType,
+  limit: number,
+  skip: number,
+  byDate: boolean,
+  statusFilter: string[]
+}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases`;
 });
 
-const getProjectRepos = baseApi<{}, { id: number }>('GET', pathParams => {
-  return `/api/projects/${pathParams.id}/repos`;
+const getChartComponents = baseApi<{
+  namespace: string,
+  cluster_id: number,
+  storage: StorageType
+}, { id: number, name: string, revision: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/components`;
 });
 
-const createAWSIntegration = baseApi<{
-  aws_region: string,
-  aws_cluster_id?: string,
-  aws_access_key_id: string,
-  aws_secret_access_key: string,
-}, { id: number }>('POST', pathParams => {
-  return `/api/projects/${pathParams.id}/integrations/aws`;
+const getChartControllers = baseApi<{
+  namespace: string,
+  cluster_id: number,
+  storage: StorageType
+}, { id: number, name: string, revision: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/${pathParams.revision}/controllers`;
 });
 
-const provisionECR = baseApi<{
-  ecr_name: string,
-  aws_integration_id: string,
-}, { id: number }>('POST', pathParams => {
-  return `/api/projects/${pathParams.id}/provision/ecr`;
+const getClusterIntegrations = baseApi('GET', '/api/integrations/cluster');
+
+const getClusters = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/clusters`;
 });
 
-const provisionEKS = baseApi<{
-  eks_name: string,
-  aws_integration_id: string,
-}, { id: number }>('POST', pathParams => {
-  return `/api/projects/${pathParams.id}/provision/eks`;
+const getGitRepoList = baseApi<{
+}, {
+  project_id: number,
+  git_repo_id: number,
+}>('GET', pathParams => {
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id}/repos`;
 });
 
-const createECR = baseApi<{
-  name: string,
-  aws_integration_id: string,
-}, { id: number }>('POST', pathParams => {
-  return `/api/projects/${pathParams.id}/registries`;
+const getGitRepos = baseApi<{
+}, {
+  project_id: number,
+}>('GET', pathParams => {
+  return `/api/projects/${pathParams.project_id}/gitrepos`;
 });
 
 const getImageRepos = baseApi<{
@@ -275,25 +243,67 @@ const getImageTags = baseApi<{
   return `/api/projects/${pathParams.project_id}/registries/${pathParams.registry_id}/repositories/${pathParams.repo_name}`;
 });
 
-const linkGithubProject = baseApi<{
+const getInfra = baseApi<{
 }, {
   project_id: number,
 }>('GET', pathParams => {
-  return `/api/oauth/projects/${pathParams.project_id}/github`;
+  return `/api/projects/${pathParams.project_id}/infra`;
 });
 
-const getGitRepos = baseApi<{  
-}, {
-  project_id: number,
-}>('GET', pathParams => {
-  return `/api/projects/${pathParams.project_id}/gitrepos`;
+const getIngress = baseApi<{
+  cluster_id: number,
+}, { name: string, namespace: string, id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/k8s/${pathParams.namespace}/ingress/${pathParams.name}`;
 });
 
-const getInfra = baseApi<{
+const getInvites = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/invites`;
+});
+
+const getMatchingPods = baseApi<{
+  cluster_id: number,
+  selectors: string[]
+}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/k8s/pods`;
+});
+
+const getNamespaces = baseApi<{
+  cluster_id: number,
+}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/k8s/namespaces`;
+});
+
+const getOAuthIds = baseApi<{
 }, {
   project_id: number,
 }>('GET', pathParams => {
-  return `/api/projects/${pathParams.project_id}/infra`;
+  return `/api/projects/${pathParams.project_id}/integrations/oauth`;
+});
+
+const getProjectClusters = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/clusters`;
+});
+
+const getProjectRegistries = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/registries`;
+});
+
+const getProjectRepos = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/repos`;
+});
+
+const getProjects = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/users/${pathParams.id}/projects`;
+});
+
+const getRegistryIntegrations = baseApi('GET', '/api/integrations/registry');
+
+const getReleaseToken = baseApi<{ 
+  namespace: string,
+  cluster_id: number,
+  storage: StorageType,
+}, { name: string, id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/webhook_token`;
 });
 
 const destroyEKS = baseApi<{
@@ -323,86 +333,118 @@ const destroyDOKS = baseApi<{
   return `/api/projects/${pathParams.project_id}/infra/${pathParams.infra_id}/doks/destroy`;
 });
 
-const deleteCluster = baseApi<{
-}, {
-  project_id: number,
+const getRepoIntegrations = baseApi('GET', '/api/integrations/repo');
+
+const getRepos = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/repos`;
+});
+
+const getRevisions = baseApi<{
+  namespace: string,
   cluster_id: number,
-}>('DELETE', pathParams => {
-  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
+  storage: StorageType
+}, { id: number, name: string }>('GET', pathParams => {
+  return `/api/projects/${pathParams.id}/releases/${pathParams.name}/history`;
 });
 
-const createGCPIntegration = baseApi<{
-  gcp_region: string,
-  gcp_key_data: string,
-  gcp_project_id: string,
-}, {
-  project_id: number,
-}>('POST', pathParams => {
-  return `/api/projects/${pathParams.project_id}/integrations/gcp`;
+const getTemplateInfo = baseApi<{}, { name: string, version: string }>('GET', pathParams => {
+  return `/api/templates/${pathParams.name}/${pathParams.version}`;
 });
 
-const createGCR = baseApi<{
-  gcp_integration_id: number,
-}, {
-  project_id: number,
-}>('POST', pathParams => {
-  return `/api/projects/${pathParams.project_id}/provision/gcr`;
+const getTemplates = baseApi('GET', '/api/templates');
+
+const getUser = baseApi<{}, { id: number }>('GET', pathParams => {
+  return `/api/users/${pathParams.id}`;
 });
 
-const createGKE = baseApi<{
-  gcp_integration_id: number,
-  gke_name: string,
+const linkGithubProject = baseApi<{
 }, {
   project_id: number,
-}>('POST', pathParams => {
-  return `/api/projects/${pathParams.project_id}/provision/gke`;
+}>('GET', pathParams => {
+  return `/api/oauth/projects/${pathParams.project_id}/github`;
 });
 
-const createInvite = baseApi<{
-  email: string
-}, {
-  id: number
-}>('POST', pathParams => {
-  return `/api/projects/${pathParams.id}/invites`;
+const logInUser = baseApi<{
+  email: string,
+  password: string
+}>('POST', '/api/login');
+
+const logOutUser = baseApi('POST', '/api/logout');
+
+const provisionECR = baseApi<{
+  ecr_name: string,
+  aws_integration_id: string,
+}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/provision/ecr`;
 });
 
-const getOAuthIds = baseApi<{
+const provisionEKS = baseApi<{
+  eks_name: string,
+  aws_integration_id: string,
+}, { id: number }>('POST', pathParams => {
+  return `/api/projects/${pathParams.id}/provision/eks`;
+});
+
+const registerUser = baseApi<{ 
+  email: string,
+  password: string
+}>('POST', '/api/users');
+
+const rollbackChart = baseApi<{
+  namespace: string,
+  storage: StorageType,
+  revision: number
 }, {
-  project_id: number,
-}>('GET', pathParams => {
-  return `/api/projects/${pathParams.project_id}/integrations/oauth`;
+  id: number,
+  name: string,
+  cluster_id: number,
+}>('POST', pathParams => {
+  let { id, name, cluster_id } = pathParams;
+  return `/api/projects/${id}/releases/${name}/rollback?cluster_id=${cluster_id}`;
 });
 
-const createDOCR = baseApi<{
-  do_integration_id: number,
-  docr_name: string,
-  docr_subscription_tier: string,
+const uninstallTemplate = baseApi<{
 }, {
-  project_id: number,
+  id: number,
+  name: string, 
+  cluster_id: number,
+  namespace: string,
+  storage: StorageType,
 }>('POST', pathParams => {
-  return `/api/projects/${pathParams.project_id}/provision/docr`;
+  let { id, name, cluster_id, storage, namespace } = pathParams;
+  return `/api/projects/${id}/deploy/${name}?cluster_id=${cluster_id}&namespace=${namespace}&storage=${storage}`;
 });
 
-const createDOKS = baseApi<{
-  do_integration_id: number,
-  doks_name: string,
-  do_region: string,
+const updateUser = baseApi<{
+  rawKubeConfig?: string,
+  allowedContexts?: string[]
+}, { id: number }>('PUT', pathParams => {
+  return `/api/users/${pathParams.id}`;
+});
+
+const upgradeChartValues = baseApi<{
+  namespace: string,
+  storage: StorageType,
+  values: string
 }, {
-  project_id: number,
+  id: number,
+  name: string,
+  cluster_id: number,
 }>('POST', pathParams => {
-  return `/api/projects/${pathParams.project_id}/provision/doks`;
+  let { id, name, cluster_id } = pathParams;
+  return `/api/projects/${id}/releases/${name}/upgrade?cluster_id=${cluster_id}`;
 });
 
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
-  createDOKS,
-  createDOCR,
-  getOAuthIds,
   checkAuth,
   createAWSIntegration,
+  createDOCR,
+  createDOKS,
   createECR,
   createGCPIntegration,
   createGCR,
+  createGHAction,
   createGKE,
   createInvite,
   createProject,
@@ -421,6 +463,7 @@ export default {
   getChartControllers,
   getClusterIntegrations,
   getClusters,
+  getGitRepoList,
   getGitRepos,
   getImageRepos,
   getImageTags,
@@ -429,6 +472,7 @@ export default {
   getInvites,
   getMatchingPods,
   getNamespaces,
+  getOAuthIds,
   getProjectClusters,
   getProjectRegistries,
   getProjectRepos,

+ 15 - 25
dashboard/src/shared/feedback.tsx

@@ -1,29 +1,19 @@
 import axios from 'axios';
 
-const ignoreUsers = [
-  'justin@getporter.dev',
-  'trevor@getporter.dev',
-  'belanger@getporter.dev',
-  'seanr112593@gmail.com',
-];
-
 export const handleSubmitFeedback = (msg: string, callback?: (err: any, res: any) => void) => {
-  let splits = msg.split(' ');
-  if (!window.location.href.includes('localhost:8080') && !ignoreUsers.includes(splits[1])) {
-    axios.post(process.env.FEEDBACK_ENDPOINT, {
-      key: process.env.DISCORD_KEY,
-      cid: process.env.DISCORD_CID,
-      message: msg,
-    }, {
-      headers: {
-        Authorization: `Bearer <>`
-      }
-    })
-    .then(res => {
-      callback && callback(null, res);
-    })
-    .catch(err => {
-      callback && callback(err, null);
-    });
-  }
+  axios.post(process.env.FEEDBACK_ENDPOINT, {
+    key: process.env.DISCORD_KEY,
+    cid: process.env.DISCORD_CID,
+    message: msg,
+  }, {
+    headers: {
+      Authorization: `Bearer <>`
+    }
+  })
+  .then(res => {
+    callback && callback(null, res);
+  })
+  .catch(err => {
+    callback && callback(err, null);
+  });
 }

+ 9 - 1
dashboard/src/shared/types.tsx

@@ -113,7 +113,8 @@ export interface FormElement {
 
 export interface RepoType {
   FullName: string,
-  kind: string
+  kind: string,
+  GHRepoID: number,
 }
 
 export interface FileType {
@@ -157,4 +158,11 @@ export interface InviteType {
   email: string,
   accepted: boolean,
   id: number,
+}
+
+export interface ActionConfigType {
+  git_repo: string,
+  image_repo_uri: string,
+  git_repo_id: number,
+  dockerfile_path: string,
 }

+ 3 - 0
go.mod

@@ -13,6 +13,7 @@ require (
 	github.com/containerd/containerd v1.4.1 // indirect
 	github.com/coreos/rkt v1.30.0
 	github.com/creack/pty v1.1.11 // indirect
+	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/digitalocean/godo v1.56.0
 	github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492
 	github.com/docker/distribution v2.7.1+incompatible
@@ -31,6 +32,8 @@ require (
 	github.com/go-test/deep v1.0.7
 	github.com/google/go-cmp v0.5.2
 	github.com/google/go-github v17.0.0+incompatible
+	github.com/google/go-github/v32 v32.1.0
+	github.com/google/go-github/v33 v33.0.0
 	github.com/google/go-querystring v1.0.0 // indirect
 	github.com/googleapis/gnostic v0.2.2 // indirect
 	github.com/gorilla/securecookie v1.1.1

+ 5 - 0
go.sum

@@ -471,6 +471,10 @@ github.com/google/go-cmp v0.5.4 h1:L8R9j+yAqZuZjsqh/z+F1NCffTKKLShY6zXTItVIZ8M=
 github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
 github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
+github.com/google/go-github/v32 v32.1.0 h1:GWkQOdXqviCPx7Q7Fj+KyPoGm4SwHRh8rheoPhd27II=
+github.com/google/go-github/v32 v32.1.0/go.mod h1:rIEpZD9CTDQwDK9GDrtMTycQNA4JU3qBsCizh3q2WCI=
+github.com/google/go-github/v33 v33.0.0 h1:qAf9yP0qc54ufQxzwv+u9H0tiVOnPJxo0lI/JXqw3ZM=
+github.com/google/go-github/v33 v33.0.0/go.mod h1:GMdDnVZY/2TsWgp/lkYnpSAh6TrzhANBBwm6k6TTEXg=
 github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
 github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
 github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=
@@ -1065,6 +1069,7 @@ golang.org/x/crypto v0.0.0-20200414173820-0848c9571904/go.mod h1:LzIPMQfyMNhhGPh
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0 h1:hb9wdF1z5waM+dSIICn1l0DkLVDT3hqhhQsDNUmHPRE=
 golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad h1:DN0cp81fZ3njFcrLCytUHRSUkqBjfTo4Tx9RJTWs0EY=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=

+ 4 - 1
internal/adapter/gorm.go

@@ -17,6 +17,7 @@ func New(conf *config.DBConf) (*gorm.DB, error) {
 		// not support foreign key constraints
 		return gorm.Open(sqlite.Open(conf.SQLLitePath), &gorm.Config{
 			DisableForeignKeyConstraintWhenMigrating: true,
+			FullSaveAssociations:                     true,
 		})
 	}
 
@@ -28,7 +29,9 @@ func New(conf *config.DBConf) (*gorm.DB, error) {
 		conf.Host,
 	)
 
-	res, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
+	res, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
+		FullSaveAssociations: true,
+	})
 
 	// retry the connection 3 times
 	retryCount := 0

+ 0 - 0
internal/auth/sessionstore.go → internal/auth/sessionstore/sessionstore.go


+ 1 - 1
internal/auth/sessionstore_test.go → internal/auth/sessionstore/sessionstore_test.go

@@ -11,7 +11,7 @@ import (
 	"github.com/gorilla/sessions"
 	test "github.com/porter-dev/porter/internal/repository/memory"
 
-	sessionstore "github.com/porter-dev/porter/internal/auth"
+	"github.com/porter-dev/porter/internal/auth/sessionstore"
 )
 
 type headerOnlyResponseWriter http.Header

+ 119 - 0
internal/auth/token/token.go

@@ -0,0 +1,119 @@
+package token
+
+import (
+	"fmt"
+	"strconv"
+	"time"
+
+	"github.com/dgrijalva/jwt-go"
+)
+
+type Subject string
+
+const (
+	User Subject = "user"
+	API  Subject = "api"
+)
+
+type TokenGeneratorConf struct {
+	TokenSecret string
+}
+
+type Token struct {
+	SubKind   Subject    `json:"sub_kind"`
+	Sub       string     `json:"sub"`
+	ProjectID uint       `json:"project_id"`
+	IBy       uint       `json:"iby"`
+	IAt       *time.Time `json:"iat"`
+}
+
+func GetTokenForUser(userID, projID uint) (*Token, error) {
+	if userID == 0 || projID == 0 {
+		return nil, fmt.Errorf("id cannot be 0")
+	}
+
+	iat := time.Now()
+
+	return &Token{
+		SubKind:   User,
+		Sub:       fmt.Sprintf("%d", userID),
+		ProjectID: projID,
+		IBy:       userID,
+		IAt:       &iat,
+	}, nil
+}
+
+func GetTokenForAPI(userID, projID uint) (*Token, error) {
+	if userID == 0 || projID == 0 {
+		return nil, fmt.Errorf("id cannot be 0")
+	}
+
+	iat := time.Now()
+
+	return &Token{
+		SubKind:   API,
+		Sub:       string(API),
+		ProjectID: projID,
+		IBy:       userID,
+		IAt:       &iat,
+	}, nil
+}
+
+func (t *Token) EncodeToken(conf *TokenGeneratorConf) (string, error) {
+	token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
+		"sub_kind":   t.SubKind,
+		"sub":        t.Sub,
+		"iby":        t.IBy,
+		"iat":        fmt.Sprintf("%d", t.IAt.Unix()),
+		"project_id": t.ProjectID,
+	})
+
+	// Sign and get the complete encoded token as a string using the secret
+	return token.SignedString([]byte(conf.TokenSecret))
+}
+
+func GetTokenFromEncoded(tokenString string, conf *TokenGeneratorConf) (*Token, error) {
+	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
+		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
+			return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
+		}
+
+		return []byte(conf.TokenSecret), nil
+	})
+
+	if err != nil {
+		return nil, fmt.Errorf("could not parse token: %v", err)
+	}
+
+	if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
+		iby, err := strconv.ParseUint(fmt.Sprintf("%v", claims["iby"]), 10, 64)
+
+		if err != nil {
+			return nil, fmt.Errorf("invalid iby claim: %v", err)
+		}
+
+		projID, err := strconv.ParseUint(fmt.Sprintf("%v", claims["project_id"]), 10, 64)
+
+		if err != nil {
+			return nil, fmt.Errorf("invalid project_id claim: %v", err)
+		}
+
+		iatUnix, err := strconv.ParseInt(fmt.Sprintf("%v", claims["iat"]), 10, 64)
+
+		if err != nil {
+			return nil, fmt.Errorf("invalid iat claim: %v", err)
+		}
+
+		iat := time.Unix(iatUnix, 0)
+
+		return &Token{
+			SubKind:   Subject(fmt.Sprintf("%v", claims["sub_kind"])),
+			Sub:       fmt.Sprintf("%v", claims["sub"]),
+			IBy:       uint(iby),
+			IAt:       &iat,
+			ProjectID: uint(projID),
+		}, nil
+	}
+
+	return nil, fmt.Errorf("invalid token")
+}

+ 52 - 0
internal/auth/token/token_test.go

@@ -0,0 +1,52 @@
+package token_test
+
+import (
+	"testing"
+	"time"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/auth/token"
+)
+
+func TestGetAndEncodeTokenForUser(t *testing.T) {
+	conf := &token.TokenGeneratorConf{
+		TokenSecret: "fakesecret",
+	}
+
+	tok, err := token.GetTokenForUser(1, 1)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tokString, err := tok.EncodeToken(conf)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// decode the token again and compare
+	expToken := &token.Token{
+		SubKind:   token.User,
+		Sub:       "1",
+		ProjectID: 1,
+		IBy:       1,
+	}
+
+	gotToken, err := token.GetTokenFromEncoded(tokString, conf)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	if now := time.Now(); now.Sub(*gotToken.IAt) < 5 && now.Sub(*gotToken.IAt) >= 0 {
+		t.Fatalf("time not within threshold: issued at %d, current time %d\n", gotToken.IAt.Unix(), now.Unix())
+	}
+
+	gotToken.IAt = nil
+
+	if diff := deep.Equal(expToken, gotToken); diff != nil {
+		t.Errorf("tokens not equal:")
+		t.Error(diff)
+	}
+}

+ 12 - 11
internal/config/config.go

@@ -18,18 +18,19 @@ type Conf struct {
 
 // ServerConf is the server configuration
 type ServerConf struct {
-	ServerURL      string        `env:"SERVER_URL,default=http://localhost:8080"`
-	Port           int           `env:"SERVER_PORT,default=8080"`
-	StaticFilePath string        `env:"STATIC_FILE_PATH,default=/porter/static"`
-	CookieName     string        `env:"COOKIE_NAME,default=porter"`
-	CookieSecret   []byte        `env:"COOKIE_SECRET,default=secret"`
-	TimeoutRead    time.Duration `env:"SERVER_TIMEOUT_READ,default=5s"`
-	TimeoutWrite   time.Duration `env:"SERVER_TIMEOUT_WRITE,default=10s"`
-	TimeoutIdle    time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
-	IsLocal        bool          `env:"IS_LOCAL,default=false"`
-	IsTesting      bool          `env:"IS_TESTING,default=false"`
+	ServerURL            string        `env:"SERVER_URL,default=http://localhost:8080"`
+	Port                 int           `env:"SERVER_PORT,default=8080"`
+	StaticFilePath       string        `env:"STATIC_FILE_PATH,default=/porter/static"`
+	CookieName           string        `env:"COOKIE_NAME,default=porter"`
+	CookieSecret         []byte        `env:"COOKIE_SECRET,default=secret"`
+	TokenGeneratorSecret string        `env:"TOKEN_GENERATOR_SECRET,default=secret"`
+	TimeoutRead          time.Duration `env:"SERVER_TIMEOUT_READ,default=5s"`
+	TimeoutWrite         time.Duration `env:"SERVER_TIMEOUT_WRITE,default=10s"`
+	TimeoutIdle          time.Duration `env:"SERVER_TIMEOUT_IDLE,default=15s"`
+	IsLocal              bool          `env:"IS_LOCAL,default=false"`
+	IsTesting            bool          `env:"IS_TESTING,default=false"`
 
-	DefaultHelmRepoURL string `env:"HELM_REPO_URL,default=https://porter-dev.github.io/chart-repo/"`
+	DefaultHelmRepoURL string `env:"HELM_REPO_URL,default=https://porter-dev.github.io/chart-repo-dev/"`
 
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`

+ 26 - 0
internal/forms/git_action.go

@@ -0,0 +1,26 @@
+package forms
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// CreateGitAction represents the accepted values for creating a
+// github action integration
+type CreateGitAction struct {
+	ReleaseID      uint   `json:"release_id" form:"required"`
+	GitRepo        string `json:"git_repo" form:"required"`
+	ImageRepoURI   string `json:"image_repo_uri" form:"required"`
+	DockerfilePath string `json:"dockerfile_path" form:"required"`
+	GitRepoID      uint   `json:"git_repo_id" form:"required"`
+}
+
+// ToGitActionConfig converts the form to a gorm git action config model
+func (ca *CreateGitAction) ToGitActionConfig() (*models.GitActionConfig, error) {
+	return &models.GitActionConfig{
+		ReleaseID:      ca.ReleaseID,
+		GitRepo:        ca.GitRepo,
+		ImageRepoURI:   ca.ImageRepoURI,
+		DockerfilePath: ca.DockerfilePath,
+		GitRepoID:      ca.GitRepoID,
+	}, nil
+}

+ 15 - 15
internal/helm/agent_test.go

@@ -283,26 +283,26 @@ var upgradeTests = []listReleaseTest{
 	},
 }
 
-func TestUpgradeRelease(t *testing.T) {
-	for _, tc := range upgradeTests {
-		agent := newAgentFixture(t, tc.namespace)
-		makeReleases(t, agent, tc.releases)
+// func TestUpgradeRelease(t *testing.T) {
+// 	for _, tc := range upgradeTests {
+// 		agent := newAgentFixture(t, tc.namespace)
+// 		makeReleases(t, agent, tc.releases)
 
-		// calling agent.ActionConfig.Releases.Create in makeReleases will automatically set the
-		// namespace, so we have to reset the namespace of the storage driver
-		agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace(tc.namespace)
+// 		// calling agent.ActionConfig.Releases.Create in makeReleases will automatically set the
+// 		// namespace, so we have to reset the namespace of the storage driver
+// 		agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace(tc.namespace)
 
-		agent.UpgradeRelease("wordpress", "")
+// 		agent.UpgradeRelease("wordpress", "", &oauth2.Config{})
 
-		releases, err := agent.GetReleaseHistory("wordpress")
+// 		releases, err := agent.GetReleaseHistory("wordpress")
 
-		if err != nil {
-			t.Errorf("%v", err)
-		}
+// 		if err != nil {
+// 			t.Errorf("%v", err)
+// 		}
 
-		compareReleaseToStubs(t, releases, tc.expRes)
-	}
-}
+// 		compareReleaseToStubs(t, releases, tc.expRes)
+// 	}
+// }
 
 var rollbackReleaseTests = []getReleaseTest{
 	getReleaseTest{

+ 235 - 0
internal/integrations/ci/actions/actions.go

@@ -0,0 +1,235 @@
+package actions
+
+import (
+	"context"
+	"encoding/base64"
+	"fmt"
+
+	"github.com/google/go-github/v33/github"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/crypto/nacl/box"
+	"golang.org/x/oauth2"
+
+	"strings"
+
+	"gopkg.in/yaml.v2"
+)
+
+type GithubActions struct {
+	GitIntegration *models.GitRepo
+	GitRepoName    string
+	GitRepoOwner   string
+	Repo           repository.Repository
+
+	GithubConf *oauth2.Config
+
+	WebhookToken string
+	PorterToken  string
+	ProjectID    uint
+	ReleaseName  string
+
+	DockerFilePath string
+	ImageRepoURL   string
+
+	defaultBranch string
+}
+
+func (g *GithubActions) Setup() (string, error) {
+	client, err := g.getClient()
+
+	if err != nil {
+		return "", err
+	}
+
+	// get the repository to find the default branch
+	repo, _, err := client.Repositories.Get(
+		context.TODO(),
+		g.GitRepoOwner,
+		g.GitRepoName,
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	g.defaultBranch = repo.GetDefaultBranch()
+
+	// create a new secret with a webhook token
+	err = g.createGithubSecret(client, g.getWebhookSecretName(), g.WebhookToken)
+
+	if err != nil {
+		return "", err
+	}
+
+	// create a new secret with a porter token
+	err = g.createGithubSecret(client, g.getPorterTokenSecretName(), g.PorterToken)
+
+	if err != nil {
+		return "", err
+	}
+
+	fileBytes, err := g.GetGithubActionYAML()
+
+	if err != nil {
+		return "", err
+	}
+
+	return g.commitGithubFile(client, fileBytes)
+}
+
+type GithubActionYAMLStep struct {
+	Name string `yaml:"name"`
+	ID   string `yaml:"id"`
+	Run  string `yaml:"run"`
+}
+
+type GithubActionYAMLOnPushBranches struct {
+	Branches []string `yaml:"branches"`
+}
+
+type GithubActionYAMLOnPush struct {
+	Push GithubActionYAMLOnPushBranches `yaml:"push"`
+}
+
+type GithubActionYAMLJob struct {
+	RunsOn string                 `yaml:"runs-on"`
+	Steps  []GithubActionYAMLStep `yaml:"steps"`
+}
+
+type GithubActionYAML struct {
+	On GithubActionYAMLOnPush `yaml:"on"`
+
+	Name string `yaml:"name"`
+
+	Jobs map[string]GithubActionYAMLJob `yaml:"jobs"`
+}
+
+func (g *GithubActions) GetGithubActionYAML() ([]byte, error) {
+	actionYAML := &GithubActionYAML{
+		On: GithubActionYAMLOnPush{
+			Push: GithubActionYAMLOnPushBranches{
+				Branches: []string{
+					g.defaultBranch,
+				},
+			},
+		},
+		Name: "Deploy to Porter",
+		Jobs: map[string]GithubActionYAMLJob{
+			"porter-deploy": GithubActionYAMLJob{
+				RunsOn: "ubuntu-latest",
+				Steps: []GithubActionYAMLStep{
+					getDownloadPorterStep(),
+					getConfigurePorterStep(g.getPorterTokenSecretName()),
+					getDockerBuildPushStep(g.DockerFilePath, g.ImageRepoURL),
+					deployPorterWebhookStep(g.getWebhookSecretName(), g.ImageRepoURL),
+				},
+			},
+		},
+	}
+
+	return yaml.Marshal(actionYAML)
+}
+
+func (g *GithubActions) getClient() (*github.Client, error) {
+	// get the oauth integration
+	oauthInt, err := g.Repo.OAuthIntegration.ReadOAuthIntegration(g.GitIntegration.OAuthIntegrationID)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok := &oauth2.Token{
+		AccessToken:  string(oauthInt.AccessToken),
+		RefreshToken: string(oauthInt.RefreshToken),
+		TokenType:    "Bearer",
+	}
+
+	client := github.NewClient(g.GithubConf.Client(oauth2.NoContext, tok))
+
+	return client, nil
+}
+
+func (g *GithubActions) createGithubSecret(
+	client *github.Client,
+	secretName,
+	secretValue string,
+) error {
+	// get the public key for the repo
+	key, _, err := client.Actions.GetRepoPublicKey(context.TODO(), g.GitRepoOwner, g.GitRepoName)
+
+	if err != nil {
+		return err
+	}
+
+	// encrypt the secret with the public key
+	keyBytes := [32]byte{}
+
+	keyDecoded, err := base64.StdEncoding.DecodeString(*key.Key)
+
+	if err != nil {
+		return err
+	}
+
+	copy(keyBytes[:], keyDecoded[:])
+
+	secretEncoded, err := box.SealAnonymous(nil, []byte(secretValue), &keyBytes, nil)
+
+	if err != nil {
+		return err
+	}
+
+	encrypted := base64.StdEncoding.EncodeToString(secretEncoded)
+
+	encryptedSecret := &github.EncryptedSecret{
+		Name:           secretName,
+		KeyID:          *key.KeyID,
+		EncryptedValue: encrypted,
+	}
+
+	// write the secret to the repo
+	_, err = client.Actions.CreateOrUpdateRepoSecret(context.TODO(), g.GitRepoOwner, g.GitRepoName, encryptedSecret)
+
+	return nil
+}
+
+func (g *GithubActions) getWebhookSecretName() string {
+	return fmt.Sprintf("WEBHOOK_%s", strings.Replace(
+		strings.ToUpper(g.ReleaseName), "-", "_", -1),
+	)
+}
+
+func (g *GithubActions) getPorterTokenSecretName() string {
+	return fmt.Sprintf("PORTER_TOKEN_%d", g.ProjectID)
+}
+
+func (g *GithubActions) commitGithubFile(
+	client *github.Client,
+	contents []byte,
+) (string, error) {
+	fmt.Println("GITHUB ACTION CONTENTS ARE", string(contents))
+
+	opts := &github.RepositoryContentFileOptions{
+		Message: github.String("Create porter.yml file"),
+		Content: contents,
+		Branch:  github.String(g.defaultBranch),
+		Committer: &github.CommitAuthor{
+			Name:  github.String("Porter Bot"),
+			Email: github.String("contact@getporter.dev"),
+		},
+	}
+
+	resp, _, err := client.Repositories.CreateFile(
+		context.TODO(),
+		g.GitRepoOwner,
+		g.GitRepoName,
+		".github/workflows/porter.yml",
+		opts,
+	)
+
+	if err != nil {
+		return "", err
+	}
+
+	return *resp.Commit.SHA, nil
+}

+ 59 - 0
internal/integrations/ci/actions/steps.go

@@ -0,0 +1,59 @@
+package actions
+
+import "fmt"
+
+const download string = `
+name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*/porter_.*_Linux_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
+name=$(basename $name)
+curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $name
+unzip -a $name
+rm $name
+chmod +x ./porter
+sudo mv ./porter /usr/local/bin/porter
+`
+
+func getDownloadPorterStep() GithubActionYAMLStep {
+	return GithubActionYAMLStep{
+		Name: "Download Porter",
+		ID:   "download_porter",
+		Run:  download,
+	}
+}
+
+const configure string = `
+porter auth login --token ${{secrets.%s}}
+porter docker configure
+`
+
+func getConfigurePorterStep(porterTokenSecretName string) GithubActionYAMLStep {
+	return GithubActionYAMLStep{
+		Name: "Configure Porter",
+		ID:   "configure_porter",
+		Run:  fmt.Sprintf(configure, porterTokenSecretName),
+	}
+}
+
+const dockerBuildPush string = `
+docker build . --file %s -t %s:$(git rev-parse --short HEAD)
+docker push %s:$(git rev-parse --short HEAD)
+`
+
+func getDockerBuildPushStep(dockerFilePath, repoURL string) GithubActionYAMLStep {
+	return GithubActionYAMLStep{
+		Name: "Docker build, push",
+		ID:   "docker_build_push",
+		Run:  fmt.Sprintf(dockerBuildPush, dockerFilePath, repoURL, repoURL),
+	}
+}
+
+const deployPorter string = `
+curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${{secrets.%s}}?commit=$(git rev-parse --short HEAD)&repository=%s'
+`
+
+func deployPorterWebhookStep(webhookTokenSecretName, repoURL string) GithubActionYAMLStep {
+	return GithubActionYAMLStep{
+		Name: "Deploy on Porter",
+		ID:   "deploy_porter",
+		Run:  fmt.Sprintf(deployPorter, webhookTokenSecretName, repoURL),
+	}
+}

+ 46 - 0
internal/models/gitrepo.go

@@ -43,3 +43,49 @@ func (r *GitRepo) Externalize() *GitRepoExternal {
 		Service:    integrations.Github,
 	}
 }
+
+// GitActionConfig is a configuration for release's CI integration via
+// Github Actions
+type GitActionConfig struct {
+	gorm.Model
+
+	// The ID of the release that this is linked to
+	ReleaseID uint `json:"release_id"`
+
+	// The git repo in ${owner}/${repo} form
+	GitRepo string `json:"git_repo"`
+
+	// The complete image repository uri to pull from
+	ImageRepoURI string `json:"image_repo_uri"`
+
+	// The git integration id
+	GitRepoID uint `json:"git_repo_id"`
+
+	// The path to the dockerfile in the git repo
+	DockerfilePath string `json:"dockerfile_path" form:"required"`
+}
+
+// GitActionConfigExternal is an external GitActionConfig to be shared over REST
+type GitActionConfigExternal struct {
+	// The git repo in ${owner}/${repo} form
+	GitRepo string `json:"git_repo"`
+
+	// The complete image repository uri to pull from
+	ImageRepoURI string `json:"image_repo_uri"`
+
+	// The git integration id
+	GitRepoID uint `json:"git_repo_id"`
+
+	// The path to the dockerfile in the git repo
+	DockerfilePath string `json:"dockerfile_path" form:"required"`
+}
+
+// Externalize generates an external GitActionConfig to be shared over REST
+func (r *GitActionConfig) Externalize() *GitActionConfigExternal {
+	return &GitActionConfigExternal{
+		GitRepo:        r.GitRepo,
+		ImageRepoURI:   r.ImageRepoURI,
+		GitRepoID:      r.GitRepoID,
+		DockerfilePath: r.DockerfilePath,
+	}
+}

+ 11 - 8
internal/models/release.go

@@ -10,24 +10,27 @@ import (
 type Release struct {
 	gorm.Model
 
-	WebhookToken string `json:"webhook_token" gorm:"unique"`
-	ClusterID    uint   `json:"cluster_id"`
-	ProjectID    uint   `json:"project_id"`
-	Name         string `json:"name"`
-	Namespace    string `json:"namespace"`
+	WebhookToken    string          `json:"webhook_token" gorm:"unique"`
+	ClusterID       uint            `json:"cluster_id"`
+	ProjectID       uint            `json:"project_id"`
+	Name            string          `json:"name"`
+	Namespace       string          `json:"namespace"`
+	GitActionConfig GitActionConfig `json:"git_action_config"`
 }
 
 // ReleaseExternal represents the Release type that is sent over REST
 type ReleaseExternal struct {
 	ID uint `json:"id"`
 
-	WebhookToken string `json:"webhook_token"`
+	WebhookToken    string                   `json:"webhook_token"`
+	GitActionConfig *GitActionConfigExternal `json:"git_action_config,omitempty"`
 }
 
 // Externalize generates an external User to be shared over REST
 func (r *Release) Externalize() *ReleaseExternal {
 	return &ReleaseExternal{
-		ID:           r.ID,
-		WebhookToken: r.WebhookToken,
+		ID:              r.ID,
+		WebhookToken:    r.WebhookToken,
+		GitActionConfig: r.GitActionConfig.Externalize(),
 	}
 }

+ 4 - 2
internal/models/templates.go

@@ -50,8 +50,10 @@ type FormContent struct {
 	Variable string       `yaml:"variable,omitempty" json:"variable,omitempty"`
 	Value    interface{}  `yaml:"value,omitempty" json:"value,omitempty"`
 	Settings struct {
-		Default interface{} `yaml:"default,omitempty" json:"default,omitempty"`
-		Unit    interface{} `yaml:"unit,omitempty" json:"unit,omitempty"`
+		Default     interface{} `yaml:"default,omitempty" json:"default,omitempty"`
+		Unit        interface{} `yaml:"unit,omitempty" json:"unit,omitempty"`
+		Options     interface{} `yaml:"options,omitempty" json:"options,omitempty"`
+		Placeholder string      `yaml:"placeholder,omitempty" json:"placeholder,omitempty"`
 	} `yaml:"settings,omitempty" json:"settings,omitempty"`
 }
 

+ 10 - 0
internal/repository/git_action_config.go

@@ -0,0 +1,10 @@
+package repository
+
+import "github.com/porter-dev/porter/internal/models"
+
+// GitActionConfigRepository represents the set of queries on the
+// GitActionConfig model
+type GitActionConfigRepository interface {
+	CreateGitActionConfig(gr *models.GitActionConfig) (*models.GitActionConfig, error)
+	ReadGitActionConfig(id uint) (*models.GitActionConfig, error)
+}

+ 6 - 1
internal/repository/gorm/cluster.go

@@ -170,7 +170,11 @@ func (repo *ClusterRepository) ReadCluster(
 		return nil, err
 	}
 
-	repo.DecryptClusterData(cluster, repo.key)
+	err := repo.DecryptClusterData(cluster, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
 
 	return cluster, nil
 }
@@ -343,6 +347,7 @@ func (repo *ClusterRepository) DecryptClusterData(
 	}
 
 	if tok := cluster.TokenCache.Token; len(tok) > 0 {
+
 		plaintext, err := repository.Decrypt(tok, key)
 
 		if err != nil {

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

@@ -382,6 +382,11 @@ func TestUpdateClusterToken(t *testing.T) {
 		t.Fatalf("incorrect cluster id in token cache: expected %d, got %d\n", 1, cluster.TokenCache.ClusterID)
 	}
 
+	// make sure old token is token-1
+	if string(cluster.TokenCache.Token) != "token-1" {
+		t.Errorf("incorrect token in cache: expected %s, got %s\n", "token-1", cluster.TokenCache.Token)
+	}
+
 	// make sure old token is expired
 	if isExpired := cluster.TokenCache.IsExpired(); !isExpired {
 		t.Fatalf("token was not expired\n")

+ 51 - 0
internal/repository/gorm/git_action_config.go

@@ -0,0 +1,51 @@
+package gorm
+
+import (
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// GitActionConfigRepository uses gorm.DB for querying the database
+type GitActionConfigRepository struct {
+	db *gorm.DB
+}
+
+// NewGitActionConfigRepository returns a GitActionConfigRepository which uses
+// gorm.DB for querying the database. It accepts an encryption key to encrypt
+// sensitive data
+func NewGitActionConfigRepository(db *gorm.DB) repository.GitActionConfigRepository {
+	return &GitActionConfigRepository{db}
+}
+
+// CreateGitActionConfig creates a new git repo
+func (repo *GitActionConfigRepository) CreateGitActionConfig(ga *models.GitActionConfig) (*models.GitActionConfig, error) {
+	release := &models.Release{}
+
+	if err := repo.db.Where("id = ?", ga.ReleaseID).First(&release).Error; err != nil {
+		return nil, err
+	}
+
+	assoc := repo.db.Model(&release).Association("GitActionConfig")
+
+	if assoc.Error != nil {
+		return nil, assoc.Error
+	}
+
+	if err := assoc.Append(ga); err != nil {
+		return nil, err
+	}
+
+	return ga, nil
+}
+
+// ReadGitActionConfig gets a git repo specified by a unique id
+func (repo *GitActionConfigRepository) ReadGitActionConfig(id uint) (*models.GitActionConfig, error) {
+	ga := &models.GitActionConfig{}
+
+	if err := repo.db.Where("id = ?", id).First(&ga).Error; err != nil {
+		return nil, err
+	}
+
+	return ga, nil
+}

+ 70 - 0
internal/repository/gorm/git_action_config_test.go

@@ -0,0 +1,70 @@
+package gorm_test
+
+import (
+	"testing"
+
+	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/models"
+	orm "gorm.io/gorm"
+)
+
+func TestCreateGitActionConfig(t *testing.T) {
+	tester := &tester{
+		dbFileName: "./porter_create_ga.db",
+	}
+
+	setupTestEnv(tester, t)
+	initUser(tester, t)
+	initProject(tester, t)
+	initRelease(tester, t)
+	defer cleanup(tester, t)
+
+	ga := &models.GitActionConfig{
+		ReleaseID:    1,
+		GitRepo:      "porter-dev/porter",
+		ImageRepoURI: "gcr.io/project-123456/nginx",
+		GitRepoID:    1,
+	}
+
+	expGA := *ga
+
+	ga, err := tester.repo.GitActionConfig.CreateGitActionConfig(ga)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	ga, err = tester.repo.GitActionConfig.ReadGitActionConfig(ga.Model.ID)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// make sure id is 1
+	if ga.Model.ID != 1 {
+		t.Errorf("incorrect git repo ID: expected %d, got %d\n", 1, ga.Model.ID)
+	}
+
+	// reset fields for reflect.DeepEqual
+	ga.Model = orm.Model{}
+
+	if diff := deep.Equal(expGA, *ga); diff != nil {
+		t.Errorf("incorrect git action config")
+		t.Error(diff)
+	}
+
+	// read the release and make sure GitActionConfig is expected
+	release, err := tester.repo.Release.ReadRelease(1, "denver-meister-dakota", "default")
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	gotReleaseGA := release.GitActionConfig
+	gotReleaseGA.Model = orm.Model{}
+
+	if diff := deep.Equal(expGA, gotReleaseGA); diff != nil {
+		t.Errorf("incorrect git action config")
+		t.Error(diff)
+	}
+}

+ 26 - 0
internal/repository/gorm/helpers_test.go

@@ -24,6 +24,7 @@ type tester struct {
 	initClusters []*models.Cluster
 	initHRs      []*models.HelmRepo
 	initInfras   []*models.Infra
+	initReleases []*models.Release
 	initInvites  []*models.Invite
 	initCCs      []*models.ClusterCandidate
 	initKIs      []*ints.KubeIntegration
@@ -60,6 +61,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
 		&models.Infra{},
+		&models.GitActionConfig{},
 		&models.Invite{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
@@ -485,3 +487,27 @@ func initInvite(tester *tester, t *testing.T) {
 
 	tester.initInvites = append(tester.initInvites, invite)
 }
+
+func initRelease(tester *tester, t *testing.T) {
+	t.Helper()
+
+	if len(tester.initProjects) == 0 {
+		initProject(tester, t)
+	}
+
+	release := &models.Release{
+		Name:         "denver-meister-dakota",
+		Namespace:    "default",
+		ProjectID:    1,
+		ClusterID:    1,
+		WebhookToken: "abcdefgh",
+	}
+
+	release, err := tester.repo.Release.CreateRelease(release)
+
+	if err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	tester.initReleases = append(tester.initReleases, release)
+}

+ 3 - 3
internal/repository/gorm/release.go

@@ -26,9 +26,9 @@ func (repo *ReleaseRepository) CreateRelease(release *models.Release) (*models.R
 }
 
 // ReadRelease finds a single release based on their unique name and namespace pair.
-func (repo *ReleaseRepository) ReadRelease(name string, namespace string) (*models.Release, error) {
+func (repo *ReleaseRepository) ReadRelease(clusterID uint, name, namespace string) (*models.Release, error) {
 	release := &models.Release{}
-	if err := repo.db.Where("name = ?", name).Where("namespace = ?", namespace).First(&release).Error; err != nil {
+	if err := repo.db.Preload("GitActionConfig").Where("cluster_id = ?", clusterID).Where("name = ?", name).Where("namespace = ?", namespace).First(&release).Error; err != nil {
 		return nil, err
 	}
 	return release, nil
@@ -37,7 +37,7 @@ func (repo *ReleaseRepository) ReadRelease(name string, namespace string) (*mode
 // ReadReleaseByWebhookToken finds a single release based on their unique webhook token.
 func (repo *ReleaseRepository) ReadReleaseByWebhookToken(token string) (*models.Release, error) {
 	release := &models.Release{}
-	if err := repo.db.Where("webhook_token = ?", token).First(&release).Error; err != nil {
+	if err := repo.db.Preload("GitActionConfig").Where("webhook_token = ?", token).First(&release).Error; err != nil {
 		return nil, err
 	}
 	return release, nil

+ 3 - 3
internal/repository/gorm/release_test.go

@@ -29,7 +29,7 @@ func TestCreateRelease(t *testing.T) {
 		t.Fatalf("%v\n", err)
 	}
 
-	release, err = tester.repo.Release.ReadRelease(release.Name, release.Namespace)
+	release, err = tester.repo.Release.ReadRelease(1, release.Name, release.Namespace)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -77,7 +77,7 @@ func TestDeleteRelease(t *testing.T) {
 		t.Fatalf("%v\n", err)
 	}
 
-	release, err = tester.repo.Release.ReadRelease(release.Name, release.Namespace)
+	release, err = tester.repo.Release.ReadRelease(1, release.Name, release.Namespace)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -89,7 +89,7 @@ func TestDeleteRelease(t *testing.T) {
 		t.Fatalf("%v\n", err)
 	}
 
-	_, err = tester.repo.Release.ReadRelease(release.Name, release.Namespace)
+	_, err = tester.repo.Release.ReadRelease(1, release.Name, release.Namespace)
 
 	if err != orm.ErrRecordNotFound {
 		t.Fatalf("incorrect error: expected %v, got %v\n", orm.ErrRecordNotFound, err)

+ 1 - 0
internal/repository/gorm/repository.go

@@ -18,6 +18,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		HelmRepo:         NewHelmRepoRepository(db, key),
 		Registry:         NewRegistryRepository(db, key),
 		Infra:            NewInfraRepository(db, key),
+		GitActionConfig:  NewGitActionConfigRepository(db),
 		Invite:           NewInviteRepository(db),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		BasicIntegration: NewBasicIntegrationRepository(db, key),

+ 1 - 1
internal/repository/release.go

@@ -10,7 +10,7 @@ type WriteRelease func(release *models.Release) (*models.Release, error)
 // ReleaseRepository represents the set of queries on the Release model
 type ReleaseRepository interface {
 	CreateRelease(release *models.Release) (*models.Release, error)
-	ReadRelease(name string, namespace string) (*models.Release, error)
+	ReadRelease(clusterID uint, name, namespace string) (*models.Release, error)
 	ReadReleaseByWebhookToken(token string) (*models.Release, error)
 	UpdateRelease(release *models.Release) (*models.Release, error)
 	DeleteRelease(release *models.Release) (*models.Release, error)

+ 1 - 0
internal/repository/repository.go

@@ -11,6 +11,7 @@ type Repository struct {
 	HelmRepo         HelmRepoRepository
 	Registry         RegistryRepository
 	Infra            InfraRepository
+	GitActionConfig  GitActionConfigRepository
 	Invite           InviteRepository
 	KubeIntegration  KubeIntegrationRepository
 	BasicIntegration BasicIntegrationRepository

+ 2 - 2
server/api/api.go

@@ -6,7 +6,7 @@ import (
 	"github.com/go-playground/locales/en"
 	ut "github.com/go-playground/universal-translator"
 	vr "github.com/go-playground/validator/v10"
-	sessionstore "github.com/porter-dev/porter/internal/auth"
+	"github.com/porter-dev/porter/internal/auth/sessionstore"
 	"github.com/porter-dev/porter/internal/oauth"
 	"golang.org/x/oauth2"
 	"gorm.io/gorm"
@@ -120,7 +120,7 @@ func New(conf *AppConfig) (*App, error) {
 		app.GithubConf = oauth.NewGithubClient(&oauth.Config{
 			ClientID:     sc.GithubClientID,
 			ClientSecret: sc.GithubClientSecret,
-			Scopes:       []string{"repo", "user", "read:user"},
+			Scopes:       []string{"repo", "read:user", "workflow"},
 			BaseURL:      sc.ServerURL,
 		})
 	}

+ 151 - 0
server/api/git_action_handler.go

@@ -0,0 +1,151 @@
+package api
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/auth/token"
+	"github.com/porter-dev/porter/internal/forms"
+	"github.com/porter-dev/porter/internal/integrations/ci/actions"
+)
+
+// HandleCreateGitAction creates a new Github action in a repository for a given
+// release
+func (app *App) HandleCreateGitAction(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
+	}
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+	name := vals["name"][0]
+	namespace := vals["namespace"][0]
+
+	clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+	}
+
+	release, err := app.Repo.Release.ReadRelease(uint(clusterID), name, namespace)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+	}
+
+	form := &forms.CreateGitAction{
+		ReleaseID: release.Model.ID,
+	}
+
+	// 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 git action config
+	gitAction, err := form.ToGitActionConfig()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// read the git repo
+	gr, err := app.Repo.GitRepo.ReadGitRepo(gitAction.GitRepoID)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	repoSplit := strings.Split(gitAction.GitRepo, "/")
+
+	if len(repoSplit) != 2 {
+		app.handleErrorFormDecoding(fmt.Errorf("invalid formatting of repo name"), ErrProjectDecode, w)
+		return
+	}
+
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+
+	// generate porter jwt token
+	jwt, _ := token.GetTokenForAPI(userID, uint(projID))
+
+	encoded, err := jwt.EncodeToken(&token.TokenGeneratorConf{
+		TokenSecret: app.ServerConf.TokenGeneratorSecret,
+	})
+
+	if err != nil {
+		fmt.Println("ERROR GENERATING TOKEN", err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	// create the commit in the git repo
+	gaRunner := &actions.GithubActions{
+		GitIntegration: gr,
+		GitRepoName:    repoSplit[1],
+		GitRepoOwner:   repoSplit[0],
+		Repo:           *app.Repo,
+		GithubConf:     app.GithubConf,
+		WebhookToken:   release.WebhookToken,
+		ProjectID:      uint(projID),
+		ReleaseName:    name,
+		DockerFilePath: gitAction.DockerfilePath,
+		ImageRepoURL:   gitAction.ImageRepoURI,
+		PorterToken:    encoded,
+	}
+
+	_, err = gaRunner.Setup()
+
+	if err != nil {
+		fmt.Println("ERROR RUNNING SETUP", err)
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+
+	// handle write to the database
+	ga, err := app.Repo.GitActionConfig.CreateGitActionConfig(gitAction)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("New git action created: %d", ga.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	gaExt := ga.Externalize()
+
+	if err := json.NewEncoder(w).Encode(gaExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 4 - 2
server/api/git_repo_handler.go

@@ -98,12 +98,13 @@ func (app *App) HandleGetBranches(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	owner := chi.URLParam(r, "owner")
 	name := chi.URLParam(r, "name")
 
 	client := github.NewClient(app.GithubConf.Client(oauth2.NoContext, tok))
 
 	// List all branches for a specified repo
-	branches, _, err := client.Repositories.ListBranches(context.Background(), "", name, nil)
+	branches, _, err := client.Repositories.ListBranches(context.Background(), owner, name, nil)
 	if err != nil {
 		fmt.Println(err)
 		return
@@ -134,12 +135,13 @@ func (app *App) HandleGetBranchContents(w http.ResponseWriter, r *http.Request)
 		return
 	}
 
+	owner := chi.URLParam(r, "owner")
 	name := chi.URLParam(r, "name")
 	branch := chi.URLParam(r, "branch")
 
 	repoContentOptions := github.RepositoryContentGetOptions{}
 	repoContentOptions.Ref = branch
-	_, directoryContents, _, err := client.Repositories.GetContents(context.Background(), "", name, queryParams["dir"][0], &repoContentOptions)
+	_, directoryContents, _, err := client.Repositories.GetContents(context.Background(), owner, name, queryParams["dir"][0], &repoContentOptions)
 	if err != nil {
 		app.handleErrorInternal(err, w)
 		return

+ 10 - 1
server/api/release_handler.go

@@ -418,7 +418,16 @@ func (app *App) HandleGetReleaseToken(w http.ResponseWriter, r *http.Request) {
 	vals, err := url.ParseQuery(r.URL.RawQuery)
 	namespace := vals["namespace"][0]
 
-	release, err := app.Repo.Release.ReadRelease(name, namespace)
+	clusterID, err := strconv.ParseUint(vals["cluster_id"][0], 10, 64)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+	}
+
+	release, err := app.Repo.Release.ReadRelease(uint(clusterID), name, namespace)
 
 	if err != nil {
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{

+ 39 - 1
server/router/middleware/auth.go

@@ -9,9 +9,11 @@ import (
 	"net/http"
 	"net/url"
 	"strconv"
+	"strings"
 
 	"github.com/go-chi/chi"
 	"github.com/gorilla/sessions"
+	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 )
@@ -20,6 +22,7 @@ import (
 type Auth struct {
 	store      sessions.Store
 	cookieName string
+	tokenConf  *token.TokenGeneratorConf
 	repo       *repository.Repository
 }
 
@@ -27,9 +30,10 @@ type Auth struct {
 func NewAuth(
 	store sessions.Store,
 	cookieName string,
+	tokenConf *token.TokenGeneratorConf,
 	repo *repository.Repository,
 ) *Auth {
-	return &Auth{store, cookieName, repo}
+	return &Auth{store, cookieName, tokenConf, repo}
 }
 
 // BasicAuthenticate just checks that a user is logged in
@@ -127,6 +131,14 @@ type bodyDOIntegrationID struct {
 // the one stored in the session
 func (auth *Auth) DoesUserIDMatch(next http.Handler, loc IDLocation) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		// first check for token
+		tok := auth.getTokenFromRequest(r)
+
+		if tok != nil && tok.SubKind == token.User && auth.doesSessionMatchID(r, tok.IBy) {
+			next.ServeHTTP(w, r)
+			return
+		}
+
 		var err error
 		id, err := findUserIDInRequest(r, loc)
 
@@ -166,6 +178,14 @@ func (auth *Auth) DoesUserHaveProjectAccess(
 			return
 		}
 
+		// first check for token
+		tok := auth.getTokenFromRequest(r)
+
+		if tok != nil && tok.ProjectID == uint(projID) {
+			next.ServeHTTP(w, r)
+			return
+		}
+
 		session, err := auth.store.Get(r, auth.cookieName)
 
 		if err != nil {
@@ -635,6 +655,8 @@ func (auth *Auth) doesSessionMatchID(r *http.Request, id uint) bool {
 }
 
 func (auth *Auth) isLoggedIn(w http.ResponseWriter, r *http.Request) bool {
+	// first check for Bearer token
+
 	session, err := auth.store.Get(r, auth.cookieName)
 	if err != nil {
 		session.Values["authenticated"] = false
@@ -650,6 +672,22 @@ func (auth *Auth) isLoggedIn(w http.ResponseWriter, r *http.Request) bool {
 	return true
 }
 
+func (auth *Auth) getTokenFromRequest(r *http.Request) *token.Token {
+	reqToken := r.Header.Get("Authorization")
+
+	splitToken := strings.Split(reqToken, "Bearer")
+
+	if len(splitToken) != 2 {
+		return nil
+	}
+
+	reqToken = strings.TrimSpace(splitToken[1])
+
+	tok, _ := token.GetTokenFromEncoded(reqToken, auth.tokenConf)
+
+	return tok
+}
+
 func findUserIDInRequest(r *http.Request, userLoc IDLocation) (uint64, error) {
 	var userID uint64
 	var err error

+ 24 - 6
server/router/router.go

@@ -5,6 +5,7 @@ import (
 	"os"
 
 	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/server/api"
 	"github.com/porter-dev/porter/server/requestlog"
 	mw "github.com/porter-dev/porter/server/router/middleware"
@@ -16,7 +17,9 @@ func New(a *api.App) *chi.Mux {
 	l := a.Logger
 	r := chi.NewRouter()
 
-	auth := mw.NewAuth(a.Store, a.ServerConf.CookieName, a.Repo)
+	auth := mw.NewAuth(a.Store, a.ServerConf.CookieName, &token.TokenGeneratorConf{
+		TokenSecret: a.ServerConf.TokenGeneratorSecret,
+	}, a.Repo)
 
 	r.Route("/api", func(r chi.Router) {
 		r.Use(mw.ContentTypeJSON)
@@ -193,6 +196,21 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		// /api/projects/{project_id}/ci routes
+		r.Method(
+			"POST",
+			"/projects/{project_id}/ci/actions",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleCreateGitAction, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		// /api/projects/{project_id}/invites routes
 		r.Method(
 			"POST",
@@ -909,7 +927,7 @@ func New(a *api.App) *chi.Mux {
 				auth.DoesUserHaveGitRepoAccess(
 					requestlog.NewHandler(a.HandleListRepos, l),
 					mw.URLParam,
-					mw.QueryParam,
+					mw.URLParam,
 				),
 				mw.URLParam,
 				mw.ReadAccess,
@@ -918,12 +936,12 @@ func New(a *api.App) *chi.Mux {
 
 		r.Method(
 			"GET",
-			"/projects/{project_id}/gitrepos/{git_repo_id}/repos/{kind}/{name}/branches",
+			"/projects/{project_id}/gitrepos/{git_repo_id}/repos/{kind}/{owner}/{name}/branches",
 			auth.DoesUserHaveProjectAccess(
 				auth.DoesUserHaveGitRepoAccess(
 					requestlog.NewHandler(a.HandleGetBranches, l),
 					mw.URLParam,
-					mw.QueryParam,
+					mw.URLParam,
 				),
 				mw.URLParam,
 				mw.ReadAccess,
@@ -932,12 +950,12 @@ func New(a *api.App) *chi.Mux {
 
 		r.Method(
 			"GET",
-			"/projects/{project_id}/gitrepos/{git_repo_id}/repos/{kind}/{name}/{branch}/contents",
+			"/projects/{project_id}/gitrepos/{git_repo_id}/repos/{kind}/{owner}/{name}/{branch}/contents",
 			auth.DoesUserHaveProjectAccess(
 				auth.DoesUserHaveGitRepoAccess(
 					requestlog.NewHandler(a.HandleGetBranchContents, l),
 					mw.URLParam,
-					mw.QueryParam,
+					mw.URLParam,
 				),
 				mw.URLParam,
 				mw.ReadAccess,