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

Merge branch 'master' of https://github.com/porter-dev/porter into main

sunguroku 5 лет назад
Родитель
Сommit
030cabf950
76 измененных файлов с 3982 добавлено и 347 удалено
  1. 2 5
      .github/workflows/release.yaml
  2. 2 2
      .github/workflows/staging.yaml
  3. 32 0
      cli/cmd/api/integration.go
  4. 87 0
      cli/cmd/api/registry.go
  5. 26 0
      cli/cmd/connect.go
  6. 114 0
      cli/cmd/connect/docr.go
  7. 43 12
      cli/cmd/docker.go
  8. 1 1
      cli/cmd/registry.go
  9. 1 1
      cli/cmd/server.go
  10. 1 1
      cli/cmd/version.go
  11. 1 1
      cmd/app/main.go
  12. 69 1
      cmd/docker-credential-porter/helper/helper.go
  13. 1 1
      cmd/migrate/main.go
  14. 1 0
      dashboard/src/components/Selector.tsx
  15. 94 0
      dashboard/src/components/values-form/Base64InputRow.tsx
  16. 35 0
      dashboard/src/components/values-form/ValuesForm.tsx
  17. 4 0
      dashboard/src/components/values-form/ValuesWrapper.tsx
  18. 22 6
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  19. 64 0
      dashboard/src/main/home/cluster-dashboard/SortSelector.tsx
  20. 19 4
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  21. 81 10
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  22. 29 2
      dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx
  23. 115 12
      dashboard/src/main/home/new-project/NewProject.tsx
  24. 5 1
      dashboard/src/main/home/templates/Templates.tsx
  25. 13 3
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  26. 44 1
      dashboard/src/shared/api.tsx
  27. 3 0
      go.mod
  28. 7 0
      go.sum
  29. 4 0
      internal/config/config.go
  30. 103 8
      internal/forms/infra.go
  31. 2 0
      internal/forms/registry.go
  32. 5 1
      internal/forms/release.go
  33. 73 14
      internal/helm/agent.go
  34. 37 26
      internal/helm/config.go
  35. 443 0
      internal/helm/postrenderer.go
  36. 278 21
      internal/kubernetes/agent.go
  37. 27 1
      internal/kubernetes/config.go
  38. 20 0
      internal/kubernetes/provisioner/do/do.go
  39. 25 0
      internal/kubernetes/provisioner/do/docr/docr.go
  40. 25 0
      internal/kubernetes/provisioner/do/doks/doks.go
  41. 18 0
      internal/kubernetes/provisioner/gcp/gke/gke.go
  42. 115 8
      internal/kubernetes/provisioner/global_stream.go
  43. 47 12
      internal/kubernetes/provisioner/provisioner.go
  44. 2 0
      internal/models/cluster.go
  45. 32 21
      internal/models/infra.go
  46. 40 1
      internal/models/integrations/gcp.go
  47. 1 0
      internal/models/integrations/integration.go
  48. 5 2
      internal/models/integrations/oauth.go
  49. 1 1
      internal/models/project.go
  50. 3 0
      internal/models/registry.go
  51. 50 0
      internal/oauth/config.go
  52. 294 19
      internal/registry/registry.go
  53. 23 0
      internal/repository/gorm/auth.go
  54. 12 0
      internal/repository/gorm/cluster.go
  55. 23 23
      internal/repository/gorm/helpers_test.go
  56. 19 19
      internal/repository/gorm/infra.go
  57. 12 12
      internal/repository/gorm/infra_test.go
  58. 1 1
      internal/repository/gorm/repository.go
  59. 6 6
      internal/repository/infra.go
  60. 1 0
      internal/repository/integrations.go
  61. 18 0
      internal/repository/memory/auth.go
  62. 29 29
      internal/repository/memory/infra.go
  63. 1 1
      internal/repository/repository.go
  64. 7 2
      internal/templater/helm/values/writer.go
  65. 10 0
      server/api/api.go
  66. 59 6
      server/api/deploy_handler.go
  67. 2 2
      server/api/infra_handler.go
  68. 31 0
      server/api/integration_handler.go
  69. 10 5
      server/api/k8s_handler.go
  70. 99 0
      server/api/oauth_do_handler.go
  71. 1 1
      server/api/oauth_github_handler.go
  72. 648 21
      server/api/provision_handler.go
  73. 62 2
      server/api/registry_handler.go
  74. 76 14
      server/api/release_handler.go
  75. 105 1
      server/router/middleware/auth.go
  76. 161 3
      server/router/router.go

+ 2 - 5
.github/workflows/release.yaml

@@ -67,6 +67,8 @@ jobs:
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+          POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
+          POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
           EOL
       - name: Build and zip static folder
         run: |
@@ -78,11 +80,6 @@ jobs:
           zip --junk-paths ./release/static/static_${{steps.tag_name.outputs.tag}}.zip ./dashboard/build/*
         env:
           NODE_ENV: production
-          API_SERVER: ${{ secrets.API_SERVER }}
-          FULLSTORY_ORG_ID: ${{ secrets.FULLSTORY_ORG_ID }}
-          DISCORD_KEY: ${{ secrets.DISCORD_KEY }}
-          DISCORD_CID: ${{ secrets.DISCORD_CID }}
-          FEEDBACK_ENDPOINT: ${{ secrets.FEEDBACK_ENDPOINT }}
       - name: Build Linux binaries
         run: |
           go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${{steps.tag_name.outputs.tag}}'" -a -tags cli -o ./porter ./cli &

+ 2 - 2
.github/workflows/staging.yaml

@@ -26,9 +26,9 @@ jobs:
         DISCORD_KEY=${{secrets.DISCORD_KEY}}
         DISCORD_CID=${{secrets.DISCORD_CID}}
         FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+        POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
+        POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
         EOL
-
-        cat ./dashboard/.env
     - name: Build
       run: |
         DOCKER_BUILDKIT=1 docker build . -t gcr.io/porter-dev-273614/porter-prov:latest -f ./docker/Dockerfile

+ 32 - 0
cli/cmd/api/integration.go

@@ -147,3 +147,35 @@ func (c *Client) CreateBasicAuthIntegration(
 
 	return bodyResp, nil
 }
+
+// ListOAuthIntegrationResponse is the list of oauth integrations in a project
+type ListOAuthIntegrationResponse []ints.OAuthIntegrationExternal
+
+// ListOAuthIntegrations lists the oauth integrations in a project
+func (c *Client) ListOAuthIntegrations(
+	ctx context.Context,
+	projectID uint,
+) (ListOAuthIntegrationResponse, error) {
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/integrations/oauth", c.BaseURL, projectID),
+		nil,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &ListOAuthIntegrationResponse{}
+
+	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
+}

+ 87 - 0
cli/cmd/api/registry.go

@@ -106,6 +106,53 @@ func (c *Client) CreateGCR(
 	return bodyResp, nil
 }
 
+// CreateDOCRRequest represents the accepted fields for creating
+// a DOCR registry
+type CreateDOCRRequest struct {
+	Name            string `json:"name"`
+	DOIntegrationID uint   `json:"do_integration_id"`
+	URL             string `json:"url"`
+}
+
+// CreateDOCRResponse is the resulting registry after creation
+type CreateDOCRResponse models.RegistryExternal
+
+// CreateDOCR creates an Digital Ocean Container Registry integration
+func (c *Client) CreateDOCR(
+	ctx context.Context,
+	projectID uint,
+	createDOCR *CreateDOCRRequest,
+) (*CreateDOCRResponse, error) {
+	data, err := json.Marshal(createDOCR)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"POST",
+		fmt.Sprintf("%s/projects/%d/registries", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req = req.WithContext(ctx)
+	bodyResp := &CreateDOCRResponse{}
+
+	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
+}
+
 // ListRegistryResponse is the list of registries for a project
 type ListRegistryResponse []models.RegistryExternal
 
@@ -243,6 +290,46 @@ func (c *Client) GetGCRAuthorizationToken(
 	return bodyResp, nil
 }
 
+type GetDOCRTokenRequest struct {
+	ServerURL string `json:"server_url"`
+}
+
+// GetDOCRAuthorizationToken gets a DOCR authorization token
+func (c *Client) GetDOCRAuthorizationToken(
+	ctx context.Context,
+	projectID uint,
+	docrRequest *GetDOCRTokenRequest,
+) (*GetTokenResponse, error) {
+	data, err := json.Marshal(docrRequest)
+
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest(
+		"GET",
+		fmt.Sprintf("%s/projects/%d/registries/docr/token", c.BaseURL, projectID),
+		strings.NewReader(string(data)),
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	bodyResp := &GetTokenResponse{}
+	req = req.WithContext(ctx)
+
+	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
+}
+
 // ListRegistryRepositoryResponse is the list of repositories in a registry
 type ListRegistryRepositoryResponse []registry.Repository
 

+ 26 - 0
cli/cmd/connect.go

@@ -55,6 +55,18 @@ var connectGCRCmd = &cobra.Command{
 	},
 }
 
+var connectDOCRCmd = &cobra.Command{
+	Use:   "docr",
+	Short: "Adds a DOCR instance to a project",
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runConnectDOCR)
+
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 var connectHRCmd = &cobra.Command{
 	Use:     "helmrepo",
 	Aliases: []string{"helm", "helmrepos"},
@@ -103,6 +115,7 @@ func init() {
 
 	connectCmd.AddCommand(connectECRCmd)
 	connectCmd.AddCommand(connectGCRCmd)
+	connectCmd.AddCommand(connectDOCRCmd)
 	connectCmd.AddCommand(connectHRCmd)
 }
 
@@ -154,6 +167,19 @@ func runConnectGCR(_ *api.AuthCheckResponse, client *api.Client, _ []string) err
 	return setRegistry(regID)
 }
 
+func runConnectDOCR(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
+	regID, err := connect.DOCR(
+		client,
+		getProjectID(),
+	)
+
+	if err != nil {
+		return err
+	}
+
+	return setRegistry(regID)
+}
+
 func runConnectHelmRepoBasic(_ *api.AuthCheckResponse, client *api.Client, _ []string) error {
 	hrID, err := connect.Helm(
 		client,

+ 114 - 0
cli/cmd/connect/docr.go

@@ -0,0 +1,114 @@
+package connect
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"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"
+)
+
+// DOCR creates a DOCR integration
+func DOCR(
+	client *api.Client,
+	projectID uint,
+) (uint, error) {
+	// if project ID is 0, ask the user to set the project ID or create a project
+	if projectID == 0 {
+		return 0, fmt.Errorf("no project set, please run porter project set [id]")
+	}
+
+	// list oauth integrations and make sure DO exists
+	oauthInts, err := client.ListOAuthIntegrations(context.TODO(), projectID)
+
+	if err != nil {
+		return 0, err
+	}
+
+	linkedDO := false
+	var doAuth ints.OAuthIntegrationExternal
+
+	// iterate through oauth integrations to find do
+	for _, oauthInt := range oauthInts {
+		if oauthInt.Client == ints.OAuthDigitalOcean {
+			linkedDO = true
+			doAuth = oauthInt
+			break
+		}
+	}
+
+	if !linkedDO {
+		doAuth, err = triggerDigitalOceanOAuth(client, projectID)
+
+		if err != nil {
+			return 0, err
+		}
+	}
+
+	// use the digital ocean oauth to create a registry
+	regURL, err := utils.PromptPlaintext(fmt.Sprintf(`Please provide the registry URL, in the form registry.digitalocean.com/[REGISTRY_NAME]. For example, registry.digitalocean.com/porter-test. 
+Registry URL: `))
+
+	if err != nil {
+		return 0, err
+	}
+
+	urlArr := strings.Split(regURL, "/")
+	regName := urlArr[len(urlArr)-1]
+
+	if err != nil {
+		return 0, err
+	}
+
+	reg, err := client.CreateDOCR(
+		context.Background(),
+		projectID,
+		&api.CreateDOCRRequest{
+			Name:            regName,
+			DOIntegrationID: doAuth.ID,
+			URL:             regURL,
+		},
+	)
+
+	return reg.ID, nil
+}
+
+func triggerDigitalOceanOAuth(client *api.Client, projectID uint) (ints.OAuthIntegrationExternal, error) {
+	var doAuth ints.OAuthIntegrationExternal
+
+	oauthURL := fmt.Sprintf("%s/oauth/projects/%d/digitalocean", client.BaseURL, projectID)
+
+	fmt.Printf("Please visit %s in your browser to connect to Digital Ocean (it should open automatically).", oauthURL)
+	utils.OpenBrowser(oauthURL)
+
+	for {
+		oauthInts, err := client.ListOAuthIntegrations(context.TODO(), projectID)
+
+		if err != nil {
+			return doAuth, err
+		}
+
+		linkedDO := false
+
+		// iterate through oauth integrations to find do
+		for _, oauthInt := range oauthInts {
+			if oauthInt.Client == ints.OAuthDigitalOcean {
+				linkedDO = true
+				doAuth = oauthInt
+				break
+			}
+		}
+
+		if linkedDO {
+			break
+		}
+
+		time.Sleep(2 * time.Second)
+	}
+
+	return doAuth, nil
+}

+ 43 - 12
cli/cmd/docker.go

@@ -6,9 +6,11 @@ import (
 	"io/ioutil"
 	"net/url"
 	"os"
+	"os/exec"
 	"path/filepath"
 	"strings"
 
+	"github.com/fatih/color"
 	"github.com/porter-dev/porter/cli/cmd/api"
 	"github.com/porter-dev/porter/cli/cmd/github"
 	"github.com/spf13/cobra"
@@ -93,21 +95,30 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 		return err
 	}
 
-	// download the porter cred helper
-	z := &github.ZIPReleaseGetter{
-		AssetName:           "docker-credential-porter",
-		AssetFolderDest:     "/usr/local/bin",
-		ZipFolderDest:       filepath.Join(home, ".porter"),
-		ZipName:             "docker-credential-porter_latest.zip",
-		EntityID:            "porter-dev",
-		RepoName:            "porter",
-		IsPlatformDependent: true,
+	// check if the docker credential helper exists
+	if !commandExists("docker-credential-porter") {
+		err := downloadCredMatchingRelease()
+
+		if err != nil {
+			color.New(color.FgRed).Println("Failed to download credential helper binary:", err.Error())
+			os.Exit(1)
+		}
 	}
 
-	err = z.GetLatestRelease()
+	// otherwise, check the version flag of the binary
+	cmdVersionCred := exec.Command("docker-credential-porter", "--version")
+	writer := &versionWriter{}
+	cmdVersionCred.Stdout = writer
 
-	if err != nil {
-		return err
+	err = cmdVersionCred.Run()
+
+	if err != nil || writer.Version != Version {
+		err := downloadCredMatchingRelease()
+
+		if err != nil {
+			color.New(color.FgRed).Println("Failed to download credential helper binary:", err.Error())
+			os.Exit(1)
+		}
 	}
 
 	config := &configfile.ConfigFile{
@@ -126,3 +137,23 @@ func dockerConfig(user *api.AuthCheckResponse, client *api.Client, args []string
 
 	return config.Save()
 }
+
+func downloadCredMatchingRelease() error {
+	// download the porter cred helper
+	z := &github.ZIPReleaseGetter{
+		AssetName:           "docker-credential-porter",
+		AssetFolderDest:     "/usr/local/bin",
+		ZipFolderDest:       filepath.Join(home, ".porter"),
+		ZipName:             "docker-credential-porter_latest.zip",
+		EntityID:            "porter-dev",
+		RepoName:            "porter",
+		IsPlatformDependent: true,
+	}
+
+	return z.GetRelease(Version)
+}
+
+func commandExists(cmd string) bool {
+	_, err := exec.LookPath(cmd)
+	return err == nil
+}

+ 1 - 1
cli/cmd/registry.go

@@ -201,7 +201,7 @@ func listRepos(user *api.AuthCheckResponse, client *api.Client, args []string) e
 func listImages(user *api.AuthCheckResponse, client *api.Client, args []string) error {
 	pID := getProjectID()
 	rID := getRegistryID()
-	repoName := args[1]
+	repoName := args[0]
 
 	// get the list of namespaces
 	imgs, err := client.ListImages(

+ 1 - 1
cli/cmd/server.go

@@ -270,7 +270,7 @@ func downloadMatchingRelease(porterDir string) error {
 		IsPlatformDependent: false,
 	}
 
-	return zStatic.GetLatestRelease()
+	return zStatic.GetRelease(Version)
 }
 
 type versionWriter struct {

+ 1 - 1
cli/cmd/version.go

@@ -7,7 +7,7 @@ import (
 )
 
 // Version will be linked by an ldflag during build
-var Version string = "v0.1.0-beta.3.1"
+var Version string = "dev"
 
 var versionCmd = &cobra.Command{
 	Use:     "version",

+ 1 - 1
cmd/app/main.go

@@ -56,7 +56,7 @@ func main() {
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
-		&models.AWSInfra{},
+		&models.Infra{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 69 - 1
cmd/docker-credential-porter/helper/helper.go

@@ -48,13 +48,15 @@ func (p *PorterHelper) Get(serverURL string) (user string, secret string, err er
 
 	if strings.Contains(serverURL, "gcr.io") {
 		return p.getGCR(serverURL)
+	} else if strings.Contains(serverURL, "registry.digitalocean.com") {
+		return p.getDOCR(serverURL)
 	}
 
 	return p.getECR(serverURL)
 }
 
 func (p *PorterHelper) getGCR(serverURL string) (user string, secret string, err error) {
-	urlP, err := url.Parse(serverURL)
+	urlP, err := url.Parse("https://" + serverURL)
 
 	if err != nil {
 		return "", "", err
@@ -96,6 +98,72 @@ func (p *PorterHelper) getGCR(serverURL string) (user string, secret string, err
 	return "oauth2accesstoken", token, nil
 }
 
+func (p *PorterHelper) getDOCR(serverURL string) (user string, secret string, err error) {
+	urlP, err := url.Parse("https://" + serverURL)
+
+	if err != nil {
+		if p.Debug {
+			log.Printf("Error: %s\n", err.Error())
+		}
+
+		return "", "", err
+	}
+
+	credCache := BuildCredentialsCache(urlP.Host)
+	cachedEntry := credCache.Get(serverURL)
+
+	var token string
+
+	if p.Debug {
+		log.Printf("GETTING FROM DOCR", urlP)
+	}
+
+	if cachedEntry != nil && cachedEntry.IsValid(time.Now()) {
+		token = cachedEntry.AuthorizationToken
+
+		if p.Debug {
+			log.Printf("USING CACHED TOKEN", token)
+		}
+	} else {
+		host := viper.GetString("host")
+		projID := viper.GetUint("project")
+
+		client := api.NewClient(host+"/api", "cookie.json")
+
+		if p.Debug {
+			log.Printf("MAKING REQUEST", host, projID)
+		}
+
+		// get a token from the server
+		tokenResp, err := client.GetDOCRAuthorizationToken(context.Background(), projID, &api.GetDOCRTokenRequest{
+			ServerURL: serverURL,
+		})
+
+		if err != nil {
+			if p.Debug {
+				log.Printf("Error: %s\n", err.Error())
+			}
+
+			return "", "", err
+		}
+
+		token = tokenResp.Token
+
+		if t := *tokenResp.ExpiresAt; len(token) > 0 && !t.IsZero() {
+			// set the token in cache
+			credCache.Set(serverURL, &AuthEntry{
+				AuthorizationToken: token,
+				RequestedAt:        time.Now(),
+				ExpiresAt:          t,
+				ProxyEndpoint:      serverURL,
+			})
+		}
+
+	}
+
+	return token, token, nil
+}
+
 func (p *PorterHelper) getECR(serverURL string) (user string, secret string, err error) {
 	// parse the server url for region
 	matches := ecrPattern.FindStringSubmatch(serverURL)

+ 1 - 1
cmd/migrate/main.go

@@ -36,7 +36,7 @@ func main() {
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
-		&models.AWSInfra{},
+		&models.Infra{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},

+ 1 - 0
dashboard/src/components/Selector.tsx

@@ -58,6 +58,7 @@ export default class Selector extends Component<PropsType, StateType> {
           <Dropdown
             dropdownWidth={this.props.dropdownWidth ? this.props.dropdownWidth : this.props.width}
             dropdownMaxHeight={this.props.dropdownMaxHeight}
+            onClick={() => this.setState({ expanded: false })}
           >
             {this.renderDropdownLabel()}
             {this.renderOptionList()}

+ 94 - 0
dashboard/src/components/values-form/Base64InputRow.tsx

@@ -0,0 +1,94 @@
+import React, { ChangeEvent, Component } from 'react';
+import styled from 'styled-components';
+
+type PropsType = {
+  label?: string,
+  type: string,
+  value: string | number,
+  setValue: (x: string | number) => void,
+  unit?: string,
+  placeholder?: string,
+  width?: string,
+  disabled?: boolean,
+  isRequired?: boolean,
+};
+
+type StateType = {
+  readOnly: boolean
+};
+
+export default class InputRow extends Component<PropsType, StateType> {
+  state = {
+    readOnly: true
+  }
+
+  handleChange = (e: ChangeEvent<HTMLInputElement>) => {
+    this.props.setValue(e.target.value);
+  }
+  
+  render() {
+    let { label, value, type, unit, placeholder, width } = this.props;
+    value = value.toString();
+    value = atob(value);
+    return (
+      <StyledInputRow>
+        <Label>{label} <Required>{this.props.isRequired ? ' *' : null}</Required></Label>
+        <InputWrapper>
+          <Input
+            readOnly={this.state.readOnly} onFocus={() => this.setState({ readOnly: false })}
+            disabled={this.props.disabled}
+            placeholder={placeholder}
+            width={width}
+            type={type}
+            value={value}
+            onChange={this.handleChange}
+          />
+          {unit ? <Unit>{unit}</Unit> : null}
+        </InputWrapper>
+      </StyledInputRow>
+    );
+  }
+}
+
+const Required = styled.div`
+  margin-left: 8px;
+  color: #fc4976;
+`;
+
+const Unit = styled.div`
+  margin-right: 8px;
+`;
+
+const InputWrapper = styled.div`
+  display: flex;
+  margin-bottom: -1px;
+  align-items: center;
+`;
+
+const Input = styled.input`
+  outline: none;
+  border: none;
+  font-size: 13px;
+  background: #ffffff11;
+  border: 1px solid #ffffff55;
+  border-radius: 3px;
+  width: ${(props: { disabled: boolean, width: string }) => props.width ? props.width : '270px'};
+  color: ${(props: { disabled: boolean, width: string }) => props.disabled ? '#ffffff44' : 'white'};
+  padding: 5px 10px;
+  margin-right: 8px;
+  height: 30px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  margin-bottom: 10px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: 'Work Sans', sans-serif;
+`;
+
+const StyledInputRow = styled.div`
+  margin-bottom: 15px;
+  margin-top: 20px;
+`;

+ 35 - 0
dashboard/src/components/values-form/ValuesForm.tsx

@@ -8,6 +8,7 @@ import api from '../../shared/api';
 
 import CheckboxRow from './CheckboxRow';
 import InputRow from './InputRow';
+import Base64InputRow from './Base64InputRow';
 import SelectRow from './SelectRow';
 import Helper from './Helper';
 import Heading from './Heading';
@@ -149,6 +150,40 @@ export default class ValuesForm extends Component<PropsType, StateType> {
             <VeleroForm
             />
           );
+        case 'base-64':
+          return (
+            <Base64InputRow
+              key={i}
+              isRequired={item.required}
+              type='text'
+              value={this.getInputValue(item)}
+              setValue={(x: string) => {
+                if (item.settings && item.settings.unit && x !== '') {
+                  x = x + item.settings.unit;
+                }
+                this.props.setMetaState({ [key]: btoa(x) });
+              }}
+              label={item.label}
+              unit={item.settings ? item.settings.unit : null}
+            />
+          );
+        case 'base-64-password':
+          return (
+            <Base64InputRow
+              key={i}
+              isRequired={item.required}
+              type='password'
+              value={this.getInputValue(item)}
+              setValue={(x: string) => {
+                if (item.settings && item.settings.unit && x !== '') {
+                  x = x + item.settings.unit;
+                }
+                this.props.setMetaState({ [key]: btoa(x) });
+              }}
+              label={item.label}
+              unit={item.settings ? item.settings.unit : null}
+            />
+          );
         default:
       }
     });

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

@@ -53,6 +53,10 @@ export default class ValuesWrapper extends Component<PropsType, StateType> {
               case 'select':
                 metaState[key] = def ? def : item.settings.options[0].value;
                 break;
+              case 'base-64':
+                metaState[key] = def ? def : '';
+              case 'base-64-password':
+                metaState[key] = def ? def : '';
               default:
             }
           });

+ 22 - 6
dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx

@@ -8,6 +8,7 @@ import api from '../../../shared/api';
 
 import ChartList from './chart/ChartList';
 import NamespaceSelector from './NamespaceSelector';
+import SortSelector from './SortSelector';
 import ExpandedChart from './expanded-chart/ExpandedChart';
 
 type PropsType = {
@@ -18,20 +19,22 @@ type PropsType = {
 
 type StateType = {
   namespace: string,
+  sortType: string,
   currentChart: ChartType | null
 };
 
 export default class ClusterDashboard extends Component<PropsType, StateType> {
   state = {
     namespace: 'default',
+    sortType: (localStorage.getItem("SortType") ? localStorage.getItem('SortType') : 'Newest'),
     currentChart: null as (ChartType | null)
   }
 
   componentDidUpdate(prevProps: PropsType) {
-
+    localStorage.setItem("SortType", this.state.sortType);
     // Reset namespace filter and close expanded chart on cluster change
     if (prevProps.currentCluster !== this.props.currentCluster) {
-      this.setState({ namespace: 'default', currentChart: null });
+      this.setState({ namespace: 'default', sortType: 'Newest', currentChart: null });
     }
   }
 
@@ -101,15 +104,22 @@ export default class ClusterDashboard extends Component<PropsType, StateType> {
           >
             <i className="material-icons">add</i> Deploy Template
           </Button>
-          <NamespaceSelector
-            setNamespace={(namespace) => this.setState({ namespace })}
-            namespace={this.state.namespace}
-          />
+          <SortFilterWrapper>
+            <SortSelector
+              setSortType={(sortType) => this.setState({ sortType })}
+              sortType={this.state.sortType}
+            />
+            <NamespaceSelector
+              setNamespace={(namespace) => this.setState({ namespace })}
+              namespace={this.state.namespace}
+            />
+          </SortFilterWrapper>
         </ControlRow>
 
         <ChartList
           currentCluster={currentCluster}
           namespace={this.state.namespace}
+          sortType={this.state.sortType}
           setCurrentChart={(x: ChartType | null) => this.setState({ currentChart: x })}
         />
       </div>
@@ -297,4 +307,10 @@ const TitleSection = styled.div`
     }
     margin-bottom: -3px;
   }
+`;
+
+const SortFilterWrapper = styled.div`
+  width: 468px;
+  display: flex;
+  justify-content: space-between;
 `;

+ 64 - 0
dashboard/src/main/home/cluster-dashboard/SortSelector.tsx

@@ -0,0 +1,64 @@
+import React, { Component } from 'react';
+import styled from 'styled-components';
+
+import { Context } from '../../../shared/Context';
+
+import Selector from '../../../components/Selector';
+
+type PropsType = {
+  setSortType: (x: string) => void,
+  sortType: string
+};
+
+type StateType = {
+  sortOptions: { label: string, value: string }[]
+};
+
+// TODO: fix update to unmounted component 
+export default class SortSelector extends Component<PropsType, StateType> {
+  state = {
+    sortOptions: [
+      { label: 'Newest', value: 'Newest' },
+      { label: 'Oldest', value: 'Oldest' },
+      { label: 'Alphabetical', value: 'Alphabetical' }
+    ] as {label: string, value: string}[]
+  }
+
+  render() {
+    return ( 
+      <StyledSortSelector>
+        <Label>
+          <i className="material-icons">sort</i> Sort
+        </Label>
+        <Selector
+          activeValue={this.props.sortType}
+          setActiveValue={(sortType) => this.props.setSortType(sortType)}
+          options={this.state.sortOptions}
+          dropdownLabel='Sort By'
+          width='150px'
+          dropdownWidth='230px'
+          closeOverlay={true}
+        />
+      </StyledSortSelector>
+    );
+  }
+}
+
+SortSelector.contextType = Context;
+
+const Label = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: 12px;
+
+  > i {
+    margin-right: 8px;
+    font-size: 18px;
+  }
+`;
+
+const StyledSortSelector = styled.div`
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+`;

+ 19 - 4
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -11,6 +11,7 @@ import Loading from '../../../../components/Loading';
 type PropsType = {
   currentCluster: ClusterType,
   namespace: string,
+  sortType: string,
   setCurrentChart: (c: ChartType) => void
 };
 
@@ -53,6 +54,13 @@ export default class ChartList extends Component<PropsType, StateType> {
         this.setState({ loading: false, error: true });
       } else {
         let charts = res.data || [];
+        if (this.props.sortType == "Newest") {
+          charts.sort((a: any, b: any) => (Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)) ? -1 : 1);
+        } else if (this.props.sortType == "Oldest") {
+          charts.sort((a: any, b: any) => (Date.parse(a.info.last_deployed) > Date.parse(b.info.last_deployed)) ? 1 : -1);
+        } else if (this.props.sortType == "Alphabetical") {
+          charts.sort((a: any, b: any) => (a.name > b.name) ? 1: -1);
+        }
         this.setState({ charts }, () => {
           this.setState({ loading: false, error: false });
         });
@@ -176,7 +184,8 @@ export default class ChartList extends Component<PropsType, StateType> {
   componentDidUpdate(prevProps: PropsType) {
     // Ret2: Prevents reload when opening ClusterConfigModal
     if (prevProps.currentCluster !== this.props.currentCluster || 
-      prevProps.namespace !== this.props.namespace) {
+      prevProps.namespace !== this.props.namespace ||
+      prevProps.sortType !== this.props.sortType) {
       this.updateCharts(this.getControllers);
     }
   }
@@ -225,16 +234,22 @@ export default class ChartList extends Component<PropsType, StateType> {
 ChartList.contextType = Context;
 
 const Placeholder = styled.div`
-  padding-top: 100px;
   width: 100%;
   display: flex;
   justify-content: center;
   align-items: center;
   color: #ffffff44;
-  font-size: 14px;
+  background: #26282f;
+  border-radius: 5px;
+  height: 320px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff44;
+  font-size: 13px;
 
   > i {
-    font-size: 18px;
+    font-size: 16px;
     margin-right: 12px;
   }
 `;

+ 81 - 10
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -18,7 +18,8 @@ import StatusSection from './status/StatusSection';
 import ValuesWrapper from '../../../../components/values-form/ValuesWrapper';
 import ValuesForm from '../../../../components/values-form/ValuesForm';
 import SettingsSection from './SettingsSection';
-import { format } from 'util';
+import ConfirmOverlay from '../../../../components/ConfirmOverlay';
+import Loading from '../../../../components/Loading';
 
 type PropsType = {
   namespace: string,
@@ -44,6 +45,8 @@ type StateType = {
   controllers: Record<string, Record<string, any>>,
   websockets: Record<string, any>,
   url: string | null,
+  showDeleteOverlay: boolean,
+  deleting: boolean,
 };
 
 export default class ExpandedChart extends Component<PropsType, StateType> {
@@ -62,6 +65,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     controllers: {} as Record<string, Record<string, any>>,
     websockets : {} as Record<string, any>,
     url: null as string | null,
+    showDeleteOverlay: false,
+    deleting: false,
   }
 
   // Retrieve full chart data (includes form and values)
@@ -250,6 +255,7 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
             currentChart={chart}
             refreshChart={this.refreshChart}
             setCurrentView={setCurrentView}
+            setShowDeleteOverlay={(x: boolean) => this.setState({ showDeleteOverlay: x })}
           /> 
         );
       case 'graph': 
@@ -333,7 +339,6 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     // Append universal tabs
     tabOptions.push(
       { label: 'Status', value: 'status' },
-      { label: 'Deploy', value: 'settings' },
       { label: 'Chart Overview', value: 'graph' },
     );
 
@@ -344,6 +349,9 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
       );
     }
 
+    // Settings tab is always last
+    tabOptions.push({ label: 'Settings', value: 'settings' });
+
     // Filter tabs if previewing an old revision
     if (this.state.isPreview) {
       let liveTabs = ['status', 'settings', 'deploy'];
@@ -452,7 +460,6 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         console.log(err)
       } else {
         this.setState({ components: res.data.Objects });
-        console.log(res.data.Objects)
       }
     });
 
@@ -467,12 +474,13 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         console.log(err);
         return
       }
-      console.log(res.data)
       
       if (res.data?.status?.loadBalancer?.ingress) {
         this.setState({url: `http://${res.data?.status?.loadBalancer?.ingress[0]?.hostname}` })
       }
-    })
+    });
+
+    this.updateTabs();
   }
 
   componentDidUpdate(prevProps: PropsType) {
@@ -485,8 +493,8 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
   componentWillUnmount() {
     if (this.state.websockets) {
       this.state.websockets.forEach((ws: WebSocket) => {
-        ws.close()
-      })
+        ws.close();
+      });
     }
   }
 
@@ -518,15 +526,50 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
     }
   }
 
+  handleUninstallChart = () => {
+    let { currentProject, currentCluster } = this.context;
+    let { currentChart } = this.props;
+    this.setState({ deleting: true });
+    api.uninstallTemplate('<token>', {
+    }, {
+      namespace: currentChart.namespace,
+      storage: StorageType.Secret,
+      name: currentChart.name,
+      id: currentProject.id,
+      cluster_id: currentCluster.id,
+    }, (err: any, res: any) => {
+      if (err) {
+        console.log(err)
+      } else {
+        this.setState({ showDeleteOverlay: false });
+        this.props.setCurrentChart(null);
+      }
+    });
+  }
+
+  renderDeleteOverlay = () => {
+    if (this.state.deleting) {
+      return <DeleteOverlay><Loading /></DeleteOverlay>;
+    }
+  }
+
   render() {
     let { currentChart, setCurrentChart } = this.props;
     let chart = currentChart;
     let status = this.getChartStatus(chart.info.status);
 
     return ( 
-      <div>
-        <CloseOverlay onClick={() => setCurrentChart(null)}/>
+      <>
+        <CloseOverlay onClick={() => setCurrentChart(null)} />
         <StyledExpandedChart>
+          <ConfirmOverlay
+            show={this.state.showDeleteOverlay}
+            message={`Are you sure you want to delete ${currentChart.name}?`}
+            onYes={this.handleUninstallChart}
+            onNo={() => this.setState({ showDeleteOverlay: false })}
+          />
+          {this.renderDeleteOverlay()}
+          
           <HeaderWrapper>
             <TitleSection>
               <Title>
@@ -580,13 +623,41 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
             {this.renderTabContents()}
           </TabRegion>
         </StyledExpandedChart>
-      </div>
+      </>
     );
   }
 }
 
 ExpandedChart.contextType = Context;
 
+const DeleteOverlay = styled.div`
+  position: absolute;
+  top: 0px;
+  opacity: 100%;
+  left: 0px;
+  width: 100%;
+  height: 100%;
+  z-index: 999;
+  display: flex;
+  padding-bottom: 30px;
+  align-items: center;
+  justify-content: center;
+  font-family: 'Work Sans', sans-serif;
+  font-size: 18px;
+  font-weight: 500;
+  color: white;
+  flex-direction: column;
+  background: rgb(0,0,0,0.73);
+  opacity: 0;
+  animation: lindEnter 0.2s;
+  animation-fill-mode: forwards;
+
+  @keyframes lindEnter {
+    from { opacity: 0; }
+    to   { opacity: 1; }
+  }
+`;
+
 const Bolded = styled.div`
   font-weight: 500;
   color: #ffffff44;

+ 29 - 2
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -17,6 +17,7 @@ type PropsType = {
   currentChart: ChartType,
   refreshChart: () => void,
   setCurrentView: (x: string) => void,
+  setShowDeleteOverlay: (x: boolean) => void,
 };
 
 type StateType = {
@@ -156,7 +157,7 @@ export default class SettingsSection extends Component<PropsType, StateType> {
   }
 
   renderWebhookSection = () => {
-    if (this.state.webhookToken) {
+    if (true || this.state.webhookToken) {
       let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=???&repository=???'`;
       return (
         <>
@@ -184,9 +185,13 @@ export default class SettingsSection extends Component<PropsType, StateType> {
     return (
       <Wrapper>
         <StyledSettingsSection>
-          <Heading>Connected source</Heading>
+          <Heading>Connected Source</Heading>
           {this.renderSourceSection()}
           {this.renderWebhookSection()}
+          <Heading>Additional Settings</Heading>
+          <Button color='#b91133' onClick={() => this.props.setShowDeleteOverlay(true)}>
+            Delete {this.props.currentChart.name}
+          </Button>
         </StyledSettingsSection>
         <SaveButton
           text='Save Settings'
@@ -202,6 +207,27 @@ export default class SettingsSection extends Component<PropsType, StateType> {
 
 SettingsSection.contextType = Context;
 
+const Button = styled.button`
+  height: 40px;
+  font-size: 13px;
+  margin-top: 20px;
+  font-weight: 500;
+  font-family: 'Work Sans', sans-serif;
+  color: white;
+  padding: 6px 20px 7px 20px;
+  text-align: left;
+  border: 0;
+  border-radius: 5px;
+  background: ${(props) => (!props.disabled ? props.color : '#aaaabb')};
+  box-shadow: ${(props) => (!props.disabled ? '0 2px 5px 0 #00000030' : 'none')};
+  cursor: ${(props) => (!props.disabled ? 'pointer' : 'default')};
+  user-select: none;
+  :focus { outline: 0 }
+  :hover {
+    filter: ${(props) => (!props.disabled ? 'brightness(120%)' : '')};
+  }
+`;
+
 const Webhook = styled.div`
   width: 100%;
   border: 1px solid #ffffff55;
@@ -262,6 +288,7 @@ const StyledSettingsSection = styled.div`
   height: calc(100% - 60px);
   background: #ffffff11;
   padding: 0 35px;
+  padding-bottom: 50px;
   position: relative;
   border-radius: 5px;
   overflow: auto;

+ 115 - 12
dashboard/src/main/home/new-project/NewProject.tsx

@@ -26,6 +26,9 @@ type StateType = {
   awsRegion: string | null,
   awsAccessId: string | null,
   awsSecretKey: string | null,
+  gcpRegion: string | null,
+  gcpProjectId: string | null,
+  gcpKeyData: string | null,
   status: string | null,
 };
 
@@ -37,6 +40,9 @@ export default class NewProject extends Component<PropsType, StateType> {
     awsRegion: '' as string | null,
     awsAccessId: '' as string | null,
     awsSecretKey: '' as string | null,
+    gcpRegion: '' as string | null,
+    gcpProjectId: '' as string | null,
+    gcpKeyData: '' as string | null,
     status: null as string | null,
   }
 
@@ -52,11 +58,11 @@ export default class NewProject extends Component<PropsType, StateType> {
     this.setState({ selectedProvider: provider });
   }
 
-  renderTemplateList = () => {
+  renderProviderList = () => {
     return providers.map((provider: string, i: number) => {
       let providerInfo = integrationList[provider];
       return (
-        <Block 
+        <Block
           key={i} 
           onClick={() => this.handleSelectProvider(provider)}
         >
@@ -128,9 +134,41 @@ export default class NewProject extends Component<PropsType, StateType> {
           }}>
             <CloseButtonImg src={close} />
           </CloseButton>
-          <Flex>
-            GCP support is in closed beta. If you would like to run Porter in your own Google Cloud account, email <Highlight>contact@getporter.dev</Highlight>.
-          </Flex>
+          <DarkMatter />
+          <Heading>
+            GCP Credentials
+            <GuideButton href='https://docs.getporter.dev/docs/getting-started-with-porter-on-gcp' target='_blank'>
+              <i className="material-icons-outlined">help</i> 
+              Guide
+            </GuideButton>
+          </Heading>
+          <InputRow
+            type='text'
+            value={this.state.gcpRegion}
+            setValue={(x: string) => this.setState({ gcpRegion: x })}
+            label='📍 GCP Region'
+            placeholder='ex: us-central1-a'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='text'
+            value={this.state.gcpProjectId}
+            setValue={(x: string) => this.setState({ gcpProjectId: x })}
+            label='🏷️ GCP Project ID'
+            placeholder='ex: pale-moon-24601'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='password'
+            value={this.state.gcpKeyData}
+            setValue={(x: string) => this.setState({ gcpKeyData: x })}
+            label='🔒 GCP Key Data'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+            isRequired={true}
+          />
         </FormSection>
       );
     } else if (this.state.selectedProvider === 'do') {
@@ -150,7 +188,7 @@ export default class NewProject extends Component<PropsType, StateType> {
 
     return (
       <BlockList>
-        {this.renderTemplateList()}
+        {this.renderProviderList()}
       </BlockList>
     );
   }
@@ -196,12 +234,23 @@ export default class NewProject extends Component<PropsType, StateType> {
   }
 
   validateForm = () => {
-    let { projectName, selectedProvider, awsAccessId, awsSecretKey, awsRegion } = this.state;
+    let { 
+      projectName,
+      selectedProvider, 
+      awsAccessId, 
+      awsSecretKey, 
+      awsRegion,
+      gcpRegion,
+      gcpKeyData,
+      gcpProjectId,
+    } = this.state;
     if (!this.isAlphanumeric(projectName) || projectName === '') {
       return false;
     } else if (selectedProvider === 'aws') {
       return awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '';
-    }  else if (selectedProvider === 'skipped') {
+    } else if (selectedProvider === 'gcp') {
+      return gcpRegion !== '' && gcpKeyData !== '' && gcpProjectId !== '';
+    } else if (selectedProvider === 'skipped') {
       return true;
     }
     return false;
@@ -266,13 +315,64 @@ export default class NewProject extends Component<PropsType, StateType> {
         }
 
         this.props.setCurrentView('provisioner', [
-          {infra_id: ecr?.data?.id, kind: ecr?.data?.kind},
-          {infra_id: eks?.data?.id, kind: eks?.data?.kind},
+          { infra_id: ecr?.data?.id, kind: ecr?.data?.kind },
+          { infra_id: eks?.data?.id, kind: eks?.data?.kind },
         ]);
       })
     })
   }
 
+  provisionGKE = (proj: ProjectType, id: number) => {
+    let clusterName = `${proj.name}-cluster`
+    console.log('provisioning gke...');
+    api.createGKE('<token>', {
+      gke_name: clusterName,
+      gcp_integration_id: id,
+    }, { project_id: proj.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else if (res?.data) {
+        
+        // TODO: set to provisioner
+        alert('success');
+      }
+    });
+  }
+
+  provisionGCR = (proj: ProjectType, id: number) => {
+    console.log('provisioning gcr...');
+    api.createGCR('<token>', {
+      gcp_integration_id: id,
+    }, { project_id: proj.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else if (res?.data) {
+        console.log('gcr provisioned with response: ', res.data);
+        this.provisionGKE(proj, id);
+      }
+    });
+  }
+
+  provisionGCP = (proj: ProjectType) => {
+    this.setState({ status: 'loading' });
+
+    let { gcpRegion, gcpKeyData, gcpProjectId } = this.state;
+    console.log('provisioning gcp...');
+    api.createGCPIntegration('<token>', {
+      gcp_region: gcpRegion,
+      gcp_key_data: gcpKeyData,
+      gcp_project_id: gcpProjectId,
+    }, { project_id: proj.id }, (err: any, res: any) => {
+      if (err) {
+        console.log(err);
+      } else if (res?.data) {
+        console.log('gcp provisioned with response: ', res.data);
+        let { id } = res.data;
+        this.provisionGCR(proj, id);
+      }
+    });
+  }
+
   createProject = () => {
     this.setState({ status: 'loading' });
     api.createProject('<token>', {
@@ -292,7 +392,9 @@ export default class NewProject extends Component<PropsType, StateType> {
               this.context.setCurrentProject(proj);
               
               if (this.state.selectedProvider === 'aws') {
-                this.provisionECR(proj, this.provisionEKS)
+                this.provisionECR(proj, this.provisionEKS);
+              } else if (this.state.selectedProvider === 'gcp') { 
+                this.provisionGCP(proj);
               } else {
                 this.props.setCurrentView('dashboard', null);
               }
@@ -407,8 +509,9 @@ export default class NewProject extends Component<PropsType, StateType> {
   }
   
   render() {
+    let { selectedProvider } = this.state;
     return (
-      <StyledNewProject height={this.state.selectedProvider === 'aws' ? '700px' : '600px'}>
+      <StyledNewProject height={selectedProvider === 'aws' || selectedProvider === 'gcp' ? '700px' : '600px'}>
         {this.renderHeaderSection()}
         {this.renderHostingSection()}
         {this.renderButton()}

+ 5 - 1
dashboard/src/main/home/templates/Templates.tsx

@@ -46,7 +46,11 @@ export default class Templates extends Component<PropsType, StateType> {
       if (err) {
         this.setState({ loading: false, error: true });
       } else {
-        this.setState({ porterTemplates: res.data, loading: false, error: false });
+        this.setState({ porterTemplates: res.data, error: false }, () => {
+          this.state.porterTemplates.sort((a, b) => (a.name > b.name) ? 1 : -1);
+          this.state.porterTemplates.sort((a,b) => (a.name === 'docker') ? -1 : (b.name === 'docker') ? 1 : 0);
+          this.setState({ loading: false });
+        });
       }
     });
   }

+ 13 - 3
dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx

@@ -51,12 +51,20 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     namespaceOptions: [] as { label: string, value: string }[],
   };
 
-  onSubmitAddon = () => {
+  onSubmitAddon = (wildcard?: any) => {
     let { currentCluster, currentProject } = this.context;
     let name = randomWords({ exactly: 3, join: '-' });
+    this.setState({ saveValuesStatus: 'loading' });
+
+    let values = {};
+    for (let key in wildcard) {
+      _.set(values, key, wildcard[key]);
+    }
+
     api.deployTemplate('<token>', {
       templateName: this.props.currentTemplate.name,
       storage: StorageType.Secret,
+      formValues: values,
       namespace: this.state.selectedNamespace,
       name,
     }, {
@@ -91,6 +99,8 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
       let splits = this.state.selectedImageUrl.split(':');
       imageUrl = splits[0];
       tag = splits[1];
+    } else if (!tag) {
+      tag = 'latest';
     }
 
     _.set(values, "image.repository", imageUrl)
@@ -121,9 +131,9 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     return (
       <ValuesWrapper
         formTabs={this.props.form?.tabs}
-        onSubmit={this.onSubmit}
+        onSubmit={this.props.currentTemplate.name === 'docker' ? this.onSubmit : this.onSubmitAddon}
         saveValuesStatus={this.state.saveValuesStatus}
-        disabled={!this.state.selectedImageUrl && this.props.form?.hasSource}
+        disabled={this.props.form?.hasSource ? !this.state.selectedImageUrl : false}
       >
         {(metaState: any, setMetaState: any) => {
           return this.props.form?.tabs.map((tab: any, i: number) => {

+ 44 - 1
dashboard/src/shared/api.tsx

@@ -190,6 +190,18 @@ const deployTemplate = baseApi<{
   return `/api/projects/${id}/deploy/${name}/${version}?cluster_id=${cluster_id}`;
 });
 
+const uninstallTemplate = baseApi<{
+}, {
+  id: number,
+  name: string, 
+  cluster_id: number,
+  namespace: string,
+  storage: StorageType,
+}>('POST', pathParams => {
+  let { id, name, cluster_id, storage, namespace } = pathParams;
+  return `/api/projects/${id}/deploy/${name}?cluster_id=${cluster_id}&namespace=${namespace}&storage=${storage}`;
+});
+
 const getClusterIntegrations = baseApi('GET', '/api/integrations/cluster');
 
 const getRegistryIntegrations = baseApi('GET', '/api/integrations/registry');
@@ -291,10 +303,41 @@ const deleteCluster = baseApi<{
   cluster_id: number,
 }>('DELETE', pathParams => {
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
-})
+});
+
+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 createGCR = baseApi<{
+  gcp_integration_id: number,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/provision/gcr`;
+});
+
+const createGKE = baseApi<{
+  gcp_integration_id: number,
+  gke_name: string,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/provision/gke`;
+});
 
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
+  uninstallTemplate,
+  createGCR,
+  createGKE,
+  createGCPIntegration,
   deleteCluster,
   destroyCluster,
   getInfra,

+ 3 - 0
go.mod

@@ -13,7 +13,9 @@ 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/digitalocean/godo v1.56.0
 	github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492
+	github.com/docker/distribution v2.7.1+incompatible
 	github.com/docker/docker v1.4.2-0.20200203170920-46ec8731fbce
 	github.com/docker/docker-credential-helpers v0.6.3
 	github.com/docker/go-connections v0.4.0
@@ -27,6 +29,7 @@ require (
 	github.com/go-redis/redis/v7 v7.4.0
 	github.com/go-redis/redis/v8 v8.3.1
 	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-querystring v1.0.0 // indirect
 	github.com/googleapis/gnostic v0.2.2 // indirect

+ 7 - 0
go.sum

@@ -230,6 +230,8 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZm
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
 github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
+github.com/digitalocean/godo v1.56.0 h1:wXqWJyywrDO3YO2T4Kh8TwbCPOa+OI2vC8qh0/Ngmjk=
+github.com/digitalocean/godo v1.56.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU=
 github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
 github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492 h1:FwssHbCDJD025h+BchanCwE1Q8fyMgqDr2mOQAWOLGw=
 github.com/docker/cli v0.0.0-20200130152716-5d0cf8839492/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
@@ -1452,8 +1454,11 @@ gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
 gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
 gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
 gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
+helm.sh/helm v1.2.1 h1:Jrn7kKQqQ/hnFWZEX+9pMFvYqFexkzrBnGqYBmIph7c=
+helm.sh/helm v2.17.0+incompatible h1:cSe3FaQOpRWLDXvTObQNj0P7WI98IG5yloU6tQVls2k=
 helm.sh/helm/v3 v3.3.4 h1:tbad6WQVMxEw1HlVBvI2rQqOblmI5lgXOrWAMwJ198M=
 helm.sh/helm/v3 v3.3.4/go.mod h1:CyCGQa53/k1JFxXvXveGwtfJ4cuB9zkaBSGa5rnAiHU=
+helm.sh/helm/v3 v3.5.0 h1:uqIT3Bh4hVEyZRThyTPik8FkiABj3VJIY+POvDFT3a4=
 honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1476,6 +1481,8 @@ k8s.io/cli-runtime v0.18.8/go.mod h1:7EzWiDbS9PFd0hamHHVoCY4GrokSTPSL32MA4rzIu0M
 k8s.io/client-go v0.16.8/go.mod h1:WmPuN0yJTKHXoklExKxzo3jSXmr3EnN+65uaTb5VuNs=
 k8s.io/client-go v0.18.8 h1:SdbLpIxk5j5YbFr1b7fq8S7mDgDjYmUxSbszyoesoDM=
 k8s.io/client-go v0.18.8/go.mod h1:HqFqMllQ5NnQJNwjro9k5zMyfhZlOwpuTLVrxjkYSxU=
+k8s.io/client-go v1.5.1 h1:XaX/lo2/u3/pmFau8HN+sB5C/b4dc4Dmm2eXjBH4p1E=
+k8s.io/client-go v11.0.0+incompatible h1:LBbX2+lOwY9flffWlJM7f1Ct8V2SRNiMRDFeiwnJo9o=
 k8s.io/code-generator v0.16.8/go.mod h1:wFdrXdVi/UC+xIfLi+4l9elsTT/uEF61IfcN2wOLULQ=
 k8s.io/code-generator v0.18.8/go.mod h1:TgNEVx9hCyPGpdtCWA34olQYLkh3ok9ar7XfSsr8b6c=
 k8s.io/component-base v0.16.8/go.mod h1:Q8UWOWShpP3MZZny4n/15gOncfaaVtc9SbCdkM5MhUE=

+ 4 - 0
internal/config/config.go

@@ -33,6 +33,10 @@ type ServerConf struct {
 
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
+
+	DOClientID          string `env:"DO_CLIENT_ID"`
+	DOClientSecret      string `env:"DO_CLIENT_SECRET"`
+	ProvisionerImageTag string `env:"PROV_IMAGE_TAG,default-latest"`
 }
 
 // DBConf is the database configuration: if generated from environment variables,

+ 103 - 8
internal/forms/infra.go

@@ -17,10 +17,10 @@ type CreateECRInfra struct {
 	AWSIntegrationID uint   `json:"aws_integration_id" form:"required"`
 }
 
-// ToAWSInfra converts the form to a gorm aws infra model
-func (ce *CreateECRInfra) ToAWSInfra() (*models.AWSInfra, error) {
-	return &models.AWSInfra{
-		Kind:             models.AWSInfraECR,
+// ToInfra converts the form to a gorm aws infra model
+func (ce *CreateECRInfra) ToInfra() (*models.Infra, error) {
+	return &models.Infra{
+		Kind:             models.InfraECR,
 		ProjectID:        ce.ProjectID,
 		Suffix:           stringWithCharset(6, randCharset),
 		Status:           models.StatusCreating,
@@ -36,10 +36,10 @@ type CreateEKSInfra struct {
 	AWSIntegrationID uint   `json:"aws_integration_id" form:"required"`
 }
 
-// ToAWSInfra converts the form to a gorm aws infra model
-func (ce *CreateEKSInfra) ToAWSInfra() (*models.AWSInfra, error) {
-	return &models.AWSInfra{
-		Kind:             models.AWSInfraEKS,
+// ToInfra converts the form to a gorm aws infra model
+func (ce *CreateEKSInfra) ToInfra() (*models.Infra, error) {
+	return &models.Infra{
+		Kind:             models.InfraEKS,
 		ProjectID:        ce.ProjectID,
 		Suffix:           stringWithCharset(6, randCharset),
 		Status:           models.StatusCreating,
@@ -47,6 +47,83 @@ func (ce *CreateEKSInfra) ToAWSInfra() (*models.AWSInfra, error) {
 	}, nil
 }
 
+// CreateGCRInfra represents the accepted values for creating an
+// GCR infra via the provisioning container
+type CreateGCRInfra struct {
+	ProjectID        uint `json:"project_id" form:"required"`
+	GCPIntegrationID uint `json:"gcp_integration_id" form:"required"`
+}
+
+// ToInfra converts the form to a gorm aws infra model
+func (ce *CreateGCRInfra) ToInfra() (*models.Infra, error) {
+	return &models.Infra{
+		Kind:             models.InfraGCR,
+		ProjectID:        ce.ProjectID,
+		Suffix:           stringWithCharset(6, randCharset),
+		Status:           models.StatusCreating,
+		GCPIntegrationID: ce.GCPIntegrationID,
+	}, nil
+}
+
+// CreateGKEInfra represents the accepted values for creating a
+// GKE infra via the provisioning container
+type CreateGKEInfra struct {
+	GKEName          string `json:"gke_name" form:"required"`
+	ProjectID        uint   `json:"project_id" form:"required"`
+	GCPIntegrationID uint   `json:"gcp_integration_id" form:"required"`
+}
+
+// ToInfra converts the form to a gorm aws infra model
+func (ce *CreateGKEInfra) ToInfra() (*models.Infra, error) {
+	return &models.Infra{
+		Kind:             models.InfraGKE,
+		ProjectID:        ce.ProjectID,
+		Suffix:           stringWithCharset(6, randCharset),
+		Status:           models.StatusCreating,
+		GCPIntegrationID: ce.GCPIntegrationID,
+	}, nil
+}
+
+// CreateDOCRInfra represents the accepted values for creating an
+// DOCR infra via the provisioning container
+type CreateDOCRInfra struct {
+	DOCRName             string `json:"docr_name" form:"required"`
+	DOCRSubscriptionTier string `json:"docr_subscription_tier" form:"required"`
+	ProjectID            uint   `json:"project_id" form:"required"`
+	DOIntegrationID      uint   `json:"do_integration_id" form:"required"`
+}
+
+// ToInfra converts the form to a gorm infra model
+func (de *CreateDOCRInfra) ToInfra() (*models.Infra, error) {
+	return &models.Infra{
+		Kind:            models.InfraDOCR,
+		ProjectID:       de.ProjectID,
+		Suffix:          stringWithCharset(6, randCharset),
+		Status:          models.StatusCreating,
+		DOIntegrationID: de.DOIntegrationID,
+	}, nil
+}
+
+// CreateDOKSInfra represents the accepted values for creating a
+// DOKS infra via the provisioning container
+type CreateDOKSInfra struct {
+	DORegion        string `json:"do_region" form:"required"`
+	DOKSName        string `json:"doks_name" form:"required"`
+	ProjectID       uint   `json:"project_id" form:"required"`
+	DOIntegrationID uint   `json:"do_integration_id" form:"required"`
+}
+
+// ToInfra converts the form to a gorm infra model
+func (de *CreateDOKSInfra) ToInfra() (*models.Infra, error) {
+	return &models.Infra{
+		Kind:            models.InfraDOKS,
+		ProjectID:       de.ProjectID,
+		Suffix:          stringWithCharset(6, randCharset),
+		Status:          models.StatusCreating,
+		DOIntegrationID: de.DOIntegrationID,
+	}, nil
+}
+
 // DestroyECRInfra represents the accepted values for destroying an
 // ECR infra via the provisioning container
 type DestroyECRInfra struct {
@@ -59,6 +136,24 @@ type DestroyEKSInfra struct {
 	EKSName string `json:"eks_name" form:"required"`
 }
 
+// DestroyGKEInfra represents the accepted values for destroying an
+// GKE infra via the provisioning container
+type DestroyGKEInfra struct {
+	GKEName string `json:"gke_name" form:"required"`
+}
+
+// DestroyDOCRInfra represents the accepted values for destroying an
+// DOCR infra via the provisioning container
+type DestroyDOCRInfra struct {
+	DOCRName string `json:"docr_name" form:"required"`
+}
+
+// DestroyDOKSInfra represents the accepted values for destroying an
+// DOKS infra via the provisioning container
+type DestroyDOKSInfra struct {
+	DOKSName string `json:"doks_name" form:"required"`
+}
+
 // helpers for random string
 var seededRand *rand.Rand = rand.New(
 	rand.NewSource(time.Now().UnixNano()))

+ 2 - 0
internal/forms/registry.go

@@ -14,6 +14,7 @@ type CreateRegistry struct {
 	URL              string `json:"url"`
 	GCPIntegrationID uint   `json:"gcp_integration_id"`
 	AWSIntegrationID uint   `json:"aws_integration_id"`
+	DOIntegrationID  uint   `json:"do_integration_id"`
 }
 
 // ToRegistry converts the form to a gorm registry model
@@ -24,6 +25,7 @@ func (cr *CreateRegistry) ToRegistry(repo repository.Repository) (*models.Regist
 		URL:              cr.URL,
 		GCPIntegrationID: cr.GCPIntegrationID,
 		AWSIntegrationID: cr.AWSIntegrationID,
+		DOIntegrationID:  cr.DOIntegrationID,
 	}
 
 	if registry.URL == "" && registry.AWSIntegrationID != 0 {

+ 5 - 1
internal/forms/release.go

@@ -1,6 +1,7 @@
 package forms
 
 import (
+	"fmt"
 	"net/url"
 	"strconv"
 
@@ -19,6 +20,7 @@ func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(
 	vals url.Values,
 	repo repository.ClusterRepository,
 ) error {
+	fmt.Println(vals)
 	if clusterID, ok := vals["cluster_id"]; ok && len(clusterID) == 1 {
 		id, err := strconv.ParseUint(clusterID[0], 10, 64)
 
@@ -31,15 +33,17 @@ func (rf *ReleaseForm) PopulateHelmOptionsFromQueryParams(
 		if err != nil {
 			return err
 		}
-
+		fmt.Println("setting cluster")
 		rf.Cluster = cluster
 	}
 
 	if namespace, ok := vals["namespace"]; ok && len(namespace) == 1 {
+		fmt.Println("setting namespace")
 		rf.Namespace = namespace[0]
 	}
 
 	if storage, ok := vals["storage"]; ok && len(storage) == 1 {
+		fmt.Println("setting storage")
 		rf.Storage = storage[0]
 	}
 

+ 73 - 14
internal/helm/agent.go

@@ -4,6 +4,10 @@ import (
 	"fmt"
 
 	"github.com/pkg/errors"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chart"
 	"helm.sh/helm/v3/pkg/release"
@@ -13,6 +17,7 @@ import (
 // Agent is a Helm agent for performing helm operations
 type Agent struct {
 	ActionConfig *action.Configuration
+	K8sAgent     *kubernetes.Agent
 }
 
 // ListReleases lists releases based on a ListFilter
@@ -49,10 +54,19 @@ func (a *Agent) GetReleaseHistory(
 	return cmd.Run(name)
 }
 
+type UpgradeReleaseConfig struct {
+	Name       string
+	Values     map[string]interface{}
+	Cluster    *models.Cluster
+	Repo       repository.Repository
+	Registries []*models.Registry
+}
+
 // UpgradeRelease upgrades a specific release with new values.yaml
 func (a *Agent) UpgradeRelease(
-	name string,
+	conf *UpgradeReleaseConfig,
 	values string,
+	doAuth *oauth2.Config,
 ) (*release.Release, error) {
 	valuesYaml, err := chartutil.ReadValues([]byte(values))
 
@@ -60,16 +74,18 @@ func (a *Agent) UpgradeRelease(
 		return nil, fmt.Errorf("Values could not be parsed: %v", err)
 	}
 
-	return a.UpgradeReleaseByValues(name, valuesYaml)
+	conf.Values = valuesYaml
+
+	return a.UpgradeReleaseByValues(conf, doAuth)
 }
 
 // UpgradeReleaseByValues upgrades a release by unmarshaled yaml values
 func (a *Agent) UpgradeReleaseByValues(
-	name string,
-	values map[string]interface{},
+	conf *UpgradeReleaseConfig,
+	doAuth *oauth2.Config,
 ) (*release.Release, error) {
 	// grab the latest release
-	rel, err := a.GetRelease(name, 0)
+	rel, err := a.GetRelease(conf.Name, 0)
 
 	if err != nil {
 		return nil, fmt.Errorf("Could not get release to be upgraded: %v", err)
@@ -78,7 +94,23 @@ func (a *Agent) UpgradeReleaseByValues(
 	ch := rel.Chart
 
 	cmd := action.NewUpgrade(a.ActionConfig)
-	res, err := cmd.Run(name, ch, values)
+
+	if conf.Cluster != nil && a.K8sAgent != nil && conf.Registries != nil && len(conf.Registries) > 0 {
+		cmd.PostRenderer, err = NewDockerSecretsPostRenderer(
+			conf.Cluster,
+			conf.Repo,
+			a.K8sAgent,
+			rel.Namespace,
+			conf.Registries,
+			doAuth,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	res, err := cmd.Run(conf.Name, ch, conf.Values)
 
 	if err != nil {
 		return nil, fmt.Errorf("Upgrade failed: %v", err)
@@ -89,16 +121,20 @@ func (a *Agent) UpgradeReleaseByValues(
 
 // InstallChartConfig is the config required to install a chart
 type InstallChartConfig struct {
-	Chart     *chart.Chart
-	Name      string
-	Namespace string
-	Values    map[string]interface{}
+	Chart      *chart.Chart
+	Name       string
+	Namespace  string
+	Values     map[string]interface{}
+	Cluster    *models.Cluster
+	Repo       repository.Repository
+	Registries []*models.Registry
 }
 
 // InstallChartFromValuesBytes reads the raw values and calls Agent.InstallChart
 func (a *Agent) InstallChartFromValuesBytes(
 	conf *InstallChartConfig,
 	values []byte,
+	doAuth *oauth2.Config,
 ) (*release.Release, error) {
 	valuesYaml, err := chartutil.ReadValues(values)
 
@@ -108,12 +144,13 @@ func (a *Agent) InstallChartFromValuesBytes(
 
 	conf.Values = valuesYaml
 
-	return a.InstallChart(conf)
+	return a.InstallChart(conf, doAuth)
 }
 
 // InstallChart installs a new chart
 func (a *Agent) InstallChart(
 	conf *InstallChartConfig,
+	doAuth *oauth2.Config,
 ) (*release.Release, error) {
 	cmd := action.NewInstall(a.ActionConfig)
 
@@ -128,9 +165,23 @@ func (a *Agent) InstallChart(
 		return nil, err
 	}
 
-	// if chartRequested.Metadata.Deprecated {
-	// 	return nil, fmt.Errorf("This chart is deprecated")
-	// }
+	var err error
+
+	// only add the postrenderer if required fields exist
+	if conf.Cluster != nil && a.K8sAgent != nil && conf.Registries != nil && len(conf.Registries) > 0 {
+		cmd.PostRenderer, err = NewDockerSecretsPostRenderer(
+			conf.Cluster,
+			conf.Repo,
+			a.K8sAgent,
+			conf.Namespace,
+			conf.Registries,
+			doAuth,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+	}
 
 	if req := conf.Chart.Metadata.Dependencies; req != nil {
 		if err := action.CheckDependencies(conf.Chart, req); err != nil {
@@ -142,6 +193,14 @@ func (a *Agent) InstallChart(
 	return cmd.Run(conf.Chart, conf.Values)
 }
 
+// UninstallChart uninstalls a chart
+func (a *Agent) UninstallChart(
+	name string,
+) (*release.UninstallReleaseResponse, error) {
+	cmd := action.NewUninstall(a.ActionConfig)
+	return cmd.Run(name)
+}
+
 // RollbackRelease rolls a release back to a specified revision/version
 func (a *Agent) RollbackRelease(
 	name string,

+ 37 - 26
internal/helm/config.go

@@ -8,6 +8,7 @@ import (
 	"github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/chartutil"
 	"helm.sh/helm/v3/pkg/kube"
@@ -19,10 +20,11 @@ import (
 // Form represents the options for connecting to a cluster and
 // creating a Helm agent
 type Form struct {
-	Cluster   *models.Cluster `form:"required"`
-	Repo      *repository.Repository
-	Storage   string `json:"storage" form:"oneof=secret configmap memory" default:"secret"`
-	Namespace string `json:"namespace"`
+	Cluster           *models.Cluster `form:"required"`
+	Repo              *repository.Repository
+	DigitalOceanOAuth *oauth2.Config
+	Storage           string `json:"storage" form:"oneof=secret configmap memory" default:"secret"`
+	Namespace         string `json:"namespace"`
 }
 
 // GetAgentOutOfClusterConfig creates a new Agent from outside the cluster using
@@ -30,8 +32,9 @@ type Form struct {
 func GetAgentOutOfClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 	// create a kubernetes agent
 	conf := &kubernetes.OutOfClusterConfig{
-		Cluster: form.Cluster,
-		Repo:    form.Repo,
+		Cluster:           form.Cluster,
+		Repo:              form.Repo,
+		DigitalOceanOAuth: form.DigitalOceanOAuth,
 	}
 
 	k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(conf)
@@ -52,12 +55,15 @@ func GetAgentFromK8sAgent(stg string, ns string, l *logger.Logger, k8sAgent *kub
 	}
 
 	// use k8s agent to create Helm agent
-	return &Agent{&action.Configuration{
-		RESTClientGetter: k8sAgent.RESTClientGetter,
-		KubeClient:       kube.New(k8sAgent.RESTClientGetter),
-		Releases:         StorageMap[stg](l, clientset.CoreV1(), ns),
-		Log:              l.Printf,
-	}}, nil
+	return &Agent{
+		ActionConfig: &action.Configuration{
+			RESTClientGetter: k8sAgent.RESTClientGetter,
+			KubeClient:       kube.New(k8sAgent.RESTClientGetter),
+			Releases:         StorageMap[stg](l, clientset.CoreV1(), ns),
+			Log:              l.Printf,
+		},
+		K8sAgent: k8sAgent,
+	}, nil
 }
 
 // GetAgentInClusterConfig creates a new Agent from inside the cluster using
@@ -77,12 +83,15 @@ func GetAgentInClusterConfig(form *Form, l *logger.Logger) (*Agent, error) {
 	}
 
 	// use k8s agent to create Helm agent
-	return &Agent{&action.Configuration{
-		RESTClientGetter: k8sAgent.RESTClientGetter,
-		KubeClient:       kube.New(k8sAgent.RESTClientGetter),
-		Releases:         StorageMap[form.Storage](l, clientset.CoreV1(), form.Namespace),
-		Log:              l.Printf,
-	}}, nil
+	return &Agent{
+		ActionConfig: &action.Configuration{
+			RESTClientGetter: k8sAgent.RESTClientGetter,
+			KubeClient:       kube.New(k8sAgent.RESTClientGetter),
+			Releases:         StorageMap[form.Storage](l, clientset.CoreV1(), form.Namespace),
+			Log:              l.Printf,
+		},
+		K8sAgent: k8sAgent,
+	}, nil
 }
 
 // GetAgentTesting creates a new Agent using an optional existing storage class
@@ -93,14 +102,16 @@ func GetAgentTesting(form *Form, storage *storage.Storage, l *logger.Logger) *Ag
 		testStorage = StorageMap["memory"](nil, nil, "")
 	}
 
-	return &Agent{&action.Configuration{
-		Releases: testStorage,
-		KubeClient: &kubefake.FailingKubeClient{
-			PrintingKubeClient: kubefake.PrintingKubeClient{
-				Out: ioutil.Discard,
+	return &Agent{
+		ActionConfig: &action.Configuration{
+			Releases: testStorage,
+			KubeClient: &kubefake.FailingKubeClient{
+				PrintingKubeClient: kubefake.PrintingKubeClient{
+					Out: ioutil.Discard,
+				},
 			},
+			Capabilities: chartutil.DefaultCapabilities,
+			Log:          l.Printf,
 		},
-		Capabilities: chartutil.DefaultCapabilities,
-		Log:          l.Printf,
-	}}
+	}
 }

+ 443 - 0
internal/helm/postrenderer.go

@@ -0,0 +1,443 @@
+package helm
+
+import (
+	"bytes"
+	"io"
+	"net/url"
+	"regexp"
+	"strings"
+
+	"github.com/aws/aws-sdk-go/aws/arn"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
+	"gopkg.in/yaml.v2"
+	"helm.sh/helm/v3/pkg/postrender"
+
+	"github.com/docker/distribution/reference"
+)
+
+// DockerSecretsPostRenderer is a Helm post-renderer that adds image pull secrets to
+// pod specs that would otherwise be unable to pull an image.
+//
+// The post-renderer currently looks for two types of registries: GCR and ECR (TODO: DOCR
+// and Dockerhub). It also detects if the image pull secret is necessary: if GCR image pulls
+// occur in a GKE cluster in the same project, or if ECR image pulls exist in an EKS cluster
+// in the same organization + region, an image pull is not necessary.
+type DockerSecretsPostRenderer struct {
+	Cluster   *models.Cluster
+	Repo      repository.Repository
+	Agent     *kubernetes.Agent
+	Namespace string
+	DOAuth    *oauth2.Config
+
+	registries map[string]*models.Registry
+
+	podSpecs  []resource
+	resources []resource
+}
+
+// while manifests are map[string]interface{} at the top level,
+// nested keys will be of type map[interface{}]interface{}
+type resource map[interface{}]interface{}
+
+func NewDockerSecretsPostRenderer(
+	cluster *models.Cluster,
+	repo repository.Repository,
+	agent *kubernetes.Agent,
+	namespace string,
+	regs []*models.Registry,
+	doAuth *oauth2.Config,
+) (postrender.PostRenderer, error) {
+	// Registries is a map of registry URLs to registry ids
+	registries := make(map[string]*models.Registry)
+
+	for _, reg := range regs {
+		regURL := reg.URL
+
+		if !strings.Contains(regURL, "http") {
+			regURL = "https://" + regURL
+		}
+
+		parsedRegURL, err := url.Parse(regURL)
+
+		if err != nil {
+			continue
+		}
+
+		addReg := parsedRegURL.Host
+
+		if parsedRegURL.Path != "" {
+			addReg += "/" + strings.Trim(parsedRegURL.Path, "/")
+		}
+
+		registries[addReg] = reg
+	}
+
+	return &DockerSecretsPostRenderer{
+		Cluster:    cluster,
+		Repo:       repo,
+		Agent:      agent,
+		Namespace:  namespace,
+		DOAuth:     doAuth,
+		registries: registries,
+		podSpecs:   make([]resource, 0),
+		resources:  make([]resource, 0),
+	}, nil
+}
+
+func (d *DockerSecretsPostRenderer) Run(
+	renderedManifests *bytes.Buffer,
+) (modifiedManifests *bytes.Buffer, err error) {
+	bufCopy := bytes.NewBuffer(renderedManifests.Bytes())
+
+	linkedRegs, err := d.getRegistriesToLink(bufCopy)
+
+	// if we encountered an error here, we'll render the manifests anyway
+	// without modification
+	if err != nil {
+		return renderedManifests, nil
+	}
+
+	// create the necessary secrets
+	secrets, err := d.Agent.CreateImagePullSecrets(
+		d.Repo,
+		d.Namespace,
+		linkedRegs,
+		d.DOAuth,
+	)
+
+	if err != nil {
+		return renderedManifests, nil
+	}
+
+	d.updatePodSpecs(secrets)
+
+	modifiedManifests = bytes.NewBuffer([]byte{})
+	encoder := yaml.NewEncoder(modifiedManifests)
+	defer encoder.Close()
+
+	for _, resource := range d.resources {
+		err = encoder.Encode(resource)
+
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return modifiedManifests, nil
+}
+
+func (d *DockerSecretsPostRenderer) getRegistriesToLink(renderedManifests *bytes.Buffer) (map[string]*models.Registry, error) {
+	// create a map of registry names to registries: these are the registries
+	// that a secret will be generated for, if it does not exist
+	linkedRegs := make(map[string]*models.Registry)
+
+	err := d.decodeRenderedManifests(renderedManifests)
+
+	if err != nil {
+		return linkedRegs, err
+	}
+
+	// read the pod specs into the post-renderer object
+	d.getPodSpecs(d.resources)
+
+	for _, podSpec := range d.podSpecs {
+		// get all images
+		images := d.getImageList(podSpec)
+
+		// read the image url
+		for _, image := range images {
+			named, err := reference.ParseNormalizedNamed(image)
+
+			if err != nil {
+				continue
+			}
+
+			domain := reference.Domain(named)
+			path := reference.Path(named)
+
+			regName := domain
+
+			if pathArr := strings.Split(path, "/"); len(pathArr) > 1 {
+				regName += "/" + strings.Join(pathArr[:len(pathArr)-1], "/")
+			}
+
+			// check if the integration is native to the cluster/registry combination
+			isNative := d.isRegistryNative(regName)
+
+			if isNative {
+				continue
+			}
+
+			reg, exists := d.registries[regName]
+
+			if !exists {
+				continue
+			}
+
+			// if the registry exists, add it to the map
+			linkedRegs[regName] = reg
+		}
+	}
+
+	return linkedRegs, nil
+}
+
+func (d *DockerSecretsPostRenderer) decodeRenderedManifests(
+	renderedManifests *bytes.Buffer,
+) error {
+	// use the yaml decoder to parse the multi-document yaml.
+	decoder := yaml.NewDecoder(renderedManifests)
+
+	for {
+		res := make(resource)
+		err := decoder.Decode(&res)
+		if err == io.EOF {
+			break
+		}
+
+		if err != nil {
+			return err
+		}
+
+		d.resources = append(d.resources, res)
+	}
+
+	return nil
+}
+
+func (d *DockerSecretsPostRenderer) getPodSpecs(resources []resource) {
+	for _, res := range resources {
+		kindVal, hasKind := res["kind"]
+		if !hasKind {
+			continue
+		}
+
+		kind, ok := kindVal.(string)
+		if !ok {
+			continue
+		}
+
+		// manifests of list type will have an items field, items should
+		// be recursively parsed
+		if itemsVal, isList := res["items"]; isList {
+			if items, ok := itemsVal.([]interface{}); ok {
+				// convert items to resource
+				resArr := make([]resource, 0)
+				for _, item := range items {
+					if arrVal, ok := item.(resource); ok {
+						resArr = append(resArr, arrVal)
+					}
+				}
+
+				d.getPodSpecs(resArr)
+			}
+
+			continue
+		}
+
+		// otherwise, get the pod spec based on the type of resource
+		podSpec := getPodSpecFromResource(kind, res)
+
+		if podSpec == nil {
+			continue
+		}
+
+		d.podSpecs = append(d.podSpecs, podSpec)
+	}
+
+	return
+}
+
+func (d *DockerSecretsPostRenderer) updatePodSpecs(secrets map[string]string) {
+	for _, podSpec := range d.podSpecs {
+		containersVal, hasContainers := podSpec["containers"]
+
+		if !hasContainers {
+			continue
+		}
+
+		containers, ok := containersVal.([]interface{})
+
+		if !ok {
+			continue
+		}
+
+		var imagePullSecrets []map[string]interface{}
+		existingNames := map[string]bool{}
+		if existingPullSecrets, ok := podSpec["imagePullSecrets"]; ok {
+			imagePullSecrets = existingPullSecrets.([]map[string]interface{})
+			for _, s := range imagePullSecrets {
+				if name, ok := s["name"]; ok {
+					if n, ok := name.(string); ok {
+						existingNames[n] = true
+					}
+				}
+			}
+		}
+
+		for _, container := range containers {
+			_container, ok := container.(resource)
+
+			if !ok {
+				continue
+			}
+
+			image, ok := _container["image"].(string)
+
+			if !ok {
+				continue
+			}
+
+			named, err := reference.ParseNormalizedNamed(image)
+
+			if err != nil {
+				continue
+			}
+
+			domain := reference.Domain(named)
+			path := reference.Path(named)
+
+			regName := domain
+
+			if pathArr := strings.Split(path, "/"); len(pathArr) > 1 {
+				regName += "/" + strings.Join(pathArr[:len(pathArr)-1], "/")
+			}
+
+			imagePullSecrets = append(imagePullSecrets, map[string]interface{}{
+				"name": secrets[regName],
+			})
+		}
+
+		if len(imagePullSecrets) > 0 {
+			podSpec["imagePullSecrets"] = imagePullSecrets
+		}
+
+	}
+}
+
+func (d *DockerSecretsPostRenderer) getImageList(podSpec resource) []string {
+	images := make([]string, 0)
+
+	containersVal, hasContainers := podSpec["containers"]
+
+	if !hasContainers {
+		return images
+	}
+
+	containers, ok := containersVal.([]interface{})
+
+	if !ok {
+		return images
+	}
+
+	for _, container := range containers {
+		_container, ok := container.(resource)
+
+		if !ok {
+			continue
+		}
+
+		image, ok := _container["image"].(string)
+
+		if !ok {
+			continue
+		}
+
+		images = append(images, image)
+	}
+
+	return images
+}
+
+var ecrPattern = regexp.MustCompile(`(^[a-zA-Z0-9][a-zA-Z0-9-_]*)\.dkr\.ecr(\-fips)?\.([a-zA-Z0-9][a-zA-Z0-9-_]*)\.amazonaws\.com(\.cn)?`)
+
+func (d *DockerSecretsPostRenderer) isRegistryNative(regName string) bool {
+	isNative := false
+
+	if strings.Contains(regName, "gcr") && d.Cluster.AuthMechanism == models.GCP {
+		// get the project id of the cluster
+		gcpInt, err := d.Repo.GCPIntegration.ReadGCPIntegration(d.Cluster.GCPIntegrationID)
+
+		if err != nil {
+			return false
+		}
+
+		gkeProjectID, err := integrations.GCPProjectIDFromJSON(gcpInt.GCPKeyData)
+
+		if err != nil {
+			return false
+		}
+
+		// parse the project id of the gcr url
+		if regNameArr := strings.Split(regName, "/"); len(regNameArr) >= 2 {
+			gcrProjectID := regNameArr[1]
+
+			isNative = gcrProjectID == gkeProjectID
+		}
+	} else if strings.Contains(regName, "ecr") && d.Cluster.AuthMechanism == models.AWS {
+		matches := ecrPattern.FindStringSubmatch(regName)
+
+		if len(matches) < 3 {
+			return false
+		}
+
+		eksAccountID := matches[1]
+		eksRegion := matches[3]
+
+		awsInt, err := d.Repo.AWSIntegration.ReadAWSIntegration(d.Cluster.AWSIntegrationID)
+
+		if err != nil {
+			return false
+		}
+
+		err = awsInt.PopulateAWSArn()
+
+		if err != nil {
+			return false
+		}
+
+		parsedARN, err := arn.Parse(awsInt.AWSArn)
+
+		if err != nil {
+			return false
+		}
+
+		isNative = parsedARN.AccountID == eksAccountID && parsedARN.Region == eksRegion
+	}
+
+	return isNative
+}
+
+func getPodSpecFromResource(kind string, res resource) resource {
+	switch kind {
+	case "Pod":
+		return getNestedResource(res, "spec")
+	case "DaemonSet", "Deployment", "Job", "ReplicaSet", "ReplicationController", "StatefulSet":
+		return getNestedResource(res, "spec", "template", "spec")
+	case "PodTemplate":
+		return getNestedResource(res, "template", "spec")
+	case "CronJob":
+		return getNestedResource(res, "spec", "jobTemplate", "spec", "template", "spec")
+	}
+
+	return nil
+}
+
+func getNestedResource(res resource, keys ...string) resource {
+	curr := res
+	var ok bool
+
+	for _, key := range keys {
+		curr, ok = curr[key].(resource)
+
+		if !ok {
+			return nil
+		}
+	}
+
+	return curr
+}

+ 278 - 21
internal/kubernetes/agent.go

@@ -2,6 +2,7 @@ package kubernetes
 
 import (
 	"bufio"
+	"bytes"
 	"context"
 	"fmt"
 	"io"
@@ -11,8 +12,17 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/docr"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/doks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/porter-dev/porter/internal/registry"
+	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
 
 	"github.com/gorilla/websocket"
 	"github.com/porter-dev/porter/internal/helm/grapher"
@@ -20,6 +30,7 @@ import (
 	batchv1 "k8s.io/api/batch/v1"
 	v1 "k8s.io/api/core/v1"
 	v1beta1 "k8s.io/api/extensions/v1beta1"
+	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
 	"k8s.io/client-go/informers"
@@ -239,19 +250,21 @@ func (a *Agent) ProvisionECR(
 	projectID uint,
 	awsConf *integrations.AWSIntegration,
 	ecrName string,
-	awsInfra *models.AWSInfra,
+	infra *models.Infra,
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
+	provImageTag string,
 ) (*batchv1.Job, error) {
-	id := awsInfra.GetID()
+	id := infra.GetID()
 	prov := &provisioner.Conf{
-		ID:        id,
-		Name:      fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:      provisioner.ECR,
-		Operation: operation,
-		Redis:     redisConf,
-		Postgres:  pgConf,
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.ECR,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
 		AWS: &aws.Conf{
 			AWSRegion:          awsConf.AWSRegion,
 			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
@@ -270,19 +283,21 @@ func (a *Agent) ProvisionEKS(
 	projectID uint,
 	awsConf *integrations.AWSIntegration,
 	eksName string,
-	awsInfra *models.AWSInfra,
+	infra *models.Infra,
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
+	provImageTag string,
 ) (*batchv1.Job, error) {
-	id := awsInfra.GetID()
+	id := infra.GetID()
 	prov := &provisioner.Conf{
-		ID:        id,
-		Name:      fmt.Sprintf("prov-%s-%s", id, string(operation)),
-		Kind:      provisioner.EKS,
-		Operation: operation,
-		Redis:     redisConf,
-		Postgres:  pgConf,
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.EKS,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
 		AWS: &aws.Conf{
 			AWSRegion:          awsConf.AWSRegion,
 			AWSAccessKeyID:     string(awsConf.AWSAccessKeyID),
@@ -296,20 +311,178 @@ func (a *Agent) ProvisionEKS(
 	return a.provision(prov)
 }
 
-// ProvisionTest spawns a new provisioning pod that tests provisioning
-func (a *Agent) ProvisionTest(
+// ProvisionGCR spawns a new provisioning pod that creates a GCR instance
+func (a *Agent) ProvisionGCR(
+	projectID uint,
+	gcpConf *integrations.GCPIntegration,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	id := infra.GetID()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.GCR,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+		GCP: &gcp.Conf{
+			GCPRegion:    gcpConf.GCPRegion,
+			GCPProjectID: gcpConf.GCPProjectID,
+			GCPKeyData:   string(gcpConf.GCPKeyData),
+		},
+	}
+
+	return a.provision(prov)
+}
+
+// ProvisionGKE spawns a new provisioning pod that creates a GKE instance
+func (a *Agent) ProvisionGKE(
+	projectID uint,
+	gcpConf *integrations.GCPIntegration,
+	gkeName string,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	id := infra.GetID()
+	prov := &provisioner.Conf{
+		ID:                  id,
+		Name:                fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:                provisioner.GKE,
+		Operation:           operation,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
+		GCP: &gcp.Conf{
+			GCPRegion:    gcpConf.GCPRegion,
+			GCPProjectID: gcpConf.GCPProjectID,
+			GCPKeyData:   string(gcpConf.GCPKeyData),
+		},
+		GKE: &gke.Conf{
+			ClusterName: gkeName,
+		},
+	}
+
+	return a.provision(prov)
+}
+
+// ProvisionDOCR spawns a new provisioning pod that creates a DOCR instance
+func (a *Agent) ProvisionDOCR(
 	projectID uint,
+	doConf *integrations.OAuthIntegration,
+	doAuth *oauth2.Config,
+	repo repository.Repository,
+	docrName, docrSubscriptionTier string,
+	infra *models.Infra,
 	operation provisioner.ProvisionerOperation,
 	pgConf *config.DBConf,
 	redisConf *config.RedisConf,
 ) (*batchv1.Job, error) {
+	// get the token
+	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
+		infra.DOIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	id := infra.GetID()
 	prov := &provisioner.Conf{
-		ID:        fmt.Sprintf("%s-%d", "testing", projectID),
-		Name:      fmt.Sprintf("prov-%s-%d-%s", "testing", projectID, string(operation)),
+		ID:        id,
+		Name:      fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:      provisioner.DOCR,
 		Operation: operation,
-		Kind:      provisioner.Test,
 		Redis:     redisConf,
 		Postgres:  pgConf,
+		DO: &do.Conf{
+			DOToken: tok,
+		},
+		DOCR: &docr.Conf{
+			DOCRName:             docrName,
+			DOCRSubscriptionTier: docrSubscriptionTier,
+		},
+	}
+
+	return a.provision(prov)
+}
+
+// ProvisionDOKS spawns a new provisioning pod that creates a DOKS instance
+func (a *Agent) ProvisionDOKS(
+	projectID uint,
+	doConf *integrations.OAuthIntegration,
+	doAuth *oauth2.Config,
+	repo repository.Repository,
+	doRegion, doksClusterName string,
+	infra *models.Infra,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+) (*batchv1.Job, error) {
+	// get the token
+	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
+		infra.DOIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	id := infra.GetID()
+	prov := &provisioner.Conf{
+		ID:        id,
+		Name:      fmt.Sprintf("prov-%s-%s", id, string(operation)),
+		Kind:      provisioner.DOKS,
+		Operation: operation,
+		Redis:     redisConf,
+		Postgres:  pgConf,
+		DO: &do.Conf{
+			DOToken: tok,
+		},
+		DOKS: &doks.Conf{
+			DORegion:        doRegion,
+			DOKSClusterName: doksClusterName,
+		},
+	}
+
+	return a.provision(prov)
+}
+
+// ProvisionTest spawns a new provisioning pod that tests provisioning
+func (a *Agent) ProvisionTest(
+	projectID uint,
+	operation provisioner.ProvisionerOperation,
+	pgConf *config.DBConf,
+	redisConf *config.RedisConf,
+	provImageTag string,
+) (*batchv1.Job, error) {
+	prov := &provisioner.Conf{
+		ID:                  fmt.Sprintf("%s-%d", "testing", projectID),
+		Name:                fmt.Sprintf("prov-%s-%d-%s", "testing", projectID, string(operation)),
+		Operation:           operation,
+		Kind:                provisioner.Test,
+		Redis:               redisConf,
+		Postgres:            pgConf,
+		ProvisionerImageTag: provImageTag,
 	}
 
 	return a.provision(prov)
@@ -332,3 +505,87 @@ func (a *Agent) provision(
 		metav1.CreateOptions{},
 	)
 }
+
+// CreateImagePullSecrets will create the required image pull secrets and
+// return a map from the registry name to the name of the secret.
+func (a *Agent) CreateImagePullSecrets(
+	repo repository.Repository,
+	namespace string,
+	linkedRegs map[string]*models.Registry,
+	doAuth *oauth2.Config,
+) (map[string]string, error) {
+	res := make(map[string]string)
+
+	for key, val := range linkedRegs {
+		_reg := registry.Registry(*val)
+
+		data, err := _reg.GetDockerConfigJSON(repo, doAuth)
+
+		if err != nil {
+			return nil, err
+		}
+
+		secretName := fmt.Sprintf("porter-%s-%d", val.Externalize().Service, val.ID)
+
+		secret, err := a.Clientset.CoreV1().Secrets(namespace).Get(
+			context.TODO(),
+			secretName,
+			metav1.GetOptions{},
+		)
+
+		// if not found, create the secret
+		if err != nil && errors.IsNotFound(err) {
+			_, err = a.Clientset.CoreV1().Secrets(namespace).Create(
+				context.TODO(),
+				&v1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: secretName,
+					},
+					Data: map[string][]byte{
+						string(v1.DockerConfigJsonKey): data,
+					},
+					Type: v1.SecretTypeDockerConfigJson,
+				},
+				metav1.CreateOptions{},
+			)
+
+			if err != nil {
+				return nil, err
+			}
+
+			// add secret name to the map
+			res[key] = secretName
+
+			continue
+		} else if err != nil {
+			return nil, err
+		}
+
+		// otherwise, check that the secret contains the correct data: if
+		// if doesn't, update it
+		if !bytes.Equal(secret.Data[v1.DockerConfigJsonKey], data) {
+			_, err := a.Clientset.CoreV1().Secrets(namespace).Update(
+				context.TODO(),
+				&v1.Secret{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: secretName,
+					},
+					Data: map[string][]byte{
+						string(v1.DockerConfigJsonKey): data,
+					},
+					Type: v1.SecretTypeDockerConfigJson,
+				},
+				metav1.UpdateOptions{},
+			)
+
+			if err != nil {
+				return nil, err
+			}
+		}
+
+		// add secret name to the map
+		res[key] = secretName
+	}
+
+	return res, nil
+}

+ 27 - 1
internal/kubernetes/config.go

@@ -8,7 +8,9 @@ import (
 	"time"
 
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
 	"k8s.io/apimachinery/pkg/api/meta"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/cli-runtime/pkg/genericclioptions"
@@ -88,6 +90,9 @@ func GetAgentTesting(objects ...runtime.Object) *Agent {
 type OutOfClusterConfig struct {
 	Cluster *models.Cluster
 	Repo    *repository.Repository
+
+	// Only required if using DigitalOcean OAuth as an auth mechanism
+	DigitalOceanOAuth *oauth2.Config
 }
 
 // ToRESTConfig creates a kubernetes REST client factory -- it calls ClientConfig on
@@ -268,7 +273,11 @@ func (conf *OutOfClusterConfig) createRawConfigFromCluster() (*api.Config, error
 			return nil, err
 		}
 
-		tok, err := gcpAuth.GetBearerToken(conf.getTokenCache, conf.setTokenCache)
+		tok, err := gcpAuth.GetBearerToken(
+			conf.getTokenCache,
+			conf.setTokenCache,
+			"https://www.googleapis.com/auth/cloud-platform",
+		)
 
 		if err != nil {
 			return nil, err
@@ -291,6 +300,23 @@ func (conf *OutOfClusterConfig) createRawConfigFromCluster() (*api.Config, error
 			return nil, err
 		}
 
+		// add this as a bearer token
+		authInfoMap[authInfoName].Token = tok
+	case models.DO:
+		oauthInt, err := conf.Repo.OAuthIntegration.ReadOAuthIntegration(
+			cluster.DOIntegrationID,
+		)
+
+		if err != nil {
+			return nil, err
+		}
+
+		tok, _, err := oauth.GetAccessToken(oauthInt, conf.DigitalOceanOAuth, *conf.Repo)
+
+		if err != nil {
+			return nil, err
+		}
+
 		// add this as a bearer token
 		authInfoMap[authInfoName].Token = tok
 	default:

+ 20 - 0
internal/kubernetes/provisioner/do/do.go

@@ -0,0 +1,20 @@
+package do
+
+import (
+	v1 "k8s.io/api/core/v1"
+)
+
+// Conf is just a DO token
+type Conf struct {
+	DOToken string
+}
+
+// AttachDOEnv adds the relevant DO env for the provisioner
+func (conf *Conf) AttachDOEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "DO_TOKEN",
+		Value: conf.DOToken,
+	})
+
+	return env
+}

+ 25 - 0
internal/kubernetes/provisioner/do/docr/docr.go

@@ -0,0 +1,25 @@
+package docr
+
+import (
+	v1 "k8s.io/api/core/v1"
+)
+
+// Conf is just a DO token
+type Conf struct {
+	DOCRName, DOCRSubscriptionTier string
+}
+
+// AttachDOCREnv adds the relevant DO env for the provisioner
+func (conf *Conf) AttachDOCREnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "DOCR_NAME",
+		Value: conf.DOCRName,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DOCR_SUBSCRIPTION_TIER",
+		Value: conf.DOCRSubscriptionTier,
+	})
+
+	return env
+}

+ 25 - 0
internal/kubernetes/provisioner/do/doks/doks.go

@@ -0,0 +1,25 @@
+package doks
+
+import (
+	v1 "k8s.io/api/core/v1"
+)
+
+// Conf is just a DO token
+type Conf struct {
+	DORegion, DOKSClusterName string
+}
+
+// AttachDOKSEnv adds the relevant DO env for the provisioner
+func (conf *Conf) AttachDOKSEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "DO_REGION",
+		Value: conf.DORegion,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "DOKS_CLUSTER_NAME",
+		Value: conf.DOKSClusterName,
+	})
+
+	return env
+}

+ 18 - 0
internal/kubernetes/provisioner/gcp/gke/gke.go

@@ -0,0 +1,18 @@
+package gke
+
+import v1 "k8s.io/api/core/v1"
+
+// Conf is the GKE cluster config required for the provisioner
+type Conf struct {
+	ClusterName string
+}
+
+// AttachGKEEnv adds the relevant GKE env for the provisioner
+func (conf *Conf) AttachGKEEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "GKE_CLUSTER_NAME",
+		Value: conf.ClusterName,
+	})
+
+	return env
+}

+ 115 - 8
internal/kubernetes/provisioner/global_stream.go

@@ -114,7 +114,7 @@ func GlobalStreamListener(
 			kind, projID, infraID, err := models.ParseWorkspaceID(fmt.Sprintf("%v", msg.Values["id"]))
 
 			if fmt.Sprintf("%v", msg.Values["status"]) == "created" {
-				infra, err := repo.AWSInfra.ReadAWSInfra(infraID)
+				infra, err := repo.Infra.ReadInfra(infraID)
 
 				if err != nil {
 					continue
@@ -122,14 +122,14 @@ func GlobalStreamListener(
 
 				infra.Status = models.StatusCreated
 
-				infra, err = repo.AWSInfra.UpdateAWSInfra(infra)
+				infra, err = repo.Infra.UpdateInfra(infra)
 
 				if err != nil {
 					continue
 				}
 
 				// create ECR/EKS
-				if kind == string(models.AWSInfraECR) {
+				if kind == string(models.InfraECR) {
 					reg := &models.Registry{
 						ProjectID:        projID,
 						AWSIntegrationID: infra.AWSIntegrationID,
@@ -170,7 +170,7 @@ func GlobalStreamListener(
 					if err != nil {
 						continue
 					}
-				} else if kind == string(models.AWSInfraEKS) {
+				} else if kind == string(models.InfraEKS) {
 					cluster := &models.Cluster{
 						AuthMechanism:    models.AWS,
 						ProjectID:        projID,
@@ -201,12 +201,119 @@ func GlobalStreamListener(
 
 					cluster, err := repo.Cluster.CreateCluster(cluster)
 
+					if err != nil {
+						continue
+					}
+				} else if kind == string(models.InfraGCR) {
+					reg := &models.Registry{
+						ProjectID:        projID,
+						GCPIntegrationID: infra.GCPIntegrationID,
+						InfraID:          infra.ID,
+						Name:             "gcr-registry",
+					}
+
+					// parse raw data into ECR type
+					dataString, ok := msg.Values["data"].(string)
+
+					if ok {
+						json.Unmarshal([]byte(dataString), reg)
+					}
+
+					reg, err = repo.Registry.CreateRegistry(reg)
+
+					if err != nil {
+						continue
+					}
+				} else if kind == string(models.InfraGKE) {
+					cluster := &models.Cluster{
+						AuthMechanism:    models.GCP,
+						ProjectID:        projID,
+						GCPIntegrationID: infra.GCPIntegrationID,
+						InfraID:          infra.ID,
+					}
+
+					// parse raw data into GKE type
+					dataString, ok := msg.Values["data"].(string)
+
+					if ok {
+						json.Unmarshal([]byte(dataString), cluster)
+					}
+
+					re := regexp.MustCompile(`^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$`)
+
+					// if it matches the base64 regex, decode it
+					caData := string(cluster.CertificateAuthorityData)
+					if re.MatchString(caData) {
+						decoded, err := base64.StdEncoding.DecodeString(caData)
+
+						if err != nil {
+							continue
+						}
+
+						cluster.CertificateAuthorityData = []byte(decoded)
+					}
+
+					cluster, err := repo.Cluster.CreateCluster(cluster)
+
+					if err != nil {
+						continue
+					}
+				} else if kind == string(models.InfraDOCR) {
+					reg := &models.Registry{
+						ProjectID:       projID,
+						DOIntegrationID: infra.DOIntegrationID,
+						InfraID:         infra.ID,
+					}
+
+					// parse raw data into DOCR type
+					dataString, ok := msg.Values["data"].(string)
+
+					if ok {
+						json.Unmarshal([]byte(dataString), reg)
+					}
+
+					reg, err = repo.Registry.CreateRegistry(reg)
+
+					if err != nil {
+						continue
+					}
+				} else if kind == string(models.InfraDOKS) {
+					cluster := &models.Cluster{
+						AuthMechanism:   models.DO,
+						ProjectID:       projID,
+						DOIntegrationID: infra.DOIntegrationID,
+						InfraID:         infra.ID,
+					}
+
+					// parse raw data into GKE type
+					dataString, ok := msg.Values["data"].(string)
+
+					if ok {
+						json.Unmarshal([]byte(dataString), cluster)
+					}
+
+					re := regexp.MustCompile(`^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$`)
+
+					// if it matches the base64 regex, decode it
+					caData := string(cluster.CertificateAuthorityData)
+					if re.MatchString(caData) {
+						decoded, err := base64.StdEncoding.DecodeString(caData)
+
+						if err != nil {
+							continue
+						}
+
+						cluster.CertificateAuthorityData = []byte(decoded)
+					}
+
+					cluster, err := repo.Cluster.CreateCluster(cluster)
+
 					if err != nil {
 						continue
 					}
 				}
 			} else if fmt.Sprintf("%v", msg.Values["status"]) == "error" {
-				infra, err := repo.AWSInfra.ReadAWSInfra(infraID)
+				infra, err := repo.Infra.ReadInfra(infraID)
 
 				if err != nil {
 					continue
@@ -214,13 +321,13 @@ func GlobalStreamListener(
 
 				infra.Status = models.StatusError
 
-				infra, err = repo.AWSInfra.UpdateAWSInfra(infra)
+				infra, err = repo.Infra.UpdateInfra(infra)
 
 				if err != nil {
 					continue
 				}
 			} else if fmt.Sprintf("%v", msg.Values["status"]) == "destroyed" {
-				infra, err := repo.AWSInfra.ReadAWSInfra(infraID)
+				infra, err := repo.Infra.ReadInfra(infraID)
 
 				if err != nil {
 					continue
@@ -228,7 +335,7 @@ func GlobalStreamListener(
 
 				infra.Status = models.StatusDestroyed
 
-				infra, err = repo.AWSInfra.UpdateAWSInfra(infra)
+				infra, err = repo.Infra.UpdateInfra(infra)
 
 				if err != nil {
 					continue

+ 47 - 12
internal/kubernetes/provisioner/provisioner.go

@@ -10,8 +10,12 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/eks"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/docr"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/do/doks"
 
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp"
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp/gke"
 
 	"github.com/porter-dev/porter/internal/config"
 )
@@ -25,17 +29,21 @@ const (
 	ECR  InfraOption = "ecr"
 	EKS  InfraOption = "eks"
 	GCR  InfraOption = "gcr"
+	GKE  InfraOption = "gke"
+	DOCR InfraOption = "docr"
+	DOKS InfraOption = "doks"
 )
 
 // Conf is the config required to start a provisioner container
 type Conf struct {
-	Kind      InfraOption
-	Name      string
-	Namespace string
-	ID        string
-	Redis     *config.RedisConf
-	Postgres  *config.DBConf
-	Operation ProvisionerOperation
+	Kind                InfraOption
+	Name                string
+	Namespace           string
+	ID                  string
+	Redis               *config.RedisConf
+	Postgres            *config.DBConf
+	Operation           ProvisionerOperation
+	ProvisionerImageTag string
 
 	// provider-specific configurations
 
@@ -46,6 +54,12 @@ type Conf struct {
 
 	// GKE
 	GCP *gcp.Conf
+	GKE *gke.Conf
+
+	// DO
+	DO   *do.Conf
+	DOCR *docr.Conf
+	DOKS *doks.Conf
 }
 
 type ProvisionerOperation string
@@ -69,8 +83,13 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 	env = conf.attachDefaultEnv(env)
 
 	ttl := int32(3600)
+
 	backoffLimit := int32(5)
 
+	if operation == string(Apply) {
+		backoffLimit = int32(1)
+	}
+
 	labels := map[string]string{
 		"app": "provisioner",
 	}
@@ -87,6 +106,21 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 		args = []string{operation, "eks"}
 		env = conf.AWS.AttachAWSEnv(env)
 		env = conf.EKS.AttachEKSEnv(env)
+	} else if conf.Kind == GCR {
+		args = []string{operation, "gcr"}
+		env = conf.GCP.AttachGCPEnv(env)
+	} else if conf.Kind == GKE {
+		args = []string{operation, "gke"}
+		env = conf.GCP.AttachGCPEnv(env)
+		env = conf.GKE.AttachGKEEnv(env)
+	} else if conf.Kind == DOCR {
+		args = []string{operation, "docr"}
+		env = conf.DO.AttachDOEnv(env)
+		env = conf.DOCR.AttachDOCREnv(env)
+	} else if conf.Kind == GKE {
+		args = []string{operation, "doks"}
+		env = conf.DO.AttachDOEnv(env)
+		env = conf.DOKS.AttachDOKSEnv(env)
 	}
 
 	return &batchv1.Job{
@@ -103,13 +137,14 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 					Labels: labels,
 				},
 				Spec: v1.PodSpec{
-					RestartPolicy: v1.RestartPolicyOnFailure,
+					RestartPolicy: v1.RestartPolicyNever,
 					Containers: []v1.Container{
 						{
-							Name:  "provisioner",
-							Image: "gcr.io/porter-dev-273614/provisioner:latest",
-							Args:  args,
-							Env:   env,
+							Name:            "provisioner",
+							Image:           "gcr.io/porter-dev-273614/provisioner:" + conf.ProvisionerImageTag,
+							ImagePullPolicy: v1.PullAlways,
+							Args:            args,
+							Env:             env,
 							VolumeMounts: []v1.VolumeMount{
 								v1.VolumeMount{
 									MountPath: "/.terraform/plugin-cache",

+ 2 - 0
internal/models/cluster.go

@@ -18,6 +18,7 @@ const (
 	OIDC   ClusterAuth = "oidc"
 	GCP    ClusterAuth = "gcp-sa"
 	AWS    ClusterAuth = "aws-sa"
+	DO     ClusterAuth = "do-oauth"
 	Local  ClusterAuth = "local"
 )
 
@@ -58,6 +59,7 @@ type Cluster struct {
 	OIDCIntegrationID uint
 	GCPIntegrationID  uint
 	AWSIntegrationID  uint
+	DOIntegrationID   uint
 
 	// A token cache that can be used by an auth mechanism, if desired
 	TokenCache integrations.ClusterTokenCache `json:"token_cache"`

+ 32 - 21
internal/models/infra.go

@@ -20,22 +20,26 @@ const (
 	StatusDestroyed  InfraStatus = "destroyed"
 )
 
-// AWSInfraKind is the kind that aws infra can be
-type AWSInfraKind string
+// InfraKind is the kind that infra can be
+type InfraKind string
 
-// The supported AWS infra kinds
+// The supported infra kinds
 const (
-	AWSInfraECR AWSInfraKind = "ecr"
-	AWSInfraEKS AWSInfraKind = "eks"
+	InfraECR  InfraKind = "ecr"
+	InfraEKS  InfraKind = "eks"
+	InfraGCR  InfraKind = "gcr"
+	InfraGKE  InfraKind = "gke"
+	InfraDOCR InfraKind = "docr"
+	InfraDOKS InfraKind = "doks"
 )
 
-// AWSInfra represents the metadata for an infrastructure type provisioned on
-// AWS
-type AWSInfra struct {
+// Infra represents the metadata for an infrastructure type provisioned on
+// Porter
+type Infra struct {
 	gorm.Model
 
 	// The type of infra that was provisioned
-	Kind AWSInfraKind `json:"kind"`
+	Kind InfraKind `json:"kind"`
 
 	// A random 6-byte suffix to ensure workspace/stream ids are unique
 	Suffix string
@@ -48,35 +52,42 @@ type AWSInfra struct {
 
 	// The AWS integration that was used to create the infra
 	AWSIntegrationID uint
+
+	// The GCP integration that was used to create the infra
+	GCPIntegrationID uint
+
+	// The DO integration that was used to create the infra:
+	// this points to an OAuthIntegrationID
+	DOIntegrationID uint
 }
 
-// AWSInfraExternal is an external AWSInfra to be shared over REST
-type AWSInfraExternal struct {
+// InfraExternal is an external Infra to be shared over REST
+type InfraExternal struct {
 	ID uint `json:"id"`
 
 	// The project that this integration belongs to
 	ProjectID uint `json:"project_id"`
 
 	// The type of infra that was provisioned
-	Kind AWSInfraKind `json:"kind"`
+	Kind InfraKind `json:"kind"`
 
 	// Status is the status of the infra
 	Status InfraStatus `json:"status"`
 }
 
-// Externalize generates an external AWSInfra to be shared over REST
-func (ai *AWSInfra) Externalize() *AWSInfraExternal {
-	return &AWSInfraExternal{
-		ID:        ai.ID,
-		ProjectID: ai.ProjectID,
-		Kind:      ai.Kind,
-		Status:    ai.Status,
+// Externalize generates an external Infra to be shared over REST
+func (i *Infra) Externalize() *InfraExternal {
+	return &InfraExternal{
+		ID:        i.ID,
+		ProjectID: i.ProjectID,
+		Kind:      i.Kind,
+		Status:    i.Status,
 	}
 }
 
 // GetID returns the unique id for this infra
-func (ai *AWSInfra) GetID() string {
-	return fmt.Sprintf("%s-%d-%d-%s", ai.Kind, ai.ProjectID, ai.ID, ai.Suffix)
+func (i *Infra) GetID() string {
+	return fmt.Sprintf("%s-%d-%d-%s", i.Kind, i.ProjectID, i.ID, i.Suffix)
 }
 
 // ParseWorkspaceID returns the (kind, projectID, infraID)

+ 40 - 1
internal/models/integrations/gcp.go

@@ -2,6 +2,7 @@ package integrations
 
 import (
 	"context"
+	"encoding/json"
 
 	"golang.org/x/oauth2/google"
 	"gorm.io/gorm"
@@ -81,6 +82,7 @@ func (g *GCPIntegration) ToProjectIntegration(
 func (g *GCPIntegration) GetBearerToken(
 	getTokenCache GetTokenCacheFunc,
 	setTokenCache SetTokenCacheFunc,
+	scopes ...string,
 ) (string, error) {
 	cache, err := getTokenCache()
 
@@ -94,7 +96,7 @@ func (g *GCPIntegration) GetBearerToken(
 	creds, err := google.CredentialsFromJSON(
 		context.Background(),
 		g.GCPKeyData,
-		"https://www.googleapis.com/auth/cloud-platform",
+		scopes...,
 	)
 
 	if err != nil {
@@ -112,3 +114,40 @@ func (g *GCPIntegration) GetBearerToken(
 
 	return tok.AccessToken, nil
 }
+
+// credentialsFile is the unmarshalled representation of a GCP credentials file.
+// Source; golang.org/x/oauth2/google
+type credentialsFile struct {
+	Type string `json:"type"` // serviceAccountKey or userCredentialsKey
+
+	// Service Account fields
+	ClientEmail  string `json:"client_email"`
+	PrivateKeyID string `json:"private_key_id"`
+	PrivateKey   string `json:"private_key"`
+	TokenURL     string `json:"token_uri"`
+	ProjectID    string `json:"project_id"`
+
+	// User Credential fields
+	// (These typically come from gcloud auth.)
+	ClientSecret string `json:"client_secret"`
+	ClientID     string `json:"client_id"`
+	RefreshToken string `json:"refresh_token"`
+
+	// External Account fields
+	Audience                       string `json:"audience"`
+	SubjectTokenType               string `json:"subject_token_type"`
+	TokenURLExternal               string `json:"token_url"`
+	TokenInfoURL                   string `json:"token_info_url"`
+	ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"`
+	// CredentialSource               externalaccount.CredentialSource `json:"credential_source"`
+	QuotaProjectID string `json:"quota_project_id"`
+}
+
+func GCPProjectIDFromJSON(jsonData []byte) (string, error) {
+	var f credentialsFile
+	if err := json.Unmarshal(jsonData, &f); err != nil {
+		return "", err
+	}
+
+	return f.ProjectID, nil
+}

+ 1 - 0
internal/models/integrations/integration.go

@@ -13,6 +13,7 @@ const (
 	Kube     IntegrationService = "kube"
 	GCR      IntegrationService = "gcr"
 	ECR      IntegrationService = "ecr"
+	DOCR     IntegrationService = "docr"
 	Github   IntegrationService = "github"
 	Docker   IntegrationService = "docker"
 )

+ 5 - 2
internal/models/integrations/oauth.go

@@ -1,13 +1,16 @@
 package integrations
 
-import "gorm.io/gorm"
+import (
+	"gorm.io/gorm"
+)
 
 // OAuthIntegrationClient is the name of an OAuth mechanism client
 type OAuthIntegrationClient string
 
 // The supported oauth mechanism clients
 const (
-	OAuthGithub OAuthIntegrationClient = "github"
+	OAuthGithub       OAuthIntegrationClient = "github"
+	OAuthDigitalOcean OAuthIntegrationClient = "do"
 )
 
 // OAuthIntegration is an auth mechanism that uses oauth

+ 1 - 1
internal/models/project.go

@@ -27,7 +27,7 @@ type Project struct {
 	HelmRepos []HelmRepo `json:"helm_repos"`
 
 	// provisioned aws infra
-	AWSInfras []AWSInfra `json:"aws_infras"`
+	Infras []Infra `json:"infras"`
 
 	// auth mechanisms
 	KubeIntegrations  []ints.KubeIntegration  `json:"kube_integrations"`

+ 3 - 0
internal/models/registry.go

@@ -28,6 +28,7 @@ type Registry struct {
 
 	GCPIntegrationID uint
 	AWSIntegrationID uint
+	DOIntegrationID  uint
 
 	// A token cache that can be used by an auth mechanism (integration), if desired
 	TokenCache integrations.RegTokenCache
@@ -61,6 +62,8 @@ func (r *Registry) Externalize() *RegistryExternal {
 		serv = integrations.ECR
 	} else if r.GCPIntegrationID != 0 {
 		serv = integrations.GCR
+	} else if r.DOIntegrationID != 0 {
+		serv = integrations.DOCR
 	}
 
 	return &RegistryExternal{

+ 50 - 0
internal/oauth/config.go

@@ -1,9 +1,13 @@
 package oauth
 
 import (
+	"context"
 	"crypto/rand"
 	"encoding/base64"
+	"time"
 
+	"github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/repository"
 	"golang.org/x/oauth2"
 )
 
@@ -27,6 +31,19 @@ func NewGithubClient(cfg *Config) *oauth2.Config {
 	}
 }
 
+func NewDigitalOceanClient(cfg *Config) *oauth2.Config {
+	return &oauth2.Config{
+		ClientID:     cfg.ClientID,
+		ClientSecret: cfg.ClientSecret,
+		Endpoint: oauth2.Endpoint{
+			AuthURL:  "https://cloud.digitalocean.com/v1/oauth/authorize",
+			TokenURL: "https://cloud.digitalocean.com/v1/oauth/token",
+		},
+		RedirectURL: cfg.BaseURL + "/api/oauth/digitalocean/callback",
+		Scopes:      cfg.Scopes,
+	}
+}
+
 func CreateRandomState() string {
 	b := make([]byte, 16)
 	rand.Read(b)
@@ -35,3 +52,36 @@ func CreateRandomState() string {
 
 	return state
 }
+
+// GetAccessToken retrieves an access token for a given client. It updates the
+// access token in the DB if necessary
+func GetAccessToken(
+	o *integrations.OAuthIntegration,
+	conf *oauth2.Config,
+	repo repository.Repository,
+) (string, *time.Time, error) {
+	tokSource := conf.TokenSource(context.TODO(), &oauth2.Token{
+		AccessToken:  string(o.AccessToken),
+		RefreshToken: string(o.RefreshToken),
+		TokenType:    "Bearer",
+	})
+
+	token, err := tokSource.Token()
+
+	if err != nil {
+		return "", nil, err
+	}
+
+	if token.AccessToken != string(o.AccessToken) {
+		o.AccessToken = []byte(token.AccessToken)
+		o.RefreshToken = []byte(token.RefreshToken)
+
+		o, err = repo.OAuthIntegration.UpdateOAuthIntegration(o)
+
+		if err != nil {
+			return "", nil, err
+		}
+	}
+
+	return token.AccessToken, &token.Expiry, nil
+}

+ 294 - 19
internal/registry/registry.go

@@ -1,16 +1,26 @@
 package registry
 
 import (
+	"context"
+	"encoding/base64"
 	"encoding/json"
 	"fmt"
 	"net/http"
+	"net/url"
+	"strings"
 	"time"
 
 	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
 	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/oauth2"
 
 	ints "github.com/porter-dev/porter/internal/models/integrations"
+
+	"github.com/digitalocean/godo"
+	"github.com/docker/cli/cli/config/configfile"
+	"github.com/docker/cli/cli/config/types"
 )
 
 // Registry wraps the gorm Registry model
@@ -44,7 +54,10 @@ type Image struct {
 }
 
 // ListRepositories lists the repositories for a registry
-func (r *Registry) ListRepositories(repo repository.Repository) ([]*Repository, error) {
+func (r *Registry) ListRepositories(
+	repo repository.Repository,
+	doAuth *oauth2.Config, // only required if using DOCR
+) ([]*Repository, error) {
 	// switch on the auth mechanism to get a token
 	if r.AWSIntegrationID != 0 {
 		return r.listECRRepositories(repo)
@@ -54,6 +67,10 @@ func (r *Registry) ListRepositories(repo repository.Repository) ([]*Repository,
 		return r.listGCRRepositories(repo)
 	}
 
+	if r.DOIntegrationID != 0 {
+		return r.listDOCRRepositories(repo, doAuth)
+	}
+
 	return nil, fmt.Errorf("error listing repositories")
 }
 
@@ -76,7 +93,11 @@ func (r *Registry) GetGCRToken(repo repository.Repository) (*ints.TokenCache, er
 	}
 
 	// get oauth2 access token
-	_, err = gcp.GetBearerToken(r.getTokenCache, r.setTokenCacheFunc(repo))
+	_, err = gcp.GetBearerToken(
+		r.getTokenCache,
+		r.setTokenCacheFunc(repo),
+		"https://www.googleapis.com/auth/devstorage.read_write",
+	)
 
 	if err != nil {
 		return nil, err
@@ -103,14 +124,8 @@ func (r *Registry) listGCRRepositories(
 		return nil, err
 	}
 
-	// get oauth2 access token
-	oauthTok, err := gcp.GetBearerToken(r.getTokenCache, r.setTokenCacheFunc(repo))
-
-	if err != nil {
-		return nil, err
-	}
-
-	// use JWT token to request catalog
+	// Just use service account key to authenticate, since scopes may not be in place
+	// for oauth. This also prevents us from making more requests.
 	client := &http.Client{}
 
 	req, err := http.NewRequest(
@@ -123,9 +138,7 @@ func (r *Registry) listGCRRepositories(
 		return nil, err
 	}
 
-	req.SetBasicAuth("oauth2accesstoken", oauthTok)
-
-	// req.Header.Add("Authorization", "Bearer "+jwtTok)
+	req.SetBasicAuth("_json_key", string(gcp.GCPKeyData))
 
 	resp, err := client.Do(req)
 
@@ -141,9 +154,16 @@ func (r *Registry) listGCRRepositories(
 
 	res := make([]*Repository, 0)
 
+	parsedURL, err := url.Parse("https://" + r.URL)
+
+	if err != nil {
+		return nil, err
+	}
+
 	for _, repo := range gcrResp.Repositories {
 		res = append(res, &Repository{
 			Name: repo,
+			URI:  parsedURL.Host + "/" + repo,
 		})
 	}
 
@@ -186,6 +206,52 @@ func (r *Registry) listECRRepositories(repo repository.Repository) ([]*Repositor
 	return res, nil
 }
 
+func (r *Registry) listDOCRRepositories(
+	repo repository.Repository,
+	doAuth *oauth2.Config,
+) ([]*Repository, error) {
+	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
+		r.DOIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	client := godo.NewFromToken(tok)
+
+	urlArr := strings.Split(r.URL, "/")
+
+	if len(urlArr) != 2 {
+		return nil, fmt.Errorf("invalid digital ocean registry url")
+	}
+
+	name := urlArr[1]
+
+	repos, _, err := client.Registry.ListRepositories(context.TODO(), name, &godo.ListOptions{})
+
+	if err != nil {
+		return nil, err
+	}
+
+	res := make([]*Repository, 0)
+
+	for _, repo := range repos {
+		res = append(res, &Repository{
+			Name: repo.Name,
+			URI:  r.URL + "/" + repo.Name,
+		})
+	}
+
+	return res, nil
+}
+
 func (r *Registry) getTokenCache() (tok *ints.TokenCache, err error) {
 	return &ints.TokenCache{
 		Token:  r.TokenCache.Token,
@@ -215,6 +281,7 @@ func (r *Registry) setTokenCacheFunc(
 func (r *Registry) ListImages(
 	repoName string,
 	repo repository.Repository,
+	doAuth *oauth2.Config, // only required if using DOCR
 ) ([]*Image, error) {
 	// switch on the auth mechanism to get a token
 	if r.AWSIntegrationID != 0 {
@@ -225,6 +292,10 @@ func (r *Registry) ListImages(
 		return r.listGCRImages(repoName, repo)
 	}
 
+	if r.DOIntegrationID != 0 {
+		return r.listDOCRImages(repoName, repo, doAuth)
+	}
+
 	return nil, fmt.Errorf("error listing images")
 }
 
@@ -279,19 +350,20 @@ func (r *Registry) listGCRImages(repoName string, repo repository.Repository) ([
 		return nil, err
 	}
 
-	// get oauth2 access token
-	oauthTok, err := gcp.GetBearerToken(r.getTokenCache, r.setTokenCacheFunc(repo))
+	// use JWT token to request catalog
+	client := &http.Client{}
+
+	parsedURL, err := url.Parse("https://" + r.URL)
 
 	if err != nil {
 		return nil, err
 	}
 
-	// use JWT token to request catalog
-	client := &http.Client{}
+	trimmedPath := strings.Trim(parsedURL.Path, "/")
 
 	req, err := http.NewRequest(
 		"GET",
-		fmt.Sprintf("https://gcr.io/v2/%s/tags/list", repoName),
+		fmt.Sprintf("https://%s/v2/%s/%s/tags/list", parsedURL.Host, trimmedPath, repoName),
 		nil,
 	)
 
@@ -299,7 +371,7 @@ func (r *Registry) listGCRImages(repoName string, repo repository.Repository) ([
 		return nil, err
 	}
 
-	req.SetBasicAuth("oauth2accesstoken", oauthTok)
+	req.SetBasicAuth("_json_key", string(gcp.GCPKeyData))
 
 	resp, err := client.Do(req)
 
@@ -324,3 +396,206 @@ func (r *Registry) listGCRImages(repoName string, repo repository.Repository) ([
 
 	return res, nil
 }
+
+func (r *Registry) listDOCRImages(
+	repoName string,
+	repo repository.Repository,
+	doAuth *oauth2.Config,
+) ([]*Image, error) {
+	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
+		r.DOIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	client := godo.NewFromToken(tok)
+
+	urlArr := strings.Split(r.URL, "/")
+
+	if len(urlArr) != 2 {
+		return nil, fmt.Errorf("invalid digital ocean registry url")
+	}
+
+	name := urlArr[1]
+
+	tags, _, err := client.Registry.ListRepositoryTags(context.TODO(), name, repoName, &godo.ListOptions{})
+
+	if err != nil {
+		return nil, err
+	}
+
+	res := make([]*Image, 0)
+
+	for _, tag := range tags {
+		res = append(res, &Image{
+			RepositoryName: repoName,
+			Tag:            tag.Tag,
+		})
+	}
+
+	return res, nil
+}
+
+// GetDockerConfigJSON returns a dockerconfigjson file contents with "auths"
+// populated.
+func (r *Registry) GetDockerConfigJSON(
+	repo repository.Repository,
+	doAuth *oauth2.Config, // only required if using DOCR
+) ([]byte, error) {
+	var conf *configfile.ConfigFile
+	var err error
+
+	// switch on the auth mechanism to get a token
+	if r.AWSIntegrationID != 0 {
+		conf, err = r.getECRDockerConfigFile(repo)
+	}
+
+	if r.GCPIntegrationID != 0 {
+		conf, err = r.getGCRDockerConfigFile(repo)
+	}
+
+	if r.DOIntegrationID != 0 {
+		conf, err = r.getDOCRDockerConfigFile(repo, doAuth)
+	}
+
+	if err != nil {
+		return nil, err
+	}
+
+	return json.Marshal(conf)
+}
+
+func (r *Registry) getECRDockerConfigFile(
+	repo repository.Repository,
+) (*configfile.ConfigFile, error) {
+	aws, err := repo.AWSIntegration.ReadAWSIntegration(
+		r.AWSIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	sess, err := aws.GetSession()
+
+	if err != nil {
+		return nil, err
+	}
+
+	ecrSvc := ecr.New(sess)
+
+	output, err := ecrSvc.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
+
+	if err != nil {
+		return nil, err
+	}
+
+	token := *output.AuthorizationData[0].AuthorizationToken
+
+	decodedToken, err := base64.StdEncoding.DecodeString(token)
+
+	if err != nil {
+		return nil, err
+	}
+
+	parts := strings.SplitN(string(decodedToken), ":", 2)
+
+	if len(parts) < 2 {
+		return nil, err
+	}
+
+	key := r.URL
+
+	if !strings.Contains(key, "http") {
+		key = "https://" + key
+	}
+
+	return &configfile.ConfigFile{
+		AuthConfigs: map[string]types.AuthConfig{
+			key: types.AuthConfig{
+				Username: parts[0],
+				Password: parts[1],
+				Auth:     token,
+			},
+		},
+	}, nil
+}
+
+func (r *Registry) getGCRDockerConfigFile(
+	repo repository.Repository,
+) (*configfile.ConfigFile, error) {
+	gcp, err := repo.GCPIntegration.ReadGCPIntegration(
+		r.GCPIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	key := r.URL
+
+	if !strings.Contains(key, "http") {
+		key = "https://" + key
+	}
+
+	parsedURL, _ := url.Parse(key)
+
+	return &configfile.ConfigFile{
+		AuthConfigs: map[string]types.AuthConfig{
+			parsedURL.Host: types.AuthConfig{
+				Username: "_json_key",
+				Password: string(gcp.GCPKeyData),
+				Auth:     generateAuthToken("_json_key", string(gcp.GCPKeyData)),
+			},
+		},
+	}, nil
+}
+
+func (r *Registry) getDOCRDockerConfigFile(
+	repo repository.Repository,
+	doAuth *oauth2.Config,
+) (*configfile.ConfigFile, error) {
+	oauthInt, err := repo.OAuthIntegration.ReadOAuthIntegration(
+		r.DOIntegrationID,
+	)
+
+	if err != nil {
+		return nil, err
+	}
+
+	tok, _, err := oauth.GetAccessToken(oauthInt, doAuth, repo)
+
+	if err != nil {
+		return nil, err
+	}
+
+	key := r.URL
+
+	if !strings.Contains(key, "http") {
+		key = "https://" + key
+	}
+
+	parsedURL, _ := url.Parse(key)
+
+	return &configfile.ConfigFile{
+		AuthConfigs: map[string]types.AuthConfig{
+			parsedURL.Host: types.AuthConfig{
+				Username: tok,
+				Password: tok,
+				Auth:     generateAuthToken(tok, tok),
+			},
+		},
+	}, nil
+}
+
+func generateAuthToken(username, password string) string {
+	return base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
+}

+ 23 - 0
internal/repository/gorm/auth.go

@@ -670,6 +670,29 @@ func (repo *OAuthIntegrationRepository) ListOAuthIntegrationsByProjectID(
 	return oauths, nil
 }
 
+// UpdateOAuthIntegration modifies an existing oauth integration in the database
+func (repo *OAuthIntegrationRepository) UpdateOAuthIntegration(
+	am *ints.OAuthIntegration,
+) (*ints.OAuthIntegration, error) {
+	err := repo.EncryptOAuthIntegrationData(am, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	if err := repo.db.Save(am).Error; err != nil {
+		return nil, err
+	}
+
+	err = repo.DecryptOAuthIntegrationData(am, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return am, nil
+}
+
 // EncryptOAuthIntegrationData will encrypt the oauth integration data before
 // writing to the DB
 func (repo *OAuthIntegrationRepository) EncryptOAuthIntegrationData(

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

@@ -197,10 +197,22 @@ func (repo *ClusterRepository) ListClustersByProjectID(
 func (repo *ClusterRepository) UpdateCluster(
 	cluster *models.Cluster,
 ) (*models.Cluster, error) {
+	err := repo.EncryptClusterData(cluster, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
 	if err := repo.db.Save(cluster).Error; err != nil {
 		return nil, err
 	}
 
+	err = repo.DecryptClusterData(cluster, repo.key)
+
+	if err != nil {
+		return nil, err
+	}
+
 	return cluster, nil
 }
 

+ 23 - 23
internal/repository/gorm/helpers_test.go

@@ -13,23 +13,23 @@ import (
 )
 
 type tester struct {
-	repo          *repository.Repository
-	key           *[32]byte
-	dbFileName    string
-	initUsers     []*models.User
-	initProjects  []*models.Project
-	initGRs       []*models.GitRepo
-	initRegs      []*models.Registry
-	initClusters  []*models.Cluster
-	initHRs       []*models.HelmRepo
-	initAWSInfras []*models.AWSInfra
-	initCCs       []*models.ClusterCandidate
-	initKIs       []*ints.KubeIntegration
-	initBasics    []*ints.BasicIntegration
-	initOIDCs     []*ints.OIDCIntegration
-	initOAuths    []*ints.OAuthIntegration
-	initGCPs      []*ints.GCPIntegration
-	initAWSs      []*ints.AWSIntegration
+	repo         *repository.Repository
+	key          *[32]byte
+	dbFileName   string
+	initUsers    []*models.User
+	initProjects []*models.Project
+	initGRs      []*models.GitRepo
+	initRegs     []*models.Registry
+	initClusters []*models.Cluster
+	initHRs      []*models.HelmRepo
+	initInfras   []*models.Infra
+	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) {
@@ -57,7 +57,7 @@ func setupTestEnv(tester *tester, t *testing.T) {
 		&models.Cluster{},
 		&models.ClusterCandidate{},
 		&models.ClusterResolver{},
-		&models.AWSInfra{},
+		&models.Infra{},
 		&ints.KubeIntegration{},
 		&ints.BasicIntegration{},
 		&ints.OIDCIntegration{},
@@ -436,24 +436,24 @@ func initHelmRepo(tester *tester, t *testing.T) {
 	tester.initHRs = append(tester.initHRs, hr)
 }
 
-func initAWSInfra(tester *tester, t *testing.T) {
+func initInfra(tester *tester, t *testing.T) {
 	t.Helper()
 
 	if len(tester.initProjects) == 0 {
 		initProject(tester, t)
 	}
 
-	infra := &models.AWSInfra{
-		Kind:      models.AWSInfraECR,
+	infra := &models.Infra{
+		Kind:      models.InfraECR,
 		ProjectID: tester.initProjects[0].Model.ID,
 		Status:    models.StatusCreated,
 	}
 
-	infra, err := tester.repo.AWSInfra.CreateAWSInfra(infra)
+	infra, err := tester.repo.Infra.CreateInfra(infra)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
 	}
 
-	tester.initAWSInfras = append(tester.initAWSInfras, infra)
+	tester.initInfras = append(tester.initInfras, infra)
 }

+ 19 - 19
internal/repository/gorm/infra.go

@@ -6,26 +6,26 @@ import (
 	"gorm.io/gorm"
 )
 
-// AWSInfraRepository uses gorm.DB for querying the database
-type AWSInfraRepository struct {
+// InfraRepository uses gorm.DB for querying the database
+type InfraRepository struct {
 	db *gorm.DB
 }
 
-// NewAWSInfraRepository returns a AWSInfraRepository which uses
+// NewInfraRepository returns a InfraRepository which uses
 // gorm.DB for querying the database
-func NewAWSInfraRepository(db *gorm.DB) repository.AWSInfraRepository {
-	return &AWSInfraRepository{db}
+func NewInfraRepository(db *gorm.DB) repository.InfraRepository {
+	return &InfraRepository{db}
 }
 
-// CreateAWSInfra creates a new aws infra
-func (repo *AWSInfraRepository) CreateAWSInfra(infra *models.AWSInfra) (*models.AWSInfra, error) {
+// CreateInfra creates a new aws infra
+func (repo *InfraRepository) CreateInfra(infra *models.Infra) (*models.Infra, error) {
 	project := &models.Project{}
 
 	if err := repo.db.Where("id = ?", infra.ProjectID).First(&project).Error; err != nil {
 		return nil, err
 	}
 
-	assoc := repo.db.Model(&project).Association("AWSInfras")
+	assoc := repo.db.Model(&project).Association("Infras")
 
 	if assoc.Error != nil {
 		return nil, assoc.Error
@@ -38,9 +38,9 @@ func (repo *AWSInfraRepository) CreateAWSInfra(infra *models.AWSInfra) (*models.
 	return infra, nil
 }
 
-// ReadAWSInfra gets a aws infra specified by a unique id
-func (repo *AWSInfraRepository) ReadAWSInfra(id uint) (*models.AWSInfra, error) {
-	infra := &models.AWSInfra{}
+// ReadInfra gets a aws infra specified by a unique id
+func (repo *InfraRepository) ReadInfra(id uint) (*models.Infra, error) {
+	infra := &models.Infra{}
 
 	if err := repo.db.Where("id = ?", id).First(&infra).Error; err != nil {
 		return nil, err
@@ -49,12 +49,12 @@ func (repo *AWSInfraRepository) ReadAWSInfra(id uint) (*models.AWSInfra, error)
 	return infra, nil
 }
 
-// ListAWSInfrasByProjectID finds all aws infras
+// ListInfrasByProjectID finds all aws infras
 // for a given project id
-func (repo *AWSInfraRepository) ListAWSInfrasByProjectID(
+func (repo *InfraRepository) ListInfrasByProjectID(
 	projectID uint,
-) ([]*models.AWSInfra, error) {
-	infras := []*models.AWSInfra{}
+) ([]*models.Infra, error) {
+	infras := []*models.Infra{}
 
 	if err := repo.db.Where("project_id = ?", projectID).Find(&infras).Error; err != nil {
 		return nil, err
@@ -63,10 +63,10 @@ func (repo *AWSInfraRepository) ListAWSInfrasByProjectID(
 	return infras, nil
 }
 
-// UpdateAWSInfra modifies an existing AWSInfra in the database
-func (repo *AWSInfraRepository) UpdateAWSInfra(
-	ai *models.AWSInfra,
-) (*models.AWSInfra, error) {
+// UpdateInfra modifies an existing Infra in the database
+func (repo *InfraRepository) UpdateInfra(
+	ai *models.Infra,
+) (*models.Infra, error) {
 	if err := repo.db.Save(ai).Error; err != nil {
 		return nil, err
 	}

+ 12 - 12
internal/repository/gorm/infra_test.go

@@ -8,7 +8,7 @@ import (
 	"gorm.io/gorm"
 )
 
-func TestCreateAWSInfra(t *testing.T) {
+func TestCreateInfra(t *testing.T) {
 	tester := &tester{
 		dbFileName: "./porter_create_aws_infra.db",
 	}
@@ -17,19 +17,19 @@ func TestCreateAWSInfra(t *testing.T) {
 	initProject(tester, t)
 	defer cleanup(tester, t)
 
-	infra := &models.AWSInfra{
-		Kind:      models.AWSInfraECR,
+	infra := &models.Infra{
+		Kind:      models.InfraECR,
 		ProjectID: tester.initProjects[0].Model.ID,
 		Status:    models.StatusCreated,
 	}
 
-	infra, err := tester.repo.AWSInfra.CreateAWSInfra(infra)
+	infra, err := tester.repo.Infra.CreateInfra(infra)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
 	}
 
-	infra, err = tester.repo.AWSInfra.ReadAWSInfra(infra.Model.ID)
+	infra, err = tester.repo.Infra.ReadInfra(infra.Model.ID)
 
 	if err != nil {
 		t.Fatalf("%v\n", err)
@@ -40,8 +40,8 @@ func TestCreateAWSInfra(t *testing.T) {
 		t.Errorf("incorrect registry ID: expected %d, got %d\n", 1, infra.Model.ID)
 	}
 
-	if infra.Kind != models.AWSInfraECR {
-		t.Errorf("incorrect aws infra kind: expected %s, got %s\n", models.AWSInfraECR, infra.Kind)
+	if infra.Kind != models.InfraECR {
+		t.Errorf("incorrect aws infra kind: expected %s, got %s\n", models.InfraECR, infra.Kind)
 	}
 
 	if infra.Status != models.StatusCreated {
@@ -49,17 +49,17 @@ func TestCreateAWSInfra(t *testing.T) {
 	}
 }
 
-func TestListAWSInfrasByProjectID(t *testing.T) {
+func TestListInfrasByProjectID(t *testing.T) {
 	tester := &tester{
 		dbFileName: "./porter_list_aws_infras.db",
 	}
 
 	setupTestEnv(tester, t)
 	initProject(tester, t)
-	initAWSInfra(tester, t)
+	initInfra(tester, t)
 	defer cleanup(tester, t)
 
-	infras, err := tester.repo.AWSInfra.ListAWSInfrasByProjectID(
+	infras, err := tester.repo.Infra.ListInfrasByProjectID(
 		tester.initProjects[0].Model.ID,
 	)
 
@@ -72,7 +72,7 @@ func TestListAWSInfrasByProjectID(t *testing.T) {
 	}
 
 	// make sure data is correct
-	expAWSInfra := models.AWSInfra{
+	expInfra := models.Infra{
 		Kind:      "ecr",
 		ProjectID: tester.initProjects[0].Model.ID,
 		Status:    models.StatusCreated,
@@ -83,7 +83,7 @@ func TestListAWSInfrasByProjectID(t *testing.T) {
 	// reset fields for reflect.DeepEqual
 	infra.Model = gorm.Model{}
 
-	if diff := deep.Equal(expAWSInfra, *infra); diff != nil {
+	if diff := deep.Equal(expInfra, *infra); diff != nil {
 		t.Errorf("incorrect aws infra")
 		t.Error(diff)
 	}

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

@@ -17,7 +17,7 @@ func NewRepository(db *gorm.DB, key *[32]byte) *repository.Repository {
 		Cluster:          NewClusterRepository(db, key),
 		HelmRepo:         NewHelmRepoRepository(db, key),
 		Registry:         NewRegistryRepository(db, key),
-		AWSInfra:         NewAWSInfraRepository(db),
+		Infra:         NewInfraRepository(db),
 		KubeIntegration:  NewKubeIntegrationRepository(db, key),
 		BasicIntegration: NewBasicIntegrationRepository(db, key),
 		OIDCIntegration:  NewOIDCIntegrationRepository(db, key),

+ 6 - 6
internal/repository/infra.go

@@ -4,10 +4,10 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 )
 
-// AWSInfraRepository represents the set of queries on the AWSInfra model
-type AWSInfraRepository interface {
-	CreateAWSInfra(repo *models.AWSInfra) (*models.AWSInfra, error)
-	ReadAWSInfra(id uint) (*models.AWSInfra, error)
-	ListAWSInfrasByProjectID(projectID uint) ([]*models.AWSInfra, error)
-	UpdateAWSInfra(repo *models.AWSInfra) (*models.AWSInfra, error)
+// InfraRepository represents the set of queries on the Infra model
+type InfraRepository interface {
+	CreateInfra(repo *models.Infra) (*models.Infra, error)
+	ReadInfra(id uint) (*models.Infra, error)
+	ListInfrasByProjectID(projectID uint) ([]*models.Infra, error)
+	UpdateInfra(repo *models.Infra) (*models.Infra, error)
 }

+ 1 - 0
internal/repository/integrations.go

@@ -34,6 +34,7 @@ type OAuthIntegrationRepository interface {
 	CreateOAuthIntegration(am *ints.OAuthIntegration) (*ints.OAuthIntegration, error)
 	ReadOAuthIntegration(id uint) (*ints.OAuthIntegration, error)
 	ListOAuthIntegrationsByProjectID(projectID uint) ([]*ints.OAuthIntegration, error)
+	UpdateOAuthIntegration(am *ints.OAuthIntegration) (*ints.OAuthIntegration, error)
 }
 
 // AWSIntegrationRepository represents the set of queries on the AWS auth

+ 18 - 0
internal/repository/memory/auth.go

@@ -265,6 +265,24 @@ func (repo *OAuthIntegrationRepository) ListOAuthIntegrationsByProjectID(
 	return res, nil
 }
 
+// UpdateOAuthIntegration updates an oauth integration in the DB
+func (repo *OAuthIntegrationRepository) UpdateOAuthIntegration(
+	am *ints.OAuthIntegration,
+) (*ints.OAuthIntegration, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(am.ID-1) >= len(repo.oIntegrations) || repo.oIntegrations[am.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(am.ID - 1)
+	repo.oIntegrations[index] = am
+
+	return am, nil
+}
+
 // AWSIntegrationRepository implements repository.AWSIntegrationRepository
 type AWSIntegrationRepository struct {
 	canQuery        bool

+ 29 - 29
internal/repository/memory/infra.go

@@ -8,62 +8,62 @@ import (
 	"gorm.io/gorm"
 )
 
-// AWSInfraRepository implements repository.AWSInfraRepository
-type AWSInfraRepository struct {
+// InfraRepository implements repository.InfraRepository
+type InfraRepository struct {
 	canQuery  bool
-	awsInfras []*models.AWSInfra
+	infras []*models.Infra
 }
 
-// NewAWSInfraRepository will return errors if canQuery is false
-func NewAWSInfraRepository(canQuery bool) repository.AWSInfraRepository {
-	return &AWSInfraRepository{
+// NewInfraRepository will return errors if canQuery is false
+func NewInfraRepository(canQuery bool) repository.InfraRepository {
+	return &InfraRepository{
 		canQuery,
-		[]*models.AWSInfra{},
+		[]*models.Infra{},
 	}
 }
 
-// CreateAWSInfra creates a new aws infra
-func (repo *AWSInfraRepository) CreateAWSInfra(
-	infra *models.AWSInfra,
-) (*models.AWSInfra, error) {
+// CreateInfra creates a new aws infra
+func (repo *InfraRepository) CreateInfra(
+	infra *models.Infra,
+) (*models.Infra, error) {
 	if !repo.canQuery {
 		return nil, errors.New("Cannot write database")
 	}
 
-	repo.awsInfras = append(repo.awsInfras, infra)
-	infra.ID = uint(len(repo.awsInfras))
+	repo.infras = append(repo.infras, infra)
+	infra.ID = uint(len(repo.infras))
 
 	return infra, nil
 }
 
-// ReadAWSInfra finds a aws infra by id
-func (repo *AWSInfraRepository) ReadAWSInfra(
+// ReadInfra finds a aws infra by id
+func (repo *InfraRepository) ReadInfra(
 	id uint,
-) (*models.AWSInfra, error) {
+) (*models.Infra, error) {
 	if !repo.canQuery {
 		return nil, errors.New("Cannot read from database")
 	}
 
-	if int(id-1) >= len(repo.awsInfras) || repo.awsInfras[id-1] == nil {
+	if int(id-1) >= len(repo.infras) || repo.infras[id-1] == nil {
 		return nil, gorm.ErrRecordNotFound
 	}
 
 	index := int(id - 1)
-	return repo.awsInfras[index], nil
+	return repo.infras[index], nil
 }
 
-// ListAWSInfrasByProjectID finds all aws infras
+// ListInfrasByProjectID finds all aws infras
 // for a given project id
-func (repo *AWSInfraRepository) ListAWSInfrasByProjectID(
+func (repo *InfraRepository) ListInfrasByProjectID(
 	projectID uint,
-) ([]*models.AWSInfra, error) {
+) ([]*models.Infra, error) {
 	if !repo.canQuery {
 		return nil, errors.New("Cannot read from database")
 	}
 
-	res := make([]*models.AWSInfra, 0)
+	res := make([]*models.Infra, 0)
 
-	for _, infra := range repo.awsInfras {
+	for _, infra := range repo.infras {
 		if infra != nil && infra.ProjectID == projectID {
 			res = append(res, infra)
 		}
@@ -72,20 +72,20 @@ func (repo *AWSInfraRepository) ListAWSInfrasByProjectID(
 	return res, nil
 }
 
-// UpdateAWSInfra modifies an existing AWSInfra in the database
-func (repo *AWSInfraRepository) UpdateAWSInfra(
-	ai *models.AWSInfra,
-) (*models.AWSInfra, error) {
+// UpdateInfra modifies an existing Infra in the database
+func (repo *InfraRepository) UpdateInfra(
+	ai *models.Infra,
+) (*models.Infra, error) {
 	if !repo.canQuery {
 		return nil, errors.New("Cannot write database")
 	}
 
-	if int(ai.ID-1) >= len(repo.awsInfras) || repo.awsInfras[ai.ID-1] == nil {
+	if int(ai.ID-1) >= len(repo.infras) || repo.infras[ai.ID-1] == nil {
 		return nil, gorm.ErrRecordNotFound
 	}
 
 	index := int(ai.ID - 1)
-	repo.awsInfras[index] = ai
+	repo.infras[index] = ai
 
 	return ai, nil
 }

+ 1 - 1
internal/repository/repository.go

@@ -10,7 +10,7 @@ type Repository struct {
 	Cluster          ClusterRepository
 	HelmRepo         HelmRepoRepository
 	Registry         RegistryRepository
-	AWSInfra         AWSInfraRepository
+	Infra         InfraRepository
 	KubeIntegration  KubeIntegrationRepository
 	BasicIntegration BasicIntegrationRepository
 	OIDCIntegration  OIDCIntegrationRepository

+ 7 - 2
internal/templater/helm/values/writer.go

@@ -42,7 +42,7 @@ func (w *TemplateWriter) Create(
 		Values:    vals,
 	}
 
-	_, err := w.Agent.InstallChart(conf)
+	_, err := w.Agent.InstallChart(conf, nil)
 
 	if err != nil {
 		return nil, err
@@ -59,7 +59,12 @@ func (w *TemplateWriter) Update(
 		return nil, fmt.Errorf("release not set")
 	}
 
-	_, err := w.Agent.UpgradeReleaseByValues(w.ReleaseName, vals)
+	conf := &helm.UpgradeReleaseConfig{
+		Name:   w.ReleaseName,
+		Values: vals,
+	}
+
+	_, err := w.Agent.UpgradeReleaseByValues(conf, nil)
 
 	if err != nil {
 		return nil, err

+ 10 - 0
server/api/api.go

@@ -69,6 +69,7 @@ type App struct {
 
 	// oauth-specific clients
 	GithubConf *oauth2.Config
+	DOConf     *oauth2.Config
 
 	db         *gorm.DB
 	validator  *vr.Validate
@@ -124,5 +125,14 @@ func New(conf *AppConfig) (*App, error) {
 		})
 	}
 
+	if sc := conf.ServerConf; sc.DOClientID != "" && sc.DOClientSecret != "" {
+		app.DOConf = oauth.NewDigitalOceanClient(&oauth.Config{
+			ClientID:     sc.DOClientID,
+			ClientSecret: sc.DOClientSecret,
+			Scopes:       []string{"read", "write"},
+			BaseURL:      sc.ServerURL,
+		})
+	}
+
 	return app, nil
 }

+ 59 - 6
server/api/deploy_handler.go

@@ -5,6 +5,7 @@ import (
 	"math/rand"
 	"net/http"
 	"net/url"
+	"strconv"
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
@@ -15,6 +16,13 @@ import (
 
 // HandleDeployTemplate triggers a chart deployment from a template
 func (app *App) HandleDeployTemplate(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
+	}
+
 	name := chi.URLParam(r, "name")
 	version := chi.URLParam(r, "version")
 
@@ -49,7 +57,8 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 	form := &forms.InstallChartTemplateForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.Repo,
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
 			},
 		},
 		ChartTemplateForm: &forms.ChartTemplateForm{},
@@ -76,14 +85,24 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	registries, err := app.Repo.Registry.ListRegistriesByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
 	conf := &helm.InstallChartConfig{
-		Chart:     chart,
-		Name:      form.ChartTemplateForm.Name,
-		Namespace: form.ReleaseForm.Form.Namespace,
-		Values:    form.ChartTemplateForm.FormValues,
+		Chart:      chart,
+		Name:       form.ChartTemplateForm.Name,
+		Namespace:  form.ReleaseForm.Form.Namespace,
+		Values:     form.ChartTemplateForm.FormValues,
+		Cluster:    form.ReleaseForm.Cluster,
+		Repo:       *app.Repo,
+		Registries: registries,
 	}
 
-	_, err = agent.InstallChart(conf)
+	_, err = agent.InstallChart(conf, app.DOConf)
 
 	if err != nil {
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
@@ -121,3 +140,37 @@ func (app *App) HandleDeployTemplate(w http.ResponseWriter, r *http.Request) {
 
 	w.WriteHeader(http.StatusOK)
 }
+
+// HandleUninstallTemplate triggers a chart deployment from a template
+func (app *App) HandleUninstallTemplate(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+
+	form := &forms.GetReleaseForm{
+		ReleaseForm: &forms.ReleaseForm{
+			Form: &helm.Form{
+				Repo: app.Repo,
+			},
+		},
+		Name: name,
+	}
+
+	agent, err := app.getAgentFromQueryParams(
+		w,
+		r,
+		form.ReleaseForm,
+		form.ReleaseForm.PopulateHelmOptionsFromQueryParams,
+	)
+
+	// errors are handled in app.getAgentFromQueryParams
+	if err != nil {
+		return
+	}
+
+	_, err = agent.UninstallChart(name)
+	if err != nil {
+		return
+	}
+
+	w.WriteHeader(http.StatusOK)
+	return
+}

+ 2 - 2
server/api/infra_handler.go

@@ -18,14 +18,14 @@ func (app *App) HandleListProjectInfra(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	infras, err := app.Repo.AWSInfra.ListAWSInfrasByProjectID(uint(projID))
+	infras, err := app.Repo.Infra.ListInfrasByProjectID(uint(projID))
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
 		return
 	}
 
-	extInfras := make([]*models.AWSInfraExternal, 0)
+	extInfras := make([]*models.InfraExternal, 0)
 
 	for _, infra := range infras {
 		extInfras = append(extInfras, infra.Externalize())

+ 31 - 0
server/api/integration_handler.go

@@ -8,6 +8,7 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
 
+	"github.com/porter-dev/porter/internal/models/integrations"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 
@@ -251,3 +252,33 @@ func (app *App) HandleCreateBasicAuthIntegration(w http.ResponseWriter, r *http.
 		return
 	}
 }
+
+// HandleListProjectOAuthIntegrations lists the oauth integrations for the project
+func (app *App) HandleListProjectOAuthIntegrations(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
+	}
+
+	oauthInts, err := app.Repo.OAuthIntegration.ListOAuthIntegrationsByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	res := make([]*integrations.OAuthIntegrationExternal, 0)
+
+	for _, oauthInt := range oauthInts {
+		res = append(res, oauthInt.Externalize())
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(res); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}

+ 10 - 5
server/api/k8s_handler.go

@@ -35,7 +35,8 @@ func (app *App) HandleListNamespaces(w http.ResponseWriter, r *http.Request) {
 	// get the filter options
 	form := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			Repo: app.Repo,
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
 		},
 	}
 
@@ -95,7 +96,8 @@ func (app *App) HandleGetPodLogs(w http.ResponseWriter, r *http.Request) {
 	// get the filter options
 	form := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			Repo: app.Repo,
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
 		},
 	}
 
@@ -158,7 +160,8 @@ func (app *App) HandleGetIngress(w http.ResponseWriter, r *http.Request) {
 	// get the filter options
 	form := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			Repo: app.Repo,
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
 		},
 	}
 
@@ -214,7 +217,8 @@ func (app *App) HandleListPods(w http.ResponseWriter, r *http.Request) {
 	// get the filter options
 	form := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			Repo: app.Repo,
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
 		},
 	}
 
@@ -277,7 +281,8 @@ func (app *App) HandleStreamControllerStatus(w http.ResponseWriter, r *http.Requ
 	// get the filter options
 	form := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			Repo: app.Repo,
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
 		},
 	}
 

+ 99 - 0
server/api/oauth_do_handler.go

@@ -0,0 +1,99 @@
+package api
+
+import (
+	"fmt"
+	"net/http"
+
+	"github.com/porter-dev/porter/internal/oauth"
+	"golang.org/x/oauth2"
+
+	"github.com/porter-dev/porter/internal/models/integrations"
+)
+
+// HandleDOOAuthStartProject starts the oauth2 flow for a project digitalocean request.
+// In this handler, the project id gets written to the session (along with the oauth
+// state param), so that the correct project id can be identified in the callback.
+func (app *App) HandleDOOAuthStartProject(w http.ResponseWriter, r *http.Request) {
+	state := oauth.CreateRandomState()
+
+	err := app.populateOAuthSession(w, r, state, true)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// specify access type offline to get a refresh token
+	url := app.DOConf.AuthCodeURL(state, oauth2.AccessTypeOffline)
+
+	http.Redirect(w, r, url, 302)
+}
+
+// HandleDOOAuthCallback verifies the callback request by checking that the
+// state parameter has not been modified, and validates the token.
+func (app *App) HandleDOOAuthCallback(w http.ResponseWriter, r *http.Request) {
+	session, err := app.Store.Get(r, app.ServerConf.CookieName)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	if _, ok := session.Values["state"]; !ok {
+		app.sendExternalError(
+			err,
+			http.StatusForbidden,
+			HTTPError{
+				Code: http.StatusForbidden,
+				Errors: []string{
+					"Could not read cookie: are cookies enabled?",
+				},
+			},
+			w,
+		)
+
+		return
+	}
+
+	if r.URL.Query().Get("state") != session.Values["state"] {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	token, err := app.DOConf.Exchange(oauth2.NoContext, r.URL.Query().Get("code"))
+
+	if err != nil {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	if !token.Valid() {
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	}
+
+	userID, _ := session.Values["user_id"].(uint)
+	projID, _ := session.Values["project_id"].(uint)
+
+	oauthInt := &integrations.OAuthIntegration{
+		Client:       integrations.OAuthDigitalOcean,
+		UserID:       userID,
+		ProjectID:    projID,
+		AccessToken:  []byte(token.AccessToken),
+		RefreshToken: []byte(token.RefreshToken),
+	}
+
+	// create the oauth integration first
+	oauthInt, err = app.Repo.OAuthIntegration.CreateOAuthIntegration(oauthInt)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	if session.Values["query_params"] != "" {
+		http.Redirect(w, r, fmt.Sprintf("/dashboard?%s", session.Values["query_params"]), 302)
+	} else {
+		http.Redirect(w, r, "/dashboard", 302)
+	}
+}

+ 1 - 1
server/api/oauth_github_handler.go

@@ -139,7 +139,7 @@ func (app *App) populateOAuthSession(w http.ResponseWriter, r *http.Request, sta
 			return fmt.Errorf("could not read project id")
 		}
 
-		session.Values["project_id"] = projID
+		session.Values["project_id"] = uint(projID)
 		session.Values["query_params"] = r.URL.RawQuery
 	}
 

+ 648 - 21
server/api/provision_handler.go

@@ -38,6 +38,7 @@ func (app *App) HandleProvisionTest(w http.ResponseWriter, r *http.Request) {
 		provisioner.Apply,
 		&app.DBConf,
 		app.RedisConf,
+		app.ServerConf.ProvisionerImageTag,
 	)
 
 	if err != nil {
@@ -74,7 +75,7 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// convert the form to an aws infra instance
-	infra, err := form.ToAWSInfra()
+	infra, err := form.ToInfra()
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -82,7 +83,7 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// handle write to the database
-	infra, err = app.Repo.AWSInfra.CreateAWSInfra(infra)
+	infra, err = app.Repo.Infra.CreateInfra(infra)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
@@ -93,7 +94,7 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 
 	if err != nil {
 		infra.Status = models.StatusError
-		infra, _ = app.Repo.AWSInfra.UpdateAWSInfra(infra)
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
 
 		app.handleErrorDataRead(err, w)
 		return
@@ -104,7 +105,7 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 
 	if err != nil {
 		infra.Status = models.StatusError
-		infra, _ = app.Repo.AWSInfra.UpdateAWSInfra(infra)
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
 
 		app.handleErrorDataRead(err, w)
 		return
@@ -118,11 +119,12 @@ func (app *App) HandleProvisionAWSECRInfra(w http.ResponseWriter, r *http.Reques
 		provisioner.Apply,
 		&app.DBConf,
 		app.RedisConf,
+		app.ServerConf.ProvisionerImageTag,
 	)
 
 	if err != nil {
 		infra.Status = models.StatusError
-		infra, _ = app.Repo.AWSInfra.UpdateAWSInfra(infra)
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
 
 		app.handleErrorInternal(err, w)
 		return
@@ -151,7 +153,7 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	// read infra to get id
-	infra, err := app.Repo.AWSInfra.ReadAWSInfra(uint(infraID))
+	infra, err := app.Repo.Infra.ReadInfra(uint(infraID))
 
 	if err != nil {
 		app.handleErrorDataRead(err, w)
@@ -165,7 +167,7 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 	// decode from JSON to form value
 	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
 		infra.Status = models.StatusError
-		infra, _ = app.Repo.AWSInfra.UpdateAWSInfra(infra)
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
 
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
@@ -174,7 +176,7 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
 		infra.Status = models.StatusError
-		infra, _ = app.Repo.AWSInfra.UpdateAWSInfra(infra)
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
 
 		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
 		return
@@ -185,7 +187,7 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 
 	if err != nil {
 		infra.Status = models.StatusError
-		infra, _ = app.Repo.AWSInfra.UpdateAWSInfra(infra)
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
 
 		app.handleErrorDataRead(err, w)
 		return
@@ -193,7 +195,7 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
-	infra, err = app.Repo.AWSInfra.UpdateAWSInfra(infra)
+	infra, err = app.Repo.Infra.UpdateInfra(infra)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
@@ -208,6 +210,7 @@ func (app *App) HandleDestroyAWSECRInfra(w http.ResponseWriter, r *http.Request)
 		provisioner.Destroy,
 		&app.DBConf,
 		app.RedisConf,
+		app.ServerConf.ProvisionerImageTag,
 	)
 
 	if err != nil {
@@ -246,7 +249,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// convert the form to an aws infra instance
-	infra, err := form.ToAWSInfra()
+	infra, err := form.ToInfra()
 
 	if err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
@@ -254,7 +257,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 	}
 
 	// handle write to the database
-	infra, err = app.Repo.AWSInfra.CreateAWSInfra(infra)
+	infra, err = app.Repo.Infra.CreateInfra(infra)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
@@ -265,7 +268,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 
 	if err != nil {
 		infra.Status = models.StatusError
-		infra, _ = app.Repo.AWSInfra.UpdateAWSInfra(infra)
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
 
 		app.handleErrorDataRead(err, w)
 		return
@@ -276,7 +279,7 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 
 	if err != nil {
 		infra.Status = models.StatusError
-		infra, _ = app.Repo.AWSInfra.UpdateAWSInfra(infra)
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
 
 		app.handleErrorDataRead(err, w)
 		return
@@ -290,11 +293,12 @@ func (app *App) HandleProvisionAWSEKSInfra(w http.ResponseWriter, r *http.Reques
 		provisioner.Apply,
 		&app.DBConf,
 		app.RedisConf,
+		app.ServerConf.ProvisionerImageTag,
 	)
 
 	if err != nil {
 		infra.Status = models.StatusError
-		infra, _ = app.Repo.AWSInfra.UpdateAWSInfra(infra)
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
 
 		app.handleErrorInternal(err, w)
 		return
@@ -323,7 +327,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 	}
 
 	// read infra to get id
-	infra, err := app.Repo.AWSInfra.ReadAWSInfra(uint(infraID))
+	infra, err := app.Repo.Infra.ReadInfra(uint(infraID))
 
 	if err != nil {
 		app.handleErrorDataRead(err, w)
@@ -337,7 +341,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 	// decode from JSON to form value
 	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
 		infra.Status = models.StatusError
-		infra, _ = app.Repo.AWSInfra.UpdateAWSInfra(infra)
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
 
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
@@ -346,7 +350,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 	// validate the form
 	if err := app.validator.Struct(form); err != nil {
 		infra.Status = models.StatusError
-		infra, _ = app.Repo.AWSInfra.UpdateAWSInfra(infra)
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
 
 		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
 		return
@@ -357,7 +361,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 
 	if err != nil {
 		infra.Status = models.StatusError
-		infra, _ = app.Repo.AWSInfra.UpdateAWSInfra(infra)
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
 
 		app.handleErrorDataRead(err, w)
 		return
@@ -365,7 +369,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 
 	// mark infra for deletion
 	infra.Status = models.StatusDestroying
-	infra, err = app.Repo.AWSInfra.UpdateAWSInfra(infra)
+	infra, err = app.Repo.Infra.UpdateInfra(infra)
 
 	if err != nil {
 		app.handleErrorDataWrite(err, w)
@@ -380,6 +384,7 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 		provisioner.Destroy,
 		&app.DBConf,
 		app.RedisConf,
+		app.ServerConf.ProvisionerImageTag,
 	)
 
 	if err != nil {
@@ -392,6 +397,272 @@ func (app *App) HandleDestroyAWSEKSInfra(w http.ResponseWriter, r *http.Request)
 	w.WriteHeader(http.StatusOK)
 }
 
+// HandleProvisionGCPGCRInfra enables GCR for a project
+func (app *App) HandleProvisionGCPGCRInfra(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateGCRInfra{
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to an aws infra instance
+	infra, err := form.ToInfra()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	infra, err = app.Repo.Infra.CreateInfra(infra)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	gcpInt, err := app.Repo.GCPIntegration.ReadGCPIntegration(infra.GCPIntegrationID)
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// launch provisioning pod
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	_, err = agent.ProvisionGCR(
+		uint(projID),
+		gcpInt,
+		infra,
+		provisioner.Apply,
+		&app.DBConf,
+		app.RedisConf,
+		app.ServerConf.ProvisionerImageTag,
+	)
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("New gcp gcr infra created: %d", infra.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	infraExt := infra.Externalize()
+
+	if err := json.NewEncoder(w).Encode(infraExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleProvisionGCPGKEInfra provisions a new GKE instance for a project
+func (app *App) HandleProvisionGCPGKEInfra(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateGKEInfra{
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to an aws infra instance
+	infra, err := form.ToInfra()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	infra, err = app.Repo.Infra.CreateInfra(infra)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	gcpInt, err := app.Repo.GCPIntegration.ReadGCPIntegration(infra.GCPIntegrationID)
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// launch provisioning pod
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	_, err = agent.ProvisionGKE(
+		uint(projID),
+		gcpInt,
+		form.GKEName,
+		infra,
+		provisioner.Apply,
+		&app.DBConf,
+		app.RedisConf,
+		app.ServerConf.ProvisionerImageTag,
+	)
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("New gcp gke infra created: %d", infra.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	infraExt := infra.Externalize()
+
+	if err := json.NewEncoder(w).Encode(infraExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleDestroyGCPGKEInfra destroys gke infra
+func (app *App) HandleDestroyGCPGKEInfra(w http.ResponseWriter, r *http.Request) {
+	// get path parameters
+	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// read infra to get id
+	infra, err := app.Repo.Infra.ReadInfra(uint(infraID))
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	gcpInt, err := app.Repo.GCPIntegration.ReadGCPIntegration(infra.GCPIntegrationID)
+
+	form := &forms.DestroyGKEInfra{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// launch provisioning destruction pod
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// mark infra for deletion
+	infra.Status = models.StatusDestroying
+	infra, err = app.Repo.Infra.UpdateInfra(infra)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	_, err = agent.ProvisionGKE(
+		infra.ProjectID,
+		gcpInt,
+		form.GKEName,
+		infra,
+		provisioner.Destroy,
+		&app.DBConf,
+		app.RedisConf,
+		app.ServerConf.ProvisionerImageTag,
+	)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("GCP GKE infra marked for destruction: %d", infra.ID)
+
+	w.WriteHeader(http.StatusOK)
+}
+
 // HandleGetProvisioningLogs returns real-time logs of the provisioning process via websockets
 func (app *App) HandleGetProvisioningLogs(w http.ResponseWriter, r *http.Request) {
 	// get path parameters
@@ -403,7 +674,7 @@ func (app *App) HandleGetProvisioningLogs(w http.ResponseWriter, r *http.Request
 	}
 
 	// read infra to get id
-	infra, err := app.Repo.AWSInfra.ReadAWSInfra(uint(infraID))
+	infra, err := app.Repo.Infra.ReadInfra(uint(infraID))
 
 	if err != nil {
 		app.handleErrorDataRead(err, w)
@@ -435,3 +706,359 @@ func (app *App) HandleGetProvisioningLogs(w http.ResponseWriter, r *http.Request
 		return
 	}
 }
+
+// HandleProvisionDODOCRInfra provisions a new digitalocean DOCR instance for a project
+func (app *App) HandleProvisionDODOCRInfra(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateDOCRInfra{
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to an aws infra instance
+	infra, err := form.ToInfra()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	infra, err = app.Repo.Infra.CreateInfra(infra)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	oauthInt, err := app.Repo.OAuthIntegration.ReadOAuthIntegration(infra.DOIntegrationID)
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// launch provisioning pod
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	_, err = agent.ProvisionDOCR(
+		uint(projID),
+		oauthInt,
+		app.DOConf,
+		*app.Repo,
+		form.DOCRName,
+		form.DOCRSubscriptionTier,
+		infra,
+		provisioner.Apply,
+		&app.DBConf,
+		app.RedisConf,
+	)
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("New do docr infra created: %d", infra.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	infraExt := infra.Externalize()
+
+	if err := json.NewEncoder(w).Encode(infraExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleDestroyAWSDOCRInfra destroys docr infra
+func (app *App) HandleDestroyDODOCRInfra(w http.ResponseWriter, r *http.Request) {
+	// get path parameters
+	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// read infra to get id
+	infra, err := app.Repo.Infra.ReadInfra(uint(infraID))
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	oauthInt, err := app.Repo.OAuthIntegration.ReadOAuthIntegration(infra.DOIntegrationID)
+
+	form := &forms.DestroyDOCRInfra{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// launch provisioning destruction pod
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// mark infra for deletion
+	infra.Status = models.StatusDestroying
+	infra, err = app.Repo.Infra.UpdateInfra(infra)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	_, err = agent.ProvisionDOCR(
+		infra.ProjectID,
+		oauthInt,
+		app.DOConf,
+		*app.Repo,
+		form.DOCRName,
+		"basic", // this doesn't matter for destroy
+		infra,
+		provisioner.Destroy,
+		&app.DBConf,
+		app.RedisConf,
+	)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("DO DOCR infra marked for destruction: %d", infra.ID)
+
+	w.WriteHeader(http.StatusOK)
+}
+
+// HandleProvisionDODOKSInfra provisions a new DO DOKS instance for a project
+func (app *App) HandleProvisionDODOKSInfra(w http.ResponseWriter, r *http.Request) {
+	projID, err := strconv.ParseUint(chi.URLParam(r, "project_id"), 0, 64)
+
+	if err != nil || projID == 0 {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	form := &forms.CreateDOKSInfra{
+		ProjectID: uint(projID),
+	}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// convert the form to an aws infra instance
+	infra, err := form.ToInfra()
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// handle write to the database
+	infra, err = app.Repo.Infra.CreateInfra(infra)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	oauthInt, err := app.Repo.OAuthIntegration.ReadOAuthIntegration(infra.DOIntegrationID)
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// launch provisioning pod
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	_, err = agent.ProvisionDOKS(
+		uint(projID),
+		oauthInt,
+		app.DOConf,
+		*app.Repo,
+		form.DORegion,
+		form.DOKSName,
+		infra,
+		provisioner.Apply,
+		&app.DBConf,
+		app.RedisConf,
+	)
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("New do doks infra created: %d", infra.ID)
+
+	w.WriteHeader(http.StatusCreated)
+
+	infraExt := infra.Externalize()
+
+	if err := json.NewEncoder(w).Encode(infraExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleDestroyDODOKSInfra destroys DOKS infra
+func (app *App) HandleDestroyDODOKSInfra(w http.ResponseWriter, r *http.Request) {
+	// get path parameters
+	infraID, err := strconv.ParseUint(chi.URLParam(r, "infra_id"), 10, 64)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// read infra to get id
+	infra, err := app.Repo.Infra.ReadInfra(uint(infraID))
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	oauthInt, err := app.Repo.OAuthIntegration.ReadOAuthIntegration(infra.DOIntegrationID)
+
+	form := &forms.DestroyDOKSInfra{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// validate the form
+	if err := app.validator.Struct(form); err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorFormValidation(err, ErrProjectValidateFields, w)
+		return
+	}
+
+	// launch provisioning destruction pod
+	agent, err := kubernetes.GetAgentInClusterConfig()
+
+	if err != nil {
+		infra.Status = models.StatusError
+		infra, _ = app.Repo.Infra.UpdateInfra(infra)
+
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	// mark infra for deletion
+	infra.Status = models.StatusDestroying
+	infra, err = app.Repo.Infra.UpdateInfra(infra)
+
+	if err != nil {
+		app.handleErrorDataWrite(err, w)
+		return
+	}
+
+	_, err = agent.ProvisionDOKS(
+		infra.ProjectID,
+		oauthInt,
+		app.DOConf,
+		*app.Repo,
+		"nyc1",
+		form.DOKSName,
+		infra,
+		provisioner.Destroy,
+		&app.DBConf,
+		app.RedisConf,
+	)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	app.Logger.Info().Msgf("DO DOKS infra marked for destruction: %d", infra.ID)
+
+	w.WriteHeader(http.StatusOK)
+}

+ 62 - 2
server/api/registry_handler.go

@@ -7,6 +7,8 @@ import (
 	"strings"
 	"time"
 
+	"github.com/porter-dev/porter/internal/oauth"
+
 	"github.com/porter-dev/porter/internal/registry"
 
 	"github.com/go-chi/chi"
@@ -211,6 +213,64 @@ func (app *App) HandleGetProjectRegistryGCRToken(w http.ResponseWriter, r *http.
 
 			token = string(tokenCache.Token)
 			expiresAt = &tokenCache.Expiry
+			break
+		}
+	}
+
+	resp := &RegTokenResponse{
+		Token:     token,
+		ExpiresAt: expiresAt,
+	}
+
+	w.WriteHeader(http.StatusOK)
+
+	if err := json.NewEncoder(w).Encode(resp); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+}
+
+// HandleGetProjectRegistryDOCRToken gets a DOCR token for a registry
+func (app *App) HandleGetProjectRegistryDOCRToken(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
+	}
+
+	reqBody := &GCRTokenRequestBody{}
+
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(reqBody); err != nil {
+		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
+		return
+	}
+
+	// list registries and find one that matches the region
+	regs, err := app.Repo.Registry.ListRegistriesByProjectID(uint(projID))
+	var token string
+	var expiresAt *time.Time
+
+	for _, reg := range regs {
+		if reg.DOIntegrationID != 0 && strings.Contains(reg.URL, reqBody.ServerURL) {
+			oauthInt, err := app.Repo.OAuthIntegration.ReadOAuthIntegration(reg.DOIntegrationID)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			tok, expiry, err := oauth.GetAccessToken(oauthInt, app.DOConf, *app.Repo)
+
+			if err != nil {
+				app.handleErrorDataRead(err, w)
+				return
+			}
+
+			token = tok
+			expiresAt = expiry
+			break
 		}
 	}
 
@@ -331,7 +391,7 @@ func (app *App) HandleListRepositories(w http.ResponseWriter, r *http.Request) {
 	_reg := registry.Registry(*reg)
 	regAPI := &_reg
 
-	repos, err := regAPI.ListRepositories(*app.Repo)
+	repos, err := regAPI.ListRepositories(*app.Repo, app.DOConf)
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)
@@ -368,7 +428,7 @@ func (app *App) HandleListImages(w http.ResponseWriter, r *http.Request) {
 	_reg := registry.Registry(*reg)
 	regAPI := &_reg
 
-	imgs, err := regAPI.ListImages(repoName, *app.Repo)
+	imgs, err := regAPI.ListImages(repoName, *app.Repo, app.DOConf)
 
 	if err != nil {
 		app.handleErrorRead(err, ErrProjectDataRead, w)

+ 76 - 14
server/api/release_handler.go

@@ -34,7 +34,8 @@ func (app *App) HandleListReleases(w http.ResponseWriter, r *http.Request) {
 	form := &forms.ListReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.Repo,
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
 			},
 		},
 		ListFilter: &helm.ListFilter{},
@@ -80,7 +81,8 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 	form := &forms.GetReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.Repo,
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
 			},
 		},
 		Name:     name,
@@ -113,7 +115,8 @@ func (app *App) HandleGetRelease(w http.ResponseWriter, r *http.Request) {
 	// get the filter options
 	k8sForm := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			Repo: app.Repo,
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
 		},
 	}
 
@@ -187,7 +190,8 @@ func (app *App) HandleGetReleaseComponents(w http.ResponseWriter, r *http.Reques
 	form := &forms.GetReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.Repo,
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
 			},
 		},
 		Name:     name,
@@ -243,7 +247,8 @@ func (app *App) HandleGetReleaseControllers(w http.ResponseWriter, r *http.Reque
 	form := &forms.GetReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.Repo,
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
 			},
 		},
 		Name:     name,
@@ -283,7 +288,8 @@ func (app *App) HandleGetReleaseControllers(w http.ResponseWriter, r *http.Reque
 	// get the filter options
 	k8sForm := &forms.K8sForm{
 		OutOfClusterConfig: &kubernetes.OutOfClusterConfig{
-			Repo: app.Repo,
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
 		},
 	}
 
@@ -369,7 +375,8 @@ func (app *App) HandleListReleaseHistory(w http.ResponseWriter, r *http.Request)
 	form := &forms.ListReleaseHistoryForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.Repo,
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
 			},
 		},
 		Name: name,
@@ -430,6 +437,13 @@ func (app *App) HandleGetReleaseToken(w http.ResponseWriter, r *http.Request) {
 
 // HandleUpgradeRelease upgrades a release with new values.yaml
 func (app *App) HandleUpgradeRelease(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
+	}
+
 	name := chi.URLParam(r, "name")
 
 	vals, err := url.ParseQuery(r.URL.RawQuery)
@@ -442,7 +456,8 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 	form := &forms.UpgradeReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.Repo,
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
 			},
 		},
 		Name: name,
@@ -469,7 +484,21 @@ func (app *App) HandleUpgradeRelease(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	_, err = agent.UpgradeRelease(form.Name, form.Values)
+	registries, err := app.Repo.Registry.ListRegistriesByProjectID(uint(projID))
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	conf := &helm.UpgradeReleaseConfig{
+		Name:       form.Name,
+		Cluster:    form.ReleaseForm.Cluster,
+		Repo:       *app.Repo,
+		Registries: registries,
+	}
+
+	_, err = agent.UpgradeRelease(conf, form.Values, app.DOConf)
 
 	if err != nil {
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
@@ -499,7 +528,8 @@ func (app *App) HandleReleaseDeployHook(w http.ResponseWriter, r *http.Request)
 	form := &forms.UpgradeReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.Repo,
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
 			},
 		},
 		Name: name,
@@ -533,7 +563,22 @@ func (app *App) HandleReleaseDeployHook(w http.ResponseWriter, r *http.Request)
 	newval := map[string]interface{}{}
 	newval["image"] = image
 
-	_, err = agent.UpgradeReleaseByValues(form.Name, newval)
+	registries, err := app.Repo.Registry.ListRegistriesByProjectID(uint(form.ReleaseForm.Cluster.ProjectID))
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	conf := &helm.UpgradeReleaseConfig{
+		Name:       form.Name,
+		Cluster:    form.ReleaseForm.Cluster,
+		Repo:       *app.Repo,
+		Registries: registries,
+		Values:     newval,
+	}
+
+	_, err = agent.UpgradeReleaseByValues(conf, app.DOConf)
 
 	if err != nil {
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
@@ -581,7 +626,8 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 	form := &forms.UpgradeReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.Repo,
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
 			},
 		},
 		Name: release.Name,
@@ -610,7 +656,22 @@ func (app *App) HandleReleaseDeployWebhook(w http.ResponseWriter, r *http.Reques
 	newval := map[string]interface{}{}
 	newval["image"] = image
 
-	_, err = agent.UpgradeReleaseByValues(form.Name, newval)
+	registries, err := app.Repo.Registry.ListRegistriesByProjectID(uint(form.ReleaseForm.Cluster.ProjectID))
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	conf := &helm.UpgradeReleaseConfig{
+		Name:       form.Name,
+		Cluster:    form.ReleaseForm.Cluster,
+		Repo:       *app.Repo,
+		Registries: registries,
+		Values:     newval,
+	}
+
+	_, err = agent.UpgradeReleaseByValues(conf, app.DOConf)
 
 	if err != nil {
 		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
@@ -638,7 +699,8 @@ func (app *App) HandleRollbackRelease(w http.ResponseWriter, r *http.Request) {
 	form := &forms.RollbackReleaseForm{
 		ReleaseForm: &forms.ReleaseForm{
 			Form: &helm.Form{
-				Repo: app.Repo,
+				Repo:              app.Repo,
+				DigitalOceanOAuth: app.DOConf,
 			},
 		},
 		Name: name,

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

@@ -90,6 +90,10 @@ type bodyGCPIntegrationID struct {
 	GCPIntegrationID uint64 `json:"gcp_integration_id"`
 }
 
+type bodyDOIntegrationID struct {
+	DOIntegrationID uint64 `json:"do_integration_id"`
+}
+
 // DoesUserIDMatch checks the id URL parameter and verifies that it matches
 // the one stored in the session
 func (auth *Auth) DoesUserIDMatch(next http.Handler, loc IDLocation) http.Handler {
@@ -349,7 +353,7 @@ func (auth *Auth) DoesUserHaveInfraAccess(
 			return
 		}
 
-		infras, err := auth.repo.AWSInfra.ListAWSInfrasByProjectID(uint(projID))
+		infras, err := auth.repo.Infra.ListInfrasByProjectID(uint(projID))
 
 		if err != nil {
 			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
@@ -485,6 +489,61 @@ func (auth *Auth) DoesUserHaveGCPIntegrationAccess(
 	})
 }
 
+// DoesUserHaveDOIntegrationAccess looks for a project_id parameter and an
+// do_integration_id parameter, and verifies that the infra belongs
+// to the project
+func (auth *Auth) DoesUserHaveDOIntegrationAccess(
+	next http.Handler,
+	projLoc IDLocation,
+	doLoc IDLocation,
+	optional bool,
+) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		doID, err := findDOIntegrationIDInRequest(r, doLoc)
+
+		if doID == 0 && optional {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		if doID == 0 || err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		projID, err := findProjIDInRequest(r, projLoc)
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+			return
+		}
+
+		oauthInts, err := auth.repo.OAuthIntegration.ListOAuthIntegrationsByProjectID(uint(projID))
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			return
+		}
+
+		doesExist := false
+
+		for _, oauthInt := range oauthInts {
+			if oauthInt.ID == uint(doID) {
+				doesExist = true
+				break
+			}
+		}
+
+		if doesExist {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	})
+}
+
 // Helpers
 func (auth *Auth) doesSessionMatchID(r *http.Request, id uint) bool {
 	session, _ := auth.store.Get(r, auth.cookieName)
@@ -871,3 +930,48 @@ func findGCPIntegrationIDInRequest(r *http.Request, gcpLoc IDLocation) (uint64,
 
 	return gcpID, nil
 }
+
+func findDOIntegrationIDInRequest(r *http.Request, doLoc IDLocation) (uint64, error) {
+	var doID uint64
+	var err error
+
+	if doLoc == URLParam {
+		doID, err = strconv.ParseUint(chi.URLParam(r, "do_integration_id"), 0, 64)
+
+		if err != nil {
+			return 0, err
+		}
+	} else if doLoc == BodyParam {
+		form := &bodyDOIntegrationID{}
+		body, err := ioutil.ReadAll(r.Body)
+
+		if err != nil {
+			return 0, err
+		}
+
+		err = json.Unmarshal(body, form)
+
+		if err != nil {
+			return 0, err
+		}
+
+		doID = form.DOIntegrationID
+
+		// need to create a new stream for the body
+		r.Body = ioutil.NopCloser(bytes.NewReader(body))
+	} else {
+		vals, err := url.ParseQuery(r.URL.RawQuery)
+
+		if err != nil {
+			return 0, err
+		}
+
+		if regStrArr, ok := vals["do_integration_id"]; ok && len(regStrArr) == 1 {
+			doID, err = strconv.ParseUint(regStrArr[0], 10, 64)
+		} else {
+			return 0, errors.New("do integration id not found")
+		}
+	}
+
+	return doID, nil
+}

+ 161 - 3
server/router/router.go

@@ -148,6 +148,22 @@ func New(a *api.App) *chi.Mux {
 			requestlog.NewHandler(a.HandleGithubOAuthCallback, l),
 		)
 
+		r.Method(
+			"GET",
+			"/oauth/projects/{project_id}/digitalocean",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleDOOAuthStartProject, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
+		r.Method(
+			"GET",
+			"/oauth/digitalocean/callback",
+			requestlog.NewHandler(a.HandleDOOAuthCallback, l),
+		)
+
 		// /api/projects routes
 		r.Method(
 			"GET",
@@ -207,7 +223,7 @@ func New(a *api.App) *chi.Mux {
 					requestlog.NewHandler(a.HandleProvisionAWSECRInfra, l),
 					mw.URLParam,
 					mw.BodyParam,
-					true,
+					false,
 				),
 				mw.URLParam,
 				mw.ReadAccess,
@@ -222,7 +238,67 @@ func New(a *api.App) *chi.Mux {
 					requestlog.NewHandler(a.HandleProvisionAWSEKSInfra, l),
 					mw.URLParam,
 					mw.BodyParam,
-					true,
+					false,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/provision/gcr",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveGCPIntegrationAccess(
+					requestlog.NewHandler(a.HandleProvisionGCPGCRInfra, l),
+					mw.URLParam,
+					mw.BodyParam,
+					false,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/provision/gke",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveGCPIntegrationAccess(
+					requestlog.NewHandler(a.HandleProvisionGCPGKEInfra, l),
+					mw.URLParam,
+					mw.BodyParam,
+					false,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/provision/docr",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveDOIntegrationAccess(
+					requestlog.NewHandler(a.HandleProvisionDODOCRInfra, l),
+					mw.URLParam,
+					mw.BodyParam,
+					false,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/provision/doks",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveDOIntegrationAccess(
+					requestlog.NewHandler(a.HandleProvisionDODOKSInfra, l),
+					mw.URLParam,
+					mw.BodyParam,
+					false,
 				),
 				mw.URLParam,
 				mw.ReadAccess,
@@ -285,6 +361,48 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"POST",
+			"/projects/{project_id}/infra/{infra_id}/gke/destroy",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveInfraAccess(
+					requestlog.NewHandler(a.HandleDestroyGCPGKEInfra, l),
+					mw.URLParam,
+					mw.URLParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/infra/{infra_id}/docr/destroy",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveInfraAccess(
+					requestlog.NewHandler(a.HandleDestroyDODOCRInfra, l),
+					mw.URLParam,
+					mw.URLParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
+		r.Method(
+			"POST",
+			"/projects/{project_id}/infra/{infra_id}/doks/destroy",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveInfraAccess(
+					requestlog.NewHandler(a.HandleDestroyDODOKSInfra, l),
+					mw.URLParam,
+					mw.URLParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		// /api/projects/{project_id}/clusters routes
 		r.Method(
 			"GET",
@@ -420,6 +538,16 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/integrations/oauth",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleListProjectOAuthIntegrations, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
 		// /api/projects/{project_id}/helmrepos routes
 		r.Method(
 			"POST",
@@ -468,7 +596,12 @@ func New(a *api.App) *chi.Mux {
 			auth.DoesUserHaveProjectAccess(
 				auth.DoesUserHaveAWSIntegrationAccess(
 					auth.DoesUserHaveGCPIntegrationAccess(
-						requestlog.NewHandler(a.HandleCreateRegistry, l),
+						auth.DoesUserHaveDOIntegrationAccess(
+							requestlog.NewHandler(a.HandleCreateRegistry, l),
+							mw.URLParam,
+							mw.BodyParam,
+							true,
+						),
 						mw.URLParam,
 						mw.BodyParam,
 						true,
@@ -526,6 +659,16 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		r.Method(
+			"GET",
+			"/projects/{project_id}/registries/docr/token",
+			auth.DoesUserHaveProjectAccess(
+				requestlog.NewHandler(a.HandleGetProjectRegistryDOCRToken, l),
+				mw.URLParam,
+				mw.WriteAccess,
+			),
+		)
+
 		r.Method(
 			"DELETE",
 			"/projects/{project_id}/registries/{registry_id}",
@@ -765,6 +908,21 @@ func New(a *api.App) *chi.Mux {
 			),
 		)
 
+		// /api/projects/{project_id}/deploy routes
+		r.Method(
+			"POST",
+			"/projects/{project_id}/deploy/{name}",
+			auth.DoesUserHaveProjectAccess(
+				auth.DoesUserHaveClusterAccess(
+					requestlog.NewHandler(a.HandleUninstallTemplate, l),
+					mw.URLParam,
+					mw.QueryParam,
+				),
+				mw.URLParam,
+				mw.ReadAccess,
+			),
+		)
+
 		// /api/projects/{project_id}/k8s routes
 		r.Method(
 			"GET",