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

Merge pull request #217 from porter-dev/master

Test GCP integration on staging
abelanger5 5 лет назад
Родитель
Сommit
4f554ef5c5
52 измененных файлов с 1185 добавлено и 264 удалено
  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_CID=${{secrets.DISCORD_CID}}
           FEEDBACK_ENDPOINT=${{secrets.FEEDBACK_ENDPOINT}}
+          POSTHOG_API_KEY=${{secrets.POSTHOG_API_KEY}}
+          POSTHOG_HOST=${{secrets.POSTHOG_HOST}}
           EOL
       - name: Build and zip static folder
         run: |
@@ -78,11 +80,6 @@ jobs:
           zip --junk-paths ./release/static/static_${{steps.tag_name.outputs.tag}}.zip ./dashboard/build/*
         env:
           NODE_ENV: production
-          API_SERVER: ${{ secrets.API_SERVER }}
-          FULLSTORY_ORG_ID: ${{ secrets.FULLSTORY_ORG_ID }}
-          DISCORD_KEY: ${{ secrets.DISCORD_KEY }}
-          DISCORD_CID: ${{ secrets.DISCORD_CID }}
-          FEEDBACK_ENDPOINT: ${{ secrets.FEEDBACK_ENDPOINT }}
       - name: Build Linux binaries
         run: |
           go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${{steps.tag_name.outputs.tag}}'" -a -tags cli -o ./porter ./cli &
@@ -92,6 +89,7 @@ jobs:
         env:
           GOOS: linux
           GOARCH: amd64
+          CGO_ENABLED: 1
       # 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
       # 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/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
-      - name: Build Darwin binaries
+      - name: Build and zip Darwin binaries
         run: |
-          go build -ldflags="-w -s -X 'github.com/porter-dev/porter/cli/cmd.Version=${{steps.tag_name.outputs.tag}}'" -a -tags cli -o ./porter ./cli &
-          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: |
-          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
         uses: actions/upload-artifact@v2
         with:

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

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

+ 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.
 
-![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?
 ### 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)
 
@@ -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
 
 - 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)
 
 ### DevOps Mode
 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`
 - In-depth view of releases, including revision histories and component graphs
 - 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)
 
@@ -47,7 +46,7 @@ Run the following command to grab the latest binary:
 
 ```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)
 curl -L https://github.com/porter-dev/porter/releases/latest/download/$name --output $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"
 	"fmt"
 	"strings"
+	"time"
 
+	"github.com/aws/aws-sdk-go/service/ecr"
 	"github.com/fatih/color"
 	"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/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
@@ -50,6 +55,8 @@ Would you like to proceed? %s `,
 			return ecrManual(client, projectID, region)
 		}
 
+		waitForAuthorizationToken(region, creds)
+
 		integration, err := client.CreateAWSIntegration(
 			context.Background(),
 			projectID,
@@ -142,3 +149,31 @@ func linkRegistry(client *api.Client, projectID uint, intID uint) (uint, error)
 
 	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
 		}
 
+		// delete file if exists
+		os.Remove(fpath)
+
 		// Make File
 		if err = os.MkdirAll(filepath.Dir(fpath), os.ModePerm); err != nil {
 			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
 	}
 
-	policyArn := "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
+	policyArn := "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryFullAccess"
 
 	_, err = a.IAMService.AttachUserPolicy(&iam.AttachUserPolicyInput{
 		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 {
 	pID := getProjectID()
 	rID := getRegistryID()
-	repoName := args[1]
+	repoName := args[0]
 
 	// get the list of namespaces
 	imgs, err := client.ListImages(

+ 33 - 5
cli/cmd/server.go

@@ -5,6 +5,7 @@ import (
 	"os"
 	"os/exec"
 	"path/filepath"
+	"strings"
 
 	"github.com/fatih/color"
 	"github.com/porter-dev/porter/cli/cmd/docker"
@@ -175,10 +176,26 @@ func startLocal(
 	staticFilePath := filepath.Join(home, ".porter", "static")
 
 	if _, err := os.Stat(cmdPath); os.IsNotExist(err) {
-		err := downloadLatestReleases(porterDir)
+		err := downloadMatchingRelease(porterDir)
 
 		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)
 		}
 	}
@@ -190,12 +207,13 @@ func startLocal(
 		"SQL_LITE=true",
 		"SQL_LITE_PATH=" + sqlLitePath,
 		"STATIC_FILE_PATH=" + staticFilePath,
+		"REDIS_ENABLED=false",
 	}...)
 
 	cmdPorter.Stdout = os.Stdout
 	cmdPorter.Stderr = os.Stderr
 
-	err := cmdPorter.Run()
+	err = cmdPorter.Run()
 
 	if err != nil {
 		color.New(color.FgRed).Println("Failed:", err.Error())
@@ -225,7 +243,7 @@ func stopDocker() error {
 	return nil
 }
 
-func downloadLatestReleases(porterDir string) error {
+func downloadMatchingRelease(porterDir string) error {
 	z := &github.ZIPReleaseGetter{
 		AssetName:           "portersvr",
 		AssetFolderDest:     porterDir,
@@ -236,7 +254,7 @@ func downloadLatestReleases(porterDir string) error {
 		IsPlatformDependent: true,
 	}
 
-	err := z.GetLatestRelease()
+	err := z.GetRelease(Version)
 
 	if err != nil {
 		return err
@@ -254,3 +272,13 @@ func downloadLatestReleases(porterDir string) error {
 
 	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
-var Version string = "dev"
+var Version string = "v0.1.0-beta.3.1"
 
 var versionCmd = &cobra.Command{
 	Use:     "version",

+ 15 - 0
cmd/app/main.go

@@ -1,9 +1,11 @@
 package main
 
 import (
+	"flag"
 	"fmt"
 	"log"
 	"net/http"
+	"os"
 
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository/gorm"
@@ -19,7 +21,20 @@ import (
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 
+// Version will be linked by an ldflag during build
+var Version string = "dev"
+
 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()
 
 	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
             dropdownWidth={this.props.dropdownWidth ? this.props.dropdownWidth : this.props.width}
             dropdownMaxHeight={this.props.dropdownMaxHeight}
+            onClick={() => this.setState({ expanded: false })}
           >
             {this.renderDropdownLabel()}
             {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 InputRow from './InputRow';
+import Base64InputRow from './Base64InputRow';
 import SelectRow from './SelectRow';
 import Helper from './Helper';
 import Heading from './Heading';
@@ -149,6 +150,40 @@ export default class ValuesForm extends Component<PropsType, StateType> {
             <VeleroForm
             />
           );
+        case 'base-64':
+          return (
+            <Base64InputRow
+              key={i}
+              isRequired={item.required}
+              type='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:
       }
     });

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

@@ -5,7 +5,6 @@ import logo from '../assets/logo.png';
 import api from '../shared/api';
 import { emailRegex } from '../shared/regex';
 import { Context } from '../shared/Context';
-import { handleSubmitFeedback } from '../shared/feedback'; 
 
 type PropsType = {
   authenticate: () => void
@@ -61,8 +60,6 @@ export default class Register extends Component<PropsType, StateType> {
         email: email,
         password: password
       }, {}, (err: any, res: any) => {
-        let msg = '📡 ' + email + ' registered for Porter.';
-        handleSubmitFeedback(msg);
         setUser(res?.data?.id, res?.data?.email)
         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 api from '../../shared/api';
 import { InfraType } from '../../shared/types';
-import { handleSubmitFeedback } from '../../shared/feedback';
 
 import Sidebar from './sidebar/Sidebar';
 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) {
-                console.log('setting to provisioner...');
                 this.setState({ currentView: 'provisioner', viewData, sidebarReady: true, });
               } else {
                 this.setState({ sidebarReady: true });
@@ -94,8 +91,6 @@ export default class Home extends Component<PropsType, StateType> {
   }
 
   componentDidMount() {
-    let msg = '👋 ' + this.context.user.email + ' logged in.';
-    handleSubmitFeedback(msg);
     this.getProjects();
   }
 
@@ -108,7 +103,6 @@ export default class Home extends Component<PropsType, StateType> {
           prevProjectId: this.context.currentProject.id,
           currentView: 'dashboard'
         });
-        console.log('setting view to dashboard from Home');
       }
     }
   }
@@ -300,8 +294,8 @@ const ProjectModalStyles = {
     width: '565px',
     maxWidth: '80vw',
     margin: '0 auto',
-    height: '225px',
-    top: 'calc(50% - 120px)',
+    height: '275px',
+    top: 'calc(50% - 160px)',
     backgroundColor: '#202227',
     animation: 'floatInModal 0.5s 0s',
     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 NamespaceSelector from './NamespaceSelector';
+import SortSelector from './SortSelector';
 import ExpandedChart from './expanded-chart/ExpandedChart';
 
 type PropsType = {
@@ -18,20 +19,28 @@ type PropsType = {
 
 type StateType = {
   namespace: string,
+  sortType: string,
   currentChart: ChartType | null
 };
 
 export default class ClusterDashboard extends Component<PropsType, StateType> {
   state = {
     namespace: 'default',
+    sortType: 'Newest',
     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
     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
           </Button>
-          <NamespaceSelector
-            setNamespace={(namespace) => this.setState({ namespace })}
-            namespace={this.state.namespace}
-          />
+          <SortFilterWrapper>
+            <SortSelector
+              setSortType={(sortType) => this.setState({ sortType })}
+              sortType={this.state.sortType}
+            />
+            <NamespaceSelector
+              setNamespace={(namespace) => this.setState({ namespace })}
+              namespace={this.state.namespace}
+            />
+          </SortFilterWrapper>
         </ControlRow>
 
         <ChartList
           currentCluster={currentCluster}
           namespace={this.state.namespace}
+          sortType={this.state.sortType}
           setCurrentChart={(x: ChartType | null) => this.setState({ currentChart: x })}
         />
       </div>
@@ -297,4 +313,10 @@ const TitleSection = styled.div`
     }
     margin-bottom: -3px;
   }
+`;
+
+const SortFilterWrapper = styled.div`
+  width: 468px;
+  display: flex;
+  justify-content: space-between;
 `;

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

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

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

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

+ 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
       }
       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 = () => {
     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 {
       let serviceName = 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;
 
+const Bolded = styled.div`
+  font-weight: 500;
+  color: #ffffff44;
+  margin-right: 6px;
+`;
+
 const Url = styled.a`
   display: block;
-  margin-left: 1px;
+  margin-left: 2px;
   font-size: 13px;
-  margin-top: 15px;
+  margin-top: 16px;
+  user-select: all;
   margin-bottom: -5px;
   user-select: text;
   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
   componentDidUpdate(prevProps: PropsType) {
     if (prevProps.components !== this.props.components) {
-      console.log(this.props.components);
       this.storeChartGraph(prevProps);
       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) => {
-    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'
     }
 
@@ -92,12 +92,11 @@ export default class ControllerTab extends Component<PropsType, StateType> {
     if (status?.phase == 'Running') {
       let collatedStatus = 'running';
 
-      status.containerStatuses.forEach((s: any) => {
+      status?.containerStatuses?.forEach((s: any) => {
         if (s.state?.waiting) {
           collatedStatus = 'waiting'
         } else if (s.state?.terminated) {
           collatedStatus = 'failed'
-          throw {};
         }
       })
       return collatedStatus;
@@ -106,8 +105,10 @@ export default class ControllerTab extends Component<PropsType, StateType> {
 
   render() {
     let { controller, selectedPod, isLast, selectPod } = this.props;
+    console.log(controller)
     let [available, total] = this.getAvailability(controller.kind, controller);
     let status = (available == total) ? 'running' : 'waiting'
+    console.log('state', this.state)
     return (
       <ResourceTab
         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>
 
+        <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
           text='Delete Cluster'
           color='#b91133'
@@ -104,12 +114,6 @@ export default class UpdateClusterModal extends Component<PropsType, StateType>
           onYes={this.handleDelete}
           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>
       );
   }
@@ -117,6 +121,42 @@ export default class UpdateClusterModal extends Component<PropsType, StateType>
 
 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`
   width: 25px;
   min-width: 25px;
@@ -198,17 +238,8 @@ const StyledUpdateProjectModal= styled.div`
   left: 0;
   top: 0;
   height: 100%;
-  padding: 25px 32px;
+  padding: 25px 30px;
   overflow: hidden;
   border-radius: 6px;
   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>
 
+        <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
           text='Delete Project'
           color='#b91133'
@@ -128,17 +138,49 @@ export default class UpdateProjectModal extends Component<PropsType, StateType>
           onYes={this.handleDelete}
           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>
-      );
+    );
   }
 }
 
 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`
   height: 100%;
   width: 100%;
@@ -232,13 +274,4 @@ const StyledUpdateProjectModal= styled.div`
   overflow: hidden;
   border-radius: 6px;
   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 { Context } from '../../../shared/Context';
 import { integrationList } from '../../../shared/common';
-import { handleSubmitFeedback } from '../../../shared/feedback';
 import { ProjectType } from '../../../shared/types';
 
 import InputRow from '../../../components/values-form/InputRow';
@@ -27,6 +26,9 @@ type StateType = {
   awsRegion: string | null,
   awsAccessId: string | null,
   awsSecretKey: string | null,
+  gcpRegion: string | null,
+  gcpProjectId: string | null,
+  gcpKeyData: string | null,
   status: string | null,
 };
 
@@ -38,6 +40,9 @@ export default class NewProject extends Component<PropsType, StateType> {
     awsRegion: '' as string | null,
     awsAccessId: '' as string | null,
     awsSecretKey: '' as string | null,
+    gcpRegion: '' as string | null,
+    gcpProjectId: '' as string | null,
+    gcpKeyData: '' as string | null,
     status: null as string | null,
   }
 
@@ -50,16 +55,14 @@ export default class NewProject extends Component<PropsType, StateType> {
   }
 
   handleSelectProvider = (provider: string) => {
-    let msg = '🤔 ' + this.context.user.email + ' selected ' + provider + '.';
-    handleSubmitFeedback(msg);
     this.setState({ selectedProvider: provider });
   }
 
-  renderTemplateList = () => {
+  renderProviderList = () => {
     return providers.map((provider: string, i: number) => {
       let providerInfo = integrationList[provider];
       return (
-        <Block 
+        <Block
           key={i} 
           onClick={() => this.handleSelectProvider(provider)}
         >
@@ -130,9 +133,41 @@ export default class NewProject extends Component<PropsType, StateType> {
           }}>
             <CloseButtonImg src={close} />
           </CloseButton>
-          <Flex>
-            GCP support is in closed beta. If you would like to run Porter in your own Google Cloud account, email <Highlight>contact@getporter.dev</Highlight>.
-          </Flex>
+          <DarkMatter />
+          <Heading>
+            GCP Credentials
+            <GuideButton href='https://docs.getporter.dev/docs/getting-started-with-porter-on-gcp' target='_blank'>
+              <i className="material-icons-outlined">help</i> 
+              Guide
+            </GuideButton>
+          </Heading>
+          <InputRow
+            type='text'
+            value={this.state.gcpRegion}
+            setValue={(x: string) => this.setState({ gcpRegion: x })}
+            label='📍 GCP Region'
+            placeholder='ex: us-central1-a'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='text'
+            value={this.state.gcpProjectId}
+            setValue={(x: string) => this.setState({ gcpProjectId: x })}
+            label='🏷️ GCP Project ID'
+            placeholder='ex: pale-moon-24601'
+            width='100%'
+            isRequired={true}
+          />
+          <InputRow
+            type='password'
+            value={this.state.gcpKeyData}
+            setValue={(x: string) => this.setState({ gcpKeyData: x })}
+            label='🔒 GCP Key Data'
+            placeholder='○ ○ ○ ○ ○ ○ ○ ○ ○'
+            width='100%'
+            isRequired={true}
+          />
         </FormSection>
       );
     } else if (this.state.selectedProvider === 'do') {
@@ -152,7 +187,7 @@ export default class NewProject extends Component<PropsType, StateType> {
 
     return (
       <BlockList>
-        {this.renderTemplateList()}
+        {this.renderProviderList()}
       </BlockList>
     );
   }
@@ -198,12 +233,23 @@ export default class NewProject extends Component<PropsType, StateType> {
   }
 
   validateForm = () => {
-    let { projectName, selectedProvider, awsAccessId, awsSecretKey, awsRegion } = this.state;
+    let { 
+      projectName,
+      selectedProvider, 
+      awsAccessId, 
+      awsSecretKey, 
+      awsRegion,
+      gcpRegion,
+      gcpKeyData,
+      gcpProjectId,
+    } = this.state;
     if (!this.isAlphanumeric(projectName) || projectName === '') {
       return false;
     } else if (selectedProvider === 'aws') {
       return awsAccessId !== '' && awsSecretKey !== '' && awsRegion !== '';
-    }  else if (selectedProvider === 'skipped') {
+    } else if (selectedProvider === 'gcp') {
+      return gcpRegion !== '' && gcpKeyData !== '' && gcpProjectId !== '';
+    } else if (selectedProvider === 'skipped') {
       return true;
     }
     return false;
@@ -268,16 +314,66 @@ export default class NewProject extends Component<PropsType, StateType> {
         }
 
         this.props.setCurrentView('provisioner', [
-          {infra_id: ecr?.data?.id, kind: ecr?.data?.kind},
-          {infra_id: eks?.data?.id, kind: eks?.data?.kind},
+          { infra_id: ecr?.data?.id, kind: ecr?.data?.kind },
+          { infra_id: eks?.data?.id, kind: eks?.data?.kind },
         ]);
       })
     })
   }
 
-  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' });
 
+    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>', {
       name: this.state.projectName
     }, {}, (err: any, res: any) => {
@@ -295,7 +391,9 @@ export default class NewProject extends Component<PropsType, StateType> {
               this.context.setCurrentProject(proj);
               
               if (this.state.selectedProvider === 'aws') {
-                this.provisionECR(proj, this.provisionEKS)
+                this.provisionECR(proj, this.provisionEKS);
+              } else if (this.state.selectedProvider === 'gcp') { 
+                this.provisionGCP(proj);
               } else {
                 this.props.setCurrentView('dashboard', null);
               }
@@ -317,10 +415,6 @@ export default class NewProject extends Component<PropsType, StateType> {
         if (res.data.length > 0) {
           let proj = res.data.find((el: ProjectType) => el.name === this.state.projectName);
           this.context.setCurrentProject(proj);
-          
-          let msg = '🏗️ ' + this.context.user.email + ' began provisioning.';
-          handleSubmitFeedback(msg);
-
           if (this.state.selectedProvider === 'aws') {
             this.provisionECR(proj, this.provisionEKS)
 
@@ -414,8 +508,9 @@ export default class NewProject extends Component<PropsType, StateType> {
   }
   
   render() {
+    let { selectedProvider } = this.state;
     return (
-      <StyledNewProject height={this.state.selectedProvider === 'aws' ? '700px' : '600px'}>
+      <StyledNewProject height={selectedProvider === 'aws' || selectedProvider === 'gcp' ? '700px' : '600px'}>
         {this.renderHeaderSection()}
         {this.renderHostingSection()}
         {this.renderButton()}

+ 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 { Context } from '../../../shared/Context';
 import ansiparse from '../../../shared/ansiparser'
-import { handleSubmitFeedback } from '../../../shared/feedback';
 import loading from '../../../assets/loading.gif';
 import warning from '../../../assets/warning.png';
 
@@ -181,7 +180,6 @@ export default class Provisioner extends Component<PropsType, StateType> {
         <TitleSection>
           <Title><img src={loading} /> Setting Up Porter</Title>
         </TitleSection>
-
         <Helper>
           Porter is currently being provisioned to your AWS account:
         </Helper>
@@ -190,16 +188,12 @@ export default class Provisioner extends Component<PropsType, StateType> {
   }
 
   onEnd = () => {
-    let msg = '🛠️ ' + this.context.user.email + ' completed provisioning.';
-    handleSubmitFeedback(msg);
     let myInterval = setInterval(() => {
-      console.log('interval')
       api.getClusters('<token>', {}, { id: this.context.currentProject.id }, (err: any, res: any) => {
         if (err) {
           console.log(err);
         } else if (res.data) {
           let clusters = res.data;
-          console.log('found clusters:', res.data);
           if (clusters.length > 0) {
             this.props.setCurrentView('dashboard');
             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
         if (res.data) {
           let clusters = res.data;
-          console.log('found clusters:', res.data);
           if (clusters.length > 0) {
             this.setState({ clusters });
             setCurrentCluster(clusters[0]);
           } else if (this.props.currentView !== 'provisioner') {
             this.setState({ clusters: [] });
             setCurrentCluster(null);
-            console.log('set to dashboard from clustersection');
             this.props.setCurrentView('dashboard');
           }
         }
@@ -70,7 +68,6 @@ export default class ClusterSection extends Component<PropsType, StateType> {
 
   componentDidMount() {
     this.updateClusters();
-    console.log('mounted clustersection');
   }
 
   // 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) {
         this.updateClusters();
         this.props.setRefreshClusters(false);
-        console.log('hard refereshed clusters');
       }
 
       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) {
           this.props.setCurrentView('provisioner', viewData);
         } else {
-          console.log('set to dashboard from projectsection');
           this.props.setCurrentView('dashboard');
         }
       }

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

@@ -13,6 +13,12 @@ const tabOptions = [
   { label: 'Community Templates', value: 'community' }
 ];
 
+// TODO: read in from metadata
+const hardcodedNames: any = {
+  'postgresql': 'PostgreSQL',
+  'docker': 'Docker',
+};
+
 type PropsType = {
   setCurrentView: (x: string) => void, // Link to add integration from source selector
 };
@@ -20,7 +26,7 @@ type PropsType = {
 type StateType = {
   currentTemplate: PorterTemplate | null,
   currentTab: string,
-  PorterTemplates: PorterTemplate[],
+  porterTemplates: PorterTemplate[],
   loading: boolean,
   error: boolean
 };
@@ -29,7 +35,7 @@ export default class Templates extends Component<PropsType, StateType> {
   state = {
     currentTemplate: null as (PorterTemplate | null),
     currentTab: 'community',
-    PorterTemplates: [] as PorterTemplate[],
+    porterTemplates: [] as PorterTemplate[],
     loading: true,
     error: false,
   }
@@ -39,7 +45,7 @@ export default class Templates extends Component<PropsType, StateType> {
       if (err) {
         this.setState({ loading: false, error: true });
       } else {
-        this.setState({ PorterTemplates: res.data, loading: false, error: false });
+        this.setState({ porterTemplates: res.data, loading: false, error: false });
       }
     });
   }
@@ -55,7 +61,7 @@ export default class Templates extends Component<PropsType, StateType> {
   }
 
   renderTemplateList = () => {
-    let { loading, error, PorterTemplates } = this.state;
+    let { loading, error, porterTemplates } = this.state;
 
     if (loading) {
       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.
         </Placeholder>
       );
-    } else if (PorterTemplates.length === 0) {
+    } else if (porterTemplates.length === 0) {
       return (
         <Placeholder>
           <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;
+      if (hardcodedNames[name]) {
+        name = hardcodedNames[name];
+      }
       return (
         <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>
       )
     });

+ 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 }[],
   };
 
-  onSubmitAddon = () => {
+  onSubmitAddon = (wildcard?: any) => {
     let { currentCluster, currentProject } = this.context;
     let name = randomWords({ exactly: 3, join: '-' });
+    this.setState({ saveValuesStatus: 'loading' });
     api.deployTemplate('<token>', {
       templateName: this.props.currentTemplate.name,
       storage: StorageType.Secret,
@@ -121,28 +122,26 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
     return (
       <ValuesWrapper
         formTabs={this.props.form?.tabs}
-        onSubmit={this.onSubmit}
+        onSubmit={this.props.currentTemplate.name === 'docker' ? this.onSubmit : this.onSubmitAddon}
         saveValuesStatus={this.state.saveValuesStatus}
-        disabled={!this.state.selectedImageUrl}
+        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>
     );
   }
@@ -212,19 +211,28 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
   renderTabRegion = () => {
     if (this.state.tabOptions.length > 0) {
       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 {
       return (
         <Wrapper>
           <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>
           <SaveButton
             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() {
     let { name, icon } = this.props.currentTemplate;
     let { currentTemplate } = this.props;
@@ -281,20 +310,7 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
             closeOverlay={true}
           />
         </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()}
       </StyledLaunchTemplate>
     );
@@ -303,9 +319,21 @@ export default class LaunchTemplate extends Component<PropsType, StateType> {
 
 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`
   width: 100%;
   position: relative;
+  padding-top: 20px;
   padding-bottom: 70px;
 `;
 
@@ -315,7 +343,7 @@ const Placeholder = styled.div`
   background: #ffffff11;
   border: 1px solid #ffffff44;
   border-radius: 5px;
-  color: #ffffff44;
+  color: #aaaabb;
   font-size: 13px;
   display: flex;
   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 { Context } from '../../../../shared/Context';
-import api from '../../../../shared/api';
 import Loading from '../../../../components/Loading';
 
 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 = {
   currentTemplate: any,
@@ -52,8 +57,11 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
     return currentTemplate.description;
   }
 
+
   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 (
         <TagSection>
           <i className="material-icons">local_offer</i>
@@ -96,8 +104,13 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
 
   render() {
     let { currentCluster } = this.context;
-    let { name, icon } = this.props.currentTemplate;
+    let { name, icon, description } = this.props.currentTemplate;
     let { currentTemplate } = this.props;
+
+    if (hardcodedNames[name]) {
+      name = hardcodedNames[name];
+    }
+
     return (
       <StyledExpandedTemplate>
         <TitleSection>
@@ -116,6 +129,7 @@ export default class TemplateInfo extends Component<PropsType, StateType> {
             Launch Template
           </Button>
         </TitleSection>
+        <Helper>{description}</Helper>
         {this.renderTagSection()}
         <LineBreak />
         {this.renderBanner()}
@@ -182,7 +196,7 @@ const Tag = styled.div`
 `;
 
 const TagSection = styled.div`
-  margin-top: 20px;
+  margin-top: 25px;
   display: flex;
   font-size: 13px;
   font-family: 'Work Sans', sans-serif;

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

@@ -291,10 +291,40 @@ const deleteCluster = baseApi<{
   cluster_id: number,
 }>('DELETE', pathParams => {
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}`;
-})
+});
+
+const createGCPIntegration = baseApi<{
+  gcp_region: string,
+  gcp_key_data: string,
+  gcp_project_id: string,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/integrations/gcp`;
+});
+
+const createGCR = baseApi<{
+  gcp_integration_id: number,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/provision/gcr`;
+});
+
+const createGKE = baseApi<{
+  gcp_integration_id: number,
+  gke_name: string,
+}, {
+  project_id: number,
+}>('POST', pathParams => {
+  return `/api/projects/${pathParams.project_id}/provision/gke`;
+});
 
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
+  createGCR,
+  createGKE,
+  createGCPIntegration,
   deleteCluster,
   destroyCluster,
   getInfra,

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

@@ -1,7 +1,15 @@
 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) => {
-  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, {
       key: process.env.DISCORD_KEY,
       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/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/etcd v3.3.10+incompatible h1:jFneRYjIvLMLhDLCzuTuU4rSJUjRplcJQ7pD7MnhC04=
 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-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-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-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c=
 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-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/rkt v1.30.0 h1:Kkt6sYeEGKxA3Y7SCrY+nHoXkWed6Jr2BBY42GqMymM=
 github.com/coreos/rkt v1.30.0/go.mod h1:O634mlH6U7qk87poQifK6M2rsFNt+FyUTWNMnP1hF1U=

+ 11 - 5
internal/auth/sessionstore.go

@@ -3,7 +3,6 @@
 package sessionstore
 
 import (
-	"database/sql"
 	"encoding/base32"
 	"net/http"
 	"strings"
@@ -13,10 +12,11 @@ import (
 
 	"github.com/gorilla/securecookie"
 	"github.com/gorilla/sessions"
-	"github.com/pkg/errors"
 
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
+
+	"gorm.io/gorm"
 )
 
 // 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...)
 		if err == nil {
 			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
-			} 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"`
 	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"`
 	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{
+		ProjectID:                ccf.ProjectID,
 		AuthMechanism:            authMechanism,
 		Name:                     ccf.Name,
 		Server:                   ccf.Server,

+ 2 - 0
internal/forms/integration.go

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

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

@@ -19,11 +19,14 @@ type GCPIntegration struct {
 	ProjectID uint `json:"project_id"`
 
 	// 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
 	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.
 	// ------------------------------------------------------------------

+ 1 - 0
internal/models/templates.go

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

+ 4 - 19
internal/registry/registry.go

@@ -103,14 +103,8 @@ func (r *Registry) listGCRRepositories(
 		return nil, err
 	}
 
-	// get oauth2 access token
-	oauthTok, err := gcp.GetBearerToken(r.getTokenCache, r.setTokenCacheFunc(repo))
-
-	if err != nil {
-		return nil, err
-	}
-
-	// use JWT token to request catalog
+	// Just use service account key to authenticate, since scopes may not be in place
+	// for oauth. This also prevents us from making more requests.
 	client := &http.Client{}
 
 	req, err := http.NewRequest(
@@ -123,9 +117,7 @@ func (r *Registry) listGCRRepositories(
 		return nil, err
 	}
 
-	req.SetBasicAuth("oauth2accesstoken", oauthTok)
-
-	// req.Header.Add("Authorization", "Bearer "+jwtTok)
+	req.SetBasicAuth("_json_key", string(gcp.GCPKeyData))
 
 	resp, err := client.Do(req)
 
@@ -279,13 +271,6 @@ func (r *Registry) listGCRImages(repoName string, repo repository.Repository) ([
 		return nil, err
 	}
 
-	// get oauth2 access token
-	oauthTok, err := gcp.GetBearerToken(r.getTokenCache, r.setTokenCacheFunc(repo))
-
-	if err != nil {
-		return nil, err
-	}
-
 	// use JWT token to request catalog
 	client := &http.Client{}
 
@@ -299,7 +284,7 @@ func (r *Registry) listGCRImages(repoName string, repo repository.Repository) ([
 		return nil, err
 	}
 
-	req.SetBasicAuth("oauth2accesstoken", oauthTok)
+	req.SetBasicAuth("_json_key", string(gcp.GCPKeyData))
 
 	resp, err := client.Do(req)
 

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

+ 2 - 1
server/api/cluster_handler_test.go

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

+ 26 - 0
server/api/integration_handler_test.go

@@ -8,6 +8,7 @@ import (
 	"testing"
 
 	"github.com/go-test/deep"
+	"github.com/porter-dev/porter/internal/forms"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
 )
 
@@ -237,6 +238,31 @@ func TestHandleCreateBasicIntegration(t *testing.T) {
 
 // ------------------------- 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) {
 	gotBody := 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  ------------------------- //
 
-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{
 	&regTest{

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

@@ -82,6 +82,14 @@ type bodyInfraID struct {
 	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
 // the one stored in the session
 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
 func (auth *Auth) doesSessionMatchID(r *http.Request, id uint) bool {
 	session, _ := auth.store.Get(r, auth.cookieName)
@@ -663,3 +781,93 @@ func findInfraIDInRequest(r *http.Request, infraLoc IDLocation) (uint64, error)
 
 	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",
 			"/projects/{project_id}/provision/ecr",
 			auth.DoesUserHaveProjectAccess(
-				requestlog.NewHandler(a.HandleProvisionAWSECRInfra, l),
+				auth.DoesUserHaveAWSIntegrationAccess(
+					requestlog.NewHandler(a.HandleProvisionAWSECRInfra, l),
+					mw.URLParam,
+					mw.BodyParam,
+					true,
+				),
 				mw.URLParam,
 				mw.ReadAccess,
 			),
@@ -213,7 +218,12 @@ func New(a *api.App) *chi.Mux {
 			"POST",
 			"/projects/{project_id}/provision/eks",
 			auth.DoesUserHaveProjectAccess(
-				requestlog.NewHandler(a.HandleProvisionAWSEKSInfra, l),
+				auth.DoesUserHaveAWSIntegrationAccess(
+					requestlog.NewHandler(a.HandleProvisionAWSEKSInfra, l),
+					mw.URLParam,
+					mw.BodyParam,
+					true,
+				),
 				mw.URLParam,
 				mw.ReadAccess,
 			),
@@ -290,9 +300,19 @@ func New(a *api.App) *chi.Mux {
 			"POST",
 			"/projects/{project_id}/clusters",
 			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.ReadAccess,
+				mw.WriteAccess,
 			),
 		)
 
@@ -405,7 +425,17 @@ func New(a *api.App) *chi.Mux {
 			"POST",
 			"/projects/{project_id}/helmrepos",
 			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.WriteAccess,
 			),
@@ -436,7 +466,17 @@ func New(a *api.App) *chi.Mux {
 			"POST",
 			"/projects/{project_id}/registries",
 			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.WriteAccess,
 			),