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

Merge pull request #916 from porter-dev/0.6.0-911-webhook-generation

[0.6.0] Fixes #911: it should be possible to regenerate webhooks
abelanger5 4 лет назад
Родитель
Сommit
be658bd1fc

+ 55 - 16
dashboard/src/components/SaveButton.tsx

@@ -12,6 +12,8 @@ type PropsType = {
 
   // Makes flush with corner if not within a modal
   makeFlush?: boolean;
+  clearPosition?: boolean;
+  statusPosition?: "right" | "left";
 };
 
 type StateType = {};
@@ -21,28 +23,37 @@ export default class SaveButton extends Component<PropsType, StateType> {
     if (this.props.status) {
       if (this.props.status === "successful") {
         return (
-          <StatusWrapper successful={true}>
+          <StatusWrapper position={this.props.statusPosition} successful={true}>
             <i className="material-icons">done</i>
             <StatusTextWrapper>Successfully updated</StatusTextWrapper>
           </StatusWrapper>
         );
       } else if (this.props.status === "loading") {
         return (
-          <StatusWrapper successful={false}>
+          <StatusWrapper
+            position={this.props.statusPosition}
+            successful={false}
+          >
             <LoadingGif src={loading} />
             <StatusTextWrapper>Updating . . .</StatusTextWrapper>
           </StatusWrapper>
         );
       } else if (this.props.status === "error") {
         return (
-          <StatusWrapper successful={false}>
+          <StatusWrapper
+            position={this.props.statusPosition}
+            successful={false}
+          >
             <i className="material-icons">error_outline</i>
             <StatusTextWrapper>Could not update</StatusTextWrapper>
           </StatusWrapper>
         );
       } else {
         return (
-          <StatusWrapper successful={false}>
+          <StatusWrapper
+            position={this.props.statusPosition}
+            successful={false}
+          >
             <i className="material-icons">error_outline</i>
             <StatusTextWrapper>{this.props.status}</StatusTextWrapper>
           </StatusWrapper>
@@ -50,15 +61,22 @@ export default class SaveButton extends Component<PropsType, StateType> {
       }
     } else if (this.props.helper) {
       return (
-        <StatusWrapper successful={true}>{this.props.helper}</StatusWrapper>
+        <StatusWrapper position={this.props.statusPosition} successful={true}>
+          {this.props.helper}
+        </StatusWrapper>
       );
     }
   };
 
   render() {
     return (
-      <ButtonWrapper makeFlush={this.props.makeFlush}>
-        <div>{this.renderStatus()}</div>
+      <ButtonWrapper
+        makeFlush={this.props.makeFlush}
+        clearPosition={this.props.clearPosition}
+      >
+        {this.props.statusPosition !== "right" && (
+          <div>{this.renderStatus()}</div>
+        )}
         <Button
           disabled={this.props.disabled}
           onClick={this.props.onClick}
@@ -66,6 +84,9 @@ export default class SaveButton extends Component<PropsType, StateType> {
         >
           {this.props.text}
         </Button>
+        {this.props.statusPosition === "right" && (
+          <div>{this.renderStatus()}</div>
+        )}
       </ButtonWrapper>
     );
   }
@@ -87,13 +108,21 @@ const StatusTextWrapper = styled.p`
   margin: 0;
 `;
 
-const StatusWrapper = styled.div`
+const StatusWrapper = styled.div<{
+  successful: boolean;
+  position: "right" | "left";
+}>`
   display: flex;
   align-items: center;
   font-family: "Work Sans", sans-serif;
   font-size: 13px;
   color: #ffffff55;
-  margin-right: 25px;
+  ${(props) => {
+    if (props.position !== "right") {
+      return "margin-right: 25px;";
+    }
+    return "margin-left: 25px;";
+  }}
   max-width: 500px;
   overflow: hidden;
   text-overflow: ellipsis;
@@ -102,8 +131,7 @@ const StatusWrapper = styled.div`
     font-size: 18px;
     margin-right: 10px;
     float: left;
-    color: ${(props: { successful: boolean }) =>
-      props.successful ? "#4797ff" : "#fcba03"};
+    color: ${(props) => (props.successful ? "#4797ff" : "#fcba03")};
   }
 
   animation: statusFloatIn 0.5s;
@@ -122,19 +150,30 @@ const StatusWrapper = styled.div`
 `;
 
 const ButtonWrapper = styled.div`
-  display: flex;
-  align-items: center;
-  position: absolute;
-  justify-content: flex-end;
-  ${(props: { makeFlush: boolean }) => {
+  ${(props: { makeFlush: boolean; clearPosition?: boolean }) => {
+    const baseStyles = `
+      display: flex;
+      align-items: center;
+    `;
+
+    if (props.clearPosition) {
+      return baseStyles;
+    }
+
     if (!props.makeFlush) {
       return `
+        ${baseStyles}
+        position: absolute;
+        justify-content: flex-end;
         bottom: 25px;
         right: 27px;
         left: 27px;
       `;
     }
     return `
+      ${baseStyles}
+      position: absolute;
+      justify-content: flex-end;
       bottom: 5px;
       right: 0;
     `;

+ 206 - 148
dashboard/src/main/home/cluster-dashboard/expanded-chart/SettingsSection.tsx

@@ -1,4 +1,4 @@
-import React, { Component } from "react";
+import React, { Component, useContext, useEffect, useState } from "react";
 import styled from "styled-components";
 import api from "shared/api";
 import yaml from "js-yaml";
@@ -15,9 +15,10 @@ import ImageSelector from "components/image-selector/ImageSelector";
 import SaveButton from "components/SaveButton";
 import Heading from "components/values-form/Heading";
 import Helper from "components/values-form/Helper";
-import InputRow from "components/values-form/InputRow";
 import _ from "lodash";
 import CopyToClipboard from "components/CopyToClipboard";
+import useAuth from "shared/auth/useAuth";
+import Loading from "components/Loading";
 
 type PropsType = {
   currentChart: ChartType;
@@ -27,201 +28,258 @@ type PropsType = {
   saveButtonText?: string | null;
 };
 
-type StateType = {
-  sourceType: string;
-  selectedImageUrl: string | null;
-  selectedTag: string | null;
-  saveValuesStatus: string | null;
-  values: string;
-  webhookToken: string;
-  highlightCopyButton: boolean;
-  action: ActionConfigType;
-};
-
-export default class SettingsSection extends Component<PropsType, StateType> {
-  state = {
-    sourceType: "",
-    selectedImageUrl: "",
-    selectedTag: "",
-    values: "",
-    saveValuesStatus: null as string | null,
-    webhookToken: "",
-    highlightCopyButton: false,
-    action: {
-      git_repo: "",
-      image_repo_uri: "",
-      git_repo_id: 0,
-    } as ActionConfigType,
-  };
-
-  // TODO: read in set image from form context instead of config
-  componentDidMount() {
-    let { currentCluster, currentProject } = this.context;
-
-    let image = this.props.currentChart.config?.image;
-    this.setState({
-      selectedImageUrl: image?.repository,
-      selectedTag: image?.tag,
-    });
+const SettingsSection: React.FC<PropsType> = ({
+  currentChart,
+  refreshChart,
+  setShowDeleteOverlay,
+  showSource,
+  saveButtonText,
+}) => {
+  const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>("");
+  const [selectedTag, setSelectedTag] = useState<string | null>("");
+  const [saveValuesStatus, setSaveValuesStatus] = useState<string | null>(null);
+  const [highlightCopyButton, setHighlightCopyButton] = useState<boolean>(
+    false
+  );
+  const [webhookToken, setWebhookToken] = useState<string>("");
+  const [
+    createWebhookButtonStatus,
+    setCreateWebhookButtonStatus,
+  ] = useState<string>("");
+  const [loadingWebhookToken, setLoadingWebhookToken] = useState<boolean>(true);
+
+  const [action, setAction] = useState<ActionConfigType>({
+    git_repo: "",
+    image_repo_uri: "",
+    git_repo_id: 0,
+    branch: "",
+  });
+
+  const { currentCluster, currentProject, setCurrentError } = useContext(
+    Context
+  );
+  const [isAuthorized] = useAuth();
+
+  useEffect(() => {
+    let isSubscribed = true;
+    setLoadingWebhookToken(true);
+    const image = currentChart?.config?.image;
+    setSelectedImageUrl(image?.repository);
+    setSelectedTag(image?.tag);
 
     api
       .getReleaseToken(
         "<token>",
         {
-          namespace: this.props.currentChart.namespace,
+          namespace: currentChart?.namespace,
           cluster_id: currentCluster.id,
           storage: StorageType.Secret,
         },
-        { id: currentProject.id, name: this.props.currentChart.name }
+        { id: currentProject.id, name: currentChart?.name }
       )
       .then((res) => {
-        this.setState({
-          action: res.data.git_action_config,
-          webhookToken: res.data.webhook_token,
-        });
-      })
-      .catch(console.log);
-  }
+        if (!isSubscribed) {
+          return;
+        }
 
-  renderWebhookSection = () => {
-    if (!this.props.currentChart?.form?.hasSource) {
-      return;
-    }
+        setAction(res.data.git_action_config);
+        setWebhookToken(res.data.webhook_token);
+      })
+      .catch(console.log)
+      .finally(() => setLoadingWebhookToken(false));
 
-    if (true || this.state.webhookToken) {
-      let webhookText = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${this.state.webhookToken}?commit=YOUR_COMMIT_HASH'`;
-      return (
-        <>
-          {this.props.showSource && (
-            <>
-              <Heading>Source Settings</Heading>
-              <Helper>Specify an image tag to use.</Helper>
-              <ImageSelector
-                selectedTag={this.state.selectedTag}
-                selectedImageUrl={this.state.selectedImageUrl}
-                setSelectedImageUrl={(x: string) =>
-                  this.setState({ selectedImageUrl: x })
-                }
-                setSelectedTag={(x: string) =>
-                  this.setState({ selectedTag: x })
-                }
-                forceExpanded={true}
-                disableImageSelect={true}
-              />
-              <Br />
-            </>
-          )}
-          <Heading>Redeploy Webhook</Heading>
-          <Helper>
-            Programmatically deploy by calling this secret webhook.
-          </Helper>
-          <Webhook copiedToClipboard={this.state.highlightCopyButton}>
-            <div>{webhookText}</div>
-            <CopyToClipboard
-              as="i"
-              text={webhookText}
-              onSuccess={() => this.setState({ highlightCopyButton: true })}
-              wrapperProps={{
-                className: "material-icons",
-                onMouseLeave: () =>
-                  this.setState({ highlightCopyButton: false }),
-              }}
-            >
-              content_copy
-            </CopyToClipboard>
-          </Webhook>
-        </>
-      );
-    }
-  };
+    return () => (isSubscribed = false);
+  }, [currentChart, currentCluster, currentProject]);
 
-  handleSubmit = () => {
-    let { currentCluster, setCurrentError, currentProject } = this.context;
-    this.setState({ saveValuesStatus: "loading" });
+  const handleSubmit = async () => {
+    setSaveValuesStatus("loading");
 
-    console.log(this.state.selectedImageUrl);
+    console.log(selectedImageUrl);
 
     let values = {};
-    if (this.state.selectedTag) {
-      _.set(values, "image.repository", this.state.selectedImageUrl);
-      _.set(values, "image.tag", this.state.selectedTag);
+    if (selectedTag) {
+      _.set(values, "image.repository", selectedImageUrl);
+      _.set(values, "image.tag", selectedTag);
     }
 
     // if this is a job, set it to paused
-    if (this.props.currentChart.chart.metadata.name == "job") {
+    if (currentChart?.chart?.metadata?.name == "job") {
       _.set(values, "paused", true);
     }
 
     // Weave in preexisting values and convert to yaml
     let conf = yaml.dump(
       {
-        ...(this.props.currentChart.config as Object),
+        ...(currentChart?.config as Object),
         ...values,
       },
       { forceQuotes: true }
     );
 
-    api
-      .upgradeChartValues(
+    try {
+      await api.upgradeChartValues(
         "<token>",
         {
-          namespace: this.props.currentChart.namespace,
+          namespace: currentChart?.namespace,
           storage: StorageType.Secret,
           values: conf,
         },
         {
           id: currentProject.id,
-          name: this.props.currentChart.name,
+          name: currentChart?.name,
           cluster_id: currentCluster.id,
         }
-      )
-      .then((res) => {
-        this.setState({ saveValuesStatus: "successful" });
-        this.props.refreshChart();
-      })
-      .catch((err) => {
-        let parsedErr =
-          err?.response?.data?.errors && err.response.data.errors[0];
+      );
+      setSaveValuesStatus("successful");
+      refreshChart();
+    } catch (err) {
+      let parsedErr =
+        err?.response?.data?.errors && err.response.data.errors[0];
+
+      if (parsedErr) {
+        err = parsedErr;
+      }
+
+      setSaveValuesStatus(parsedErr);
+      setCurrentError(parsedErr);
+    }
+  };
 
-        if (parsedErr) {
-          err = parsedErr;
+  const handleCreateWebhookToken = async () => {
+    setCreateWebhookButtonStatus("loading");
+    const { id: cluster_id } = currentCluster;
+    const { id: project_id } = currentProject;
+    const { name: chart_name, namespace } = currentChart;
+    try {
+      const res = await api.createWebhookToken(
+        "<token>",
+        {},
+        {
+          project_id,
+          chart_name,
+          namespace,
+          cluster_id,
+          storage: StorageType.Secret,
         }
+      );
+      setCreateWebhookButtonStatus("successful");
+      setTimeout(() => {
+        setAction(res.data.git_action_config);
+        setWebhookToken(res.data.webhook_token);
+      }, 500);
+    } catch (err) {
+      let parsedErr =
+        err?.response?.data?.errors && err.response.data.errors[0];
+
+      if (parsedErr) {
+        err = parsedErr;
+      }
+
+      setCreateWebhookButtonStatus(parsedErr);
+      setCurrentError(parsedErr);
+    }
+  };
 
-        this.setState({
-          saveValuesStatus: parsedErr,
-        });
+  const renderWebhookSection = () => {
+    if (!currentChart?.form?.hasSource) {
+      return;
+    }
 
-        setCurrentError(parsedErr);
-      });
-  };
+    const curlWebhook = `curl -X POST 'https://dashboard.getporter.dev/api/webhooks/deploy/${webhookToken}?commit=YOUR_COMMIT_HASH'`;
+
+    const isAuthorizedToCreateWebhook = isAuthorized("application", "", [
+      "get",
+      "create",
+      "update",
+    ]);
+
+    let buttonStatus = createWebhookButtonStatus;
+
+    if (!isAuthorizedToCreateWebhook) {
+      buttonStatus = "Unauthorized to create webhook token";
+    }
 
-  render() {
     return (
-      <Wrapper>
-        <StyledSettingsSection showSource={this.props.showSource}>
-          {this.renderWebhookSection()}
+      <>
+        {showSource && (
+          <>
+            <Heading>Source Settings</Heading>
+            <Helper>Specify an image tag to use.</Helper>
+            <ImageSelector
+              selectedTag={selectedTag}
+              selectedImageUrl={selectedImageUrl}
+              setSelectedImageUrl={(x: string) => setSelectedImageUrl(x)}
+              setSelectedTag={(x: string) => setSelectedTag(x)}
+              forceExpanded={true}
+              disableImageSelect={true}
+            />
+            <Br />
+          </>
+        )}
+
+        <>
+          <Heading>Redeploy Webhook</Heading>
+          <Helper>
+            Programmatically deploy by calling this secret webhook.
+          </Helper>
+
+          {!loadingWebhookToken && !webhookToken.length && (
+            <SaveButton
+              text={"Create Webhook"}
+              status={buttonStatus}
+              onClick={handleCreateWebhookToken}
+              clearPosition={true}
+              statusPosition={"right"}
+              disabled={!isAuthorizedToCreateWebhook}
+            />
+          )}
+          {webhookToken.length > 0 && (
+            <Webhook copiedToClipboard={highlightCopyButton}>
+              <div>{curlWebhook}</div>
+              <CopyToClipboard
+                as="i"
+                text={curlWebhook}
+                onSuccess={() => setHighlightCopyButton(true)}
+                wrapperProps={{
+                  className: "material-icons",
+                  onMouseLeave: () => setHighlightCopyButton(false),
+                }}
+              >
+                content_copy
+              </CopyToClipboard>
+            </Webhook>
+          )}
+        </>
+      </>
+    );
+  };
+
+  return (
+    <Wrapper>
+      {!loadingWebhookToken ? (
+        <StyledSettingsSection showSource={showSource}>
+          {renderWebhookSection()}
           <Heading>Additional Settings</Heading>
-          <Button
-            color="#b91133"
-            onClick={() => this.props.setShowDeleteOverlay(true)}
-          >
-            Delete {this.props.currentChart.name}
+          <Button color="#b91133" onClick={() => setShowDeleteOverlay(true)}>
+            Delete {currentChart.name}
           </Button>
         </StyledSettingsSection>
-        {this.props.showSource && (
-          <SaveButton
-            text={this.props.saveButtonText || "Save Config"}
-            status={this.state.saveValuesStatus}
-            onClick={this.handleSubmit}
-            makeFlush={true}
-          />
-        )}
-      </Wrapper>
-    );
-  }
-}
+      ) : (
+        <Loading />
+      )}
+      {!loadingWebhookToken && showSource && (
+        <SaveButton
+          text={saveButtonText || "Save Config"}
+          status={saveValuesStatus}
+          onClick={handleSubmit}
+          makeFlush={true}
+        />
+      )}
+    </Wrapper>
+  );
+};
 
-SettingsSection.contextType = Context;
+export default SettingsSection;
 
 const Br = styled.div`
   width: 100%;

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

@@ -949,6 +949,21 @@ const getPolicyDocument = baseApi<{}, { project_id: number }>(
   ({ project_id }) => `/api/projects/${project_id}/policy`
 );
 
+const createWebhookToken = baseApi<
+  {},
+  {
+    project_id: number;
+    chart_name: string;
+    namespace: string;
+    cluster_id: number;
+    storage: StorageType;
+  }
+>(
+  "POST",
+  ({ project_id, chart_name, namespace, cluster_id, storage }) =>
+    `/api/projects/${project_id}/releases/${chart_name}/webhook_token?namespace=${namespace}&cluster_id=${cluster_id}&storage=${storage}`
+);
+
 // Bundle export to allow default api import (api.<method> is more readable)
 export default {
   checkAuth,
@@ -1047,4 +1062,5 @@ export default {
   updateCollaborator,
   removeCollaborator,
   getPolicyDocument,
+  createWebhookToken,
 };

+ 90 - 0
server/api/release_handler.go

@@ -783,6 +783,96 @@ func (app *App) HandleGetReleaseToken(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// HandleCreateWebhookToken creates a new webhook token for a release
+func (app *App) HandleCreateWebhookToken(w http.ResponseWriter, r *http.Request) {
+	name := chi.URLParam(r, "name")
+
+	vals, err := url.ParseQuery(r.URL.RawQuery)
+
+	if err != nil {
+		app.sendExternalError(err, http.StatusInternalServerError, HTTPError{
+			Code:   ErrReleaseReadData,
+			Errors: []string{"release not found"},
+		}, w)
+	}
+
+	// read the release from the target cluster
+	form := &forms.ReleaseForm{
+		Form: &helm.Form{
+			Repo:              app.Repo,
+			DigitalOceanOAuth: app.DOConf,
+		},
+	}
+
+	form.PopulateHelmOptionsFromQueryParams(
+		vals,
+		app.Repo.Cluster,
+	)
+
+	agent, err := app.getAgentFromReleaseForm(
+		w,
+		r,
+		form,
+	)
+
+	if err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
+
+	rel, err := agent.GetRelease(name, 0)
+
+	if err != nil {
+		app.handleErrorDataRead(err, w)
+		return
+	}
+
+	token, err := repository.GenerateRandomBytes(16)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	// create release with webhook token in db
+	image, ok := rel.Config["image"].(map[string]interface{})
+	if !ok {
+		app.handleErrorInternal(fmt.Errorf("Could not find field image in config"), w)
+		return
+	}
+
+	repository := image["repository"]
+	repoStr, ok := repository.(string)
+
+	if !ok {
+		app.handleErrorInternal(fmt.Errorf("Could not find field repository in config"), w)
+		return
+	}
+
+	release := &models.Release{
+		ClusterID:    form.Form.Cluster.ID,
+		ProjectID:    form.Form.Cluster.ProjectID,
+		Namespace:    form.Form.Namespace,
+		Name:         name,
+		WebhookToken: token,
+		ImageRepoURI: repoStr,
+	}
+
+	release, err = app.Repo.Release.CreateRelease(release)
+
+	if err != nil {
+		app.handleErrorInternal(err, w)
+		return
+	}
+
+	releaseExt := release.Externalize()
+
+	if err := json.NewEncoder(w).Encode(releaseExt); err != nil {
+		app.handleErrorFormDecoding(err, ErrReleaseDecode, w)
+		return
+	}
+}
+
 type ContainerEnvConfig struct {
 	Container struct {
 		Env struct {

+ 14 - 0
server/router/router.go

@@ -1117,6 +1117,20 @@ func New(a *api.App) *chi.Mux {
 				),
 			)
 
+			r.Method(
+				"POST",
+				"/projects/{project_id}/releases/{name}/webhook_token",
+				auth.DoesUserHaveProjectAccess(
+					auth.DoesUserHaveClusterAccess(
+						requestlog.NewHandler(a.HandleCreateWebhookToken, l),
+						mw.URLParam,
+						mw.QueryParam,
+					),
+					mw.URLParam,
+					mw.WriteAccess,
+				),
+			)
+
 			r.Method(
 				"GET",
 				"/projects/{project_id}/releases/{name}/{revision}",