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

Merge branch 'beta.3.github-actions-ci' of https://github.com/porter-dev/porter into sean-testing

Sean Rhee 5 лет назад
Родитель
Сommit
e8bf9e7954
43 измененных файлов с 1936 добавлено и 61 удалено
  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. 492 0
      cmd/migrate/keyrotate/helpers_test.go
  14. 58 0
      cmd/migrate/keyrotate/rotate.go
  15. 49 0
      cmd/migrate/keyrotate/rotate_test.go
  16. 1 0
      cmd/migrate/main.go
  17. 3 0
      go.mod
  18. 5 0
      go.sum
  19. 0 0
      internal/auth/sessionstore/sessionstore.go
  20. 1 1
      internal/auth/sessionstore/sessionstore_test.go
  21. 119 0
      internal/auth/token/token.go
  22. 52 0
      internal/auth/token/token_test.go
  23. 11 10
      internal/config/config.go
  24. 26 0
      internal/forms/git_action.go
  25. 15 15
      internal/helm/agent_test.go
  26. 235 0
      internal/integrations/ci/actions/actions.go
  27. 59 0
      internal/integrations/ci/actions/steps.go
  28. 48 0
      internal/models/gitrepo.go
  29. 11 8
      internal/models/release.go
  30. 10 0
      internal/repository/git_action_config.go
  31. 51 0
      internal/repository/gorm/git_action_config.go
  32. 70 0
      internal/repository/gorm/git_action_config_test.go
  33. 26 0
      internal/repository/gorm/helpers_test.go
  34. 3 3
      internal/repository/gorm/release.go
  35. 3 3
      internal/repository/gorm/release_test.go
  36. 1 0
      internal/repository/gorm/repository.go
  37. 1 1
      internal/repository/release.go
  38. 1 0
      internal/repository/repository.go
  39. 2 2
      server/api/api.go
  40. 151 0
      server/api/git_action_handler.go
  41. 10 1
      server/api/release_handler.go
  42. 39 1
      server/router/middleware/auth.go
  43. 19 1
      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])

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

@@ -0,0 +1,492 @@
+package keyrotate_test
+
+import (
+	"os"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
+	"github.com/porter-dev/porter/internal/repository/gorm"
+
+	_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"),
+	}
+
+	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)
+}

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

@@ -0,0 +1,58 @@
+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).Find(&clusters).Error; err != nil {
+			return err
+		}
+
+		// decrypt with the old key
+		for _, cluster := range clusters {
+			repo.DecryptClusterData(cluster, oldKey)
+		}
+
+		// encrypt with the new key and re-insert
+		for _, cluster := range clusters {
+			repo.EncryptClusterData(cluster, newKey)
+
+			if err := db.Save(cluster).Error; err != nil {
+				return err
+			}
+		}
+	}
+
+	return nil
+}

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

@@ -0,0 +1,49 @@
+package keyrotate_test
+
+import (
+	"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 < 100; i++ {
+		initCluster(tester, t)
+	}
+
+	defer cleanup(tester, t)
+
+	keyrotate.Rotate(tester.DB, tester.Key, &newKey)
+
+	// very all clusters decoded properly
+	repo := gorm.NewClusterRepository(tester.DB, &newKey).(*gorm.ClusterRepository)
+
+	clusters := []*models.Cluster{}
+
+	if err := tester.DB.Find(&clusters).Error; err != nil {
+		t.Fatalf("%v\n", err)
+	}
+
+	// decrypt with the old key
+	for _, cluster := range clusters {
+		repo.DecryptClusterData(cluster, &newKey)
+
+		if string(cluster.CertificateAuthorityData) != "-----BEGIN" {
+			t.Errorf("%s\n", string(cluster.CertificateAuthorityData))
+		}
+	}
+}

+ 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{},

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

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

+ 11 - 10
internal/config/config.go

@@ -18,16 +18,17 @@ 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/"`
 

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

+ 48 - 0
internal/models/gitrepo.go

@@ -43,3 +43,51 @@ 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 {
+	gorm.Model
+
+	// 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(),
 	}
 }

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

+ 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.ReadReleaseByWebhookToken("abcdefgh")
+
+	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.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
+	}
+}

+ 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

+ 19 - 1
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,20 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+<<<<<<< HEAD
+		// /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",
@@ -233,6 +250,7 @@ func New(a *api.App) *chi.Mux {
 				),
 				mw.URLParam,
 				mw.WriteAccess,
+>>>>>>> master
 			),
 		)