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

Merge pull request #2092 from porter-dev/gitlab-integration-nico

Implement create and list endpoints for gitlab integration
Mohammed Nafees 4 лет назад
Родитель
Сommit
603ce24d5a

+ 55 - 0
api/server/handlers/project_integration/create_gitlab.go

@@ -0,0 +1,55 @@
+package project_integration
+
+import (
+	"net/http"
+
+	"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"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	ints "github.com/porter-dev/porter/internal/models/integrations"
+)
+
+type CreateGitlabIntegration struct {
+	handlers.PorterHandlerReadWriter
+}
+
+func NewCreateGitlabIntegration(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *CreateGitlabIntegration {
+	return &CreateGitlabIntegration{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+	}
+}
+
+func (p *CreateGitlabIntegration) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	request := &types.CreateGitlabRequest{}
+
+	if ok := p.DecodeAndValidate(w, r, request); !ok {
+		return
+	}
+
+	gitlabIntegration, err := p.Repo().GitlabIntegration().CreateGitlabIntegration(&ints.GitlabIntegration{
+		ProjectID:       project.ID,
+		InstanceURL:     request.InstanceURL,
+		AppClientID:     []byte(request.AppClientID),
+		AppClientSecret: []byte(request.AppClientSecret),
+	})
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := types.CreateGitlabResponse{
+		GitlabIntegration: gitlabIntegration.ToGitlabIntegrationType(),
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 44 - 0
api/server/handlers/project_integration/list_gitlab.go

@@ -0,0 +1,44 @@
+package project_integration
+
+import (
+	"net/http"
+
+	"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"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ListGitlabHandler struct {
+	handlers.PorterHandlerWriter
+}
+
+func NewListGitlabHandler(
+	config *config.Config,
+	writer shared.ResultWriter,
+) *ListGitlabHandler {
+	return &ListGitlabHandler{
+		PorterHandlerWriter: handlers.NewDefaultPorterHandler(config, nil, writer),
+	}
+}
+
+func (p *ListGitlabHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	project, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	gitlabInts, err := p.Repo().GitlabIntegration().ListGitlabIntegrationsByProjectID(project.ID)
+
+	if err != nil {
+		p.HandleAPIError(w, r, apierrors.NewErrInternal(err))
+		return
+	}
+
+	var res types.ListGitlabResponse = make([]*types.GitlabIntegration, 0)
+
+	for _, gitlabInt := range gitlabInts {
+		res = append(res, gitlabInt.ToGitlabIntegrationType())
+	}
+
+	p.WriteResult(w, r, res)
+}

+ 59 - 0
api/server/router/project_integration.go

@@ -327,5 +327,64 @@ func getProjectIntegrationRoutes(
 		Router:   r,
 	})
 
+	// GET /api/projects/{project_id}/integrations/gitlab
+	listGitlabEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbGet,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/gitlab",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	listGitlabHandler := project_integration.NewListGitlabHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listGitlabEndpoint,
+		Handler:  listGitlabHandler,
+		Router:   r,
+	})
+
+	// POST /api/projects/{project_id}/integrations/gitlab
+	createGitlabEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath + "/gitlab",
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+			},
+		},
+	)
+
+	createGitlabHandler := project_integration.NewCreateGitlabIntegration(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: createGitlabEndpoint,
+		Handler:  createGitlabHandler,
+		Router:   r,
+	})
+
+	// PATCH /api/projects/{project_id}/integrations/gitlab/{integration_id}
+
+	// DELETE /api/projects/{project_id}/integrations/gitlab/{integration_id}
+
 	return routes, newPath
 }

+ 5 - 5
api/types/project_integration.go

@@ -166,18 +166,18 @@ type GitlabIntegration struct {
 
 	ID uint `json:"id"`
 
-	// The id of the user that linked this auth mechanism
-	UserID uint `json:"user_id"`
-
 	// The project that this integration belongs to
 	ProjectID uint `json:"project_id"`
+
+	InstanceURL string `json:"instance_url"`
 }
 
 type ListGitlabResponse []*GitlabIntegration
 
 type CreateGitlabRequest struct {
-	SudoAccessToken string `json:"sudo_access_token" form:"required"`
-	SudoUsername    string `json:"sudo_username" form:"required"`
+	InstanceURL     string `json:"instance_url"`
+	AppClientID     string `json:"client_id"`
+	AppClientSecret string `json:"client_secret"`
 }
 
 type CreateGitlabResponse struct {

+ 205 - 0
dashboard/src/main/home/integrations/GitlabIntegrationList.tsx

@@ -0,0 +1,205 @@
+import React, { useContext, useRef, useState } from "react";
+import ConfirmOverlay from "../../../components/ConfirmOverlay";
+import styled from "styled-components";
+import { Context } from "../../../shared/Context";
+import api from "../../../shared/api";
+import { integrationList } from "shared/common";
+import DynamicLink from "components/DynamicLink";
+
+interface Props {
+  gitlabData: any[];
+}
+
+const GitlabIntegrationList: React.FC<Props> = (props) => {
+  const [isDelete, setIsDelete] = useState(false);
+  const [deleteIndex, setDeleteIndex] = useState(-1); // guaranteed to be set when used
+  const { currentProject, setCurrentError } = useContext(Context);
+  const deleted = useRef(new Set());
+
+  const handleDelete = () => {
+    alert("NOT IMPLEMENTED");
+    // api
+    //   .deleteSlackIntegration(
+    //     "<token>",
+    //     {},
+    //     {
+    //       project_id: currentProject.id,
+    //       slack_integration_id: props.gitlabData[deleteIndex].id,
+    //     }
+    //   )
+    //   .then(() => {
+    //     deleted.current.add(deleteIndex);
+    //     setIsDelete(false);
+    //   })
+    //   .catch((err) => {
+    //     setCurrentError(err);
+    //   });
+  };
+
+  return (
+    <>
+      <ConfirmOverlay
+        show={isDelete}
+        message={
+          deleteIndex != -1 &&
+          `Are you sure you want to delete the gitlab instance ${props.gitlabData[deleteIndex].instance_url}?`
+        }
+        onYes={handleDelete}
+        onNo={() => setIsDelete(false)}
+      />
+      <StyledIntegrationList>
+        {props.gitlabData?.length > 0 ? (
+          props.gitlabData.map((inst, idx) => {
+            if (deleted.current.has(idx)) return null;
+            return (
+              <Integration
+                onClick={() => {}}
+                disabled={false}
+                key={`${inst.team_id}-${inst.channel}`}
+              >
+                <MainRow disabled={false}>
+                  <Flex>
+                    <Icon src={integrationList.gitlab.icon} />
+                    <Label>{inst.instance_url}</Label>
+                  </Flex>
+                  <MaterialIconTray disabled={false}>
+                    <i
+                      className="material-icons"
+                      onClick={() => {
+                        setDeleteIndex(idx);
+                        setIsDelete(true);
+                      }}
+                    >
+                      delete
+                    </i>
+                    <i
+                      className="material-icons"
+                      onClick={() => {
+                        window.open(inst.instance_url, "_blank");
+                      }}
+                    >
+                      launch
+                    </i>
+                  </MaterialIconTray>
+                </MainRow>
+              </Integration>
+            );
+          })
+        ) : (
+          <Placeholder>No GitLab instances setted up yet.</Placeholder>
+        )}
+      </StyledIntegrationList>
+    </>
+  );
+};
+
+export default GitlabIntegrationList;
+
+const Placeholder = styled.div`
+  width: 100%;
+  height: 250px;
+  display: flex;
+  align-items: center;
+  font-size: 13px;
+  font-family: "Work Sans", sans-serif;
+  justify-content: center;
+  margin-top: 30px;
+  background: #ffffff11;
+  color: #ffffff44;
+  border-radius: 5px;
+`;
+
+const Label = styled.div`
+  color: #ffffff;
+  font-size: 14px;
+  font-weight: 500;
+`;
+
+const StyledIntegrationList = styled.div`
+  margin-top: 20px;
+  margin-bottom: 80px;
+`;
+
+const MainRow = styled.div`
+  height: 70px;
+  width: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 25px;
+  border-radius: 5px;
+  :hover {
+    background: ${(props: { disabled: boolean }) =>
+      props.disabled ? "" : "#ffffff11"};
+    > i {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+
+  > i {
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    color: #ffffff44;
+    margin-right: -7px;
+    :hover {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+`;
+
+const Integration = styled.div`
+  margin-left: -2px;
+  display: flex;
+  flex-direction: column;
+  background: #26282f;
+  cursor: ${(props: { disabled: boolean }) =>
+    props.disabled ? "not-allowed" : "pointer"};
+  margin-bottom: 15px;
+  border-radius: 8px;
+  box-shadow: 0 4px 15px 0px #00000055;
+`;
+
+const Icon = styled.img`
+  width: 27px;
+  margin-right: 12px;
+  margin-bottom: -1px;
+`;
+
+const Flex = styled.div`
+  display: flex;
+  align-items: center;
+
+  > i {
+    cursor: pointer;
+    font-size: 24px;
+    color: #969fbbaa;
+    padding: 3px;
+    margin-right: 11px;
+    border-radius: 100px;
+    :hover {
+      background: #ffffff11;
+    }
+  }
+`;
+
+const MaterialIconTray = styled.div`
+  max-width: 60px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  > i {
+    background: #26282f;
+    border-radius: 20px;
+    font-size: 18px;
+    padding: 5px;
+    margin: 0 5px;
+    color: #ffffff44;
+    :hover {
+      background: ${(props: { disabled: boolean }) =>
+        props.disabled ? "" : "#ffffff11"};
+    }
+  }
+`;

+ 22 - 3
dashboard/src/main/home/integrations/IntegrationCategories.tsx

@@ -10,6 +10,7 @@ import { pushFiltered } from "shared/routing";
 import Loading from "../../../components/Loading";
 import SlackIntegrationList from "./SlackIntegrationList";
 import TitleSection from "components/TitleSection";
+import GitlabIntegrationList from "./GitlabIntegrationList";
 
 type Props = RouteComponentProps & {
   category: string;
@@ -22,6 +23,7 @@ const IntegrationCategories: React.FC<Props> = (props) => {
   const [currentIntegrationData, setCurrentIntegrationData] = useState([]);
   const [loading, setLoading] = useState(false);
   const [slackData, setSlackData] = useState([]);
+  const [gitlabData, setGitlabData] = useState([]);
 
   const { currentProject, setCurrentModal } = useContext(Context);
 
@@ -79,6 +81,17 @@ const IntegrationCategories: React.FC<Props> = (props) => {
           })
           .catch(console.log);
         break;
+      case "gitlab":
+        api
+          .getGitlabIntegration(
+            "<token>",
+            {},
+            { project_id: currentProject.id }
+          )
+          .then((res) => {
+            setGitlabData(res.data);
+            setLoading(false);
+          });
       default:
         console.log("Unknown integration category.");
     }
@@ -110,7 +123,11 @@ const IntegrationCategories: React.FC<Props> = (props) => {
         </TitleSection>
         <Button
           onClick={() => {
-            if (props.category != "slack") {
+            if (props.category === "gitlab") {
+              pushFiltered(props, `/integrations/gitlab/create/gitlab`, [
+                "project_id",
+              ]);
+            } else if (props.category != "slack") {
               setCurrentModal("IntegrationsModal", {
                 category: currentCategory,
                 setCurrentIntegration: (x: string) =>
@@ -131,6 +148,8 @@ const IntegrationCategories: React.FC<Props> = (props) => {
       </Flex>
       {loading ? (
         <Loading />
+      ) : props.category === "gitlab" ? (
+        <GitlabIntegrationList gitlabData={gitlabData} />
       ) : props.category == "slack" ? (
         <SlackIntegrationList slackData={slackData} />
       ) : (
@@ -158,8 +177,8 @@ const Flex = styled.div`
 
   > i {
     cursor: pointer;
-    font-size 24px;
-    color: #969Fbbaa;
+    font-size: 24px;
+    color: #969fbbaa;
     padding: 3px;
     margin-right: 11px;
     border-radius: 100px;

+ 8 - 4
dashboard/src/main/home/integrations/Integrations.tsx

@@ -12,7 +12,11 @@ import TitleSection from "components/TitleSection";
 
 type PropsType = RouteComponentProps;
 
-const IntegrationCategoryStrings = ["registry", "slack"]; /*"kubernetes",*/
+const IntegrationCategoryStrings = [
+  "registry",
+  "slack",
+  "gitlab",
+]; /*"kubernetes",*/
 
 const Integrations: React.FC<PropsType> = (props) => {
   return (
@@ -69,7 +73,7 @@ const Integrations: React.FC<PropsType> = (props) => {
 
             <IntegrationList
               currentCategory={""}
-              integrations={["registry", "slack"]}
+              integrations={["registry", "slack", "gitlab"]}
               setCurrent={(x) =>
                 pushFiltered(props, `/integrations/${x}`, ["project_id"])
               }
@@ -106,8 +110,8 @@ const Flex = styled.div`
 
   > i {
     cursor: pointer;
-    font-size 24px;
-    color: #969Fbbaa;
+    font-size: 24px;
+    color: #969fbbaa;
     padding: 3px;
     margin-right: 11px;
     border-radius: 100px;

+ 2 - 2
dashboard/src/main/home/integrations/SlackIntegrationList.tsx

@@ -176,8 +176,8 @@ const Flex = styled.div`
 
   > i {
     cursor: pointer;
-    font-size 24px;
-    color: #969Fbbaa;
+    font-size: 24px;
+    color: #969fbbaa;
     padding: 3px;
     margin-right: 11px;
     border-radius: 100px;

+ 3 - 0
dashboard/src/main/home/integrations/create-integration/CreateIntegrationForm.tsx

@@ -7,6 +7,7 @@ import GKEForm from "./GKEForm";
 import EKSForm from "./EKSForm";
 import GCRForm from "./GCRForm";
 import ECRForm from "./ECRForm";
+import GitlabForm from "./GitlabForm";
 
 type PropsType = {
   integrationName: string;
@@ -33,6 +34,8 @@ export default class CreateIntegrationForm extends Component<
         return <ECRForm closeForm={this.props.closeForm} />;
       case "gcr":
         return <GCRForm closeForm={this.props.closeForm} />;
+      case "gitlab":
+        return <GitlabForm closeForm={this.props.closeForm} />;
       default:
         return null;
     }

+ 145 - 0
dashboard/src/main/home/integrations/create-integration/GitlabForm.tsx

@@ -0,0 +1,145 @@
+import Heading from "components/form-components/Heading";
+import InputRow from "components/form-components/InputRow";
+import SaveButton from "components/SaveButton";
+import React, { useContext, useState } from "react";
+import api from "shared/api";
+import { Context } from "shared/Context";
+import { useRouting } from "shared/routing";
+import styled from "styled-components";
+
+const URLRegex = /(http(s)?):\/\/[(www\.)?a-zA-Z0-9@:%._\+~#=\-]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/;
+
+type Props = {
+  closeForm: () => void;
+};
+
+const GitlabForm: React.FC<Props> = () => {
+  const { currentProject } = useContext(Context);
+  const [instanceUrl, setInstanceUrl] = useState("");
+  const [clientId, setClientId] = useState("");
+  const [clientSecret, setClientSecret] = useState("");
+  const [error, setError] = useState<{
+    message: string;
+    input: "client_id" | "client_secret" | "instance_url";
+  }>(null);
+
+  const [buttonStatus, setButtonStatus] = useState("");
+
+  const { pushFiltered } = useRouting();
+
+  const submit = async () => {
+    if (!URLRegex.test(instanceUrl)) {
+      if (!instanceUrl.includes("http") || !instanceUrl.includes("https")) {
+        setError({
+          message:
+            "Invalid URL, please make sure the URL contains the http/s protocol.",
+          input: "instance_url",
+        });
+        return;
+      }
+
+      setError({
+        message: "Invalid URL, please check again.",
+        input: "instance_url",
+      });
+      return;
+    }
+
+    if (!clientId || !clientId.trim().length) {
+      setError({
+        message: "Invalid Client ID",
+        input: "client_id",
+      });
+      return;
+    }
+
+    if (!clientSecret || !clientSecret.trim().length) {
+      setError({
+        message: "Invalid Client Secret",
+        input: "client_secret",
+      });
+      return;
+    }
+
+    setError(null);
+
+    setButtonStatus("loading");
+
+    try {
+      await api.createGitlabIntegration(
+        "<token>",
+        {
+          instance_url: instanceUrl,
+          client_id: clientId,
+          client_secret: clientSecret,
+        },
+        { id: currentProject.id }
+      );
+
+      setButtonStatus("successful");
+      pushFiltered(`/integrations/gitlab`, ["project_id"]);
+    } catch (error) {
+      setButtonStatus("Couldn't save the instance. Please try again.");
+    } finally {
+      setTimeout(() => {
+        setButtonStatus("");
+      }, 1000);
+    }
+  };
+
+  return (
+    <>
+      <StyledForm>
+        <CredentialWrapper>
+          <Heading>Gitlab instance settings</Heading>
+
+          <InputRow
+            type="string"
+            label="Instance URL"
+            value={instanceUrl}
+            setValue={(val: string) => setInstanceUrl(val)}
+            isRequired
+            width="100%"
+            hasError={error?.input === "instance_url"}
+          />
+          <InputRow
+            type="string"
+            label="Client ID"
+            value={clientId}
+            setValue={(val: string) => setClientId(val)}
+            isRequired
+            width="100%"
+            hasError={error?.input === "client_id"}
+          />
+          <InputRow
+            type="string"
+            label="Client Secret"
+            value={clientSecret}
+            setValue={(val: string) => setClientSecret(val)}
+            isRequired
+            width="100%"
+            hasError={error?.input === "client_secret"}
+          />
+        </CredentialWrapper>
+        <SaveButton
+          onClick={submit}
+          text="Save new Gitlab instance"
+          status={buttonStatus || error?.message}
+        />
+      </StyledForm>
+    </>
+  );
+};
+
+export default GitlabForm;
+
+const CredentialWrapper = styled.div`
+  padding: 5px 40px 25px;
+  background: #ffffff11;
+  border-radius: 5px;
+`;
+
+const StyledForm = styled.div`
+  position: relative;
+  padding-bottom: 75px;
+`;

+ 18 - 0
dashboard/src/shared/api.tsx

@@ -61,6 +61,11 @@ const getAzureIntegration = baseApi<{}, { project_id: number }>(
   ({ project_id }) => `/api/projects/${project_id}/integrations/azure`
 );
 
+const getGitlabIntegration = baseApi<{}, { project_id: number }>(
+  "GET",
+  ({ project_id }) => `/api/projects/${project_id}/integrations/gitlab`
+);
+
 const createAWSIntegration = baseApi<
   {
     aws_region: string;
@@ -99,6 +104,17 @@ const createAzureIntegration = baseApi<
   return `/api/projects/${pathParams.id}/integrations/azure`;
 });
 
+const createGitlabIntegration = baseApi<
+  {
+    instance_url: string;
+    client_id: string;
+    client_secret: string;
+  },
+  { id: number }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.id}/integrations/gitlab`;
+});
+
 const createEmailVerification = baseApi<{}, {}>("POST", (pathParams) => {
   return `/api/email/verify/initiate`;
 });
@@ -1807,9 +1823,11 @@ export default {
   getAWSIntegration,
   getGCPIntegration,
   getAzureIntegration,
+  getGitlabIntegration,
   createAWSIntegration,
   overwriteAWSIntegration,
   createAzureIntegration,
+  createGitlabIntegration,
   createEmailVerification,
   createEnvironment,
   deleteEnvironment,

+ 1 - 0
dashboard/src/shared/common.tsx

@@ -108,6 +108,7 @@ export const integrationList: any = {
   gitlab: {
     icon: "https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png",
     label: "Gitlab",
+    buttonText: "Add instance",
   },
   rds: {
     icon:

+ 13 - 1
internal/models/integrations/gitlab.go

@@ -1,6 +1,9 @@
 package integrations
 
-import "gorm.io/gorm"
+import (
+	"github.com/porter-dev/porter/api/types"
+	"gorm.io/gorm"
+)
 
 // GitlabIntegration takes care of Gitlab app related data
 type GitlabIntegration struct {
@@ -22,3 +25,12 @@ type GitlabIntegration struct {
 	// Gitlab instance-wide app's client secret
 	AppClientSecret []byte `json:"app_client_secret"`
 }
+
+func (gi *GitlabIntegration) ToGitlabIntegrationType() *types.GitlabIntegration {
+	return &types.GitlabIntegration{
+		CreatedAt:   gi.CreatedAt,
+		ID:          gi.ID,
+		ProjectID:   gi.ProjectID,
+		InstanceURL: gi.InstanceURL,
+	}
+}