2
0
Эх сурвалжийг харах

GKE Provisioning flow updates (#3273)

Stefan McShane 2 жил өмнө
parent
commit
77a2c679a1

+ 0 - 56
api/server/handlers/api_contract/update.go

@@ -1,12 +1,10 @@
 package api_contract
 
 import (
-	"encoding/base64"
 	"net/http"
 
 	"connectrpc.com/connect"
 
-	"github.com/google/uuid"
 	helpers "github.com/porter-dev/api-contracts/generated/go/helpers"
 	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/handlers"
@@ -38,7 +36,6 @@ func (c *APIContractUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 	ctx, span := telemetry.NewSpan(r.Context(), "serve-update-api-contract")
 	defer span.End()
 
-	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 	user, _ := ctx.Value(types.UserScope).(*models.User)
 
 	var apiContract porterv1.Contract
@@ -50,59 +47,6 @@ func (c *APIContractUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
-	if !project.CapiProvisionerEnabled && !c.Config().EnableCAPIProvisioner {
-		// return dummy data if capi provisioner disabled in project settings, and as env var
-		// TODO: remove this stub when we can spin up all services locally, easily
-		clusterID := apiContract.Cluster.ClusterId
-		if apiContract.Cluster.ClusterId == 0 {
-			dbcli := models.Cluster{
-				ProjectID:                         uint(apiContract.Cluster.ProjectId),
-				Status:                            "UPDATING_UNAVAILABLE",
-				ProvisionedBy:                     "CAPI",
-				CloudProvider:                     "AWS",
-				CloudProviderCredentialIdentifier: apiContract.Cluster.CloudProviderCredentialsId,
-				Name:                              apiContract.Cluster.GetEksKind().ClusterName,
-				VanityName:                        apiContract.Cluster.GetEksKind().ClusterName,
-			}
-			dbcl, err := c.Config().Repo.Cluster().CreateCluster(&dbcli)
-			if err != nil {
-				e := telemetry.Error(ctx, span, err, "error updating mocking contract")
-				c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
-				return
-			}
-			clusterID = int32(dbcl.ID)
-		}
-
-		by, err := helpers.MarshalContractObject(ctx, &apiContract)
-		if err != nil {
-			e := telemetry.Error(ctx, span, err, "error marshalling mock api contract")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
-			return
-		}
-		b64Contract := base64.StdEncoding.EncodeToString([]byte(by))
-
-		revisionInput := models.APIContractRevision{
-			ID:             uuid.New(),
-			ClusterID:      int(clusterID),
-			ProjectID:      int(apiContract.Cluster.ProjectId),
-			Base64Contract: b64Contract,
-		}
-		revision, err := c.Config().Repo.APIContractRevisioner().Insert(ctx, revisionInput)
-		if err != nil {
-			e := telemetry.Error(ctx, span, err, "error updating mock api contract")
-			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
-			return
-		}
-		resp := &porterv1.ContractRevision{
-			ClusterId:  int32(clusterID),
-			ProjectId:  apiContract.Cluster.ProjectId,
-			RevisionId: revision.ID.String(),
-		}
-		w.WriteHeader(http.StatusCreated)
-		c.WriteResult(w, r, resp)
-		return
-	}
-
 	apiContract.User = &porterv1.User{
 		Id: int32(user.ID),
 	}

+ 46 - 2
api/server/handlers/project_integration/create_gcp.go

@@ -1,8 +1,11 @@
 package project_integration
 
 import (
+	"encoding/base64"
 	"net/http"
 
+	"connectrpc.com/connect"
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
 	"github.com/porter-dev/porter/api/server/handlers"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apierrors"
@@ -10,6 +13,7 @@ import (
 	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/models"
 	ints "github.com/porter-dev/porter/internal/models/integrations"
+	"github.com/porter-dev/porter/internal/telemetry"
 )
 
 type CreateGCPHandler struct {
@@ -27,8 +31,11 @@ func NewCreateGCPHandler(
 }
 
 func (p *CreateGCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	user, _ := r.Context().Value(types.UserScope).(*models.User)
-	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-create-gcp-credentials")
+	defer span.End()
+
+	user, _ := ctx.Value(types.UserScope).(*models.User)
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
 
 	request := &types.CreateGCPRequest{}
 
@@ -36,6 +43,43 @@ func (p *CreateGCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
+	if project.CapiProvisionerEnabled {
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "capi-provisioner-enabled", Value: true})
+
+		b64Key := base64.StdEncoding.EncodeToString([]byte(request.GCPKeyData))
+
+		ccpCredentialsInput := &connect.Request[porterv1.UpdateCloudProviderCredentialsRequest]{
+			Msg: &porterv1.UpdateCloudProviderCredentialsRequest{
+				ProjectId:     int64(project.ID),
+				CloudProvider: porterv1.EnumCloudProvider_ENUM_CLOUD_PROVIDER_GCP,
+				CloudProviderCredentials: &porterv1.UpdateCloudProviderCredentialsRequest_GcpCredentials{
+					GcpCredentials: &porterv1.GCPCredentials{
+						ServiceAccountJsonBase64: b64Key,
+					},
+				},
+			},
+		}
+		ccpCredentialsResponse, err := p.Config().ClusterControlPlaneClient.UpdateCloudProviderCredentials(ctx, ccpCredentialsInput)
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "failed to update cloud provider credentials")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
+			return
+		}
+		if ccpCredentialsResponse.Msg == nil {
+			e := telemetry.Error(ctx, span, nil, "nil response when updating provider credentials")
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
+			return
+		}
+
+		res := types.CreateGCPResponse{
+			IsCCPCluster:                      true,
+			CloudProviderCredentialIdentifier: ccpCredentialsResponse.Msg.CredentialsIdentifier,
+		}
+
+		p.WriteResult(w, r, res)
+		return
+	}
+
 	gcp := CreateGCPIntegration(request, project.ID, user.ID)
 
 	gcp, err := p.Repo().GCPIntegration().CreateGCPIntegration(gcp)

+ 55 - 14
api/server/handlers/registry/get_token.go

@@ -67,7 +67,7 @@ func (c *RegistryGetECRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 
 		resp := &types.GetRegistryTokenResponse{
 			Token:     ecrResponse.Msg.Token,
-			ExpiresAt: &expiry,
+			ExpiresAt: expiry,
 		}
 
 		c.WriteResult(w, r, resp)
@@ -82,7 +82,7 @@ func (c *RegistryGetECRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 	}
 
 	var token string
-	var expiresAt *time.Time
+	var expiresAt time.Time
 
 	for _, reg := range regs {
 		if reg.AWSIntegrationID != 0 {
@@ -123,8 +123,11 @@ func (c *RegistryGetECRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 					return
 				}
 
+				if output == nil || output.AuthorizationData == nil || len(output.AuthorizationData) == 0 {
+					continue
+				}
 				token = *output.AuthorizationData[0].AuthorizationToken
-				expiresAt = output.AuthorizationData[0].ExpiresAt
+				expiresAt = *output.AuthorizationData[0].ExpiresAt
 			}
 		}
 	}
@@ -172,7 +175,7 @@ func (c *RegistryGetGCRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 	}
 
 	var token string
-	var expiresAt *time.Time
+	var expiresAt time.Time
 
 	for _, reg := range regs {
 		if reg.GCPIntegrationID != 0 && strings.Contains(reg.URL, request.ServerURL) {
@@ -191,7 +194,7 @@ func (c *RegistryGetGCRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 			}
 
 			token = oauthTok.AccessToken
-			expiresAt = &oauthTok.Expiry
+			expiresAt = oauthTok.Expiry
 			break
 		}
 	}
@@ -238,8 +241,43 @@ func (c *RegistryGetGARTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 		return
 	}
 
+	if len(regs) == 0 {
+		e := telemetry.Error(ctx, span, err, "no registries found")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusNotFound))
+		return
+	}
+
+	if proj.CapiProvisionerEnabled {
+		regInput := connect.NewRequest(&porterv1.TokenForRegistryRequest{
+			ProjectId:   int64(proj.ID),
+			RegistryUri: regs[0].URL,
+		})
+		regOutput, err := c.Config().ClusterControlPlaneClient.TokenForRegistry(ctx, regInput)
+		if err != nil {
+			e := telemetry.Error(ctx, span, err, "error getting gar token")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
+			return
+		}
+		if regOutput == nil || regOutput.Msg == nil {
+			e := telemetry.Error(ctx, span, err, "error reading gar token")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
+			return
+		}
+		if regOutput.Msg.Token == "" {
+			e := telemetry.Error(ctx, span, err, "no token for for registry")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusInternalServerError))
+			return
+		}
+		resp := &types.GetRegistryTokenResponse{
+			Token:     regOutput.Msg.Token,
+			ExpiresAt: regOutput.Msg.Expiry.AsTime(),
+		}
+		c.WriteResult(w, r, resp)
+		return
+	}
+
 	var token string
-	var expiresAt *time.Time
+	var expiresAt time.Time
 
 	for _, reg := range regs {
 		if reg.GCPIntegrationID != 0 && strings.Contains(reg.URL, request.ServerURL) {
@@ -257,8 +295,11 @@ func (c *RegistryGetGARTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 				c.HandleAPIErrorNoWrite(w, r, apierrors.NewErrInternal(e))
 			}
 
+			if oauthTok == nil {
+				continue
+			}
 			token = oauthTok.AccessToken
-			expiresAt = &oauthTok.Expiry
+			expiresAt = oauthTok.Expiry
 			break
 		}
 	}
@@ -302,7 +343,7 @@ func (c *RegistryGetDOCRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 	}
 
 	var token string
-	var expiresAt *time.Time
+	var expiresAt time.Time
 
 	for _, reg := range regs {
 		if reg.DOIntegrationID != 0 && strings.Contains(reg.URL, request.ServerURL) {
@@ -323,7 +364,7 @@ func (c *RegistryGetDOCRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 			}
 
 			token = tok
-			expiresAt = expiry
+			expiresAt = *expiry
 			break
 		}
 	}
@@ -361,7 +402,7 @@ func (c *RegistryGetDockerhubTokenHandler) ServeHTTP(w http.ResponseWriter, r *h
 	}
 
 	var token string
-	var expiresAt *time.Time
+	var expiresAt time.Time
 
 	for _, reg := range regs {
 		if reg.BasicIntegrationID != 0 && strings.Contains(reg.URL, "index.docker.io") {
@@ -375,7 +416,7 @@ func (c *RegistryGetDockerhubTokenHandler) ServeHTTP(w http.ResponseWriter, r *h
 
 			// we'll just set an arbitrary 30-day expiry time (this is not enforced)
 			timeExpires := time.Now().Add(30 * 24 * 3600 * time.Second)
-			expiresAt = &timeExpires
+			expiresAt = timeExpires
 		}
 	}
 
@@ -435,7 +476,7 @@ func (c *RegistryGetACRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 	}
 
 	var token string
-	var expiresAt *time.Time
+	var expiresAt time.Time
 
 	var matchingReg *models.Registry
 	for _, reg := range regs {
@@ -482,7 +523,7 @@ func (c *RegistryGetACRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 
 		// we'll just set an arbitrary 30-day expiry time (this is not enforced)
 		timeExpires := time.Now().UTC().Add(30 * 24 * time.Hour)
-		expiresAt = &timeExpires
+		expiresAt = timeExpires
 	}
 
 	if matchingReg.AzureIntegrationID != 0 {
@@ -499,7 +540,7 @@ func (c *RegistryGetACRTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 		token = base64.StdEncoding.EncodeToString([]byte(string(username) + ":" + string(pw)))
 		// we'll just set an arbitrary 30-day expiry time (this is not enforced)
 		timeExpires := time.Now().UTC().Add(30 * 24 * time.Hour)
-		expiresAt = &timeExpires
+		expiresAt = timeExpires
 	}
 
 	if token == "" {

+ 4 - 0
api/types/project_integration.go

@@ -138,6 +138,10 @@ type CreateGCPRequest struct {
 
 type CreateGCPResponse struct {
 	*GCPIntegration
+	// IsCCPCluster is true if the cluster is managed through CCP, instead of the legacy provisioner
+	IsCCPCluster bool `json:"is_ccp_cluster"`
+	// CloudProviderCredentialIdentifier is the identifier for the cloud provider credential for CCP clusters
+	CloudProviderCredentialIdentifier string `json:"cloud_provider_credentials_id"`
 }
 
 type AzureIntegration struct {

+ 2 - 2
api/types/registry.go

@@ -172,8 +172,8 @@ type UpdateRegistryRequest struct {
 }
 
 type GetRegistryTokenResponse struct {
-	Token     string     `json:"token"`
-	ExpiresAt *time.Time `json:"expires_at"`
+	Token     string    `json:"token"`
+	ExpiresAt time.Time `json:"expires_at"`
 }
 
 type GetRegistryACRTokenRequest struct {

+ 6 - 6
cli/cmd/docker/auth.go

@@ -91,7 +91,7 @@ func (a *AuthGetter) GetGCRCredentials(serverURL string, projID uint) (user stri
 		a.Cache.Set(serverURL, &AuthEntry{
 			AuthorizationToken: token,
 			RequestedAt:        time.Now(),
-			ExpiresAt:          *tokenResp.ExpiresAt,
+			ExpiresAt:          tokenResp.ExpiresAt,
 			ProxyEndpoint:      serverURL,
 		})
 	}
@@ -136,7 +136,7 @@ func (a *AuthGetter) GetGARCredentials(serverURL string, projID uint) (user stri
 		a.Cache.Set(serverURL, &AuthEntry{
 			AuthorizationToken: token,
 			RequestedAt:        time.Now(),
-			ExpiresAt:          *tokenResp.ExpiresAt,
+			ExpiresAt:          tokenResp.ExpiresAt,
 			ProxyEndpoint:      serverURL,
 		})
 	}
@@ -163,7 +163,7 @@ func (a *AuthGetter) GetDOCRCredentials(serverURL string, projID uint) (user str
 
 		token = tokenResp.Token
 
-		if t := *tokenResp.ExpiresAt; len(token) > 0 && !t.IsZero() {
+		if t := tokenResp.ExpiresAt; len(token) > 0 && !t.IsZero() {
 			// set the token in cache
 			a.Cache.Set(serverURL, &AuthEntry{
 				AuthorizationToken: token,
@@ -215,7 +215,7 @@ func (a *AuthGetter) GetECRCredentials(serverURL string, projID uint) (user stri
 		a.Cache.Set(serverURL, &AuthEntry{
 			AuthorizationToken: token,
 			RequestedAt:        time.Now(),
-			ExpiresAt:          *tokenResp.ExpiresAt,
+			ExpiresAt:          tokenResp.ExpiresAt,
 			ProxyEndpoint:      serverURL,
 		})
 	}
@@ -242,7 +242,7 @@ func (a *AuthGetter) GetDockerHubCredentials(serverURL string, projID uint) (use
 		a.Cache.Set(serverURL, &AuthEntry{
 			AuthorizationToken: token,
 			RequestedAt:        time.Now(),
-			ExpiresAt:          *tokenResp.ExpiresAt,
+			ExpiresAt:          tokenResp.ExpiresAt,
 			ProxyEndpoint:      serverURL,
 		})
 	}
@@ -269,7 +269,7 @@ func (a *AuthGetter) GetACRCredentials(serverURL string, projID uint) (user stri
 		a.Cache.Set(serverURL, &AuthEntry{
 			AuthorizationToken: token,
 			RequestedAt:        time.Now(),
-			ExpiresAt:          *tokenResp.ExpiresAt,
+			ExpiresAt:          tokenResp.ExpiresAt,
 			ProxyEndpoint:      serverURL,
 		})
 	}

+ 7 - 7
dashboard/package-lock.json

@@ -12,7 +12,7 @@
         "@loadable/component": "^5.15.2",
         "@material-ui/core": "^4.11.3",
         "@material-ui/lab": "^4.0.0-alpha.61",
-        "@porter-dev/api-contracts": "^0.0.85",
+        "@porter-dev/api-contracts": "^0.0.86",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
         "@tanstack/react-query": "^4.13.0",
@@ -2440,9 +2440,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.0.85",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.85.tgz",
-      "integrity": "sha512-usSfoZQljk/LVIdsGnD022NEpKktmK1hNHH3wfx4VCC+G/yC0GsPcZM/7HOct5y3zh5slIZ6raqvdB3cvZ9LsQ==",
+      "version": "0.0.86",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.86.tgz",
+      "integrity": "sha512-nihcZuR+FsbbBBr+7gIsvpxSJvRS+eGurSAElytN5LIimL8TbYN4T+7EDAA0sDvRp95qF7B+vdezPbHTsWRkcA==",
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
       }
@@ -16698,9 +16698,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.0.85",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.85.tgz",
-      "integrity": "sha512-usSfoZQljk/LVIdsGnD022NEpKktmK1hNHH3wfx4VCC+G/yC0GsPcZM/7HOct5y3zh5slIZ6raqvdB3cvZ9LsQ==",
+      "version": "0.0.86",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.86.tgz",
+      "integrity": "sha512-nihcZuR+FsbbBBr+7gIsvpxSJvRS+eGurSAElytN5LIimL8TbYN4T+7EDAA0sDvRp95qF7B+vdezPbHTsWRkcA==",
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"
       }

+ 1 - 1
dashboard/package.json

@@ -7,7 +7,7 @@
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
-    "@porter-dev/api-contracts": "^0.0.85",
+    "@porter-dev/api-contracts": "^0.0.86",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
     "@tanstack/react-query": "^4.13.0",

+ 1 - 1
dashboard/src/components/CredentialsForm.tsx

@@ -188,7 +188,7 @@ const CredentialsForm: React.FC<Props> = ({
         <HSpacer />
         <Img src={aws} />
         Set AWS credentials
-        <HelperButton onClick={() => window.open("https://docs.porter.run/getting-started/provisioning-on-aws/", "_blank")}>
+        <HelperButton onClick={() => window.open("https://docs.porter.run/standard/getting-started/provisioning-on-aws", "_blank")}>
           <i className="material-icons">help_outline</i>
         </HelperButton>
       </Text>

+ 135 - 0
dashboard/src/components/GCPCostConsent.tsx

@@ -0,0 +1,135 @@
+import React, { useState, useContext } from "react";
+import styled from "styled-components";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+
+import Modal from "./porter/Modal";
+import Text from "./porter/Text";
+import Spacer from "./porter/Spacer";
+import Fieldset from "./porter/Fieldset";
+import Button from "./porter/Button";
+import ExpandableSection from "./porter/ExpandableSection";
+import Input from "./porter/Input";
+import Link from "./porter/Link";
+
+type Props = {
+  setCurrentStep: (step: string) => void;
+  setShowCostConfirmModal: (show: boolean) => void;
+  markCostConsentComplete: () => void;
+};
+
+const GCPCostConsent: React.FC<Props> = ({
+  setCurrentStep,
+  setShowCostConfirmModal,
+  markCostConsentComplete,
+}) => {
+  const [confirmCost, setConfirmCost] = useState("");
+
+  const costTotal = "224.58";
+  return (
+    <>
+      <Modal
+        closeModal={() => {
+          setConfirmCost("");
+          setShowCostConfirmModal(false);
+        }}
+      >
+        <Text size={16}>Base GCP cost consent</Text>
+        <Spacer height="15px" />
+        <Text color="helper">
+          Porter will create the underlying infrastructure in your own GCP project.
+          You will be separately charged by GCP for this infrastructure.
+          The cost for this base infrastructure is as follows:
+        </Text>
+        <Spacer y={1} />
+        <ExpandableSection
+          noWrapper
+          expandText="[+] Show details"
+          collapseText="[-] Hide details"
+          Header={<Cost>${costTotal} / mo</Cost>}
+          ExpandedSection={
+            <>
+              <Spacer height="15px" />
+              <Fieldset background="#1b1d2688">
+                • Amazon Elastic Kubernetes Service (EKS) = $73/mo
+                <Spacer height="15px" />
+                • Amazon EC2:
+                <Spacer height="15px" />
+                <Tab />+ System workloads: t3.medium instance (2) = $60.74/mo
+                <Spacer height="15px" />
+                <Tab />+ Monitoring workloads: t3.large instance (1) = $60.74/mo
+                <Spacer height="15px" />
+                <Tab />+ Application workloads: t3.medium instance (1) =
+                $30.1/mo
+              </Fieldset>
+            </>
+          }
+        />
+        <Spacer y={1} />
+        <Text color="helper">
+          The base GCP infrastructure covers up to 2 vCPU and 4GB of RAM.
+          Separate from the GCP cost, Porter charges based on your resource
+          usage.
+        </Text>
+        <Spacer inline width="5px" />
+        <Spacer y={0.5} />
+        <Link hasunderline to="https://porter.run/pricing" target="_blank">
+          Learn more about our pricing.
+        </Link>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          You can use your GCP credits to pay for the underlying infrastructure,
+          and if you are a startup with less than 5M in funding, you may qualify
+          for our startup program that gives you $10k in credits.
+        </Text>
+        <Spacer y={0.5} />
+        <Link
+          hasunderline
+          to="https://gcpjnf9adme.typeform.com/to/vUg9SDWf"
+          target="_blank"
+        >
+          You can apply here.
+        </Link>
+        <Spacer y={0.5} />
+        <Text color="helper">
+          All GCP resources will be automatically deleted when you delete your
+          Porter project. Please enter the GCP base cost ("{costTotal}") below to
+          proceed:
+        </Text>
+        <Spacer y={1} />
+        <Input
+          placeholder={costTotal}
+          value={confirmCost}
+          setValue={setConfirmCost}
+          width="100%"
+          height="40px"
+        />
+        <Spacer y={1} />
+        <Button
+          disabled={confirmCost !== costTotal}
+          onClick={() => {
+            setShowCostConfirmModal(false);
+            setConfirmCost("");
+            markCostConsentComplete();
+            setCurrentStep("credentials");
+          }}
+        >
+          Continue
+        </Button>
+      </Modal>
+    </>
+  );
+};
+
+export default GCPCostConsent;
+
+const Cost = styled.div`
+  font-weight: 600;
+  font-size: 20px;
+`;
+
+const Tab = styled.span`
+  margin-left: 20px;
+  height: 1px;
+`;

+ 232 - 0
dashboard/src/components/GCPCredentialsForm.tsx

@@ -0,0 +1,232 @@
+import React, { useContext, useState, useEffect } from "react";
+import gcp from "assets/gcp.png";
+
+import { Context } from "shared/Context";
+import api from "shared/api";
+import styled from "styled-components";
+import Loading from "components/Loading";
+import Placeholder from "components/OldPlaceholder";
+import Helper from "components/form-components/Helper";
+import UploadArea from "components/form-components/UploadArea";
+import Text from "components/porter/Text";
+import Button from "components/porter/Button";
+import Spacer from "./porter/Spacer";
+import Container from "./porter/Container";
+
+
+type Props = {
+  goBack: () => void;
+  proceed: (id: string) => void;
+};
+
+const GCPCredentialsForm: React.FC<Props> = ({ goBack, proceed }) => {
+  const { currentProject } = useContext(Context);
+  const [isContinueEnabled, setIsContinueEnabled] = useState(false);
+  const [projectId, setProjectId] = useState("");
+  const [serviceAccountKey, setServiceAccountKey] = useState("");
+  const [isLoading, setIsLoading] = useState(false);
+  const [errorMessage, setErrorMessage] = useState("");
+  const [detected, setDetected] = useState<Detected | undefined>(undefined);
+
+  useEffect(() => {
+    setDetected(undefined);
+  }, []);
+
+  interface FailureState {
+    condition: boolean;
+    errorMessage: string;
+  }
+  const failureStates: FailureState[] = [
+    {
+      condition: currentProject == null,
+      errorMessage: "Project ID is required",
+    },
+  ]
+
+  type Detected = {
+    detected: boolean;
+    message: string;
+  };
+
+  const saveCredentials = async () => {
+    failureStates.forEach((failureState) => {
+      if (failureState.condition) {
+        setErrorMessage(failureState.errorMessage);
+      }
+    })
+    setIsLoading(true);
+
+    try {
+      const gcpIntegrationResponse = await api.createGCPIntegration(
+        "<token>",
+        {
+          gcp_key_data: serviceAccountKey,
+          gcp_project_id: projectId,
+        },
+        {
+          project_id: currentProject.id,
+        });
+      if (gcpIntegrationResponse.data.cloud_provider_credentials_id == "") {
+        setErrorMessage("Unable to store cluster credentials. Please try again later. If the problem persists, contact support@porter.run")
+        return;
+      }
+      const gcpCloudProviderCredentialID = gcpIntegrationResponse.data.cloud_provider_credentials_id;
+      proceed(gcpCloudProviderCredentialID)
+
+    } catch (err) {
+      if (err.response?.data?.error) {
+        setErrorMessage(err.response?.data?.error.replace("unknown: ", ""));
+      } else {
+        setErrorMessage("Something went wrong, please try again later.");
+      }
+    };
+  }
+
+  const handleLoadJSON = (serviceAccountJSONFile: string) => {
+    setServiceAccountKey(serviceAccountJSONFile)
+    const serviceAccountCredentials = JSON.parse(serviceAccountJSONFile);
+
+    if (!serviceAccountCredentials.project_id) {
+      setIsContinueEnabled(false);
+      setProjectId("")
+      setDetected({
+        detected: false,
+        message: `Invalid GCP service account credentials. No project ID detected in uploaded file. Please try again.`,
+      });
+      return
+    }
+
+    setProjectId(serviceAccountCredentials.project_id);
+    setDetected({
+      detected: true,
+      message: `Your cluster will be provisioned in Google Project: ${serviceAccountCredentials.project_id}`,
+    });
+    setIsContinueEnabled(true);
+  }
+
+  if (isLoading) {
+    return (
+      <Placeholder>
+        <Loading />
+      </Placeholder>
+    );
+  }
+
+  return (
+    <>
+      <Container row>
+        <BackButton width="140px" onClick={goBack}>
+          <i className="material-icons">first_page</i>
+          Select cloud
+        </BackButton>
+        <Spacer x={1} inline />
+        <Img src={gcp} />
+        Set GKE credentials
+      </Container>
+      <Helper>Service account credentials for GCP permissions.</Helper>
+      <UploadArea
+        setValue={(x: string) => handleLoadJSON(x)}
+        label="🔒 GCP Key Data (JSON)"
+        placeholder="Choose a file or drag it here."
+        width="100%"
+        height="100%"
+        isRequired={true}
+      />
+
+      {detected && serviceAccountKey && (
+        <AppearingDiv color={projectId ? "#8590ff" : "#fcba03"}>
+          {detected.detected ? (
+            <I className="material-icons">check</I>
+          ) : (
+            <I className="material-icons">error</I>
+          )}
+          <Text color={detected.detected ? "#8590ff" : "#fcba03"}>
+            {detected.message}
+          </Text>
+        </AppearingDiv>
+      )}
+
+      <Spacer y={0.5} />
+
+      <Button
+        disabled={!isContinueEnabled}
+        onClick={saveCredentials}
+      >Continue</Button>
+
+    </>
+  );
+};
+
+
+export default GCPCredentialsForm;
+
+const BackButton = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  cursor: pointer;
+  font-size: 13px;
+  height: 35px;
+  padding: 5px 13px;
+  padding-right: 15px;
+  border: 1px solid #ffffff55;
+  border-radius: 100px;
+  width: ${(props: { width: string }) => props.width};
+  color: white;
+  background: #ffffff11;
+
+  :hover {
+    background: #ffffff22;
+  }
+
+  > i {
+    color: white;
+    font-size: 16px;
+    margin-right: 6px;
+    margin-left: -2px;
+  }
+`;
+
+const HelperButton = styled.div`
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  margin-left: 10px;
+  justify-content: center;
+  > i {
+    color: #aaaabb;
+    width: 24px;
+    height: 24px;
+    font-size: 20px;
+    border-radius: 20px;
+  }
+`;
+
+const Img = styled.img`
+  height: 18px;
+  margin-right: 15px;
+`;
+
+const AppearingDiv = styled.div<{ color?: string }>`
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+  display: flex;
+  align-items: center;
+  color: ${(props) => props.color || "#ffffff44"};
+  margin-left: 10px;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const I = styled.i`
+  font-size: 18px;
+  margin-right: 5px;
+`;

+ 366 - 0
dashboard/src/components/GCPProvisionerSettings.tsx

@@ -0,0 +1,366 @@
+import React, { useEffect, useState, useContext } from "react";
+import styled from "styled-components";
+import { RouteComponentProps, withRouter } from "react-router";
+
+import { OFState } from "main/home/onboarding/state";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { pushFiltered } from "shared/routing";
+
+import SelectRow from "components/form-components/SelectRow";
+import Heading from "components/form-components/Heading";
+import Helper from "components/form-components/Helper";
+import InputRow from "./form-components/InputRow";
+import {
+  Contract,
+  EnumKubernetesKind,
+  EnumCloudProvider,
+  Cluster,
+  GKE,
+  GKENetwork,
+  GKENodePool,
+  GKENodePoolType
+} from "@porter-dev/api-contracts";
+import { ClusterType } from "shared/types";
+import Button from "./porter/Button";
+import Error from "./porter/Error";
+import Spacer from "./porter/Spacer";
+import Step from "./porter/Step";
+import Link from "./porter/Link";
+import Text from "./porter/Text";
+
+const locationOptions = [
+  { value: "us-east1", label: "us-east1" },
+];
+
+const defaultClusterNetworking = new GKENetwork({
+  cidrRange: "10.78.0.0/16",
+  controlPlaneCidr: "10.77.0.0/28",
+  podCidr: "10.76.0.0/16",
+  serviceCidr: "10.75.0.0/16",
+});
+
+const defaultClusterVersion = "1.25";
+
+
+type Props = RouteComponentProps & {
+  selectedClusterVersion?: Contract;
+  provisionerError?: string;
+  credentialId: string;
+  clusterId?: number;
+};
+
+const VALID_CIDR_RANGE_PATTERN = /^(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.0\.0\/16$/;
+
+const GCPProvisionerSettings: React.FC<Props> = (props) => {
+  const {
+    user,
+    currentProject,
+    currentCluster,
+    setCurrentCluster,
+    setShouldRefreshClusters,
+  } = useContext(Context);
+  const [createStatus, setCreateStatus] = useState("");
+  const [clusterName, setClusterName] = useState("");
+  const [region, setRegion] = useState(locationOptions[0].value);
+  const [minInstances, setMinInstances] = useState(1);
+  const [maxInstances, setMaxInstances] = useState(10);
+  const [clusterNetworking, setClusterNetworking] = useState(defaultClusterNetworking);
+  const [clusterVersion, setClusterVersion] = useState(defaultClusterVersion);
+  const [isReadOnly, setIsReadOnly] = useState(false);
+  const [errorMessage, setErrorMessage] = useState<string>("");
+  const [errorDetails, setErrorDetails] = useState<string>("");
+  const [isClicked, setIsClicked] = useState(false);
+
+  const markStepStarted = async (step: string) => {
+    try {
+      await api.updateOnboardingStep("<token>", { step }, {
+        project_id: currentProject.id,
+      });
+    } catch (err) {
+      console.log(err);
+    }
+  };
+
+  const getStatus = () => {
+    if (isReadOnly && props.provisionerError == "") {
+      return "Provisioning is still in progress...";
+    } else if (errorMessage !== "") {
+      return (
+        <Error
+          message={errorDetails !== "" ? errorMessage + " (" + errorDetails + ")" : errorMessage}
+          ctaText={
+            errorMessage !== DEFAULT_ERROR_MESSAGE
+              ? "Troubleshooting steps"
+              : null
+          }
+          errorModalContents={errorMessageToModal(errorMessage)}
+        />
+      );
+    }
+    return undefined;
+  };
+
+  const isDisabled = () => {
+    return (
+      !user.email.endsWith("porter.run") &&
+      ((!clusterName && true) ||
+        (isReadOnly && props.provisionerError === "") ||
+        props.provisionerError === "" ||
+        currentCluster?.status === "UPDATING" ||
+        isClicked)
+    );
+  };
+
+  const validateInputs = (): string => {
+    if (!clusterName) {
+      return "Cluster name is required";
+    }
+    if (!region) {
+      return "GCP region is required";
+    }
+    if (!clusterNetworking.cidrRange) {
+      return "VPC CIDR range is required";
+    }
+    if (!VALID_CIDR_RANGE_PATTERN.test(clusterNetworking.cidrRange)) {
+      return "VPC CIDR range must be in the format of [0-255].[0-255].0.0/16";
+    }
+
+    return "";
+  }
+  const createCluster = async () => {
+    const err = validateInputs();
+    if (err !== "") {
+      setErrorMessage(err)
+      setErrorDetails("")
+      return;
+    }
+
+    setIsClicked(true);
+    var data = new Contract({
+      cluster: new Cluster({
+        projectId: currentProject.id,
+        kind: EnumKubernetesKind.GKE,
+        cloudProvider: EnumCloudProvider.GCP,
+        cloudProviderCredentialsId: props.credentialId,
+        kindValues: {
+          case: "gkeKind",
+          value: new GKE({
+            clusterName: clusterName,
+            clusterVersion: clusterVersion || defaultClusterVersion,
+            region: region,
+            network: new GKENetwork({
+              cidrRange: clusterNetworking.cidrRange || defaultClusterNetworking.cidrRange,
+              controlPlaneCidr: defaultClusterNetworking.controlPlaneCidr,
+              podCidr: defaultClusterNetworking.podCidr,
+              serviceCidr: defaultClusterNetworking.serviceCidr,
+            }),
+            nodePools: [
+              new GKENodePool({
+                instanceType: "custom-2-4096",
+                minInstances: 1,
+                maxInstances: 1,
+                nodePoolType: GKENodePoolType.GKE_NODE_POOL_TYPE_MONITORING
+              }),
+
+              new GKENodePool({
+                instanceType: "custom-2-4096",
+                minInstances: 1,
+                maxInstances: 2,
+                nodePoolType: GKENodePoolType.GKE_NODE_POOL_TYPE_SYSTEM
+              }),
+              new GKENodePool({
+                instanceType: "custom-2-4096",
+                minInstances: 1, // TODO: make these customizable before merging
+                maxInstances: 10, // TODO: make these customizable before merging
+                nodePoolType: GKENodePoolType.GKE_NODE_POOL_TYPE_APPLICATION
+              }),
+
+            ],
+          }),
+        },
+      }),
+    });
+
+    if (props.clusterId) {
+      data["cluster"]["clusterId"] = props.clusterId;
+    }
+
+    try {
+      setIsReadOnly(true);
+      setErrorMessage("");
+      setErrorDetails("")
+
+      if (!props.clusterId) {
+        markStepStarted("provisioning-started");
+      }
+
+      const res = await api.createContract("<token>", data, {
+        project_id: currentProject.id,
+      });
+
+      setErrorMessage("");
+      setErrorDetails("");
+
+      // Only refresh and set clusters on initial create
+      setShouldRefreshClusters(true);
+      api
+        .getClusters("<token>", {}, { id: currentProject.id })
+        .then(({ data }) => {
+          data.forEach((cluster: ClusterType) => {
+            if (cluster.id === res.data.contract_revision?.cluster_id) {
+              // setHasFinishedOnboarding(true);
+              setCurrentCluster(cluster);
+              OFState.actions.goTo("clean_up");
+              pushFiltered(props, "/cluster-dashboard", ["project_id"], {
+                cluster: cluster.name,
+              });
+            }
+          });
+        })
+        .catch((err) => {
+          setErrorMessage("Error fetching clusters");
+          setErrorDetails(err)
+        });
+
+    } catch (err) {
+      const errMessage = err.response.data.error.replace("unknown: ", "");
+      setIsClicked(false);
+      // TODO: handle different error conditions here from preflights
+      setErrorMessage(DEFAULT_ERROR_MESSAGE);
+      setErrorDetails(errMessage)
+    } finally {
+      setIsReadOnly(false);
+      setIsClicked(false);
+    }
+  };
+
+  useEffect(() => {
+    setIsReadOnly(
+      props.clusterId &&
+      (currentCluster?.status === "UPDATING" ||
+        currentCluster?.status === "UPDATING_UNAVAILABLE")
+    );
+    setClusterName(
+      `${currentProject.name}-cluster-${Math.random()
+        .toString(36)
+        .substring(2, 8)}`
+    );
+  }, []);
+
+  useEffect(() => {
+    const contract = props.selectedClusterVersion as any;
+    if (contract?.cluster) {
+      if (contract.cluster.gkeKind.nodePools) {
+        contract.cluster.gkeKind.nodePools.map((nodePool: any) => {
+          if (nodePool.nodePoolType === "NODE_POOL_TYPE_APPLICATION") {
+            setMinInstances(nodePool.minInstances);
+            setMaxInstances(nodePool.maxInstances);
+          }
+        });
+      }
+      setCreateStatus("");
+      setClusterName(contract.cluster.gkeKind.clusterName);
+      setRegion(contract.cluster.gkeKind.region);
+      setClusterVersion(contract.cluster.gkeKind.clusterVersion);
+      let cn = new GKENetwork({
+        cidrRange: contract.cluster.gkeKind.clusterNetworking?.cidrRange || defaultClusterNetworking.cidrRange,
+        controlPlaneCidr: defaultClusterNetworking.controlPlaneCidr,
+        podCidr: defaultClusterNetworking.podCidr,
+        serviceCidr: defaultClusterNetworking.serviceCidr,
+      })
+      setClusterNetworking(cn);
+    }
+  }, [props.selectedClusterVersion]);
+
+  const renderForm = () => {
+    // Render simplified form if initial create
+    if (!props.clusterId) {
+      return (
+        <>
+          <Text size={16}>Select a Google Cloud Region for your cluster</Text>
+          <Spacer y={1} />
+          <Text color="helper">
+            Porter will provision your infrastructure in the
+            specified location.
+          </Text>
+          <Spacer height="10px" />
+          <SelectRow
+            options={locationOptions}
+            width="350px"
+            disabled={isReadOnly}
+            value={region}
+            scrollBuffer={true}
+            dropdownMaxHeight="240px"
+            setActiveValue={setRegion}
+            label="📍 GCP location"
+          />
+          <InputRow
+            width="350px"
+            type="string"
+            disabled={isReadOnly}
+            value={clusterNetworking.cidrRange}
+            setValue={(x: string) => setClusterNetworking(new GKENetwork({ ...clusterNetworking, cidrRange: x }))}
+            label="VPC CIDR range"
+            placeholder="ex: 10.78.0.0/16"
+          />
+          <Spacer y={0.25} />
+          <Text color="helper">The following ranges will be used: {clusterNetworking.cidrRange}, {clusterNetworking.controlPlaneCidr}, {clusterNetworking.serviceCidr}, {clusterNetworking.podCidr}</Text>
+        </>
+      );
+    }
+
+    // If settings, update full form
+    return (
+      <>
+        <Heading isAtTop>GCP configuration</Heading>
+        <SelectRow
+          options={locationOptions}
+          width="350px"
+          disabled={isReadOnly || true}
+          value={region}
+          scrollBuffer={true}
+          dropdownMaxHeight="240px"
+          setActiveValue={setRegion}
+          label="📍 Google Cloud Region"
+        />
+      </>
+    );
+  };
+
+  return (
+    <>
+      <StyledForm>{renderForm()}</StyledForm>
+      <Button
+        disabled={isDisabled()}
+        onClick={createCluster}
+        status={getStatus()}
+      >
+        Provision
+      </Button>
+    </>
+  );
+};
+
+export default withRouter(GCPProvisionerSettings);
+
+
+const StyledForm = styled.div`
+  position: relative;
+  padding: 30px 30px 25px;
+  border-radius: 5px;
+  background: ${({ theme }) => theme.fg};
+  border: 1px solid #494b4f;
+  font-size: 13px;
+  margin-bottom: 30px;
+`;
+
+const DEFAULT_ERROR_MESSAGE =
+  "An error occurred while provisioning your infrastructure. Please try again.";
+
+const errorMessageToModal = (errorMessage: string) => {
+  switch (errorMessage) {
+    default:
+      return null;
+  }
+};

+ 39 - 3
dashboard/src/components/ProvisionerFlow.tsx

@@ -8,10 +8,12 @@ import api from "shared/api";
 import ProvisionerForm from "components/ProvisionerForm";
 import CloudFormationForm from "components/CloudFormationForm";
 import CredentialsForm from "components/CredentialsForm";
+import GCPCredentialsForm from "components/GCPCredentialsForm";
 import Helper from "components/form-components/Helper";
 import AzureCredentialForm from "components/AzureCredentialForm";
 import AWSCostConsent from "./AWSCostConsent";
 import AzureCostConsent from "./AzureCostConsent";
+import GCPCostConsent from "./GCPCostConsent";
 
 const providers = ["aws", "gcp", "azure"];
 
@@ -66,14 +68,14 @@ const ProvisionerFlow: React.FC<Props> = ({ }) => {
                   disabled={
                     isUsageExceeded ||
                     (provider === "azure" && !currentProject?.azure_enabled) ||
-                    provider === "gcp"
+                    (provider === "gcp" && !currentProject?.azure_enabled)
                   }
                   onClick={() => {
                     if (
                       !(
                         isUsageExceeded ||
                         (provider === "azure" && !currentProject?.azure_enabled) ||
-                        provider === "gcp"
+                        (provider === "gcp" && !currentProject?.azure_enabled)
                       )
                     ) {
                       openCostConsentModal(provider);
@@ -86,7 +88,7 @@ const ProvisionerFlow: React.FC<Props> = ({ }) => {
                   <BlockTitle>{providerInfo.label}</BlockTitle>
                   <BlockDescription>
                     {(provider === "azure" && !currentProject?.azure_enabled) ||
-                      provider === "gcp" ? providerInfo.tagline : "Hosted in your own cloud"}
+                      (provider === "gcp" && !currentProject?.azure_enabled) ? providerInfo.tagline : "Hosted in your own cloud"}
                   </BlockDescription>
                 </Block>
               );
@@ -119,6 +121,31 @@ const ProvisionerFlow: React.FC<Props> = ({ }) => {
               }}
             />
           )) ||
+            ((selectedProvider === "gcp" && (
+              <GCPCostConsent
+                setCurrentStep={setCurrentStep}
+                setShowCostConfirmModal={setShowCostConfirmModal}
+                markCostConsentComplete={() => {
+                  try {
+                    markStepCostConsent("cost-consent-complete", "gcp");
+                  } catch (err) {
+                    console.log(err);
+                  }
+
+                  if (currentProject != null) {
+                    try {
+                      api.inviteAdmin(
+                        "<token>",
+                        {},
+                        { project_id: currentProject.id }
+                      );
+                    } catch (err) {
+                      console.log(err);
+                    }
+                  }
+                }}
+              />
+            ))) ||
             (selectedProvider === "azure" && (
               <AzureCostConsent
                 setCurrentStep={setCurrentStep}
@@ -174,6 +201,15 @@ const ProvisionerFlow: React.FC<Props> = ({ }) => {
             setCurrentStep("cluster");
           }}
         />
+      )) ||
+      (selectedProvider === "gcp" && (
+        <GCPCredentialsForm
+          goBack={() => setCurrentStep("cloud")}
+          proceed={(id) => {
+            setCredentialId(id);
+            setCurrentStep("cluster");
+          }}
+        />
       ))
     );
   } else if (currentStep === "cluster") {

+ 21 - 0
dashboard/src/components/ProvisionerForm.tsx

@@ -3,6 +3,7 @@ import styled from "styled-components";
 
 import aws from "assets/aws.png";
 import azure from "assets/azure.png";
+import gcp from "assets/gcp.png";
 
 import Heading from "components/form-components/Heading";
 import Helper from "./form-components/Helper";
@@ -11,6 +12,7 @@ import Text from "./porter/Text";
 import Spacer from "./porter/Spacer";
 import Container from "./porter/Container";
 import AzureProvisionerSettings from "./AzureProvisionerSettings";
+import GCPProvisionerSettings from "./GCPProvisionerSettings";
 
 type Props = {
   goBack: () => void;
@@ -63,6 +65,25 @@ const ProvisionerForm: React.FC<Props> = ({
           <AzureProvisionerSettings credentialId={credentialId} />
         </>
       )}
+      {provider === "gcp" && (
+        <>
+          <Container row>
+            <BackButton width="155px" onClick={goBack}>
+              <i className="material-icons">first_page</i>
+              Set credentials
+            </BackButton>
+            <Spacer inline width="17px" />
+            <Img src={gcp} />
+            <Text size={16}>Configure settings</Text>
+          </Container>
+          <Spacer y={1} />
+          <Text color="helper">
+            Configure settings for your GCP environment.
+          </Text>
+          <Spacer y={1} />
+          <GCPProvisionerSettings credentialId={credentialId} />
+        </>
+      )}
     </>
   );
 };

+ 9 - 6
dashboard/src/components/form-components/UploadArea.tsx

@@ -4,7 +4,7 @@ import upload from "assets/upload.svg";
 
 type PropsType = {
   label?: string;
-  setValue: (x: any) => void;
+  setValue: (x: string) => void;
   width?: string;
   height?: string;
   placeholder?: string;
@@ -17,16 +17,16 @@ type StateType = {
 
 export default class UploadArea extends Component<PropsType, StateType> {
   state = {
-    fileName: null as string,
+    fileName: "",
   };
   handleChange = (e: any) => {
     this.props.setValue(e.target.value);
   };
 
-  readFile = (file: any) => {
+  readFile = (file: File) => {
     const reader = new FileReader();
     reader.onload = async (e) => {
-      let text = e.target.result;
+      let text = e.target?.result as string;
       this.props.setValue(text);
     };
     reader.readAsText(file, "UTF-8");
@@ -61,7 +61,7 @@ export default class UploadArea extends Component<PropsType, StateType> {
             this.readFile(files[0]);
           }}
           onClick={() => {
-            document.getElementById("file").click();
+            document.getElementById("file")?.click();
           }}
         >
           <input
@@ -71,8 +71,11 @@ export default class UploadArea extends Component<PropsType, StateType> {
             accept=".json"
             onChange={(event) => {
               event.preventDefault();
+              if (!event?.target?.files) {
+                return;
+              }
               this.readFile(event.target.files[0]);
-              event.currentTarget.value = null;
+              event.currentTarget.value = "";
             }}
           />
           <Message>

+ 1 - 1
dashboard/src/components/porter/Link.tsx

@@ -28,7 +28,7 @@ const Link: React.FC<Props> = ({
           {children}
           {target === "_blank" && (
             <div>
-              <Svg data-testid="geist-icon" fill="none" height="1em" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em" data-darkreader-inline-stroke="" data-darkreader-inline-color=""><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"></path><path d="M15 3h6v6"></path><path d="M10 14L21 3"></path></Svg>
+              <Svg data-testid="geist-icon" fill="none" height="1em" shape-rendering="geometricPrecision" stroke="currentColor" stroke-linecap="round" strokeLinejoin="round" stroke-width="2" viewBox="0 0 24 24" width="1em" data-darkreader-inline-stroke="" data-darkreader-inline-color=""><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"></path><path d="M15 3h6v6"></path><path d="M10 14L21 3"></path></Svg>
             </div>
           )}
         </StyledLink>

+ 12 - 1
dashboard/src/main/home/cluster-dashboard/dashboard/Dashboard.tsx

@@ -22,6 +22,7 @@ import ClusterSettingsModal from "./ClusterSettingsModal";
 import Loading from "components/Loading";
 import Spacer from "components/porter/Spacer";
 import AzureProvisionerSettings from "components/AzureProvisionerSettings";
+import GCPProvisionerSettings from "components/GCPProvisionerSettings";
 
 type TabEnum =
   | "nodes"
@@ -89,6 +90,16 @@ export const Dashboard: React.FunctionComponent = () => {
                 }
               />
             )}
+            {context.currentCluster.cloud_provider == "GCP" && (
+              <GCPProvisionerSettings
+                selectedClusterVersion={selectedClusterVersion}
+                provisionerError={provisionFailureReason}
+                clusterId={context.currentCluster.id}
+                credentialId={
+                  context.currentCluster.cloud_provider_credential_identifier
+                }
+              />
+            )}
           </>
         );
       default:
@@ -240,7 +251,7 @@ export const Dashboard: React.FunctionComponent = () => {
                   stroke="white"
                   strokeWidth="1.5"
                   strokeLinecap="round"
-                  stroke-linejoin="round"
+                  strokeLinejoin="round"
                 />
                 <path
                   fillRule="evenodd"

+ 3 - 3
go.mod

@@ -79,7 +79,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.0.85
+	github.com/porter-dev/api-contracts v0.0.86
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d
@@ -231,7 +231,7 @@ require (
 	github.com/gogo/protobuf v1.3.2 // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/google/btree v1.1.2 // indirect
-	github.com/google/go-cmp v0.5.9
+	github.com/google/go-cmp v0.5.9 // indirect
 	github.com/google/go-containerregistry v0.9.0 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/google/gofuzz v1.2.0 // indirect
@@ -312,7 +312,7 @@ require (
 	github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
 	github.com/segmentio/backo-go v0.0.0-20200129164019-23eae7c10bd3 // indirect
 	github.com/sendgrid/rest v2.6.3+incompatible // indirect
-	github.com/sergi/go-diff v1.2.0 // indirect
+	github.com/sergi/go-diff v1.2.0
 	github.com/shopspring/decimal v1.3.1 // indirect
 	github.com/sirupsen/logrus v1.9.0 // indirect
 	github.com/spf13/afero v1.6.0 // indirect

+ 2 - 2
go.sum

@@ -1489,8 +1489,8 @@ github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
-github.com/porter-dev/api-contracts v0.0.85 h1:OYFDEeiZ3HUMu1Qm6JHUPhwkfOsUi1xYKeYW51KWubU=
-github.com/porter-dev/api-contracts v0.0.85/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.0.86 h1:5UTg8SueLTliV32YzbC4RtNUsZ3VNJf8LGUmAxd0aig=
+github.com/porter-dev/api-contracts v0.0.86/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

+ 8 - 7
internal/registry/registry.go

@@ -156,9 +156,9 @@ func (r *Registry) ListRepositories(
 
 	if project.CapiProvisionerEnabled {
 		// TODO: Remove this conditional when AWS list repos is supported in CCP
-		if strings.Contains(r.URL, ".azurecr.") {
-			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "auth-mechanism", Value: "capi-azure"})
+		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "registry-uri", Value: r.URL})
 
+		if strings.Contains(r.URL, ".azurecr.") || strings.Contains(r.URL, "-docker.pkg.dev") {
 			req := connect.NewRequest(&porterv1.ListRepositoriesForRegistryRequest{
 				ProjectId:   int64(r.ProjectID),
 				RegistryUri: r.URL,
@@ -166,7 +166,7 @@ func (r *Registry) ListRepositories(
 
 			resp, err := conf.ClusterControlPlaneClient.ListRepositoriesForRegistry(ctx, req)
 			if err != nil {
-				return nil, telemetry.Error(ctx, span, err, "error listing ecr repositories")
+				return nil, telemetry.Error(ctx, span, err, "error listing docker repositories")
 			}
 
 			res := make([]*ptypes.RegistryRepository, 0)
@@ -185,7 +185,6 @@ func (r *Registry) ListRepositories(
 
 			return res, nil
 		} else {
-			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "auth-mechanism", Value: "capi-aws"})
 			uri := strings.TrimPrefix(r.URL, "https://")
 			splits := strings.Split(uri, ".")
 			if len(splits) < 4 {
@@ -869,6 +868,8 @@ func (r *Registry) CreateRepository(
 	ctx, span := telemetry.NewSpan(ctx, "create-repository")
 	defer span.End()
 
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "registry-uri", Value: r.URL})
+
 	// if aws, create repository
 	if r.AWSIntegrationID != 0 {
 		telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "aws-integration-id", Value: r.AWSIntegrationID})
@@ -899,9 +900,9 @@ func (r *Registry) CreateRepository(
 	}
 
 	if project.CapiProvisionerEnabled {
-		// no need to create repository if pushing to ACR
-		if strings.Contains(r.URL, ".azurecr.") {
-			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "skipping-create-because-azure", Value: true})
+		// no need to create repository if pushing to ACR or GAR
+		if strings.Contains(r.URL, ".azurecr.") || strings.Contains(r.URL, "-docker.pkg.dev") {
+			telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "skipping-create-repo", Value: true})
 			return nil
 		}