Explorar o código

Adding quota checks and very basic front-end error handling (#2807)

* adding second preflight check

* committing work

* cleanups and error handling

* cleanups

---------

Co-authored-by: Feroze Mohideen <fm87@duke.edu>
Feroze Mohideen %!s(int64=3) %!d(string=hai) anos
pai
achega
48d9c810fb

+ 3 - 1
api/server/handlers/project_integration/create_aws.go

@@ -57,11 +57,13 @@ func (p *CreateAWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 			SourceArn:       "arn:aws:iam::108458755588:role/CAPIManagement", // hard coded as this is the final hop for a CAPI cluster
 			TargetAccessId:  request.AWSAccessKeyID,
 			TargetSecretKey: request.AWSSecretAccessKey,
+			TargetArn:       request.TargetArn,
+			ExternalId:      request.ExternalID,
 		}
 		credResp, err := p.Config().ClusterControlPlaneClient.CreateAssumeRoleChain(ctx, connect.NewRequest(&credReq))
 		if err != nil {
 			e := fmt.Errorf("unable to create CAPI required credential: %w", err)
-			p.HandleAPIError(w, r, apierrors.NewErrInternal(e))
+			p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusPreconditionFailed, err.Error()))
 			return
 		}
 		res.CloudProviderCredentialIdentifier = credResp.Msg.TargetArn

+ 14 - 24
api/server/handlers/project_integration/preflight_check_aws.go → api/server/handlers/project_integration/preflight_check_aws_usage.go

@@ -14,52 +14,42 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 )
 
-type CreatePreflightCheckAWSHandler struct {
+type CreatePreflightCheckAWSUsageHandler struct {
 	handlers.PorterHandlerReadWriter
 }
 
-func NewCreatePreflightCheckAWSHandler(
+func NewCreatePreflightCheckAWSUsageHandler(
 	config *config.Config,
 	decoderValidator shared.RequestDecoderValidator,
 	writer shared.ResultWriter,
-) *CreatePreflightCheckAWSHandler {
-	return &CreatePreflightCheckAWSHandler{
+) *CreatePreflightCheckAWSUsageHandler {
+	return &CreatePreflightCheckAWSUsageHandler{
 		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
 	}
 }
 
-func (p *CreatePreflightCheckAWSHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+func (p *CreatePreflightCheckAWSUsageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
 	ctx := r.Context()
 
-	request := &types.RolePreflightCheckRequest{}
+	request := &types.QuotaPreflightCheckRequest{}
 	if ok := p.DecodeAndValidate(w, r, request); !ok {
 		return
 	}
 
-	res := types.RolePreflightCheckResponse{
-		TargetARN: 	     request.TargetARN,
+	checkReq := porterv1.QuotaPreflightCheckRequest{
+		ProjectId: int64(project.ID),
+		TargetArn: request.TargetARN,
+		Region:    request.Region,
 	}
 
-	checkReq := porterv1.RolePreflightCheckRequest{
-		ProjectId:       int64(project.ID),
-		TargetArn:		 request.TargetARN,
-		ExternalId: 	 request.ExternalID,
-	}
-
-	checkResp, err := p.Config().ClusterControlPlaneClient.RolePreflightCheck(ctx, connect.NewRequest(&checkReq))
+	checkResp, err := p.Config().ClusterControlPlaneClient.QuotaPreflightCheck(ctx, connect.NewRequest(&checkReq))
 
 	if err != nil {
-		e := fmt.Errorf("preflight check failed: %w", err)
-		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(
-			e,
-			http.StatusNotFound,
-		))
-
+		e := fmt.Errorf("Pre-provision check failed: %w", err)
+		p.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusPreconditionFailed, err.Error()))
 		return
 	}
-	
-	res.TargetARN = checkResp.Msg.TargetArn
 
 	p.WriteResult(w, r, checkResp)
-}
+}

+ 6 - 6
api/server/router/project_integration.go

@@ -220,14 +220,14 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
-	// POST /api/projects/{project_id}/integrations/aws/preflightcheck -> project_integration.NewCreatePreflightCheckAWSHandler
-	preflightCheckAWSEndpoint := factory.NewAPIEndpoint(
+	// POST /api/projects/{project_id}/integrations/aws/preflightcheck/usage -> project_integration.NewCreatePreflightCheckAWSHandler
+	preflightCheckAWSUsageEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
 			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
-				RelativePath: relPath + "/aws/preflight",
+				RelativePath: relPath + "/aws/preflight/usage",
 			},
 			Scopes: []types.PermissionScope{
 				types.UserScope,
@@ -236,15 +236,15 @@ func getProjectIntegrationRoutes(
 		},
 	)
 
-	preflightCheckAWSHandler := project_integration.NewCreatePreflightCheckAWSHandler(
+	preflightCheckAWSUsageHandler := project_integration.NewCreatePreflightCheckAWSUsageHandler(
 		config,
 		factory.GetDecoderValidator(),
 		factory.GetResultWriter(),
 	)
 
 	routes = append(routes, &router.Route{
-		Endpoint: preflightCheckAWSEndpoint,
-		Handler:  preflightCheckAWSHandler,
+		Endpoint: preflightCheckAWSUsageEndpoint,
+		Handler:  preflightCheckAWSUsageHandler,
 		Router:   r,
 	})
 

+ 8 - 7
api/types/project_integration.go

@@ -75,15 +75,14 @@ type AWSIntegration struct {
 
 type ListAWSResponse []*AWSIntegration
 
-type RolePreflightCheckRequest struct {
-	ProjectID	  uint	 `json:"project_id"`
-	TargetARN     string `json:"target_arn"`
-	ExternalID	  string `json:"external_id"`
+type QuotaPreflightCheckRequest struct {
+	ProjectID  uint   `json:"project_id"`
+	TargetARN  string `json:"target_arn"`
+	ExternalID string `json:"external_id"`
+	Region     string `json:"region"`
 }
 
-type RolePreflightCheckResponse struct {
-	TargetARN     string `json:"target_arn"`
-}
+type QuotaPreflightCheckResponse struct{}
 
 type CreateAWSRequest struct {
 	AWSRegion          string `json:"aws_region"`
@@ -91,6 +90,8 @@ type CreateAWSRequest struct {
 	AWSAccessKeyID     string `json:"aws_access_key_id"`
 	AWSSecretAccessKey string `json:"aws_secret_access_key"`
 	AWSAssumeRoleArn   string `json:"aws_assume_role_arn"`
+	TargetArn          string `json:"aws_target_arn"`
+	ExternalID         string `json:"aws_external_id"`
 }
 
 type CreateAWSResponse struct {

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 14741 - 1
dashboard/package-lock.json


+ 28 - 9
dashboard/src/components/CloudFormationForm.tsx

@@ -18,23 +18,27 @@ import DocsHelper from "./DocsHelper";
 
 type Props = {
   goBack: () => void;
+  AWSAccountID: string;
+  setAWSAccountID: (id: string) => void;
   proceed: (id: string) => void;
 };
 
 const CloudFormationForm: React.FC<Props> = ({
   goBack,
   proceed,
+  AWSAccountID,
+  setAWSAccountID
 }) => {
-  const [AWSAccountID, setAWSAccountID] = useState("");
   const [grantPermissionsError, setGrantPermissionsError] = useState("");
   const [roleStatus, setRoleStatus] = useState("");
+  const [errorMessage, setErrorMessage] = useState(undefined);
   const { currentProject } = useContext(Context);
 
   const getExternalId = () => {
     let externalId = localStorage.getItem(AWSAccountID)
     console.log(externalId)
     if (!externalId) {
-      externalId = uuidv4() 
+      externalId = uuidv4()
       localStorage.setItem(AWSAccountID, externalId);
     }
 
@@ -45,24 +49,26 @@ const CloudFormationForm: React.FC<Props> = ({
     let externalId = getExternalId();
     let targetARN = `arn:aws:iam::${AWSAccountID}:role/porter-role`
     setRoleStatus("loading");
+    setErrorMessage(undefined)
     api
-      .preflightCheckAWS(
+      .createAWSIntegration(
         "<token>",
         {
-          target_arn: targetARN,
-          external_id: externalId,
+          aws_target_arn: targetARN,
+          aws_external_id: externalId,
         },
         {
           id: currentProject.id,
         }
       )
       .then(({ data }) => {
-        setRoleStatus("successful");
+        setRoleStatus("successful")
         proceed(targetARN);
       })
       .catch((err) => {
         console.log(err);
-        setRoleStatus("Role does not exist in the AWS account.");
+        setRoleStatus("");
+        setErrorMessage("Porter could not access your AWS account. Please make sure you have granted permissions and try again.")
       });
   };
 
@@ -91,7 +97,7 @@ const CloudFormationForm: React.FC<Props> = ({
             label={
               <Flex>
                 👤 AWS account ID
-                <i 
+                <i
                   className="material-icons"
                   onClick={() => {
                     window.open("https://console.aws.amazon.com/billing/home?region=us-east-1#/account", "_blank")
@@ -155,6 +161,7 @@ const CloudFormationForm: React.FC<Props> = ({
         Grant Porter permissions to create infrastructure in your AWS account.
       </Text>
       {renderContent()}
+      {errorMessage && <ErrorContainer>{errorMessage}</ErrorContainer>}
     </>
   );
 };
@@ -216,4 +223,16 @@ const StyledForm = styled.div`
   border: 1px solid #494b4f;
   font-size: 13px;
   margin-bottom: 30px;
-`;
+`;
+
+const ErrorContainer = styled.div`
+  position: relative;
+  margin-top: 20px;
+  padding: 30px 30px 25px;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  font-size: 13px;
+  margin-bottom: 30px;
+  color: red;
+`

+ 6 - 2
dashboard/src/components/ProvisionerFlow.tsx

@@ -29,6 +29,7 @@ const ProvisionerFlow: React.FC<Props> = ({
   const [credentialId, setCredentialId] = useState("");
   const [showCostConfirmModal, setShowCostConfirmModal] = useState(false);
   const [confirmCost, setConfirmCost] = useState("");
+  const [AWSAccountID, setAWSAccountID] = useState("");
 
   const isUsageExceeded = useMemo(() => {
     if (!hasBillingEnabled) {
@@ -106,7 +107,7 @@ const ProvisionerFlow: React.FC<Props> = ({
             <Text color="helper">
               Separate from the AWS cost, Porter charges based on the amount of resources that are being used. Porter pricing is as follows, prorated to the minute:
             </Text>
-            <Spacer y={1}/>
+            <Spacer y={1} />
             <Cost>$0.019/hr/vCPU + $0.009/hr/GB RAM</Cost>
             <Spacer y={1} />
             <Text color="helper">
@@ -131,12 +132,14 @@ const ProvisionerFlow: React.FC<Props> = ({
     );
   } else if (currentStep === "credentials") {
     return (
-      <CloudFormationForm 
+      <CloudFormationForm
         goBack={() => setCurrentStep("cloud")}
         proceed={(id) => {
           setCredentialId(id);
           setCurrentStep("cluster");
         }}
+        AWSAccountID={AWSAccountID}
+        setAWSAccountID={setAWSAccountID}
       />
     );
   } else if (currentStep === "cluster") {
@@ -144,6 +147,7 @@ const ProvisionerFlow: React.FC<Props> = ({
       <ProvisionerForm
         goBack={() => setCurrentStep("credentials")}
         credentialId={credentialId}
+        AWSAccountID={AWSAccountID}
       />
     );
   }

+ 4 - 2
dashboard/src/components/ProvisionerForm.tsx

@@ -10,11 +10,13 @@ import ProvisionerSettings from "./ProvisionerSettings";
 type Props = {
   goBack: () => void;
   credentialId: string;
+  AWSAccountID: string;
 };
 
 const ProvisionerForm: React.FC<Props> = ({
   goBack,
   credentialId,
+  AWSAccountID
 }) => {
   return (
     <>
@@ -28,9 +30,9 @@ const ProvisionerForm: React.FC<Props> = ({
         Configure settings
       </Heading>
       <Helper>
-        Configure settings for your new cluster. 
+        Configure settings for your new cluster.
       </Helper>
-      <ProvisionerSettings credentialId={credentialId} />
+      <ProvisionerSettings credentialId={credentialId} AWSAccountID={AWSAccountID} />
     </>
   );
 };

+ 35 - 3
dashboard/src/components/ProvisionerSettings.tsx

@@ -53,6 +53,7 @@ const clusterVersionOptions = [
 type Props = RouteComponentProps & {
   selectedClusterVersion?: Contract;
   credentialId: string;
+  AWSAccountID: string;
   clusterId?: number;
 };
 
@@ -75,6 +76,7 @@ const ProvisionerSettings: React.FC<Props> = props => {
   const [cidrRange, setCidrRange] = useState("172.0.0.0/16");
   const [clusterVersion, setClusterVersion] = useState("v1.24.0");
   const [isReadOnly, setIsReadOnly] = useState(false);
+  const [errorMessage, setErrorMessage] = useState<string>(undefined);
 
   const createCluster = async () => {
     var data = new Contract({
@@ -123,6 +125,20 @@ const ProvisionerSettings: React.FC<Props> = props => {
     }
 
     try {
+      setIsReadOnly(true)
+      setErrorMessage(undefined)
+      await api
+        .preflightCheckAWSUsage(
+          "<token>",
+          {
+            target_arn: `arn:aws:iam::${props.AWSAccountID}:role/porter-role`,
+            region: awsRegion
+          },
+          {
+            id: currentProject.id,
+          }
+        );
+
       const res = await api.createContract(
         "<token>",
         data,
@@ -153,8 +169,11 @@ const ProvisionerSettings: React.FC<Props> = props => {
             console.error(err);
           });
       }
+      setErrorMessage(undefined);
     } catch (err) {
-      console.log(err);
+      setErrorMessage(err.response.data.error.replace('unknown: ', ''));
+    } finally {
+      setIsReadOnly(false)
     }
   }
 
@@ -186,7 +205,7 @@ const ProvisionerSettings: React.FC<Props> = props => {
   }, [props.selectedClusterVersion]);
 
   const renderForm = () => {
-    
+
     // Render simplified form if initial create
     if (!props.clusterId) {
       return (
@@ -317,6 +336,7 @@ const ProvisionerSettings: React.FC<Props> = props => {
         statusPosition="right"
         status={isReadOnly && "Provisioning is still in progress"}
       />
+      {errorMessage && <ErrorContainer>{errorMessage} Please correct the issue and try to provision again.</ErrorContainer>}
     </>
   );
 };
@@ -342,4 +362,16 @@ const StyledForm = styled.div`
   border: 1px solid #494b4f;
   font-size: 13px;
   margin-bottom: 30px;
-`;
+`;
+
+const ErrorContainer = styled.div`
+  position: relative;
+  margin-top: 20px;
+  padding: 30px 30px 25px;
+  border-radius: 5px;
+  background: #26292e;
+  border: 1px solid #494b4f;
+  font-size: 13px;
+  margin-bottom: 30px;
+  color: red;
+`

+ 22 - 28
dashboard/src/shared/api.tsx

@@ -71,23 +71,25 @@ const getGitlabIntegration = baseApi<{}, { project_id: number }>(
   ({ project_id }) => `/api/projects/${project_id}/integrations/gitlab`
 );
 
-const preflightCheckAWS = baseApi<
+const preflightCheckAWSUsage = baseApi<
   {
     target_arn: string;
-    external_id: string;
+    region: string;
   },
   { id: number }
 >("POST", (pathParams) => {
-  return `/api/projects/${pathParams.id}/integrations/aws/preflight`;
+  return `/api/projects/${pathParams.id}/integrations/aws/preflight/usage`;
 });
 
 const createAWSIntegration = baseApi<
   {
-    aws_region: string;
+    aws_region?: string;
     aws_cluster_id?: string;
-    aws_access_key_id: string;
-    aws_secret_access_key: string;
+    aws_access_key_id?: string;
+    aws_secret_access_key?: string;
     aws_assume_role_arn?: string;
+    aws_target_arn?: string;
+    aws_external_id?: string;
   },
   { id: number }
 >("POST", (pathParams) => {
@@ -577,11 +579,9 @@ const detectBuildpack = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/buildpack/detect`;
 });
 
 const detectGitlabBuildpack = baseApi<
@@ -612,11 +612,9 @@ const getBranchContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/contents`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/contents`;
 });
 
 const getProcfileContents = baseApi<
@@ -632,11 +630,9 @@ const getProcfileContents = baseApi<
     branch: string;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.project_id}/gitrepos/${
-    pathParams.git_repo_id
-  }/repos/${pathParams.kind}/${pathParams.owner}/${
-    pathParams.name
-  }/${encodeURIComponent(pathParams.branch)}/procfile`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${pathParams.git_repo_id
+    }/repos/${pathParams.kind}/${pathParams.owner}/${pathParams.name
+    }/${encodeURIComponent(pathParams.branch)}/procfile`;
 });
 
 const getGitlabProcfileContents = baseApi<
@@ -1490,11 +1486,9 @@ const getEnvGroup = baseApi<
     version?: number;
   }
 >("GET", (pathParams) => {
-  return `/api/projects/${pathParams.id}/clusters/${
-    pathParams.cluster_id
-  }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${
-    pathParams.version ? "&version=" + pathParams.version : ""
-  }`;
+  return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id
+    }/namespaces/${pathParams.namespace}/envgroup?name=${pathParams.name}${pathParams.version ? "&version=" + pathParams.version : ""
+    }`;
 });
 
 const getConfigMap = baseApi<
@@ -2396,7 +2390,7 @@ const removeStackEnvGroup = baseApi<
     `/api/v1/projects/${project_id}/clusters/${cluster_id}/namespaces/${namespace}/stacks/${stack_id}/remove_env_group/${env_group_name}`
 );
 
-const getGithubStatus = baseApi<{}, {}>("GET", ({}) => `/api/status/github`);
+const getGithubStatus = baseApi<{}, {}>("GET", ({ }) => `/api/status/github`);
 
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
@@ -2573,7 +2567,7 @@ export default {
   addApplicationToEnvGroup,
   removeApplicationFromEnvGroup,
   provisionDatabase,
-  preflightCheckAWS,
+  preflightCheckAWSUsage,
   getDatabases,
   getPreviousLogsForContainer,
   upgradePorterAgent,

+ 1 - 1
go.mod

@@ -74,7 +74,7 @@ require (
 	github.com/glebarez/sqlite v1.6.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.48
+	github.com/porter-dev/api-contracts v0.0.59
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d
 	github.com/xanzy/go-gitlab v0.68.0

+ 2 - 2
go.sum

@@ -1466,8 +1466,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.48 h1:i6B6H26+am/H61YC1u9u7gAah+opnWS7qfUxZQrkWjo=
-github.com/porter-dev/api-contracts v0.0.48/go.mod h1:qr2L58mJLr5DUGV5OPw3REiSrQvJq6TgkKyEWP95dyU=
+github.com/porter-dev/api-contracts v0.0.59 h1:A0r7WJ29qng67p8kFsLAqGSdTR86pS2WjjyYe2PF+Do=
+github.com/porter-dev/api-contracts v0.0.59/go.mod h1:qr2L58mJLr5DUGV5OPw3REiSrQvJq6TgkKyEWP95dyU=
 github.com/porter-dev/switchboard v0.0.0-20221019155755-67ff2bf04935 h1:hfb3nt3AJXIBbevu6ARTg9SdOkMP6WLbKBiG5hT5rcc=
 github.com/porter-dev/switchboard v0.0.0-20221019155755-67ff2bf04935/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio