Justin Rhee 3 年 前
コミット
4e3942a8e1

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

@@ -1,7 +1,9 @@
 package api_contract
 
 import (
+	"database/sql"
 	"encoding/base64"
+	"errors"
 	"fmt"
 	"net/http"
 
@@ -47,6 +49,22 @@ func (c *APIContractUpdateHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		return
 	}
 
+	existingClusters, err := c.Config().Repo.Cluster().ListClustersByProjectID(uint(apiContract.Cluster.ProjectId))
+	if err != nil {
+		if !errors.Is(err, sql.ErrNoRows) {
+			e := fmt.Errorf("error listing clusters for given project ID: %w", err)
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+			return
+		}
+	}
+	for _, cluster := range existingClusters {
+		if cluster.Name == apiContract.Cluster.GetEksKind().ClusterName {
+			e := fmt.Errorf("cluster already exists in project %d called %s", cluster.ProjectID, cluster.Name)
+			c.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+			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

+ 0 - 8
api/server/handlers/project_integration/preflight_check_aws_usage.go

@@ -2,7 +2,6 @@ package project_integration
 
 import (
 	"fmt"
-	"log"
 	"net/http"
 
 	"github.com/bufbuild/connect-go"
@@ -33,17 +32,11 @@ func (p *CreatePreflightCheckAWSUsageHandler) ServeHTTP(w http.ResponseWriter, r
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	ctx := r.Context()
 
-	log.Println("got here")
-
 	request := &types.QuotaPreflightCheckRequest{}
 	if ok := p.DecodeAndValidate(w, r, request); !ok {
 		return
 	}
 
-	log.Println("project id: ", project.ID)
-	log.Println("target arn: ", request.TargetARN)
-	log.Println("region: ", request.Region)
-
 	checkReq := porterv1.QuotaPreflightCheckRequest{
 		ProjectId: int64(project.ID),
 		TargetArn: request.TargetARN,
@@ -51,7 +44,6 @@ func (p *CreatePreflightCheckAWSUsageHandler) ServeHTTP(w http.ResponseWriter, r
 	}
 
 	checkResp, err := p.Config().ClusterControlPlaneClient.QuotaPreflightCheck(ctx, connect.NewRequest(&checkReq))
-
 	if err != nil {
 		e := fmt.Errorf("Pre-provision check failed: %w", err)
 		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusPreconditionFailed, err.Error()))

+ 10 - 0
api/server/handlers/user/update_onboarding_step.go

@@ -45,6 +45,16 @@ func (v *UpdateOnboardingStepHandler) ServeHTTP(w http.ResponseWriter, r *http.R
 		}))
 	}
 
+	if request.Step == "pre-provisioning-check-started" {
+		v.Config().AnalyticsClient.Track(analytics.PreProvisionCheckTrack(&analytics.PreProvisionCheckTrackOpts{
+			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),
+			Email:               user.Email,
+			FirstName:           user.FirstName,
+			LastName:            user.LastName,
+			CompanyName:         user.CompanyName,
+		}))
+	}
+
 	if request.Step == "provisioning-started" {
 		v.Config().AnalyticsClient.Track(analytics.ProvisioningAttemptTrack(&analytics.ProvisioningAttemptTrackOpts{
 			UserScopedTrackOpts: analytics.GetUserScopedTrackOpts(user.ID),

+ 5 - 0
api/server/shared/config/loader/loader.go

@@ -230,6 +230,11 @@ func (e *EnvConfigLoader) LoadConfig() (res *config.Config, err error) {
 			ReadBufferSize:  1024,
 			WriteBufferSize: 1024,
 			CheckOrigin: func(r *http.Request) bool {
+				var err error
+				defer func() {
+					// TODO: this is only used to collect data for removing the `request origin not allowed by Upgrader.CheckOrigin` error
+					res.Logger.Info().Msgf("error: %s, host: %s, origin: %s, serverURL: %s", err.Error(), r.Host, r.Header.Get("Origin"), sc.ServerURL)
+				}()
 				origin := r.Header.Get("Origin")
 
 				// // check if the server url is localhost, and allow all localhost origins

+ 1 - 1
api/types/project_integration.go

@@ -69,7 +69,7 @@ type AWSIntegration struct {
 	// The project that this integration belongs to
 	ProjectID uint `json:"project_id"`
 
-	// The AWS arn this is integration is linked to
+	// The AWS arn this integration is linked to
 	AWSArn string `json:"aws_arn"`
 }
 

+ 81 - 0
cli/cmd/job.go

@@ -8,6 +8,7 @@ import (
 	"github.com/fatih/color"
 	api "github.com/porter-dev/porter/api/client"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/cli/cmd/deploy"
 	"github.com/porter-dev/porter/cli/cmd/deploy/wait"
 	"github.com/spf13/cobra"
 )
@@ -77,12 +78,43 @@ use the --namespace flag:
 	},
 }
 
+var runJobCmd = &cobra.Command{
+	Use:   "run",
+	Short: "Manually runs a job and waits for it to complete.",
+	Long: fmt.Sprintf(`
+%s
+
+Manually runs a job and waits for it to complete a run. If the job completes successfully,
+this command exits with exit code 0. Otherwise, this command exits with exit code 1.
+
+Example commands:
+
+  %s
+
+This command is namespace-scoped and uses the default namespace. To specify a different namespace,
+use the --namespace flag:
+
+  %s
+`,
+		color.New(color.FgBlue, color.Bold).Sprintf("Help for \"porter job run\":"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter job run --name job-example"),
+		color.New(color.FgGreen, color.Bold).Sprintf("porter job run --name job-example --namespace custom-namespace"),
+	),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := checkLoginAndRun(args, runJob)
+		if err != nil {
+			os.Exit(1)
+		}
+	},
+}
+
 var imageRepoURI string
 
 func init() {
 	rootCmd.AddCommand(jobCmd)
 	jobCmd.AddCommand(batchImageUpdateCmd)
 	jobCmd.AddCommand(waitCmd)
+	jobCmd.AddCommand(runJobCmd)
 
 	batchImageUpdateCmd.PersistentFlags().StringVar(
 		&tag,
@@ -124,6 +156,22 @@ func init() {
 	)
 
 	waitCmd.MarkPersistentFlagRequired("name")
+
+	runJobCmd.PersistentFlags().StringVar(
+		&namespace,
+		"namespace",
+		"",
+		"The namespace of the job.",
+	)
+
+	runJobCmd.PersistentFlags().StringVar(
+		&name,
+		"name",
+		"",
+		"The name of the job.",
+	)
+
+	runJobCmd.MarkPersistentFlagRequired("name")
 }
 
 func batchImageUpdate(_ *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
@@ -150,3 +198,36 @@ func waitForJob(_ *types.GetAuthenticatedUserResponse, client *api.Client, args
 		Name:      name,
 	})
 }
+
+func runJob(authRes *types.GetAuthenticatedUserResponse, client *api.Client, args []string) error {
+	color.New(color.FgGreen).Printf("Running job %s in namespace %s\n", name, namespace)
+
+	waitForSuccessfulDeploy = true
+
+	updateAgent := &deploy.DeployAgent{
+		App:    name,
+		Client: client,
+		Opts: &deploy.DeployOpts{
+			SharedOpts: &deploy.SharedOpts{
+				ProjectID: cliConf.Project,
+				ClusterID: cliConf.Cluster,
+				Namespace: namespace,
+			},
+		},
+	}
+
+	err := updateAgent.UpdateImageAndValues(map[string]interface{}{
+		"paused": false,
+	})
+	if err != nil {
+		return fmt.Errorf("error running job: %w", err)
+	}
+
+	err = waitForJob(authRes, client, args)
+
+	if err != nil {
+		return fmt.Errorf("error waiting for job to complete: %w", err)
+	}
+
+	return nil
+}

+ 1 - 5
dashboard/src/components/ProvisionerForm.tsx

@@ -37,11 +37,7 @@ const ProvisionerForm: React.FC<Props> = ({
         Configure settings for your AWS environment.
       </Text>
       <Spacer y={1} />
-      {useAssumeRole ? (
-        <ProvisionerSettings credentialId={credentialId} />
-      ) : (
-        <ProvisionerSettingsOld credentialId={credentialId} />
-      )}
+      <ProvisionerSettings credentialId={credentialId} />
     </>
   );
 };

+ 25 - 14
dashboard/src/components/ProvisionerSettings.tsx

@@ -90,21 +90,18 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
   const [clusterVersion, setClusterVersion] = useState("v1.24.0");
   const [isReadOnly, setIsReadOnly] = useState(false);
   const [errorMessage, setErrorMessage] = useState<string>(undefined);
+  const [isClicked, setIsClicked] = useState(false);
 
-  const markProvisioningStarted = async () => {
+  const markStepStarted = async (step: string) => {
     try {
-      const res = await api.updateOnboardingStep(
-        "<token>",
-        { step: "provisioning-started" },
-        {}
-      );
+      await api.updateOnboardingStep("<token>", { step }, {});
     } catch (err) {
       console.log(err);
     }
   };
 
   const getStatus = () => {
-    if (isReadOnly) {
+    if (isReadOnly && props.provisionerError == "") {
       return "Provisioning is still in progress...";
     } else if (errorMessage) {
       return (
@@ -121,10 +118,16 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     }
     return undefined;
   };
-
+  const isDisabled = () => {
+    return (
+      (!clusterName && true) ||
+      (isReadOnly && props.provisionerError === "") ||
+      props.provisionerError === "" ||
+      isClicked
+    );
+  };
   const createCluster = async () => {
-    markProvisioningStarted();
-
+    setIsClicked(true);
     var data = new Contract({
       cluster: new Cluster({
         projectId: currentProject.id,
@@ -173,6 +176,8 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     try {
       setIsReadOnly(true);
       setErrorMessage(undefined);
+      markStepStarted("pre-provisioning-check-started");
+
       await api.preflightCheckAWSUsage(
         "<token>",
         {
@@ -184,6 +189,8 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
         }
       );
 
+      markStepStarted("provisioning-started");
+
       const res = await api.createContract("<token>", data, {
         project_id: currentProject.id,
       });
@@ -213,6 +220,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     } catch (err) {
       const errMessage = err.response.data.error.replace("unknown: ", "");
       // hacky, need to standardize error contract with backend
+      setIsClicked(false);
       if (errMessage.includes("elastic IP")) {
         setErrorMessage(AWS_EIP_QUOTA_ERROR_MESSAGE);
       } else if (errMessage.includes("VPC")) {
@@ -228,6 +236,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
       }
     } finally {
       setIsReadOnly(false);
+      setIsClicked(false);
     }
   };
 
@@ -237,7 +246,11 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
         (currentCluster.status === "UPDATING" ||
           currentCluster.status === "UPDATING_UNAVAILABLE")
     );
-    setClusterName(`${currentProject.name}-cluster`);
+    setClusterName(
+      `${currentProject.name}-cluster-${Math.random()
+        .toString(36)
+        .substring(2, 8)}`
+    );
   }, []);
 
   useEffect(() => {
@@ -359,9 +372,7 @@ const ProvisionerSettings: React.FC<Props> = (props) => {
     <>
       <StyledForm>{renderForm()}</StyledForm>
       <Button
-        disabled={
-          (!clusterName && true) || isReadOnly || props.provisionerError == ""
-        }
+        disabled={isDisabled()}
         onClick={createCluster}
         status={getStatus()}
       >

+ 91 - 90
dashboard/src/components/ProvisionerSettingsOld.tsx

@@ -12,7 +12,15 @@ import Heading from "components/form-components/Heading";
 import Helper from "components/form-components/Helper";
 import InputRow from "./form-components/InputRow";
 import SaveButton from "./SaveButton";
-import { Contract, EnumKubernetesKind, EnumCloudProvider, NodeGroupType, EKSNodeGroup, EKS, Cluster } from "@porter-dev/api-contracts";
+import {
+  Contract,
+  EnumKubernetesKind,
+  EnumCloudProvider,
+  NodeGroupType,
+  EKSNodeGroup,
+  EKS,
+  Cluster,
+} from "@porter-dev/api-contracts";
 import { ClusterType } from "shared/types";
 import Text from "./porter/Text";
 import Spacer from "./porter/Spacer";
@@ -58,7 +66,7 @@ type Props = RouteComponentProps & {
   clusterId?: number;
 };
 
-const ProvisionerSettingsOld: React.FC<Props> = props => {
+const ProvisionerSettingsOld: React.FC<Props> = (props) => {
   const {
     user,
     currentProject,
@@ -88,7 +96,7 @@ const ProvisionerSettingsOld: React.FC<Props> = props => {
     } catch (err) {
       console.log(err);
     }
-  }
+  };
 
   const createCluster = async () => {
     markProvisioningStarted();
@@ -127,11 +135,11 @@ const ProvisionerSettingsOld: React.FC<Props> = props => {
                 maxInstances: maxInstances || 10,
                 nodeGroupType: NodeGroupType.APPLICATION,
                 isStateful: false,
-              })
-            ]
-          })
+              }),
+            ],
+          }),
         },
-      })
+      }),
     });
 
     if (props.clusterId) {
@@ -139,20 +147,15 @@ const ProvisionerSettingsOld: React.FC<Props> = props => {
     }
 
     try {
-      const res = await api.createContract(
-        "<token>",
-        data,
-        { project_id: currentProject.id }
-      );
+      const res = await api.createContract("<token>", data, {
+        project_id: currentProject.id,
+      });
 
       // Only refresh and set clusters on initial create
       if (!props.clusterId) {
         setShouldRefreshClusters(true);
-        api.getClusters(
-          "<token>",
-          {},
-          { id: currentProject.id },
-        )
+        api
+          .getClusters("<token>", {}, { id: currentProject.id })
           .then(({ data }) => {
             data.forEach((cluster: ClusterType) => {
               if (cluster.id === res.data.contract_revision?.cluster_id) {
@@ -172,16 +175,19 @@ const ProvisionerSettingsOld: React.FC<Props> = props => {
     } catch (err) {
       console.log(err);
     }
-  }
+  };
 
   useEffect(() => {
     setIsReadOnly(
-      props.clusterId && (
-        currentCluster.status === "UPDATING" ||
-        currentCluster.status === "UPDATING_UNAVAILABLE"
-      )
+      props.clusterId &&
+        (currentCluster.status === "UPDATING" ||
+          currentCluster.status === "UPDATING_UNAVAILABLE")
+    );
+    setClusterName(
+      `${currentProject.name}-cluster-${Math.random()
+        .toString(36)
+        .substring(2, 8)}`
     );
-    setClusterName(`${currentProject.name}-cluster`);
   }, []);
 
   useEffect(() => {
@@ -203,7 +209,6 @@ const ProvisionerSettingsOld: React.FC<Props> = props => {
   }, [props.selectedClusterVersion]);
 
   const renderForm = () => {
-
     // Render simplified form if initial create
     if (!props.clusterId) {
       return (
@@ -211,7 +216,8 @@ const ProvisionerSettingsOld: React.FC<Props> = props => {
           <Text size={16}>Select an AWS region</Text>
           <Spacer y={1} />
           <Text color="helper">
-            Porter will automatically provision your infrastructure in the specified region.
+            Porter will automatically provision your infrastructure in the
+            specified region.
           </Text>
           <Spacer height="10px" />
           <SelectRow
@@ -225,7 +231,7 @@ const ProvisionerSettingsOld: React.FC<Props> = props => {
             label="📍 AWS region"
           />
         </>
-      )
+      );
     }
 
     // If settings, update full form
@@ -242,72 +248,66 @@ const ProvisionerSettingsOld: React.FC<Props> = props => {
           setActiveValue={setAwsRegion}
           label="📍 AWS region"
         />
-        {
-          user?.isPorterUser && (
-            <Heading>
-              <ExpandHeader
-                onClick={() => setIsExpanded(!isExpanded)}
-                isExpanded={isExpanded}
-              >
-                <i className="material-icons">arrow_drop_down</i>
-                Advanced settings
-              </ExpandHeader>
-            </Heading>
-          )
-        }
-        {
-          isExpanded && (
-            <>
-              <SelectRow
-                options={clusterVersionOptions}
-                width="350px"
-                disabled={isReadOnly}
-                value={clusterVersion}
-                scrollBuffer={true}
-                dropdownMaxHeight="240px"
-                setActiveValue={setClusterVersion}
-                label="Cluster version"
-              />
-              <SelectRow
-                options={machineTypeOptions}
-                width="350px"
-                disabled={isReadOnly}
-                value={machineType}
-                scrollBuffer={true}
-                dropdownMaxHeight="240px"
-                setActiveValue={setMachineType}
-                label="Machine type"
-              />
-              <InputRow
-                width="350px"
-                type="number"
-                disabled={isReadOnly}
-                value={maxInstances}
-                setValue={(x: number) => setMaxInstances(x)}
-                label="Maximum number of application EC2 instances"
-                placeholder="ex: 1"
-              />
-              <InputRow
-                width="350px"
-                type="string"
-                disabled={isReadOnly}
-                value={cidrRange}
-                setValue={(x: string) => setCidrRange(x)}
-                label="VPC CIDR range"
-                placeholder="ex: 10.78.0.0/16"
-              />
-            </>
-          )
-        }
+        {user?.isPorterUser && (
+          <Heading>
+            <ExpandHeader
+              onClick={() => setIsExpanded(!isExpanded)}
+              isExpanded={isExpanded}
+            >
+              <i className="material-icons">arrow_drop_down</i>
+              Advanced settings
+            </ExpandHeader>
+          </Heading>
+        )}
+        {isExpanded && (
+          <>
+            <SelectRow
+              options={clusterVersionOptions}
+              width="350px"
+              disabled={isReadOnly}
+              value={clusterVersion}
+              scrollBuffer={true}
+              dropdownMaxHeight="240px"
+              setActiveValue={setClusterVersion}
+              label="Cluster version"
+            />
+            <SelectRow
+              options={machineTypeOptions}
+              width="350px"
+              disabled={isReadOnly}
+              value={machineType}
+              scrollBuffer={true}
+              dropdownMaxHeight="240px"
+              setActiveValue={setMachineType}
+              label="Machine type"
+            />
+            <InputRow
+              width="350px"
+              type="number"
+              disabled={isReadOnly}
+              value={maxInstances}
+              setValue={(x: number) => setMaxInstances(x)}
+              label="Maximum number of application EC2 instances"
+              placeholder="ex: 1"
+            />
+            <InputRow
+              width="350px"
+              type="string"
+              disabled={isReadOnly}
+              value={cidrRange}
+              setValue={(x: string) => setCidrRange(x)}
+              label="VPC CIDR range"
+              placeholder="ex: 10.78.0.0/16"
+            />
+          </>
+        )}
       </>
-    )
-  }
+    );
+  };
 
   return (
     <>
-      <StyledForm>
-        {renderForm()}
-      </StyledForm>
+      <StyledForm>{renderForm()}</StyledForm>
       <SaveButton
         disabled={(!clusterName && true) || isReadOnly}
         onClick={createCluster}
@@ -329,7 +329,8 @@ const ExpandHeader = styled.div<{ isExpanded: boolean }>`
   > i {
     margin-right: 7px;
     margin-left: -7px;
-    transform: ${(props) => props.isExpanded ? "rotate(0deg)" : "rotate(-90deg)"};
+    transform: ${(props) =>
+      props.isExpanded ? "rotate(0deg)" : "rotate(-90deg)"};
   }
 `;
 
@@ -341,4 +342,4 @@ const StyledForm = styled.div`
   border: 1px solid #494b4f;
   font-size: 13px;
   margin-bottom: 30px;
-`;
+`;

+ 3 - 5
dashboard/src/components/porter/Error.tsx

@@ -1,5 +1,4 @@
 import React, { useEffect, useState } from "react";
-import { createPortal } from "react-dom";
 import styled from "styled-components";
 
 import expand from "assets/expand.png";
@@ -37,12 +36,11 @@ const Error: React.FC<Props> = ({
         )}
         </Block>
       </StyledError>
-      {errorModalOpen && createPortal(
+      {errorModalOpen &&
         <Modal closeModal={() => setErrorModalOpen(false)}>
           {errorModalContents}
-        </Modal>,
-        document.body
-      )}
+        </Modal>
+      }
     </>
   );
 };

+ 19 - 11
dashboard/src/components/porter/Modal.tsx

@@ -1,5 +1,6 @@
 import React, { useEffect, useState } from "react";
 import styled from "styled-components";
+import { createPortal } from "react-dom";
 
 type Props = {
   closeModal?: () => void;
@@ -11,17 +12,24 @@ const Modal: React.FC<Props> = ({
   children,
 }) => {
   return (
-    <ModalWrapper>
-      <ModalBg onClick={closeModal} />
-      <StyledModal> 
-        {closeModal && (
-          <CloseButton onClick={closeModal}>
-            <i className="material-icons">close</i>
-          </CloseButton>
-        )}
-        {children}
-      </StyledModal>
-    </ModalWrapper>
+    <>
+      {
+        createPortal(
+          <ModalWrapper>
+            <ModalBg onClick={closeModal} />
+            <StyledModal> 
+              {closeModal && (
+                <CloseButton onClick={closeModal}>
+                  <i className="material-icons">close</i>
+                </CloseButton>
+              )}
+              {children}
+            </StyledModal>
+          </ModalWrapper>,
+          document.body
+        )
+      }
+    </>
   );
 };
 

+ 62 - 29
dashboard/src/main/home/Home.tsx

@@ -33,6 +33,10 @@ import InfrastructureRouter from "./infrastructure/InfrastructureRouter";
 import { overrideInfraTabEnabled } from "utils/infrastructure";
 import NoClusterPlaceHolder from "components/NoClusterPlaceHolder";
 import NewAddOnFlow from "./add-on-dashboard/NewAddOnFlow";
+import Modal from "components/porter/Modal";
+import Text from "components/porter/Text";
+import Spacer from "components/porter/Spacer";
+import Button from "components/porter/Button";
 
 // Guarded components
 const GuardedProjectSettings = fakeGuardedRoute("settings", "", [
@@ -86,6 +90,7 @@ const Home: React.FC<Props> = (props) => {
   const [ghRedirect, setGhRedirect] = useState(false);
   const [forceSidebar, setForceSidebar] = useState(true);
   const [theme, setTheme] = useState(standard);
+  const [showWrongEmailModal, setShowWrongEmailModal] = useState(false);
 
   const redirectToNewProject = () => {
     pushFiltered(props, "/new-project", ["project_id"]);
@@ -208,7 +213,9 @@ const Home: React.FC<Props> = (props) => {
     getProjects(defaultProjectId);
     getMetadata();
 
-    if (
+    if (err === "Wrong email for invite") {
+      setShowWrongEmailModal(true);
+    } else if (
       !hasFinishedOnboarding &&
       props.history.location.pathname &&
       !props.history.location.pathname.includes("onboarding")
@@ -258,9 +265,11 @@ const Home: React.FC<Props> = (props) => {
               .then((res) => {
                 const usage = res.data;
                 setUsage(usage);
-                if (usage.exceeded && false) {
+                /*
+                if (usage.exceeded) {
                   setCurrentModal("UsageWarningModal", { usage });
                 }
+                */
               })
               .catch(console.log);
           }
@@ -270,12 +279,16 @@ const Home: React.FC<Props> = (props) => {
   }, [props.currentProject?.id]);
 
   useEffect(() => {
+    let queryString = window.location.search;
+    let urlParams = new URLSearchParams(queryString);
+    let err = urlParams.get("error");
     if (
       !hasFinishedOnboarding &&
       props.history.location.pathname &&
       !props.history.location.pathname.includes("onboarding") &&
       !props.history.location.pathname.includes("new-project") &&
-      !props.history.location.pathname.includes("project-settings")
+      !props.history.location.pathname.includes("project-settings") &&
+      err !== "Wrong email for invite"
     ) {
       setCurrentModal("RedirectToOnboardingModal");
     }
@@ -442,36 +455,36 @@ const Home: React.FC<Props> = (props) => {
               }}
             />
             <Route
-            path={[
-              "/cluster-dashboard",
-              "/applications",
-              "/jobs",
-              "/env-groups",
-              "/databases",
-              "/preview-environments",
-              "/stacks",
-            ]}
-            render={() => {
-              if (currentCluster?.id === -1) {
-                return <Loading />;
-              } else if (!currentCluster || !currentCluster.name) {
+              path={[
+                "/cluster-dashboard",
+                "/applications",
+                "/jobs",
+                "/env-groups",
+                "/databases",
+                "/preview-environments",
+                "/stacks",
+              ]}
+              render={() => {
+                if (currentCluster?.id === -1) {
+                  return <Loading />;
+                } else if (!currentCluster || !currentCluster.name) {
+                  return (
+                    <DashboardWrapper>
+                      <NoClusterPlaceHolder></NoClusterPlaceHolder>
+                    </DashboardWrapper>
+                  );
+                }
                 return (
                   <DashboardWrapper>
-                    <NoClusterPlaceHolder></NoClusterPlaceHolder>
+                    <DashboardRouter
+                      currentCluster={currentCluster}
+                      setSidebar={setForceSidebar}
+                      currentView={props.currentRoute}
+                    />
                   </DashboardWrapper>
                 );
-              }
-              return (
-                <DashboardWrapper>
-                  <DashboardRouter
-                    currentCluster={currentCluster}
-                    setSidebar={setForceSidebar}
-                    currentView={props.currentRoute}
-                  />
-                </DashboardWrapper>
-              );
-            }}
-          />
+              }}
+            />
             <Route
               path={"/integrations"}
               render={() => <GuardedIntegrations />}
@@ -496,6 +509,26 @@ const Home: React.FC<Props> = (props) => {
           />,
           document.body
         )}
+        {showWrongEmailModal && 
+          <Modal>
+            <Text size={16}>
+              Oops! This invite link wasn't for {user?.email}
+            </Text>
+            <Spacer y={1} />
+            <Text color="helper">
+              Your account email does not match the email associated with this project invite. 
+              Please log out and sign up again with the correct email using the invite link.
+            </Text>
+            <Spacer y={1} />
+            <Text color="helper">
+              You should reach out to the person who sent you the invite link to get the correct email.
+            </Text>
+            <Spacer y={1} />
+            <Button onClick={props.logOut}>
+              Log out
+            </Button>
+          </Modal>
+        }
       </StyledHome>
     </ThemeProvider>
   );

+ 4 - 0
dashboard/src/main/home/cluster-dashboard/dashboard/ClusterSettings.tsx

@@ -360,6 +360,10 @@ const ClusterSettings: React.FC<Props> = (props) => {
         <Heading>Delete cluster</Heading>
         {helperText}
         <Button
+          disabled={
+            currentCluster.status == "UPDATING_UNAVAILABLE" ||
+            currentCluster.status == "UPDATING"
+          }
           color="#b91133"
           onClick={() => setCurrentModal("UpdateClusterModal")}
         >

+ 48 - 29
dashboard/src/main/home/cluster-dashboard/dashboard/ProvisionerStatus.tsx

@@ -16,33 +16,50 @@ type Props = {
 
 const PROVISIONING_STATUS_POLL_INTERVAL = 60 * 1000; // poll every minute
 
-const ProvisionerStatus: React.FC<Props> = ({
-  provisionFailureReason,
-}) => {
+const ProvisionerStatus: React.FC<Props> = ({ provisionFailureReason }) => {
   const { currentProject, currentCluster } = useContext(Context);
   const [progress, setProgress] = useState(1);
 
-  // Continuously poll provisioning status
-  const pollProvisioningStatus = async () => {
+  // Continuously poll provisioning status and cluster status
+  const pollProvisioningAndClusterStatus = async () => {
     try {
-      const res = await api.getClusterState(
-        "<token>",
-        {},
-        {
-          project_id: currentProject.id,
-          cluster_id: currentCluster.id,
-        }
-      );
-      const { is_control_plane_ready, is_infrastructure_ready, phase } = res.data;
+      const [resState, resStatus] = await Promise.all([
+        api.getClusterState(
+          "<token>",
+          {},
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        ),
+        api.getCluster(
+          "<token>",
+          {},
+          {
+            project_id: currentProject.id,
+            cluster_id: currentCluster.id,
+          }
+        ),
+      ]);
+
+      const {
+        is_control_plane_ready,
+        is_infrastructure_ready,
+        phase,
+      } = resState.data;
+      const status = resStatus.data.status;
       let progress = 1;
       if (is_control_plane_ready) {
-        progress += 1
+        progress += 1;
       }
       if (is_infrastructure_ready) {
-        progress += 1
+        progress += 1;
+      }
+      if (phase === "Provisioned") {
+        progress += 1;
       }
-      if (phase === 'Provisioned') {
-        progress += 1
+      if (status === "READY") {
+        window.location.reload();
       }
       setProgress(progress);
     } catch (err) {
@@ -51,8 +68,11 @@ const ProvisionerStatus: React.FC<Props> = ({
   };
 
   useEffect(() => {
-    const intervalId = setInterval(pollProvisioningStatus, PROVISIONING_STATUS_POLL_INTERVAL);
-    pollProvisioningStatus();
+    const intervalId = setInterval(
+      pollProvisioningAndClusterStatus,
+      PROVISIONING_STATUS_POLL_INTERVAL
+    );
+    pollProvisioningAndClusterStatus();
     return () => clearInterval(intervalId);
   }, []);
 
@@ -66,19 +86,18 @@ const ProvisionerStatus: React.FC<Props> = ({
         <Spacer height="18px" />
         <LoadingBar
           color={provisionFailureReason ? "failed" : undefined}
-          completed={progress} 
-          total={4} 
+          completed={progress}
+          total={4}
         />
         <Spacer height="18px" />
         <Text color="#aaaabb">
-          Setup can take up to 20 minutes. You can close this window and come back later. 
+          Setup can take up to 20 minutes. You can close this window and come
+          back later.
         </Text>
       </HeaderSection>
-      {
-        provisionFailureReason && (
-          <DummyLogs>Error: {provisionFailureReason}</DummyLogs>
-        )
-      }
+      {provisionFailureReason && (
+        <DummyLogs>Error: {provisionFailureReason}</DummyLogs>
+      )}
     </StyledProvisionerStatus>
   );
 };
@@ -117,4 +136,4 @@ const StyledProvisionerStatus = styled.div`
   font-size: 13px;
   width: 100%;
   overflow: hidden;
-`;
+`;

+ 5 - 5
dashboard/src/main/home/dashboard/ClusterSection.tsx

@@ -10,8 +10,7 @@ import ClusterList from "./ClusterList";
 import TitleSection from "components/TitleSection";
 import Spacer from "components/porter/Spacer";
 
-type Props = {
-};
+type Props = {};
 
 const ClusterSection = (props: Props) => {
   const { usage, currentCluster } = useContext(Context);
@@ -84,7 +83,8 @@ const ClusterSection = (props: Props) => {
         </TitleSection>
         <Spacer y={1} />
         <Banner>
-          You have currently provisioned {usage?.current.cluster || "0"} out of {usage?.limit.clusters || "0"} clusters for this project.
+          You have currently provisioned {usage?.current.cluster || "0"} out of{" "}
+          {usage?.limit.clusters || "0"} clusters for this project.
         </Banner>
         <Br />
         <ProvisionerFlow />
@@ -107,7 +107,7 @@ export default ClusterSection;
 
 const Br = styled.div<{ height?: string }>`
   width: 100%;
-  height: ${props => props.height || "30px"};
+  height: ${(props) => props.height || "30px"};
 `;
 
 const ClusterIcon = styled.div`
@@ -173,4 +173,4 @@ const Button = styled.div`
     margin-right: 5px;
     justify-content: center;
   }
-`;
+`;

+ 1 - 1
ee/api/server/handlers/invite/create.go

@@ -80,7 +80,7 @@ func (c *InviteCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 
 func CreateInviteWithProject(invite *types.CreateInviteRequest, projectID uint) (*models.Invite, error) {
 	// generate a token and an expiry time
-	expiry := time.Now().Add(24 * time.Hour)
+	expiry := time.Now().Add(7 * 24 * time.Hour)
 
 	return &models.Invite{
 		Email:     invite.Email,

+ 1 - 0
internal/analytics/track_events.go

@@ -10,6 +10,7 @@ const (
 
 	CostConsentComplete    SegmentEvent = "Cost Consent Complete"
 	CredentialStepComplete SegmentEvent = "Credential Step Complete"
+	PreProvisionCheck      SegmentEvent = "Pre Provision Check Started"
 	ProvisioningAttempted  SegmentEvent = "Provisioning Attempted"
 
 	ClusterProvisioningStart   SegmentEvent = "Cluster Provisioning Started"

+ 23 - 0
internal/analytics/tracks.go

@@ -162,6 +162,29 @@ func CredentialStepTrack(opts *CredentialStepTrackOpts) segmentTrack {
 	)
 }
 
+// PreProvisionCheckTrackOpts are the options for creating a track when a user checks if they can provision
+type PreProvisionCheckTrackOpts struct {
+	*UserScopedTrackOpts
+
+	Email       string
+	FirstName   string
+	LastName    string
+	CompanyName string
+}
+
+// PreProvisionCheckTrack returns a track for when a user attempts provisioning
+func PreProvisionCheckTrack(opts *PreProvisionCheckTrackOpts) segmentTrack {
+	additionalProps := make(map[string]interface{})
+	additionalProps["email"] = opts.Email
+	additionalProps["name"] = opts.FirstName + " " + opts.LastName
+	additionalProps["company"] = opts.CompanyName
+
+	return getSegmentUserTrack(
+		opts.UserScopedTrackOpts,
+		getDefaultSegmentTrack(additionalProps, PreProvisionCheck),
+	)
+}
+
 // ProvisioningAttemptedTrackOpts are the options for creating a track when a user attempts provisioning
 type ProvisioningAttemptTrackOpts struct {
 	*UserScopedTrackOpts

+ 1 - 6
workers/jobs/helm_revisions_count_tracker.go

@@ -46,15 +46,11 @@ import (
 	"gorm.io/gorm"
 )
 
-var stepSize int = 20
-
 type helmRevisionsCountTracker struct {
 	enqueueTime        time.Time
 	db                 *gorm.DB
 	repo               repository.Repository
 	doConf             *oauth2.Config
-	dbConf             *env.DBConf
-	credBackend        rcreds.CredentialStorage
 	awsAccessKeyID     string
 	awsSecretAccessKey string
 	awsRegion          string
@@ -115,8 +111,7 @@ func NewHelmRevisionsCountTracker(
 	}
 
 	return &helmRevisionsCountTracker{
-		enqueueTime, db, repo, doConf, opts.DBConf, credBackend,
-		opts.AWSAccessKeyID, opts.AWSSecretAccessKey, opts.AWSRegion,
+		enqueueTime, db, repo, doConf, opts.AWSAccessKeyID, opts.AWSSecretAccessKey, opts.AWSRegion,
 		opts.S3BucketName, &s3Key, opts.RevisionsCount,
 	}, nil
 }

+ 214 - 0
workers/jobs/preview_deployments_ttl_deleter.go

@@ -0,0 +1,214 @@
+//go:build ee
+
+package jobs
+
+import (
+	"log"
+	"sync"
+	"time"
+
+	"github.com/porter-dev/porter/api/server/shared/config/env"
+	"github.com/porter-dev/porter/ee/integrations/vault"
+	"github.com/porter-dev/porter/internal/kubernetes"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/oauth"
+	"github.com/porter-dev/porter/internal/repository"
+	rcreds "github.com/porter-dev/porter/internal/repository/credentials"
+	rgorm "github.com/porter-dev/porter/internal/repository/gorm"
+	"golang.org/x/oauth2"
+	"gorm.io/gorm"
+	"k8s.io/apimachinery/pkg/api/errors"
+)
+
+/*
+
+                         === Preview Deployments TTL Deleter Job ===
+
+   This job goes through every active preview environment in all connected clusters and deletes the
+   deployments that have exceeded their TTL, corresponding to their respective preview environment.
+
+*/
+
+const (
+	stepSize = 20
+)
+
+type previewDeploymentsTTLDeleter struct {
+	enqueueTime           time.Time
+	db                    *gorm.DB
+	doConf                *oauth2.Config
+	repo                  repository.Repository
+	previewDeploymentsTTL string
+}
+
+// PreviewDeploymentsTTLDeleterOpts holds the options required to run this job
+type PreviewDeploymentsTTLDeleterOpts struct {
+	DBConf                *env.DBConf
+	ServerURL             string
+	DOClientID            string
+	DOClientSecret        string
+	DOScopes              []string
+	PreviewDeploymentsTTL string
+}
+
+func NewPreviewDeploymentsTTLDeleter(
+	db *gorm.DB,
+	enqueueTime time.Time,
+	opts *PreviewDeploymentsTTLDeleterOpts,
+) (*previewDeploymentsTTLDeleter, error) {
+	var credBackend rcreds.CredentialStorage
+
+	if opts.DBConf.VaultAPIKey != "" && opts.DBConf.VaultServerURL != "" && opts.DBConf.VaultPrefix != "" {
+		credBackend = vault.NewClient(
+			opts.DBConf.VaultServerURL,
+			opts.DBConf.VaultAPIKey,
+			opts.DBConf.VaultPrefix,
+		)
+	}
+
+	doConf := oauth.NewDigitalOceanClient(&oauth.Config{
+		ClientID:     opts.DOClientID,
+		ClientSecret: opts.DOClientSecret,
+		Scopes:       opts.DOScopes,
+		BaseURL:      opts.ServerURL,
+	})
+
+	var key [32]byte
+
+	for i, b := range []byte(opts.DBConf.EncryptionKey) {
+		key[i] = b
+	}
+
+	repo := rgorm.NewRepository(db, &key, credBackend)
+
+	return &previewDeploymentsTTLDeleter{enqueueTime, db, doConf, repo, opts.PreviewDeploymentsTTL}, nil
+}
+
+func (n *previewDeploymentsTTLDeleter) ID() string {
+	return "preview-deployments-ttl-deleter"
+}
+
+func (n *previewDeploymentsTTLDeleter) EnqueueTime() time.Time {
+	return n.enqueueTime
+}
+
+func (n *previewDeploymentsTTLDeleter) Run() error {
+	if n.previewDeploymentsTTL == "" {
+		log.Println("no TTL set for preview deployments, skipping job altogether")
+		return nil
+	}
+
+	ttlDuration, err := time.ParseDuration(n.previewDeploymentsTTL)
+	if err != nil {
+		log.Printf("error parsing preview deployments TTL: %v. skipping job altogether", err)
+		return nil
+	}
+
+	if ttlDuration.Hours() < 24 || ttlDuration.Hours() > 720 {
+		log.Printf("preview deployments TTL must be between 24 (1 day) and 720 hours (30 days). skipping job altogether")
+		return nil
+	}
+
+	var count int64
+
+	if err := n.db.Model(&models.Cluster{}).Count(&count).Error; err != nil {
+		return err
+	}
+
+	var wg sync.WaitGroup
+
+	log.Println("starting deletion of preview deployments based on TTL")
+
+	for i := 0; i < (int(count)/stepSize)+1; i++ {
+		var clusters []*models.Cluster
+
+		if err := n.db.Order("id asc").Offset(i * stepSize).Limit(stepSize).Find(&clusters).
+			Error; err != nil {
+			return err
+		}
+
+		for _, cluster := range clusters {
+			if !cluster.PreviewEnvsEnabled {
+				continue
+			}
+
+			envs, err := n.repo.Environment().ListEnvironments(cluster.ProjectID, cluster.ID)
+			if err != nil {
+				log.Printf("error listing environments for cluster %s: %v", cluster.Name, err)
+				continue
+			}
+
+			log.Printf("found %d environments for cluster %s", len(envs), cluster.Name)
+
+			for _, env := range envs {
+				wg.Add(1)
+
+				go func(env *models.Environment, cluster *models.Cluster) {
+					defer wg.Done()
+
+					depls, err := n.repo.Environment().ListDeployments(env.ID)
+					if err != nil {
+						log.Printf("error listing deployments for %s/%s: %v", env.GitRepoOwner, env.GitRepoName, err)
+						return
+					}
+
+					log.Printf("found %d deployments for %s/%s", len(depls), env.GitRepoOwner, env.GitRepoName)
+
+					log.Printf("deleting preview deployments based on TTL %s for %s/%s",
+						n.previewDeploymentsTTL, env.GitRepoOwner, env.GitRepoName)
+
+					k8sAgent, err := kubernetes.GetAgentOutOfClusterConfig(&kubernetes.OutOfClusterConfig{
+						Cluster:                   cluster,
+						Repo:                      n.repo,
+						DigitalOceanOAuth:         n.doConf,
+						AllowInClusterConnections: false,
+						Timeout:                   10 * time.Second,
+					})
+					if err != nil {
+						log.Printf("error getting k8s agent for cluster %s: %v", cluster.Name, err)
+						return
+					}
+
+					for _, depl := range depls {
+						// delete the deployment if it has been inactive for longer than the set TTL
+						if depl.UpdatedAt.Add(ttlDuration).Before(time.Now()) {
+							if depl.Namespace != "" {
+								log.Printf("deleting namespace for deployment '%s'", depl.PRName)
+
+								_, err := k8sAgent.GetNamespace(depl.Namespace)
+
+								if err == nil {
+									err := k8sAgent.DeleteNamespace(depl.Namespace)
+									if err != nil {
+										log.Printf("error deleting namespace for deployment '%s': %v. skipping ...",
+											depl.PRName, err)
+										continue
+									}
+								} else if !errors.IsNotFound(err) {
+									log.Printf("error getting k8s namespace for deployment '%s': %v. skipping ...",
+										depl.PRName, err)
+									continue
+								}
+							}
+
+							log.Printf("deleting deployment '%s'", depl.PRName)
+
+							_, err := n.repo.Environment().DeleteDeployment(depl)
+							if err != nil {
+								log.Printf("error deleting deployment '%s': %v", depl.PRName, err)
+							}
+						}
+					}
+				}(env, cluster)
+			}
+
+			wg.Wait()
+		}
+	}
+
+	log.Println("finished deletion of preview deployments based on TTL")
+
+	return nil
+}
+
+func (n *previewDeploymentsTTLDeleter) SetData([]byte) {}

+ 39 - 10
workers/main.go

@@ -39,25 +39,39 @@ var (
 
 // EnvConf holds the environment variables for this binary
 type EnvConf struct {
-	ServerURL          string `env:"SERVER_URL,default=http://localhost:8080"`
-	DOClientID         string `env:"DO_CLIENT_ID"`
-	DOClientSecret     string `env:"DO_CLIENT_SECRET"`
-	DBConf             env.DBConf
-	MaxWorkers         uint   `env:"MAX_WORKERS,default=10"`
-	MaxQueue           uint   `env:"MAX_QUEUE,default=100"`
+	// ServerURL is the URL of the Porter server
+	ServerURL string `env:"SERVER_URL,default=http://localhost:8080"`
+
+	// Porter instance's database configuration
+	DBConf env.DBConf
+
+	// DigitalOcean OAuth2 credentials
+	DOClientID     string `env:"DO_CLIENT_ID"`
+	DOClientSecret string `env:"DO_CLIENT_SECRET"`
+
+	// Worker pool configuration
+	MaxWorkers uint `env:"MAX_WORKERS,default=10"`
+	MaxQueue   uint `env:"MAX_QUEUE,default=100"`
+	Port       uint `env:"PORT,default=3000"`
+
+	/**
+	 * Job-specific configuration
+	 */
+
+	// "helm-revisions-count-tracker"
 	AWSAccessKeyID     string `env:"AWS_ACCESS_KEY_ID"`
 	AWSSecretAccessKey string `env:"AWS_SECRET_ACCESS_KEY"`
 	AWSRegion          string `env:"AWS_REGION"`
 	S3BucketName       string `env:"S3_BUCKET_NAME"`
 	EncryptionKey      string `env:"S3_ENCRYPTION_KEY"`
+	RevisionsCount     int    `env:"REVISIONS_COUNT,default=20"`
 
+	// "recommender"
 	OPAConfigFileDir string `env:"OPA_CONFIG_FILE_DIR,default=./internal/opa"`
-
 	LegacyProjectIDs []uint `env:"LEGACY_PROJECT_IDS"`
 
-	Port uint `env:"PORT,default=3000"`
-
-	RevisionsCount int `env:"REVISIONS_COUNT,default=20"`
+	// "preview-deployments-ttl-deleter"
+	PreviewDeploymentsTTL string `env:"PREVIEW_DEPLOYMENTS_TTL"`
 }
 
 func main() {
@@ -228,6 +242,21 @@ func getJob(id string, input map[string]interface{}) worker.Job {
 			return nil
 		}
 
+		return newJob
+	} else if id == "preview-deployments-ttl-deleter" {
+		newJob, err := jobs.NewPreviewDeploymentsTTLDeleter(dbConn, time.Now().UTC(), &jobs.PreviewDeploymentsTTLDeleterOpts{
+			DBConf:                &envDecoder.DBConf,
+			ServerURL:             envDecoder.ServerURL,
+			DOClientID:            envDecoder.DOClientID,
+			DOClientSecret:        envDecoder.DOClientSecret,
+			DOScopes:              []string{"read", "write"},
+			PreviewDeploymentsTTL: envDecoder.PreviewDeploymentsTTL,
+		})
+		if err != nil {
+			log.Printf("error creating job with ID: preview-deployments-ttl-deleter. Error: %v", err)
+			return nil
+		}
+
 		return newJob
 	}