Explorar el Código

Merge pull request #217 from porter-dev/master

Test GCP integration on staging
abelanger5 hace 5 años
padre
commit
4f554ef5c5
Se han modificado 52 ficheros con 1185 adiciones y 264 borrados
  1. 13 33
      .github/workflows/release.yaml
  2. 2 2
      .github/workflows/staging.yaml
  3. 8 9
      README.md
  4. 18 0
      build/Dockerfile.osx
  5. 17 0
      build/Dockerfile.win
  6. 36 1
      cli/cmd/connect/ecr.go
  7. 3 0
      cli/cmd/github/release.go
  8. 1 1
      cli/cmd/providers/aws/agent.go
  9. 1 1
      cli/cmd/registry.go
  10. 33 5
      cli/cmd/server.go
  11. 1 1
      cli/cmd/version.go
  12. 15 0
      cmd/app/main.go
  13. 1 0
      dashboard/src/components/Selector.tsx
  14. 102 0
      dashboard/src/components/values-form/Base64InputRow.tsx
  15. 35 0
      dashboard/src/components/values-form/ValuesForm.tsx
  16. 0 3
      dashboard/src/main/Register.tsx
  17. 2 8
      dashboard/src/main/home/Home.tsx
  18. 28 6
      dashboard/src/main/home/cluster-dashboard/ClusterDashboard.tsx
  19. 64 0
      dashboard/src/main/home/cluster-dashboard/SortSelector.tsx
  20. 10 1
      dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx
  21. 25 5
      dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx
  22. 0 1
      dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx
  23. 5 4
      dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx
  24. 48 17
      dashboard/src/main/home/modals/UpdateClusterModal.tsx
  25. 48 15
      dashboard/src/main/home/modals/UpdateProjectModal.tsx
  26. 115 20
      dashboard/src/main/home/new-project/NewProject.tsx
  27. 0 6
      dashboard/src/main/home/new-project/Provisioner.tsx
  28. 0 4
      dashboard/src/main/home/sidebar/ClusterSection.tsx
  29. 0 1
      dashboard/src/main/home/sidebar/ProjectSection.tsx
  30. 18 13
      dashboard/src/main/home/templates/Templates.tsx
  31. 72 44
      dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx
  32. 19 5
      dashboard/src/main/home/templates/expanded-template/TemplateInfo.tsx
  33. 31 1
      dashboard/src/shared/api.tsx
  34. 9 1
      dashboard/src/shared/feedback.tsx
  35. 3 0
      go.sum
  36. 11 5
      internal/auth/sessionstore.go
  37. 1 1
      internal/config/config.go
  38. 1 0
      internal/forms/cluster.go
  39. 2 0
      internal/forms/integration.go
  40. 30 0
      internal/kubernetes/provisioner/gcp/gcp.go
  41. 14 1
      internal/kubernetes/provisioner/provisioner.go
  42. 4 1
      internal/models/integrations/gcp.go
  43. 1 0
      internal/models/templates.go
  44. 4 19
      internal/registry/registry.go
  45. 13 0
      scripts/build/osx.sh
  46. 13 0
      scripts/build/win.sh
  47. 3 0
      server/api/cluster_handler.go
  48. 2 1
      server/api/cluster_handler_test.go
  49. 26 0
      server/api/integration_handler_test.go
  50. 23 22
      server/api/registry_handler_test.go
  51. 208 0
      server/router/middleware/auth.go
  52. 46 6
      server/router/router.go

+ 13 - 33
.github/workflows/release.yaml

@@ -67,6 +67,8 @@ jobs:
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_KEY=${{secrets.DISCORD_KEY}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
           DISCORD_CID=${{secrets.DISCORD_CID}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+          POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
+          POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
           EOL
           EOL
       - name: Build and zip static folder
       - name: Build and zip static folder
         run: |
         run: |
@@ -78,11 +80,6 @@ jobs:
           zip --junk-paths ./release/static/static_${{steps.tag_name.outputs.tag}}.zip ./dashboard/build/*
           zip --junk-paths ./release/static/static_${{steps.tag_name.outputs.tag}}.zip ./dashboard/build/*
         env:
         env:
           NODE_ENV: production
           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
       - name: Build Linux binaries
         run: |
         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 &
           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 &
@@ -92,6 +89,7 @@ jobs:
         env:
         env:
           GOOS: linux
           GOOS: linux
           GOARCH: amd64
           GOARCH: amd64
+          CGO_ENABLED: 1
       # Note: we have to zip all binaries before uploading them as artifacts --
       # Note: we have to zip all binaries before uploading them as artifacts --
       # without this step, the binaries will be uploaded but the file metadata will
       # without this step, the binaries will be uploaded but the file metadata will
       # be listed as plaintext after downloading the artifact in a later step
       # be listed as plaintext after downloading the artifact in a later step
@@ -103,36 +101,18 @@ jobs:
           zip --junk-paths ./release/linux/porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip ./porter
           zip --junk-paths ./release/linux/porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip ./porter
           zip --junk-paths ./release/linux/portersvr_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip ./portersvr
           zip --junk-paths ./release/linux/portersvr_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip ./portersvr
           zip --junk-paths ./release/linux/docker-credential-porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip ./docker-credential-porter
           zip --junk-paths ./release/linux/docker-credential-porter_${{steps.tag_name.outputs.tag}}_Linux_x86_64.zip ./docker-credential-porter
-      - name: Build Darwin binaries
+      - name: Build and zip Darwin binaries
         run: |
         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 &
-          go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -o ./docker-credential-porter ./cmd/docker-credential-porter/ &
-          go build -ldflags="-w -s" -a -o ./portersvr ./cmd/app/ &
-          wait
-        env:
-          GOOS: darwin
-          GOARCH: amd64
-      - name: Zip Darwin binaries
-        run: |
-          mkdir -p ./release/darwin
-          zip --junk-paths ./release/darwin/UNSIGNED_porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip ./porter
-          zip --junk-paths ./release/darwin/UNSIGNED_portersvr_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip ./portersvr
-          zip --junk-paths ./release/darwin/UNSIGNED_docker-credential-porter_${{steps.tag_name.outputs.tag}}_Darwin_x86_64.zip ./docker-credential-porter
-      - name: Build Windows 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.exe ./cli &
-          go build -ldflags="-w -s -X 'main.Version=${{steps.tag_name.outputs.tag}}'" -a -o ./docker-credential-porter.exe ./cmd/docker-credential-porter/ &
-          go build -ldflags="-w -s" -a -o ./portersvr.exe ./cmd/app/ &
-          wait
-        env:
-          GOOS: windows
-          GOARCH: amd64
-      - name: Zip Windows binaries
+          docker build . --file ./build/Dockerfile.osx -t osx
+          docker run \
+          --mount type=bind,source="$(pwd)"/release,target=/release \
+          osx:latest ${{steps.tag_name.outputs.tag}}
+      - name: Build and zip Windows binaries
         run: |
         run: |
-          mkdir -p ./release/windows
-          zip --junk-paths ./release/windows/porter_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip ./porter.exe
-          zip --junk-paths ./release/windows/portersvr_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip ./portersvr.exe
-          zip --junk-paths ./release/windows/docker-credential-porter_${{steps.tag_name.outputs.tag}}_Windows_x86_64.zip ./docker-credential-porter.exe
+          docker build . --file ./build/Dockerfile.win -t win
+          docker run \
+          --mount type=bind,source="$(pwd)"/release,target=/release \
+          win:latest ${{steps.tag_name.outputs.tag}}
       - name: Upload binaries
       - name: Upload binaries
         uses: actions/upload-artifact@v2
         uses: actions/upload-artifact@v2
         with:
         with:

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

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

+ 8 - 9
README.md

@@ -3,15 +3,15 @@
 
 
 **Porter is a Kubernetes-powered PaaS that runs in your own cloud provider.** Porter brings the Heroku experience to Kubernetes without compromising its flexibility. Get started on Porter without the overhead of DevOps and fully customize your infra later when you need to.
 **Porter is a Kubernetes-powered PaaS that runs in your own cloud provider.** Porter brings the Heroku experience to Kubernetes without compromising its flexibility. Get started on Porter without the overhead of DevOps and fully customize your infra later when you need to.
 
 
-![Provisioning View](https://user-images.githubusercontent.com/65516095/103712142-09e34280-4f87-11eb-9272-a35805544fd0.png)
+![Provisioning View](https://user-images.githubusercontent.com/22849518/104234811-fe2dcb00-5421-11eb-9ce3-c0ebefc37476.png)
 
 
 
 
 ## Why Porter?
 ## Why Porter?
 ### A PaaS that grows with your applications
 ### A PaaS that grows with your applications
 
 
-Traditional PaaS's like Heroku are great at minimizing unnecessary DevOps work but don't offer enough flexibility as your applications scale. Custom network rules, resource constraints, and cost are common reasons developers move their application off Heroku beyond a certain scale. 
+A traditional PaaS like Heroku is great for minimizing unnecessary DevOps work but doesn't offer enough flexibility as your applications grow. Custom network rules, resource constraints, and cost are common reasons developers move their applications off Heroku beyond a certain scale. 
 
 
-Porter brings the simplicity of traditional PaaS's to your own cloud provider while preserving the configurability of Kubernetes. It's built on top of a popular Kubernetes framework called Helm and is compatible with standard Kubernetes management tools like `kubectl`, preparing your infra for mature DevOps work from day 1.
+Porter brings the simplicity of a traditional PaaS to your own cloud provider while preserving the configurability of Kubernetes. Porter is built on top of a popular Kubernetes package manager called Helm and is compatible with standard Kubernetes management tools like `kubectl`, preparing your infra for mature DevOps work from day one.
 
 
 ![image](https://user-images.githubusercontent.com/65516095/103713478-71e75800-4f8a-11eb-915f-adee9d4f5bf7.png)
 ![image](https://user-images.githubusercontent.com/65516095/103713478-71e75800-4f8a-11eb-915f-adee9d4f5bf7.png)
 
 
@@ -25,19 +25,18 @@ Porter brings the simplicity of traditional PaaS's to your own cloud provider wh
 - Simple deploy of any public or private Docker image
 - Simple deploy of any public or private Docker image
 
 
 - Heroku-like GUI to monitor application status, logs, and history
 - Heroku-like GUI to monitor application status, logs, and history
-- Marketplace for 1-click add-on's (e.g. MongoDB, Redis, PostgreSQL)
-- Application rollback to previous deploy versions
+- Marketplace for one click add-ons (e.g. MongoDB, Redis, PostgreSQL)
+- Application rollback to previously deployed versions
 - Native CI/CD with buildpacks (Coming Soon)
 - Native CI/CD with buildpacks (Coming Soon)
 
 
 ### DevOps Mode
 ### DevOps Mode
 For those who are familiar with Kubernetes and Helm:
 For those who are familiar with Kubernetes and Helm:
 
 
-- Visualize, deploy and configure Helm charts via the GUI
-
+- Connect to existing Kubernetes clusters that are not provisioned by Porter
+- Visualize, deploy, and configure Helm charts via the GUI
 - User-generated [form overlays](https://docs.getporter.dev/docs/porter-templates) for managing `values.yaml`
 - User-generated [form overlays](https://docs.getporter.dev/docs/porter-templates) for managing `values.yaml`
 - In-depth view of releases, including revision histories and component graphs
 - In-depth view of releases, including revision histories and component graphs
 - Rollback/update of existing releases, including editing of raw `values.yaml`
 - Rollback/update of existing releases, including editing of raw `values.yaml`
-- Connect to existing Kubernetes clusters that are not provisioned by Porter
 
 
 ![Graph View](https://user-images.githubusercontent.com/22849518/101073320-43322800-356d-11eb-9b69-a68bd951992e.png)
 ![Graph View](https://user-images.githubusercontent.com/22849518/101073320-43322800-356d-11eb-9b69-a68bd951992e.png)
 
 
@@ -47,7 +46,7 @@ Run the following command to grab the latest binary:
 
 
 ```sh
 ```sh
 {
 {
-name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*porter_.*_Darwin_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
+name=$(curl -s https://api.github.com/repos/porter-dev/porter/releases/latest | grep "browser_download_url.*/porter_.*_Darwin_x86_64\.zip" | cut -d ":" -f 2,3 | tr -d \")
 name=$(basename $name)
 name=$(basename $name)
 curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $name
 curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $name
 unzip -a $name
 unzip -a $name

+ 18 - 0
build/Dockerfile.osx

@@ -0,0 +1,18 @@
+ARG GO_VERSION=1.13.15
+
+FROM dockercore/golang-cross:${GO_VERSION}
+
+RUN apt-get update && apt-get install -y zip unzip
+
+WORKDIR /go/src/github.com/docker/cli
+COPY    . .
+
+ENV CGO_ENABLED 1
+ENV GOOS darwin
+ENV GOARCH amd64
+ENV CC o64-clang
+ENV CXX o64-clang++
+
+RUN chmod +x ./scripts/build/osx.sh
+
+ENTRYPOINT [ "./scripts/build/osx.sh" ]

+ 17 - 0
build/Dockerfile.win

@@ -0,0 +1,17 @@
+ARG GO_VERSION=1.13.15
+
+FROM	dockercore/golang-cross:${GO_VERSION}
+
+RUN apt-get update && apt-get install -y zip unzip
+
+WORKDIR /go/src/github.com/docker/cli
+COPY    . .
+
+ENV CC x86_64-w64-mingw32-gcc
+ENV CGO_ENABLED 1
+ENV GOOS windows 
+ENV GOARCH amd64
+
+RUN chmod +x ./scripts/build/win.sh
+
+ENTRYPOINT [ "./scripts/build/win.sh" ]

+ 36 - 1
cli/cmd/connect/ecr.go

@@ -4,11 +4,16 @@ import (
 	"context"
 	"context"
 	"fmt"
 	"fmt"
 	"strings"
 	"strings"
+	"time"
 
 
+	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/fatih/color"
 	"github.com/fatih/color"
 	"github.com/porter-dev/porter/cli/cmd/api"
 	"github.com/porter-dev/porter/cli/cmd/api"
-	awsLocal "github.com/porter-dev/porter/cli/cmd/providers/aws/local"
 	"github.com/porter-dev/porter/cli/cmd/utils"
 	"github.com/porter-dev/porter/cli/cmd/utils"
+	"github.com/porter-dev/porter/internal/models/integrations"
+
+	"github.com/porter-dev/porter/cli/cmd/providers/aws"
+	awsLocal "github.com/porter-dev/porter/cli/cmd/providers/aws/local"
 )
 )
 
 
 // ECR creates an ECR integration
 // ECR creates an ECR integration
@@ -50,6 +55,8 @@ Would you like to proceed? %s `,
 			return ecrManual(client, projectID, region)
 			return ecrManual(client, projectID, region)
 		}
 		}
 
 
+		waitForAuthorizationToken(region, creds)
+
 		integration, err := client.CreateAWSIntegration(
 		integration, err := client.CreateAWSIntegration(
 			context.Background(),
 			context.Background(),
 			projectID,
 			projectID,
@@ -142,3 +149,31 @@ func linkRegistry(client *api.Client, projectID uint, intID uint) (uint, error)
 
 
 	return reg.ID, nil
 	return reg.ID, nil
 }
 }
+
+func waitForAuthorizationToken(region string, creds *aws.PorterAWSCredentials) error {
+	awsInt := &integrations.AWSIntegration{
+		AWSRegion:          region,
+		AWSAccessKeyID:     []byte(creds.AWSAccessKeyID),
+		AWSSecretAccessKey: []byte(creds.AWSSecretAccessKey),
+	}
+
+	sess, err := awsInt.GetSession()
+
+	if err != nil {
+		return err
+	}
+
+	ecrSvc := ecr.New(sess)
+
+	for i := 0; i < 30; i++ {
+		_, err := ecrSvc.GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
+
+		if err == nil {
+			return nil
+		}
+
+		time.Sleep(2 * time.Second)
+	}
+
+	return fmt.Errorf("could not get ECR authorization token, please check credentials")
+}

+ 3 - 0
cli/cmd/github/release.go

@@ -240,6 +240,9 @@ func (z *ZIPReleaseGetter) unzipToDir() error {
 			continue
 			continue
 		}
 		}
 
 
+		// delete file if exists
+		os.Remove(fpath)
+
 		// Make File
 		// Make File
 		if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
 		if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
 			return err
 			return err

+ 1 - 1
cli/cmd/providers/aws/agent.go

@@ -134,7 +134,7 @@ func (a *Agent) CreateIAMECRUser(region string) (*PorterAWSCredentials, error) {
 		name = *user.UserName
 		name = *user.UserName
 	}
 	}
 
 
-	policyArn := "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
+	policyArn := "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryFullAccess"
 
 
 	_, err = a.IAMService.AttachUserPolicy(&iam.AttachUserPolicyInput{
 	_, err = a.IAMService.AttachUserPolicy(&iam.AttachUserPolicyInput{
 		PolicyArn: &policyArn,
 		PolicyArn: &policyArn,

+ 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 {
 func listImages(user *api.AuthCheckResponse, client *api.Client, args []string) error {
 	pID := getProjectID()
 	pID := getProjectID()
 	rID := getRegistryID()
 	rID := getRegistryID()
-	repoName := args[1]
+	repoName := args[0]
 
 
 	// get the list of namespaces
 	// get the list of namespaces
 	imgs, err := client.ListImages(
 	imgs, err := client.ListImages(

+ 33 - 5
cli/cmd/server.go

@@ -5,6 +5,7 @@ import (
 	"os"
 	"os"
 	"os/exec"
 	"os/exec"
 	"path/filepath"
 	"path/filepath"
+	"strings"
 
 
 	"github.com/fatih/color"
 	"github.com/fatih/color"
 	"github.com/porter-dev/porter/cli/cmd/docker"
 	"github.com/porter-dev/porter/cli/cmd/docker"
@@ -175,10 +176,26 @@ func startLocal(
 	staticFilePath := filepath.Join(home, ".porter", "static")
 	staticFilePath := filepath.Join(home, ".porter", "static")
 
 
 	if _, err := os.Stat(cmdPath); os.IsNotExist(err) {
 	if _, err := os.Stat(cmdPath); os.IsNotExist(err) {
-		err := downloadLatestReleases(porterDir)
+		err := downloadMatchingRelease(porterDir)
 
 
 		if err != nil {
 		if err != nil {
-			color.New(color.FgRed).Println("Failed:", err.Error())
+			color.New(color.FgRed).Println("Failed to download server binary:", err.Error())
+			os.Exit(1)
+		}
+	}
+
+	// otherwise, check the version flag of the binary
+	cmdVersionPorter := exec.Command(cmdPath, "--version")
+	writer := &versionWriter{}
+	cmdVersionPorter.Stdout = writer
+
+	err := cmdVersionPorter.Run()
+
+	if err != nil || writer.Version != Version {
+		err := downloadMatchingRelease(porterDir)
+
+		if err != nil {
+			color.New(color.FgRed).Println("Failed to download server binary:", err.Error())
 			os.Exit(1)
 			os.Exit(1)
 		}
 		}
 	}
 	}
@@ -190,12 +207,13 @@ func startLocal(
 		"SQL_LITE=true",
 		"SQL_LITE=true",
 		"SQL_LITE_PATH=" + sqlLitePath,
 		"SQL_LITE_PATH=" + sqlLitePath,
 		"STATIC_FILE_PATH=" + staticFilePath,
 		"STATIC_FILE_PATH=" + staticFilePath,
+		"REDIS_ENABLED=false",
 	}...)
 	}...)
 
 
 	cmdPorter.Stdout = os.Stdout
 	cmdPorter.Stdout = os.Stdout
 	cmdPorter.Stderr = os.Stderr
 	cmdPorter.Stderr = os.Stderr
 
 
-	err := cmdPorter.Run()
+	err = cmdPorter.Run()
 
 
 	if err != nil {
 	if err != nil {
 		color.New(color.FgRed).Println("Failed:", err.Error())
 		color.New(color.FgRed).Println("Failed:", err.Error())
@@ -225,7 +243,7 @@ func stopDocker() error {
 	return nil
 	return nil
 }
 }
 
 
-func downloadLatestReleases(porterDir string) error {
+func downloadMatchingRelease(porterDir string) error {
 	z := &github.ZIPReleaseGetter{
 	z := &github.ZIPReleaseGetter{
 		AssetName:           "portersvr",
 		AssetName:           "portersvr",
 		AssetFolderDest:     porterDir,
 		AssetFolderDest:     porterDir,
@@ -236,7 +254,7 @@ func downloadLatestReleases(porterDir string) error {
 		IsPlatformDependent: true,
 		IsPlatformDependent: true,
 	}
 	}
 
 
-	err := z.GetLatestRelease()
+	err := z.GetRelease(Version)
 
 
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -254,3 +272,13 @@ func downloadLatestReleases(porterDir string) error {
 
 
 	return zStatic.GetLatestRelease()
 	return zStatic.GetLatestRelease()
 }
 }
+
+type versionWriter struct {
+	Version string
+}
+
+func (v *versionWriter) Write(p []byte) (n int, err error) {
+	v.Version = strings.TrimSpace(string(p))
+
+	return len(p), nil
+}

+ 1 - 1
cli/cmd/version.go

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

+ 15 - 0
cmd/app/main.go

@@ -1,9 +1,11 @@
 package main
 package main
 
 
 import (
 import (
+	"flag"
 	"fmt"
 	"fmt"
 	"log"
 	"log"
 	"net/http"
 	"net/http"
+	"os"
 
 
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository/gorm"
 	"github.com/porter-dev/porter/internal/repository/gorm"
@@ -19,7 +21,20 @@ import (
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 )
 
 
+// Version will be linked by an ldflag during build
+var Version string = "dev"
+
 func main() {
 func main() {
+	var versionFlag bool
+	flag.BoolVar(&versionFlag, "version", false, "print version and exit")
+	flag.Parse()
+
+	// Exit safely when version is used
+	if versionFlag {
+		fmt.Println(Version)
+		os.Exit(0)
+	}
+
 	appConf := config.FromEnv()
 	appConf := config.FromEnv()
 
 
 	logger := lr.NewConsole(appConf.Debug)
 	logger := lr.NewConsole(appConf.Debug)

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

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

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

@@ -0,0 +1,102 @@
+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;
+    if (type === 'b64') {
+      type = 'string-input';
+    } else if (type === 'b64-pass') {
+      type = 'password';
+    }
+    if (value === undefined) {
+        value = '';
+    }
+    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 CheckboxRow from './CheckboxRow';
 import InputRow from './InputRow';
 import InputRow from './InputRow';
+import Base64InputRow from './Base64InputRow';
 import SelectRow from './SelectRow';
 import SelectRow from './SelectRow';
 import Helper from './Helper';
 import Helper from './Helper';
 import Heading from './Heading';
 import Heading from './Heading';
@@ -149,6 +150,40 @@ export default class ValuesForm extends Component<PropsType, StateType> {
             <VeleroForm
             <VeleroForm
             />
             />
           );
           );
+        case 'base-64':
+          return (
+            <Base64InputRow
+              key={i}
+              isRequired={item.required}
+              type='b64'
+              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='b64-pass'
+              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:
         default:
       }
       }
     });
     });

+ 0 - 3
dashboard/src/main/Register.tsx

@@ -5,7 +5,6 @@ import logo from '../assets/logo.png';
 import api from '../shared/api';
 import api from '../shared/api';
 import { emailRegex } from '../shared/regex';
 import { emailRegex } from '../shared/regex';
 import { Context } from '../shared/Context';
 import { Context } from '../shared/Context';
-import { handleSubmitFeedback } from '../shared/feedback'; 
 
 
 type PropsType = {
 type PropsType = {
   authenticate: () => void
   authenticate: () => void
@@ -61,8 +60,6 @@ export default class Register extends Component<PropsType, StateType> {
         email: email,
         email: email,
         password: password
         password: password
       }, {}, (err: any, res: any) => {
       }, {}, (err: any, res: any) => {
-        let msg = '📡 ' + email + ' registered for Porter.';
-        handleSubmitFeedback(msg);
         setUser(res?.data?.id, res?.data?.email)
         setUser(res?.data?.id, res?.data?.email)
         err ? setCurrentError(err.response.data.errors[0]) : authenticate();
         err ? setCurrentError(err.response.data.errors[0]) : authenticate();
       });
       });

+ 2 - 8
dashboard/src/main/home/Home.tsx

@@ -5,7 +5,6 @@ import ReactModal from 'react-modal';
 import { Context } from '../../shared/Context';
 import { Context } from '../../shared/Context';
 import api from '../../shared/api';
 import api from '../../shared/api';
 import { InfraType } from '../../shared/types';
 import { InfraType } from '../../shared/types';
-import { handleSubmitFeedback } from '../../shared/feedback';
 
 
 import Sidebar from './sidebar/Sidebar';
 import Sidebar from './sidebar/Sidebar';
 import Dashboard from './dashboard/Dashboard';
 import Dashboard from './dashboard/Dashboard';
@@ -77,9 +76,7 @@ export default class Home extends Component<PropsType, StateType> {
                 }
                 }
               });
               });
               
               
-              console.log('infras viewdata: ', viewData);
               if (viewData.length > 0) {
               if (viewData.length > 0) {
-                console.log('setting to provisioner...');
                 this.setState({ currentView: 'provisioner', viewData, sidebarReady: true, });
                 this.setState({ currentView: 'provisioner', viewData, sidebarReady: true, });
               } else {
               } else {
                 this.setState({ sidebarReady: true });
                 this.setState({ sidebarReady: true });
@@ -94,8 +91,6 @@ export default class Home extends Component<PropsType, StateType> {
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
-    let msg = '👋 ' + this.context.user.email + ' logged in.';
-    handleSubmitFeedback(msg);
     this.getProjects();
     this.getProjects();
   }
   }
 
 
@@ -108,7 +103,6 @@ export default class Home extends Component<PropsType, StateType> {
           prevProjectId: this.context.currentProject.id,
           prevProjectId: this.context.currentProject.id,
           currentView: 'dashboard'
           currentView: 'dashboard'
         });
         });
-        console.log('setting view to dashboard from Home');
       }
       }
     }
     }
   }
   }
@@ -300,8 +294,8 @@ const ProjectModalStyles = {
     width: '565px',
     width: '565px',
     maxWidth: '80vw',
     maxWidth: '80vw',
     margin: '0 auto',
     margin: '0 auto',
-    height: '225px',
-    top: 'calc(50% - 120px)',
+    height: '275px',
+    top: 'calc(50% - 160px)',
     backgroundColor: '#202227',
     backgroundColor: '#202227',
     animation: 'floatInModal 0.5s 0s',
     animation: 'floatInModal 0.5s 0s',
     overflow: 'visible',
     overflow: 'visible',

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

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

+ 10 - 1
dashboard/src/main/home/cluster-dashboard/chart/ChartList.tsx

@@ -11,6 +11,7 @@ import Loading from '../../../../components/Loading';
 type PropsType = {
 type PropsType = {
   currentCluster: ClusterType,
   currentCluster: ClusterType,
   namespace: string,
   namespace: string,
+  sortType: string,
   setCurrentChart: (c: ChartType) => void
   setCurrentChart: (c: ChartType) => void
 };
 };
 
 
@@ -53,6 +54,13 @@ export default class ChartList extends Component<PropsType, StateType> {
         this.setState({ loading: false, error: true });
         this.setState({ loading: false, error: true });
       } else {
       } else {
         let charts = res.data || [];
         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({ charts }, () => {
           this.setState({ loading: false, error: false });
           this.setState({ loading: false, error: false });
         });
         });
@@ -176,7 +184,8 @@ export default class ChartList extends Component<PropsType, StateType> {
   componentDidUpdate(prevProps: PropsType) {
   componentDidUpdate(prevProps: PropsType) {
     // Ret2: Prevents reload when opening ClusterConfigModal
     // Ret2: Prevents reload when opening ClusterConfigModal
     if (prevProps.currentCluster !== this.props.currentCluster || 
     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);
       this.updateCharts(this.getControllers);
     }
     }
   }
   }

+ 25 - 5
dashboard/src/main/home/cluster-dashboard/expanded-chart/ExpandedChart.tsx

@@ -468,7 +468,10 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         return
         return
       }
       }
       console.log(res.data)
       console.log(res.data)
-      this.setState({url: `http://${res.data?.status?.loadBalancer?.ingress[0]?.hostname}` })
+      
+      if (res.data?.status?.loadBalancer?.ingress) {
+        this.setState({url: `http://${res.data?.status?.loadBalancer?.ingress[0]?.hostname}` })
+      }
     })
     })
   }
   }
 
 
@@ -489,7 +492,12 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 
   renderUrl = () => {
   renderUrl = () => {
     if (this.state.url) {
     if (this.state.url) {
-      return <Url href={this.state.url} target='_blank'> <i className="material-icons">link</i> {this.state.url}</Url>;
+      return (
+        <Url href={this.state.url} target='_blank'>
+          <i className="material-icons">link</i>
+          {this.state.url}
+        </Url>
+      );
     } else {
     } else {
       let serviceName = null as string
       let serviceName = null as string
       let serviceNamespace = null as string
       let serviceNamespace = null as string
@@ -501,7 +509,12 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
         }
         }
       })
       })
 
 
-      return <Url><i className="material-icons">link</i>{`${serviceName}.${serviceNamespace}.namespace.svc.cluster.local`}</Url>
+      return (
+        <Url>
+          <Bolded>Internal URI:</Bolded>
+          {`${serviceName}.${serviceNamespace}.namespace.svc.cluster.local`}
+        </Url>
+      );
     }
     }
   }
   }
 
 
@@ -574,11 +587,18 @@ export default class ExpandedChart extends Component<PropsType, StateType> {
 
 
 ExpandedChart.contextType = Context;
 ExpandedChart.contextType = Context;
 
 
+const Bolded = styled.div`
+  font-weight: 500;
+  color: #ffffff44;
+  margin-right: 6px;
+`;
+
 const Url = styled.a`
 const Url = styled.a`
   display: block;
   display: block;
-  margin-left: 1px;
+  margin-left: 2px;
   font-size: 13px;
   font-size: 13px;
-  margin-top: 15px;
+  margin-top: 16px;
+  user-select: all;
   margin-bottom: -5px;
   margin-bottom: -5px;
   user-select: text;
   user-select: text;
   display: flex;
   display: flex;

+ 0 - 1
dashboard/src/main/home/cluster-dashboard/expanded-chart/graph/GraphDisplay.tsx

@@ -136,7 +136,6 @@ export default class GraphDisplay extends Component<PropsType, StateType> {
   // Live update on rollback/upgrade
   // Live update on rollback/upgrade
   componentDidUpdate(prevProps: PropsType) {
   componentDidUpdate(prevProps: PropsType) {
     if (prevProps.components !== this.props.components) {
     if (prevProps.components !== this.props.components) {
-      console.log(this.props.components);
       this.storeChartGraph(prevProps);
       this.storeChartGraph(prevProps);
       this.getChartGraph();
       this.getChartGraph();
     }
     }

+ 5 - 4
dashboard/src/main/home/cluster-dashboard/expanded-chart/status/ControllerTab.tsx

@@ -80,8 +80,8 @@ export default class ControllerTab extends Component<PropsType, StateType> {
   }
   }
 
 
   getPodStatus = (status: any) => {
   getPodStatus = (status: any) => {
-    if (status?.phase == 'Pending') {
-      return status?.containerStatuses[0].state.waiting.reason
+    if (status?.phase == 'Pending' && status?.containerStatuses) {
+      return status.containerStatuses[0].state.waiting.reason
       // return 'waiting'
       // return 'waiting'
     }
     }
 
 
@@ -92,12 +92,11 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     if (status?.phase == 'Running') {
     if (status?.phase == 'Running') {
       let collatedStatus = 'running';
       let collatedStatus = 'running';
 
 
-      status.containerStatuses.forEach((s: any) => {
+      status?.containerStatuses?.forEach((s: any) => {
         if (s.state?.waiting) {
         if (s.state?.waiting) {
           collatedStatus = 'waiting'
           collatedStatus = 'waiting'
         } else if (s.state?.terminated) {
         } else if (s.state?.terminated) {
           collatedStatus = 'failed'
           collatedStatus = 'failed'
-          throw {};
         }
         }
       })
       })
       return collatedStatus;
       return collatedStatus;
@@ -106,8 +105,10 @@ export default class ControllerTab extends Component<PropsType, StateType> {
 
 
   render() {
   render() {
     let { controller, selectedPod, isLast, selectPod } = this.props;
     let { controller, selectedPod, isLast, selectPod } = this.props;
+    console.log(controller)
     let [available, total] = this.getAvailability(controller.kind, controller);
     let [available, total] = this.getAvailability(controller.kind, controller);
     let status = (available == total) ? 'running' : 'waiting'
     let status = (available == total) ? 'running' : 'waiting'
+    console.log('state', this.state)
     return (
     return (
       <ResourceTab
       <ResourceTab
         label={controller.kind}
         label={controller.kind}

+ 48 - 17
dashboard/src/main/home/modals/UpdateClusterModal.tsx

@@ -91,6 +91,16 @@ export default class UpdateClusterModal extends Component<PropsType, StateType>
           />
           />
         </InputWrapper>
         </InputWrapper>
 
 
+        <Warning highlight={true}>
+          ⚠️ Deletion may result in dangling resources. Please visit the AWS console to ensure that all resources have been removed.
+        </Warning>
+        <Help 
+          href='https://docs.getporter.dev/docs/getting-started-with-porter-on-aws#deleting-provisioned-resources'
+          target='_blank'
+        >
+          <i className="material-icons">help_outline</i> Help
+        </Help>
+
         <SaveButton
         <SaveButton
           text='Delete Cluster'
           text='Delete Cluster'
           color='#b91133'
           color='#b91133'
@@ -104,12 +114,6 @@ export default class UpdateClusterModal extends Component<PropsType, StateType>
           onYes={this.handleDelete}
           onYes={this.handleDelete}
           onNo={() => this.setState({ showDeleteOverlay: false })}
           onNo={() => this.setState({ showDeleteOverlay: false })}
         />
         />
-
-        <Warning>
-         ⚠️ Deletion via Porter may result in dangling resources.  
-         Please visit the AWS console to ensure that all resources have been removed.
-        </Warning>
-
       </StyledUpdateProjectModal>
       </StyledUpdateProjectModal>
       );
       );
   }
   }
@@ -117,6 +121,42 @@ export default class UpdateClusterModal extends Component<PropsType, StateType>
 
 
 UpdateClusterModal.contextType = Context;
 UpdateClusterModal.contextType = Context;
 
 
+const Help = styled.a`
+  position: absolute;
+  left: 31px;
+  bottom: 35px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff55;
+  font-size: 13px;
+  :hover {
+    color: #ffffff;
+  }
+
+  > i {
+    margin-right: 9px;
+    font-size: 16px;
+  }
+`;
+
+const Warning = styled.div`
+  font-size: 13px;
+  display: flex;
+  border-radius: 3px;
+  width: calc(100%);
+  margin-top: 10px;
+  margin-left: 2px;
+  line-height: 1.4em;
+  align-items: center;
+  color: white;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+  color: ${(props: { highlight: boolean, makeFlush?: boolean }) => props.highlight ? '#f5cb42' : ''};
+`;
+
 const DashboardIcon = styled.div`
 const DashboardIcon = styled.div`
   width: 25px;
   width: 25px;
   min-width: 25px;
   min-width: 25px;
@@ -198,17 +238,8 @@ const StyledUpdateProjectModal= styled.div`
   left: 0;
   left: 0;
   top: 0;
   top: 0;
   height: 100%;
   height: 100%;
-  padding: 25px 32px;
+  padding: 25px 30px;
   overflow: hidden;
   overflow: hidden;
   border-radius: 6px;
   border-radius: 6px;
   background: #202227;
   background: #202227;
-`;
-
-const Warning = styled.div`
-  width: 65%;
-  margin-top: 3px;
-  font-family: 'Work Sans', sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-  text-align: justify;
-`
+`;

+ 48 - 15
dashboard/src/main/home/modals/UpdateProjectModal.tsx

@@ -115,6 +115,16 @@ export default class UpdateProjectModal extends Component<PropsType, StateType>
           />
           />
         </InputWrapper>
         </InputWrapper>
 
 
+        <Warning highlight={true}>
+          ⚠️ Deletion may result in dangling resources. Please visit the AWS console to ensure that all resources have been removed.
+        </Warning>
+        <Help 
+          href='https://docs.getporter.dev/docs/getting-started-with-porter-on-aws#deleting-provisioned-resources'
+          target='_blank'
+        >
+          <i className="material-icons">help_outline</i> Help
+        </Help>
+
         <SaveButton
         <SaveButton
           text='Delete Project'
           text='Delete Project'
           color='#b91133'
           color='#b91133'
@@ -128,17 +138,49 @@ export default class UpdateProjectModal extends Component<PropsType, StateType>
           onYes={this.handleDelete}
           onYes={this.handleDelete}
           onNo={() => this.setState({ showDeleteOverlay: false })}
           onNo={() => this.setState({ showDeleteOverlay: false })}
         />
         />
-        <Warning>
-         ⚠️ Deletion via Porter may result in dangling resources.  
-         Please visit the AWS console to ensure that all resources have been removed.
-        </Warning>
       </StyledUpdateProjectModal>
       </StyledUpdateProjectModal>
-      );
+    );
   }
   }
 }
 }
 
 
 UpdateProjectModal.contextType = Context;
 UpdateProjectModal.contextType = Context;
 
 
+const Help = styled.a`
+  position: absolute;
+  left: 31px;
+  bottom: 35px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #ffffff55;
+  font-size: 13px;
+  :hover {
+    color: #ffffff;
+  }
+
+  > i {
+    margin-right: 9px;
+    font-size: 16px;
+  }
+`;
+
+const Warning = styled.div`
+  font-size: 13px;
+  display: flex;
+  border-radius: 3px;
+  width: calc(100%);
+  margin-top: 10px;
+  margin-left: 2px;
+  line-height: 1.4em;
+  align-items: center;
+  color: white;
+  > i {
+    margin-right: 10px;
+    font-size: 18px;
+  }
+  color: ${(props: { highlight: boolean, makeFlush?: boolean }) => props.highlight ? '#f5cb42' : ''};
+`;
+
 const Letter = styled.div`
 const Letter = styled.div`
   height: 100%;
   height: 100%;
   width: 100%;
   width: 100%;
@@ -232,13 +274,4 @@ const StyledUpdateProjectModal= styled.div`
   overflow: hidden;
   overflow: hidden;
   border-radius: 6px;
   border-radius: 6px;
   background: #202227;
   background: #202227;
-`;
-
-const Warning = styled.div`
-  width: 65%;
-  margin-top: 3px;
-  font-family: 'Work Sans', sans-serif;
-  font-size: 13px;
-  color: #aaaabb;
-  text-align: justify;
-`
+`;

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

@@ -6,7 +6,6 @@ import close from '../../../assets/close.png';
 import api from '../../../shared/api';
 import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
 import { Context } from '../../../shared/Context';
 import { integrationList } from '../../../shared/common';
 import { integrationList } from '../../../shared/common';
-import { handleSubmitFeedback } from '../../../shared/feedback';
 import { ProjectType } from '../../../shared/types';
 import { ProjectType } from '../../../shared/types';
 
 
 import InputRow from '../../../components/values-form/InputRow';
 import InputRow from '../../../components/values-form/InputRow';
@@ -27,6 +26,9 @@ type StateType = {
   awsRegion: string | null,
   awsRegion: string | null,
   awsAccessId: string | null,
   awsAccessId: string | null,
   awsSecretKey: string | null,
   awsSecretKey: string | null,
+  gcpRegion: string | null,
+  gcpProjectId: string | null,
+  gcpKeyData: string | null,
   status: string | null,
   status: string | null,
 };
 };
 
 
@@ -38,6 +40,9 @@ export default class NewProject extends Component<PropsType, StateType> {
     awsRegion: '' as string | null,
     awsRegion: '' as string | null,
     awsAccessId: '' as string | null,
     awsAccessId: '' as string | null,
     awsSecretKey: '' as string | null,
     awsSecretKey: '' as string | null,
+    gcpRegion: '' as string | null,
+    gcpProjectId: '' as string | null,
+    gcpKeyData: '' as string | null,
     status: null as string | null,
     status: null as string | null,
   }
   }
 
 
@@ -50,16 +55,14 @@ export default class NewProject extends Component<PropsType, StateType> {
   }
   }
 
 
   handleSelectProvider = (provider: string) => {
   handleSelectProvider = (provider: string) => {
-    let msg = '🤔 ' + this.context.user.email + ' selected ' + provider + '.';
-    handleSubmitFeedback(msg);
     this.setState({ selectedProvider: provider });
     this.setState({ selectedProvider: provider });
   }
   }
 
 
-  renderTemplateList = () => {
+  renderProviderList = () => {
     return providers.map((provider: string, i: number) => {
     return providers.map((provider: string, i: number) => {
       let providerInfo = integrationList[provider];
       let providerInfo = integrationList[provider];
       return (
       return (
-        <Block 
+        <Block
           key={i} 
           key={i} 
           onClick={() => this.handleSelectProvider(provider)}
           onClick={() => this.handleSelectProvider(provider)}
         >
         >
@@ -130,9 +133,41 @@ export default class NewProject extends Component<PropsType, StateType> {
           }}>
           }}>
             <CloseButtonImg src={close} />
             <CloseButtonImg src={close} />
           </CloseButton>
           </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>
         </FormSection>
       );
       );
     } else if (this.state.selectedProvider === 'do') {
     } else if (this.state.selectedProvider === 'do') {
@@ -152,7 +187,7 @@ export default class NewProject extends Component<PropsType, StateType> {
 
 
     return (
     return (
       <BlockList>
       <BlockList>
-        {this.renderTemplateList()}
+        {this.renderProviderList()}
       </BlockList>
       </BlockList>
     );
     );
   }
   }
@@ -198,12 +233,23 @@ export default class NewProject extends Component<PropsType, StateType> {
   }
   }
 
 
   validateForm = () => {
   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 === '') {
     if (!this.isAlphanumeric(projectName) || projectName === '') {
       return false;
       return false;
     } else if (selectedProvider === 'aws') {
     } else if (selectedProvider === 'aws') {
       return awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '';
       return awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '';
-    }  else if (selectedProvider === 'skipped') {
+    } else if (selectedProvider === 'gcp') {
+      return gcpRegion !== '' && gcpKeyData !== '' && gcpProjectId !== '';
+    } else if (selectedProvider === 'skipped') {
       return true;
       return true;
     }
     }
     return false;
     return false;
@@ -268,16 +314,66 @@ export default class NewProject extends Component<PropsType, StateType> {
         }
         }
 
 
         this.props.setCurrentView('provisioner', [
         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 },
         ]);
         ]);
       })
       })
     })
     })
   }
   }
 
 
-  createProject = () => {
+  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' });
     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>', {
     api.createProject('<token>', {
       name: this.state.projectName
       name: this.state.projectName
     }, {}, (err: any, res: any) => {
     }, {}, (err: any, res: any) => {
@@ -295,7 +391,9 @@ export default class NewProject extends Component<PropsType, StateType> {
               this.context.setCurrentProject(proj);
               this.context.setCurrentProject(proj);
               
               
               if (this.state.selectedProvider === 'aws') {
               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 {
               } else {
                 this.props.setCurrentView('dashboard', null);
                 this.props.setCurrentView('dashboard', null);
               }
               }
@@ -317,10 +415,6 @@ export default class NewProject extends Component<PropsType, StateType> {
         if (res.data.length > 0) {
         if (res.data.length > 0) {
           let proj = res.data.find((el: ProjectType) => el.name === this.state.projectName);
           let proj = res.data.find((el: ProjectType) => el.name === this.state.projectName);
           this.context.setCurrentProject(proj);
           this.context.setCurrentProject(proj);
-          
-          let msg = '🏗️ ' + this.context.user.email + ' began provisioning.';
-          handleSubmitFeedback(msg);
-
           if (this.state.selectedProvider === 'aws') {
           if (this.state.selectedProvider === 'aws') {
             this.provisionECR(proj, this.provisionEKS)
             this.provisionECR(proj, this.provisionEKS)
 
 
@@ -414,8 +508,9 @@ export default class NewProject extends Component<PropsType, StateType> {
   }
   }
   
   
   render() {
   render() {
+    let { selectedProvider } = this.state;
     return (
     return (
-      <StyledNewProject height={this.state.selectedProvider === 'aws' ? '700px' : '600px'}>
+      <StyledNewProject height={selectedProvider === 'aws' || selectedProvider === 'gcp' ? '700px' : '600px'}>
         {this.renderHeaderSection()}
         {this.renderHeaderSection()}
         {this.renderHostingSection()}
         {this.renderHostingSection()}
         {this.renderButton()}
         {this.renderButton()}

+ 0 - 6
dashboard/src/main/home/new-project/Provisioner.tsx

@@ -4,7 +4,6 @@ import styled from 'styled-components';
 import api from '../../../shared/api';
 import api from '../../../shared/api';
 import { Context } from '../../../shared/Context';
 import { Context } from '../../../shared/Context';
 import ansiparse from '../../../shared/ansiparser'
 import ansiparse from '../../../shared/ansiparser'
-import { handleSubmitFeedback } from '../../../shared/feedback';
 import loading from '../../../assets/loading.gif';
 import loading from '../../../assets/loading.gif';
 import warning from '../../../assets/warning.png';
 import warning from '../../../assets/warning.png';
 
 
@@ -181,7 +180,6 @@ export default class Provisioner extends Component<PropsType, StateType> {
         <TitleSection>
         <TitleSection>
           <Title><img src={loading} /> Setting Up Porter</Title>
           <Title><img src={loading} /> Setting Up Porter</Title>
         </TitleSection>
         </TitleSection>
-
         <Helper>
         <Helper>
           Porter is currently being provisioned to your AWS account:
           Porter is currently being provisioned to your AWS account:
         </Helper>
         </Helper>
@@ -190,16 +188,12 @@ export default class Provisioner extends Component<PropsType, StateType> {
   }
   }
 
 
   onEnd = () => {
   onEnd = () => {
-    let msg = '🛠️ ' + this.context.user.email + ' completed provisioning.';
-    handleSubmitFeedback(msg);
     let myInterval = setInterval(() => {
     let myInterval = setInterval(() => {
-      console.log('interval')
       api.getClusters('<token>', {}, { id: this.context.currentProject.id }, (err: any, res: any) => {
       api.getClusters('<token>', {}, { id: this.context.currentProject.id }, (err: any, res: any) => {
         if (err) {
         if (err) {
           console.log(err);
           console.log(err);
         } else if (res.data) {
         } else if (res.data) {
           let clusters = res.data;
           let clusters = res.data;
-          console.log('found clusters:', res.data);
           if (clusters.length > 0) {
           if (clusters.length > 0) {
             this.props.setCurrentView('dashboard');
             this.props.setCurrentView('dashboard');
             clearInterval(myInterval);
             clearInterval(myInterval);

+ 0 - 4
dashboard/src/main/home/sidebar/ClusterSection.tsx

@@ -53,14 +53,12 @@ export default class ClusterSection extends Component<PropsType, StateType> {
         // TODO: handle uninitialized kubeconfig
         // TODO: handle uninitialized kubeconfig
         if (res.data) {
         if (res.data) {
           let clusters = res.data;
           let clusters = res.data;
-          console.log('found clusters:', res.data);
           if (clusters.length > 0) {
           if (clusters.length > 0) {
             this.setState({ clusters });
             this.setState({ clusters });
             setCurrentCluster(clusters[0]);
             setCurrentCluster(clusters[0]);
           } else if (this.props.currentView !== 'provisioner') {
           } else if (this.props.currentView !== 'provisioner') {
             this.setState({ clusters: [] });
             this.setState({ clusters: [] });
             setCurrentCluster(null);
             setCurrentCluster(null);
-            console.log('set to dashboard from clustersection');
             this.props.setCurrentView('dashboard');
             this.props.setCurrentView('dashboard');
           }
           }
         }
         }
@@ -70,7 +68,6 @@ export default class ClusterSection extends Component<PropsType, StateType> {
 
 
   componentDidMount() {
   componentDidMount() {
     this.updateClusters();
     this.updateClusters();
-    console.log('mounted clustersection');
   }
   }
 
 
   // Need to override showDrawer when the sidebar is closed
   // Need to override showDrawer when the sidebar is closed
@@ -84,7 +81,6 @@ export default class ClusterSection extends Component<PropsType, StateType> {
       } else if (this.props.forceRefreshClusters === true) {
       } else if (this.props.forceRefreshClusters === true) {
         this.updateClusters();
         this.updateClusters();
         this.props.setRefreshClusters(false);
         this.props.setRefreshClusters(false);
-        console.log('hard refereshed clusters');
       }
       }
 
 
       if (this.props.forceCloseDrawer && this.state.showDrawer) {
       if (this.props.forceCloseDrawer && this.state.showDrawer) {

+ 0 - 1
dashboard/src/main/home/sidebar/ProjectSection.tsx

@@ -42,7 +42,6 @@ export default class ProjectSection extends Component<PropsType, StateType> {
         if (viewData.length > 0) {
         if (viewData.length > 0) {
           this.props.setCurrentView('provisioner', viewData);
           this.props.setCurrentView('provisioner', viewData);
         } else {
         } else {
-          console.log('set to dashboard from projectsection');
           this.props.setCurrentView('dashboard');
           this.props.setCurrentView('dashboard');
         }
         }
       }
       }

+ 18 - 13
dashboard/src/main/home/templates/Templates.tsx

@@ -13,6 +13,12 @@ const tabOptions = [
   { label: 'Community Templates', value: 'community' }
   { label: 'Community Templates', value: 'community' }
 ];
 ];
 
 
+// TODO: read in from metadata
+const hardcodedNames: any = {
+  'postgresql': 'PostgreSQL',
+  'docker': 'Docker',
+};
+
 type PropsType = {
 type PropsType = {
   setCurrentView: (x: string) => void, // Link to add integration from source selector
   setCurrentView: (x: string) => void, // Link to add integration from source selector
 };
 };
@@ -20,7 +26,7 @@ type PropsType = {
 type StateType = {
 type StateType = {
   currentTemplate: PorterTemplate | null,
   currentTemplate: PorterTemplate | null,
   currentTab: string,
   currentTab: string,
-  PorterTemplates: PorterTemplate[],
+  porterTemplates: PorterTemplate[],
   loading: boolean,
   loading: boolean,
   error: boolean
   error: boolean
 };
 };
@@ -29,7 +35,7 @@ export default class Templates extends Component<PropsType, StateType> {
   state = {
   state = {
     currentTemplate: null as (PorterTemplate | null),
     currentTemplate: null as (PorterTemplate | null),
     currentTab: 'community',
     currentTab: 'community',
-    PorterTemplates: [] as PorterTemplate[],
+    porterTemplates: [] as PorterTemplate[],
     loading: true,
     loading: true,
     error: false,
     error: false,
   }
   }
@@ -39,7 +45,7 @@ export default class Templates extends Component<PropsType, StateType> {
       if (err) {
       if (err) {
         this.setState({ loading: false, error: true });
         this.setState({ loading: false, error: true });
       } else {
       } else {
-        this.setState({ PorterTemplates: res.data, loading: false, error: false });
+        this.setState({ porterTemplates: res.data, loading: false, error: false });
       }
       }
     });
     });
   }
   }
@@ -55,7 +61,7 @@ export default class Templates extends Component<PropsType, StateType> {
   }
   }
 
 
   renderTemplateList = () => {
   renderTemplateList = () => {
-    let { loading, error, PorterTemplates } = this.state;
+    let { loading, error, porterTemplates } = this.state;
 
 
     if (loading) {
     if (loading) {
       return <LoadingWrapper><Loading /></LoadingWrapper>
       return <LoadingWrapper><Loading /></LoadingWrapper>
@@ -65,7 +71,7 @@ export default class Templates extends Component<PropsType, StateType> {
           <i className="material-icons">error</i> Error retrieving templates.
           <i className="material-icons">error</i> Error retrieving templates.
         </Placeholder>
         </Placeholder>
       );
       );
-    } else if (PorterTemplates.length === 0) {
+    } else if (porterTemplates.length === 0) {
       return (
       return (
         <Placeholder>
         <Placeholder>
           <i className="material-icons">category</i> No templates found.
           <i className="material-icons">category</i> No templates found.
@@ -73,17 +79,16 @@ export default class Templates extends Component<PropsType, StateType> {
       );
       );
     }
     }
 
 
-    return this.state.PorterTemplates.map((template: PorterTemplate, i: number) => {
+    return this.state.porterTemplates.map((template: PorterTemplate, i: number) => {
       let { name, icon, description } = template;
       let { name, icon, description } = template;
+      if (hardcodedNames[name]) {
+        name = hardcodedNames[name];
+      }
       return (
       return (
         <TemplateBlock key={i} onClick={() => this.setState({ currentTemplate: template })}>
         <TemplateBlock key={i} onClick={() => this.setState({ currentTemplate: template })}>
-          {icon ? this.renderIcon(icon) : this.renderIcon(template.icon)}
-          <TemplateTitle>
-            {name ? name : template.name}
-          </TemplateTitle>
-          <TemplateDescription>
-            {description ? description : template.description}
-          </TemplateDescription>
+          {this.renderIcon(icon)}
+          <TemplateTitle>{name}</TemplateTitle>
+          <TemplateDescription>{description}</TemplateDescription>
         </TemplateBlock>
         </TemplateBlock>
       )
       )
     });
     });

+ 72 - 44
dashboard/src/main/home/templates/expanded-template/LaunchTemplate.tsx

@@ -51,9 +51,10 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     namespaceOptions: [] as { label: string, value: string }[],
     namespaceOptions: [] as { label: string, value: string }[],
   };
   };
 
 
-  onSubmitAddon = () => {
+  onSubmitAddon = (wildcard?: any) => {
     let { currentCluster, currentProject } = this.context;
     let { currentCluster, currentProject } = this.context;
     let name = randomWords({ exactly: 3, join: '-' });
     let name = randomWords({ exactly: 3, join: '-' });
+    this.setState({ saveValuesStatus: 'loading' });
     api.deployTemplate('<token>', {
     api.deployTemplate('<token>', {
       templateName: this.props.currentTemplate.name,
       templateName: this.props.currentTemplate.name,
       storage: StorageType.Secret,
       storage: StorageType.Secret,
@@ -121,28 +122,26 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     return (
     return (
       <ValuesWrapper
       <ValuesWrapper
         formTabs={this.props.form?.tabs}
         formTabs={this.props.form?.tabs}
-        onSubmit={this.onSubmit}
+        onSubmit={this.props.currentTemplate.name === 'docker' ? this.onSubmit : this.onSubmitAddon}
         saveValuesStatus={this.state.saveValuesStatus}
         saveValuesStatus={this.state.saveValuesStatus}
-        disabled={!this.state.selectedImageUrl}
+        disabled={this.props.form?.hasSource ? !this.state.selectedImageUrl : false}
       >
       >
-        {
-          (metaState: any, setMetaState: any) => {
-            return this.props.form?.tabs.map((tab: any, i: number) => {
-
-              // If tab is current, render
-              if (tab.name === this.state.currentTab) {
-                return (
-                  <ValuesForm 
-                    metaState={metaState}
-                    setMetaState={setMetaState}
-                    key={tab.name}
-                    sections={tab.sections} 
-                  />
-                );
-              }
-            });
-          }
-        }
+        {(metaState: any, setMetaState: any) => {
+          return this.props.form?.tabs.map((tab: any, i: number) => {
+
+            // If tab is current, render
+            if (tab.name === this.state.currentTab) {
+              return (
+                <ValuesForm 
+                  metaState={metaState}
+                  setMetaState={setMetaState}
+                  key={tab.name}
+                  sections={tab.sections} 
+                />
+              );
+            }
+          });
+        }}
       </ValuesWrapper>
       </ValuesWrapper>
     );
     );
   }
   }
@@ -212,19 +211,28 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
   renderTabRegion = () => {
   renderTabRegion = () => {
     if (this.state.tabOptions.length > 0) {
     if (this.state.tabOptions.length > 0) {
       return (
       return (
-        <TabRegion
-          options={this.state.tabOptions}
-          currentTab={this.state.currentTab}
-          setCurrentTab={(x: string) => this.setState({ currentTab: x })}
-        >
-          {this.renderTabContents()}
-        </TabRegion>
+        <>
+          <Subtitle>Configure additional settings for this template (optional).</Subtitle>
+          <TabRegion
+            options={this.state.tabOptions}
+            currentTab={this.state.currentTab}
+            setCurrentTab={(x: string) => this.setState({ currentTab: x })}
+          >
+            {this.renderTabContents()}
+          </TabRegion>
+        </>
       );
       );
     } else {
     } else {
       return (
       return (
         <Wrapper>
         <Wrapper>
           <Placeholder>
           <Placeholder>
-            No additional settings found.
+            To configure this chart through Porter, 
+            <Link 
+              target='_blank'
+              href='https://docs.getporter.dev/docs/porter-templates'
+            >
+              refer to our docs
+            </Link>.
           </Placeholder>
           </Placeholder>
           <SaveButton
           <SaveButton
             text='Deploy'
             text='Deploy'
@@ -237,6 +245,27 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     }
     }
   }
   }
 
 
+  // Display if current template uses source (image or repo)
+  renderSourceSelector = () => {
+    if (this.props.form?.hasSource) {
+      return (
+        <>
+          <Subtitle>Select the container image you would like to connect to this template.</Subtitle>
+          <DarkMatter />
+          <ImageSelector
+            selectedTag={this.state.selectedTag}
+            selectedImageUrl={this.state.selectedImageUrl}
+            setSelectedImageUrl={this.setSelectedImageUrl}
+            setSelectedTag={(x: string) => this.setState({ selectedTag: x })}
+            forceExpanded={true}
+            setCurrentView={this.props.setCurrentView}
+          />
+          <br />
+        </>
+      );
+    }
+  }
+
   render() {
   render() {
     let { name, icon } = this.props.currentTemplate;
     let { name, icon } = this.props.currentTemplate;
     let { currentTemplate } = this.props;
     let { currentTemplate } = this.props;
@@ -281,20 +310,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
             closeOverlay={true}
             closeOverlay={true}
           />
           />
         </ClusterSection>
         </ClusterSection>
-
-        <Subtitle>Select the container image you would like to connect to this template.</Subtitle>
-        <DarkMatter />
-        <ImageSelector
-          selectedTag={this.state.selectedTag}
-          selectedImageUrl={this.state.selectedImageUrl}
-          setSelectedImageUrl={this.setSelectedImageUrl}
-          setSelectedTag={(x: string) => this.setState({ selectedTag: x })}
-          forceExpanded={true}
-          setCurrentView={this.props.setCurrentView}
-        />
-
-        <br />
-        <Subtitle>Configure additional settings for this template (optional).</Subtitle>
+        {this.renderSourceSelector()}
         {this.renderTabRegion()}
         {this.renderTabRegion()}
       </StyledLaunchTemplate>
       </StyledLaunchTemplate>
     );
     );
@@ -303,9 +319,21 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
 
 LaunchTemplate.contextType = Context;
 LaunchTemplate.contextType = Context;
 
 
+const Link = styled.a`
+  margin-left: 5px;
+`;
+
+const LineBreak = styled.div`
+  width: calc(100% - 0px);
+  height: 2px;
+  background: #ffffff20;
+  margin: 35px 0px 35px;
+`;
+
 const Wrapper = styled.div`
 const Wrapper = styled.div`
   width: 100%;
   width: 100%;
   position: relative;
   position: relative;
+  padding-top: 20px;
   padding-bottom: 70px;
   padding-bottom: 70px;
 `;
 `;
 
 
@@ -315,7 +343,7 @@ const Placeholder = styled.div`
   background: #ffffff11;
   background: #ffffff11;
   border: 1px solid #ffffff44;
   border: 1px solid #ffffff44;
   border-radius: 5px;
   border-radius: 5px;
-  color: #ffffff44;
+  color: #aaaabb;
   font-size: 13px;
   font-size: 13px;
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;

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

@@ -4,11 +4,16 @@ import rocket from '../../../../assets/rocket.png';
 import Markdown from 'markdown-to-jsx';
 import Markdown from 'markdown-to-jsx';
 
 
 import { Context } from '../../../../shared/Context';
 import { Context } from '../../../../shared/Context';
-import api from '../../../../shared/api';
 import Loading from '../../../../components/Loading';
 import Loading from '../../../../components/Loading';
 
 
 import { PorterTemplate } from '../../../../shared/types';
 import { PorterTemplate } from '../../../../shared/types';
-import { timeStamp } from 'console';
+import Helper from '../../../../components/values-form/Helper';
+
+// TODO: read in from metadata
+const hardcodedNames: any = {
+  'postgresql': 'PostgreSQL',
+  'docker': 'Docker',
+};
 
 
 type PropsType = {
 type PropsType = {
   currentTemplate: any,
   currentTemplate: any,
@@ -52,8 +57,11 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
     return currentTemplate.description;
     return currentTemplate.description;
   }
   }
 
 
+
   renderTagSection = () => {
   renderTagSection = () => {
-    if (this.props.keywords && this.props.keywords.length > 0) {
+
+    // Rendering doesn't make sense until search + clicking on tags is supported
+    if (false && this.props.keywords && this.props.keywords.length > 0) {
       return (
       return (
         <TagSection>
         <TagSection>
           <i className="material-icons">local_offer</i>
           <i className="material-icons">local_offer</i>
@@ -96,8 +104,13 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
 
 
   render() {
   render() {
     let { currentCluster } = this.context;
     let { currentCluster } = this.context;
-    let { name, icon } = this.props.currentTemplate;
+    let { name, icon, description } = this.props.currentTemplate;
     let { currentTemplate } = this.props;
     let { currentTemplate } = this.props;
+
+    if (hardcodedNames[name]) {
+      name = hardcodedNames[name];
+    }
+
     return (
     return (
       <StyledExpandedTemplate>
       <StyledExpandedTemplate>
         <TitleSection>
         <TitleSection>
@@ -116,6 +129,7 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
             Launch Template
             Launch Template
           </Button>
           </Button>
         </TitleSection>
         </TitleSection>
+        <Helper>{description}</Helper>
         {this.renderTagSection()}
         {this.renderTagSection()}
         <LineBreak />
         <LineBreak />
         {this.renderBanner()}
         {this.renderBanner()}
@@ -182,7 +196,7 @@ const Tag = styled.div`
 `;
 `;
 
 
 const TagSection = styled.div`
 const TagSection = styled.div`
-  margin-top: 20px;
+  margin-top: 25px;
   display: flex;
   display: flex;
   font-size: 13px;
   font-size: 13px;
   font-family: 'Work Sans', sans-serif;
   font-family: 'Work Sans', sans-serif;

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

@@ -291,10 +291,40 @@ const deleteCluster = baseApi<{
   cluster_id: number,
   cluster_id: number,
 }>('DELETE', pathParams => {
 }>('DELETE', pathParams => {
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
   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)
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
 export default {
+  createGCR,
+  createGKE,
+  createGCPIntegration,
   deleteCluster,
   deleteCluster,
   destroyCluster,
   destroyCluster,
   getInfra,
   getInfra,

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

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

+ 3 - 0
go.sum

@@ -188,6 +188,7 @@ github.com/containerd/go-runc v0.0.0-20180907222934-5a6d9f37cfa3/go.mod h1:IV7qH
 github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=
 github.com/containerd/ttrpc v0.0.0-20190828154514-0e0f228740de/go.mod h1:PvCDdDGpgqzQIzDW1TphrGLssLDZp2GuS+X5DkEJB8o=
 github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc=
 github.com/containerd/typeurl v0.0.0-20180627222232-a93fcdb778cd/go.mod h1:Cm3kwCdlkCfMSHURc+r6fwoGH6/F1hH3S4sg0rLFWPc=
 github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
 github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
+github.com/coreos/etcd v3.3.10+incompatible h1:jFneRYjIvLMLhDLCzuTuU4rSJUjRplcJQ7pD7MnhC04=
 github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
 github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
 github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
 github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
 github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
 github.com/coreos/go-oidc v2.1.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
@@ -195,9 +196,11 @@ github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
 github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
 github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
 github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd v0.0.0-20180511133405-39ca1b05acc7/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
+github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c=
 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
 github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/coreos/pkg v0.0.0-20180108230652-97fdf19511ea/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
+github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f h1:lBNOc5arjvs8E5mO2tbpBpLoyyu8B6e44T7hJy6potg=
 github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/coreos/rkt v1.30.0 h1:Kkt6sYeEGKxA3Y7SCrY+nHoXkWed6Jr2BBY42GqMymM=
 github.com/coreos/rkt v1.30.0 h1:Kkt6sYeEGKxA3Y7SCrY+nHoXkWed6Jr2BBY42GqMymM=
 github.com/coreos/rkt v1.30.0/go.mod h1:O634mlH6U7qk87poQifK6M2rsFNt+FyUTWNMnP1hF1U=
 github.com/coreos/rkt v1.30.0/go.mod h1:O634mlH6U7qk87poQifK6M2rsFNt+FyUTWNMnP1hF1U=

+ 11 - 5
internal/auth/sessionstore.go

@@ -3,7 +3,6 @@
 package sessionstore
 package sessionstore
 
 
 import (
 import (
-	"database/sql"
 	"encoding/base32"
 	"encoding/base32"
 	"net/http"
 	"net/http"
 	"strings"
 	"strings"
@@ -13,10 +12,11 @@ import (
 
 
 	"github.com/gorilla/securecookie"
 	"github.com/gorilla/securecookie"
 	"github.com/gorilla/sessions"
 	"github.com/gorilla/sessions"
-	"github.com/pkg/errors"
 
 
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository"
+
+	"gorm.io/gorm"
 )
 )
 
 
 // structs
 // structs
@@ -158,10 +158,16 @@ func (store *PGStore) New(r *http.Request, name string) (*sessions.Session, erro
 		err = securecookie.DecodeMulti(name, c.Value, &session.ID, store.Codecs...)
 		err = securecookie.DecodeMulti(name, c.Value, &session.ID, store.Codecs...)
 		if err == nil {
 		if err == nil {
 			err = store.load(session)
 			err = store.load(session)
-			if err == nil {
+
+			if err != nil {
+				if err == gorm.ErrRecordNotFound {
+					err = nil
+				} else if strings.Contains(err.Error(), "expired timestamp") {
+					err = nil
+					session.IsNew = false
+				}
+			} else {
 				session.IsNew = false
 				session.IsNew = false
-			} else if errors.Cause(err) == sql.ErrNoRows {
-				err = nil
 			}
 			}
 		}
 		}
 	}
 	}

+ 1 - 1
internal/config/config.go

@@ -29,7 +29,7 @@ type ServerConf struct {
 	IsLocal        bool          `env:"IS_LOCAL,default=false"`
 	IsLocal        bool          `env:"IS_LOCAL,default=false"`
 	IsTesting      bool          `env:"IS_TESTING,default=false"`
 	IsTesting      bool          `env:"IS_TESTING,default=false"`
 
 
-	DefaultHelmRepoURL string `env:"HELM_REPO_URL,default=https://porter-dev.github.io/chart-repo/"`
+	DefaultHelmRepoURL string `env:"HELM_REPO_URL,default=https://s2011r2593.github.io/test-porter-chart-repo/"`
 
 
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientID     string `env:"GITHUB_CLIENT_ID"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`
 	GithubClientSecret string `env:"GITHUB_CLIENT_SECRET"`

+ 1 - 0
internal/forms/cluster.go

@@ -60,6 +60,7 @@ func (ccf *CreateClusterForm) ToCluster() (*models.Cluster, error) {
 	}
 	}
 
 
 	return &models.Cluster{
 	return &models.Cluster{
+		ProjectID:                ccf.ProjectID,
 		AuthMechanism:            authMechanism,
 		AuthMechanism:            authMechanism,
 		Name:                     ccf.Name,
 		Name:                     ccf.Name,
 		Server:                   ccf.Server,
 		Server:                   ccf.Server,

+ 2 - 0
internal/forms/integration.go

@@ -11,6 +11,7 @@ type CreateGCPIntegrationForm struct {
 	ProjectID    uint   `json:"project_id" form:"required"`
 	ProjectID    uint   `json:"project_id" form:"required"`
 	GCPKeyData   string `json:"gcp_key_data" form:"required"`
 	GCPKeyData   string `json:"gcp_key_data" form:"required"`
 	GCPProjectID string `json:"gcp_project_id"`
 	GCPProjectID string `json:"gcp_project_id"`
+	GCPRegion    string `json:"gcp_region"`
 }
 }
 
 
 // ToGCPIntegration converts the project to a gorm project model
 // ToGCPIntegration converts the project to a gorm project model
@@ -20,6 +21,7 @@ func (cgf *CreateGCPIntegrationForm) ToGCPIntegration() (*ints.GCPIntegration, e
 		ProjectID:    cgf.ProjectID,
 		ProjectID:    cgf.ProjectID,
 		GCPKeyData:   []byte(cgf.GCPKeyData),
 		GCPKeyData:   []byte(cgf.GCPKeyData),
 		GCPProjectID: cgf.GCPProjectID,
 		GCPProjectID: cgf.GCPProjectID,
+		GCPRegion:    cgf.GCPRegion,
 	}, nil
 	}, nil
 }
 }
 
 

+ 30 - 0
internal/kubernetes/provisioner/gcp/gcp.go

@@ -0,0 +1,30 @@
+package gcp
+
+import (
+	v1 "k8s.io/api/core/v1"
+)
+
+// Conf wraps the GCP integration model
+type Conf struct {
+	GCPRegion, GCPProjectID, GCPKeyData string
+}
+
+// AttachGCPEnv adds the relevant AWS env for the provisioner
+func (conf *Conf) AttachGCPEnv(env []v1.EnvVar) []v1.EnvVar {
+	env = append(env, v1.EnvVar{
+		Name:  "GCP_REGION",
+		Value: conf.GCPRegion,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "GCP_CREDENTIALS",
+		Value: conf.GCPKeyData,
+	})
+
+	env = append(env, v1.EnvVar{
+		Name:  "GCP_PROJECT_ID",
+		Value: conf.GCPProjectID,
+	})
+
+	return env
+}

+ 14 - 1
internal/kubernetes/provisioner/provisioner.go

@@ -11,6 +11,8 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes/provisioner/aws/ecr"
 	"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/aws/eks"
 
 
+	"github.com/porter-dev/porter/internal/kubernetes/provisioner/gcp"
+
 	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/config"
 )
 )
 
 
@@ -22,6 +24,7 @@ const (
 	Test InfraOption = "test"
 	Test InfraOption = "test"
 	ECR  InfraOption = "ecr"
 	ECR  InfraOption = "ecr"
 	EKS  InfraOption = "eks"
 	EKS  InfraOption = "eks"
+	GCR  InfraOption = "gcr"
 )
 )
 
 
 // Conf is the config required to start a provisioner container
 // Conf is the config required to start a provisioner container
@@ -35,9 +38,14 @@ type Conf struct {
 	Operation ProvisionerOperation
 	Operation ProvisionerOperation
 
 
 	// provider-specific configurations
 	// provider-specific configurations
+
+	// AWS
 	AWS *aws.Conf
 	AWS *aws.Conf
 	ECR *ecr.Conf
 	ECR *ecr.Conf
 	EKS *eks.Conf
 	EKS *eks.Conf
+
+	// GKE
+	GCP *gcp.Conf
 }
 }
 
 
 type ProvisionerOperation string
 type ProvisionerOperation string
@@ -61,8 +69,13 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 	env = conf.attachDefaultEnv(env)
 	env = conf.attachDefaultEnv(env)
 
 
 	ttl := int32(3600)
 	ttl := int32(3600)
+
 	backoffLimit := int32(5)
 	backoffLimit := int32(5)
 
 
+	if operation == string(Apply) {
+		backoffLimit = int32(1)
+	}
+
 	labels := map[string]string{
 	labels := map[string]string{
 		"app": "provisioner",
 		"app": "provisioner",
 	}
 	}
@@ -95,7 +108,7 @@ func (conf *Conf) GetProvisionerJobTemplate() (*batchv1.Job, error) {
 					Labels: labels,
 					Labels: labels,
 				},
 				},
 				Spec: v1.PodSpec{
 				Spec: v1.PodSpec{
-					RestartPolicy: v1.RestartPolicyOnFailure,
+					RestartPolicy: v1.RestartPolicyNever,
 					Containers: []v1.Container{
 					Containers: []v1.Container{
 						{
 						{
 							Name:  "provisioner",
 							Name:  "provisioner",

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

@@ -19,11 +19,14 @@ type GCPIntegration struct {
 	ProjectID uint `json:"project_id"`
 	ProjectID uint `json:"project_id"`
 
 
 	// The GCP project id where the service account for this auth mechanism persists
 	// The GCP project id where the service account for this auth mechanism persists
-	GCPProjectID string `json:"gcp-project-id"`
+	GCPProjectID string `json:"gcp_project_id"`
 
 
 	// The GCP user email that linked this service account
 	// The GCP user email that linked this service account
 	GCPUserEmail string `json:"gcp-user-email"`
 	GCPUserEmail string `json:"gcp-user-email"`
 
 
+	// The GCP region, which may or may not be used by the integration
+	GCPRegion string `json:"gcp_region"`
+
 	// ------------------------------------------------------------------
 	// ------------------------------------------------------------------
 	// All fields encrypted before storage.
 	// All fields encrypted before storage.
 	// ------------------------------------------------------------------
 	// ------------------------------------------------------------------

+ 1 - 0
internal/models/templates.go

@@ -59,6 +59,7 @@ type FormContent struct {
 type FormYAML struct {
 type FormYAML struct {
 	Name        string     `yaml:"name" json:"name"`
 	Name        string     `yaml:"name" json:"name"`
 	Icon        string     `yaml:"icon" json:"icon"`
 	Icon        string     `yaml:"icon" json:"icon"`
+	HasSource   string     `yaml:"hasSource" json:"hasSource"`
 	Description string     `yaml:"description" json:"description"`
 	Description string     `yaml:"description" json:"description"`
 	Tags        []string   `yaml:"tags" json:"tags"`
 	Tags        []string   `yaml:"tags" json:"tags"`
 	Tabs        []*FormTab `yaml:"tabs" json:"tabs,omitempty"`
 	Tabs        []*FormTab `yaml:"tabs" json:"tabs,omitempty"`

+ 4 - 19
internal/registry/registry.go

@@ -103,14 +103,8 @@ func (r *Registry) listGCRRepositories(
 		return nil, err
 		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{}
 	client := &http.Client{}
 
 
 	req, err := http.NewRequest(
 	req, err := http.NewRequest(
@@ -123,9 +117,7 @@ func (r *Registry) listGCRRepositories(
 		return nil, err
 		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)
 	resp, err := client.Do(req)
 
 
@@ -279,13 +271,6 @@ func (r *Registry) listGCRImages(repoName string, repo repository.Repository) ([
 		return nil, err
 		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
 	// use JWT token to request catalog
 	client := &http.Client{}
 	client := &http.Client{}
 
 
@@ -299,7 +284,7 @@ func (r *Registry) listGCRImages(repoName string, repo repository.Repository) ([
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	req.SetBasicAuth("oauth2accesstoken", oauthTok)
+	req.SetBasicAuth("_json_key", string(gcp.GCPKeyData))
 
 
 	resp, err := client.Do(req)
 	resp, err := client.Do(req)
 
 

+ 13 - 0
scripts/build/osx.sh

@@ -0,0 +1,13 @@
+#!/bin/bash
+#
+# Accepts the version as an argument
+
+go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=$1'" -a -tags cli -o ./porter ./cli &
+go build -ldflags="-w -s -X 'main.Version=$1'" -a -o ./docker-credential-porter ./cmd/docker-credential-porter/ &
+go build -ldflags="-w -s -X 'main.Version=$1'" -a -o ./portersvr ./cmd/app/ &
+wait
+
+mkdir -p /release/darwin
+zip --junk-paths /release/darwin/UNSIGNED_porter_$1_Darwin_x86_64.zip ./porter
+zip --junk-paths /release/darwin/UNSIGNED_portersvr_$1_Darwin_x86_64.zip ./portersvr
+zip --junk-paths /release/darwin/UNSIGNED_docker-credential-porter_$1_Darwin_x86_64.zip ./docker-credential-porter

+ 13 - 0
scripts/build/win.sh

@@ -0,0 +1,13 @@
+#!/bin/bash
+#
+# Accepts the version as an argument
+
+go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=$1'" -a -tags cli -o ./porter.exe ./cli &
+go build -ldflags="-w -s -X 'main.Version=$1'" -a -o ./docker-credential-porter.exe ./cmd/docker-credential-porter/ &
+go build -ldflags="-w -s -X 'main.Version=$1'" -a -o ./portersvr.exe ./cmd/app/ &
+wait
+
+mkdir -p /release/windows
+zip --junk-paths /release/windows/porter_$1_Windows_x86_64.zip ./porter.exe
+zip --junk-paths /release/windows/portersvr_$1_Windows_x86_64.zip ./portersvr.exe
+zip --junk-paths /release/windows/docker-credential-porter_$1_Windows_x86_64.zip ./docker-credential-porter.exe

+ 3 - 0
server/api/cluster_handler.go

@@ -2,6 +2,7 @@ package api
 
 
 import (
 import (
 	"encoding/json"
 	"encoding/json"
+	"fmt"
 	"net/http"
 	"net/http"
 	"strconv"
 	"strconv"
 
 
@@ -57,6 +58,8 @@ func (app *App) HandleCreateProjectCluster(w http.ResponseWriter, r *http.Reques
 
 
 	clusterExt := cluster.Externalize()
 	clusterExt := cluster.Externalize()
 
 
+	fmt.Println("CLUSTER EXTERNAL PROJECT ID", clusterExt.ProjectID, cluster.ProjectID)
+
 	if err := json.NewEncoder(w).Encode(clusterExt); err != nil {
 	if err := json.NewEncoder(w).Encode(clusterExt); err != nil {
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		app.handleErrorFormDecoding(err, ErrProjectDecode, w)
 		return
 		return

+ 2 - 1
server/api/cluster_handler_test.go

@@ -79,6 +79,7 @@ var createClusterTests = []*clusterTest{
 		initializers: []func(t *tester){
 		initializers: []func(t *tester){
 			initUserDefault,
 			initUserDefault,
 			initProject,
 			initProject,
+			initAWSIntegration,
 		},
 		},
 		msg:       "Create cluster",
 		msg:       "Create cluster",
 		method:    "POST",
 		method:    "POST",
@@ -94,7 +95,7 @@ var createClusterTests = []*clusterTest{
 }
 }
 
 
 func TestHandleCreateCluster(t *testing.T) {
 func TestHandleCreateCluster(t *testing.T) {
-	testRegistryRequests(t, createRegistryTests, true)
+	testClusterRequests(t, createClusterTests, true)
 }
 }
 
 
 var readProjectClusterTest = []*clusterTest{
 var readProjectClusterTest = []*clusterTest{

+ 26 - 0
server/api/integration_handler_test.go

@@ -8,6 +8,7 @@ import (
 	"testing"
 	"testing"
 
 
 	"github.com/go-test/deep"
 	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/forms"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 )
 
 
@@ -237,6 +238,31 @@ func TestHandleCreateBasicIntegration(t *testing.T) {
 
 
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 // ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
 
 
+func initAWSIntegration(tester *tester) {
+	proj, _ := tester.repo.Project.ReadProject(1)
+
+	form := &forms.CreateAWSIntegrationForm{
+		ProjectID: proj.ID,
+		UserID:    1,
+	}
+
+	// convert the form to a ServiceAccountCandidate
+	awsInt, _ := form.ToAWSIntegration()
+
+	tester.repo.AWSIntegration.CreateAWSIntegration(awsInt)
+}
+
+func initBasicIntegration(tester *tester) {
+	proj, _ := tester.repo.Project.ReadProject(1)
+
+	basicInt := &ints.BasicIntegration{
+		ProjectID: proj.ID,
+		UserID:    1,
+	}
+
+	tester.repo.BasicIntegration.CreateBasicIntegration(basicInt)
+}
+
 func publicIntBodyValidator(c *publicIntTest, tester *tester, t *testing.T) {
 func publicIntBodyValidator(c *publicIntTest, tester *tester, t *testing.T) {
 	gotBody := make([]*ints.PorterIntegration, 0)
 	gotBody := make([]*ints.PorterIntegration, 0)
 	expBody := make([]*ints.PorterIntegration, 0)
 	expBody := make([]*ints.PorterIntegration, 0)

+ 23 - 22
server/api/registry_handler_test.go

@@ -124,28 +124,29 @@ func testImagesRequests(t *testing.T, tests []*imagesTest, canQuery bool) {
 
 
 // ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
 // ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
 
 
-var createRegistryTests = []*regTest{
-	&regTest{
-		initializers: []func(t *tester){
-			initUserDefault,
-			initProject,
-		},
-		msg:       "Create registry",
-		method:    "POST",
-		endpoint:  "/api/projects/1/registries",
-		body:      `{"name":"registry-test","aws_integration_id":1}`,
-		expStatus: http.StatusCreated,
-		expBody:   `{"id":1,"name":"registry-test","project_id":1,"service":"ecr"}`,
-		useCookie: true,
-		validators: []func(c *regTest, tester *tester, t *testing.T){
-			regBodyValidator,
-		},
-	},
-}
-
-func TestHandleCreateRegistry(t *testing.T) {
-	testRegistryRequests(t, createRegistryTests, true)
-}
+// var createRegistryTests = []*regTest{
+// 	&regTest{
+// 		initializers: []func(t *tester){
+// 			initUserDefault,
+// 			initProject,
+// 			initAWSIntegration,
+// 		},
+// 		msg:       "Create registry",
+// 		method:    "POST",
+// 		endpoint:  "/api/projects/1/registries",
+// 		body:      `{"name":"registry-test","aws_integration_id":1}`,
+// 		expStatus: http.StatusCreated,
+// 		expBody:   `{"id":1,"name":"registry-test","project_id":1,"service":"ecr"}`,
+// 		useCookie: true,
+// 		validators: []func(c *regTest, tester *tester, t *testing.T){
+// 			regBodyValidator,
+// 		},
+// 	},
+// }
+
+// func TestHandleCreateRegistry(t *testing.T) {
+// 	testRegistryRequests(t, createRegistryTests, true)
+// }
 
 
 var listRegistryTests = []*regTest{
 var listRegistryTests = []*regTest{
 	&regTest{
 	&regTest{

+ 208 - 0
server/router/middleware/auth.go

@@ -82,6 +82,14 @@ type bodyInfraID struct {
 	InfraID uint64 `json:"infra_id"`
 	InfraID uint64 `json:"infra_id"`
 }
 }
 
 
+type bodyAWSIntegrationID struct {
+	AWSIntegrationID uint64 `json:"aws_integration_id"`
+}
+
+type bodyGCPIntegrationID struct {
+	GCPIntegrationID uint64 `json:"gcp_integration_id"`
+}
+
 // DoesUserIDMatch checks the id URL parameter and verifies that it matches
 // DoesUserIDMatch checks the id URL parameter and verifies that it matches
 // the one stored in the session
 // the one stored in the session
 func (auth *Auth) DoesUserIDMatch(next http.Handler, loc IDLocation) http.Handler {
 func (auth *Auth) DoesUserIDMatch(next http.Handler, loc IDLocation) http.Handler {
@@ -367,6 +375,116 @@ func (auth *Auth) DoesUserHaveInfraAccess(
 	})
 	})
 }
 }
 
 
+// DoesUserHaveAWSIntegrationAccess looks for a project_id parameter and an
+// aws_integration_id parameter, and verifies that the infra belongs
+// to the project
+func (auth *Auth) DoesUserHaveAWSIntegrationAccess(
+	next http.Handler,
+	projLoc IDLocation,
+	awsLoc IDLocation,
+	optional bool,
+) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		awsID, err := findAWSIntegrationIDInRequest(r, awsLoc)
+
+		if awsID == 0 && optional {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		if awsID == 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
+		}
+
+		awsInts, err := auth.repo.AWSIntegration.ListAWSIntegrationsByProjectID(uint(projID))
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			return
+		}
+
+		doesExist := false
+
+		for _, awsInt := range awsInts {
+			if awsInt.ID == uint(awsID) {
+				doesExist = true
+				break
+			}
+		}
+
+		if doesExist {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	})
+}
+
+// DoesUserHaveGCPIntegrationAccess looks for a project_id parameter and an
+// gcp_integration_id parameter, and verifies that the infra belongs
+// to the project
+func (auth *Auth) DoesUserHaveGCPIntegrationAccess(
+	next http.Handler,
+	projLoc IDLocation,
+	gcpLoc IDLocation,
+	optional bool,
+) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		gcpID, err := findGCPIntegrationIDInRequest(r, gcpLoc)
+
+		if gcpID == 0 && optional {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		if gcpID == 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
+		}
+
+		gcpInts, err := auth.repo.GCPIntegration.ListGCPIntegrationsByProjectID(uint(projID))
+
+		if err != nil {
+			http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
+			return
+		}
+
+		doesExist := false
+
+		for _, awsInt := range gcpInts {
+			if awsInt.ID == uint(gcpID) {
+				doesExist = true
+				break
+			}
+		}
+
+		if doesExist {
+			next.ServeHTTP(w, r)
+			return
+		}
+
+		http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
+		return
+	})
+}
+
 // Helpers
 // Helpers
 func (auth *Auth) doesSessionMatchID(r *http.Request, id uint) bool {
 func (auth *Auth) doesSessionMatchID(r *http.Request, id uint) bool {
 	session, _ := auth.store.Get(r, auth.cookieName)
 	session, _ := auth.store.Get(r, auth.cookieName)
@@ -663,3 +781,93 @@ func findInfraIDInRequest(r *http.Request, infraLoc IDLocation) (uint64, error)
 
 
 	return infraID, nil
 	return infraID, nil
 }
 }
+
+func findAWSIntegrationIDInRequest(r *http.Request, awsLoc IDLocation) (uint64, error) {
+	var awsID uint64
+	var err error
+
+	if awsLoc == URLParam {
+		awsID, err = strconv.ParseUint(chi.URLParam(r, "aws_integration_id"), 0, 64)
+
+		if err != nil {
+			return 0, err
+		}
+	} else if awsLoc == BodyParam {
+		form := &bodyAWSIntegrationID{}
+		body, err := ioutil.ReadAll(r.Body)
+
+		if err != nil {
+			return 0, err
+		}
+
+		err = json.Unmarshal(body, form)
+
+		if err != nil {
+			return 0, err
+		}
+
+		awsID = form.AWSIntegrationID
+
+		// 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["aws_integration_id"]; ok && len(regStrArr) == 1 {
+			awsID, err = strconv.ParseUint(regStrArr[0], 10, 64)
+		} else {
+			return 0, errors.New("aws integration id not found")
+		}
+	}
+
+	return awsID, nil
+}
+
+func findGCPIntegrationIDInRequest(r *http.Request, gcpLoc IDLocation) (uint64, error) {
+	var gcpID uint64
+	var err error
+
+	if gcpLoc == URLParam {
+		gcpID, err = strconv.ParseUint(chi.URLParam(r, "gcp_integration_id"), 0, 64)
+
+		if err != nil {
+			return 0, err
+		}
+	} else if gcpLoc == BodyParam {
+		form := &bodyGCPIntegrationID{}
+		body, err := ioutil.ReadAll(r.Body)
+
+		if err != nil {
+			return 0, err
+		}
+
+		err = json.Unmarshal(body, form)
+
+		if err != nil {
+			return 0, err
+		}
+
+		gcpID = form.GCPIntegrationID
+
+		// 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["gcp_integration_id"]; ok && len(regStrArr) == 1 {
+			gcpID, err = strconv.ParseUint(regStrArr[0], 10, 64)
+		} else {
+			return 0, errors.New("gcp integration id not found")
+		}
+	}
+
+	return gcpID, nil
+}

+ 46 - 6
server/router/router.go

@@ -203,7 +203,12 @@ func New(a *api.App) *chi.Mux {
 			"POST",
 			"POST",
 			"/projects/{project_id}/provision/ecr",
 			"/projects/{project_id}/provision/ecr",
 			auth.DoesUserHaveProjectAccess(
 			auth.DoesUserHaveProjectAccess(
-				requestlog.NewHandler(a.HandleProvisionAWSECRInfra, l),
+				auth.DoesUserHaveAWSIntegrationAccess(
+					requestlog.NewHandler(a.HandleProvisionAWSECRInfra, l),
+					mw.URLParam,
+					mw.BodyParam,
+					true,
+				),
 				mw.URLParam,
 				mw.URLParam,
 				mw.ReadAccess,
 				mw.ReadAccess,
 			),
 			),
@@ -213,7 +218,12 @@ func New(a *api.App) *chi.Mux {
 			"POST",
 			"POST",
 			"/projects/{project_id}/provision/eks",
 			"/projects/{project_id}/provision/eks",
 			auth.DoesUserHaveProjectAccess(
 			auth.DoesUserHaveProjectAccess(
-				requestlog.NewHandler(a.HandleProvisionAWSEKSInfra, l),
+				auth.DoesUserHaveAWSIntegrationAccess(
+					requestlog.NewHandler(a.HandleProvisionAWSEKSInfra, l),
+					mw.URLParam,
+					mw.BodyParam,
+					true,
+				),
 				mw.URLParam,
 				mw.URLParam,
 				mw.ReadAccess,
 				mw.ReadAccess,
 			),
 			),
@@ -290,9 +300,19 @@ func New(a *api.App) *chi.Mux {
 			"POST",
 			"POST",
 			"/projects/{project_id}/clusters",
 			"/projects/{project_id}/clusters",
 			auth.DoesUserHaveProjectAccess(
 			auth.DoesUserHaveProjectAccess(
-				requestlog.NewHandler(a.HandleCreateProjectCluster, l),
+				auth.DoesUserHaveAWSIntegrationAccess(
+					auth.DoesUserHaveGCPIntegrationAccess(
+						requestlog.NewHandler(a.HandleCreateProjectCluster, l),
+						mw.URLParam,
+						mw.BodyParam,
+						true,
+					),
+					mw.URLParam,
+					mw.BodyParam,
+					true,
+				),
 				mw.URLParam,
 				mw.URLParam,
-				mw.ReadAccess,
+				mw.WriteAccess,
 			),
 			),
 		)
 		)
 
 
@@ -405,7 +425,17 @@ func New(a *api.App) *chi.Mux {
 			"POST",
 			"POST",
 			"/projects/{project_id}/helmrepos",
 			"/projects/{project_id}/helmrepos",
 			auth.DoesUserHaveProjectAccess(
 			auth.DoesUserHaveProjectAccess(
-				requestlog.NewHandler(a.HandleCreateHelmRepo, l),
+				auth.DoesUserHaveAWSIntegrationAccess(
+					auth.DoesUserHaveGCPIntegrationAccess(
+						requestlog.NewHandler(a.HandleCreateHelmRepo, l),
+						mw.URLParam,
+						mw.BodyParam,
+						true,
+					),
+					mw.URLParam,
+					mw.BodyParam,
+					true,
+				),
 				mw.URLParam,
 				mw.URLParam,
 				mw.WriteAccess,
 				mw.WriteAccess,
 			),
 			),
@@ -436,7 +466,17 @@ func New(a *api.App) *chi.Mux {
 			"POST",
 			"POST",
 			"/projects/{project_id}/registries",
 			"/projects/{project_id}/registries",
 			auth.DoesUserHaveProjectAccess(
 			auth.DoesUserHaveProjectAccess(
-				requestlog.NewHandler(a.HandleCreateRegistry, l),
+				auth.DoesUserHaveAWSIntegrationAccess(
+					auth.DoesUserHaveGCPIntegrationAccess(
+						requestlog.NewHandler(a.HandleCreateRegistry, l),
+						mw.URLParam,
+						mw.BodyParam,
+						true,
+					),
+					mw.URLParam,
+					mw.BodyParam,
+					true,
+				),
 				mw.URLParam,
 				mw.URLParam,
 				mw.WriteAccess,
 				mw.WriteAccess,
 			),
 			),