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

improve handling for app updates when user is missing github access (#4030)

ianedwards 2 лет назад
Родитель
Сommit
590802f5c0

+ 2 - 2
dashboard/src/components/porter/Banner.tsx

@@ -4,7 +4,7 @@ import styled from "styled-components";
 import info from "assets/info.svg";
 import warning from "assets/warning.png";
 
-interface Props {
+type Props = {
   type?: string;
   icon?: React.ReactNode;
   children: React.ReactNode;
@@ -37,7 +37,7 @@ const Banner: React.FC<Props> = ({
     >
       <>
       {renderIcon()}
-      <span>{children}</span>
+      {children}
       </>
       {suffix && (
         <Suffix>{suffix}</Suffix>

+ 66 - 0
dashboard/src/lib/github/workflows.ts

@@ -0,0 +1,66 @@
+import axios from "axios";
+
+import api from "shared/api";
+
+// GithubResultErrorCode is an enum of possible errors that may occur when hitting the Github API.
+export type GithubResultErrorCode = "NO_PERMISSION" | "FILE_NOT_FOUND" | "UNKNOWN";
+
+// GithubResult is a generic type that should be returned to handle common errors resulting from hitting the Github API.
+export type GithubResult<T extends object> =
+  | ({
+      success: true;
+    } & T)
+  | {
+      success: false;
+      error: GithubResultErrorCode;
+    };
+
+// runGithubWorkflow attempts to rerun a given github workflow, and handles errors that may occur.
+export async function runGithubWorkflow(args: {
+  projectId: number;
+  clusterId: number;
+  gitInstallationId: number;
+  owner: string;
+  name: string;
+  branch: string;
+  filename: string;
+}): Promise<GithubResult<{ url: string | null }>> {
+  try {
+    const {
+      projectId,
+      clusterId,
+      gitInstallationId,
+      owner,
+      name,
+      branch,
+      filename,
+    } = args;
+
+    const res = await api.reRunGHWorkflow(
+      "<token>",
+      {},
+      {
+        project_id: projectId,
+        cluster_id: clusterId,
+        git_installation_id: gitInstallationId,
+        owner,
+        name,
+        branch,
+        filename,
+      }
+    );
+
+    return { success: true, url: res.data };
+  } catch (err) {
+    if (axios.isAxiosError(err)) {
+      if (err.response?.status === 403) {
+        return { success: false, error: "NO_PERMISSION" };
+      }
+      if (err.response?.status === 404) {
+        return { success: false, error: "FILE_NOT_FOUND" };
+      }
+    }
+
+    return { success: false, error: "UNKNOWN" };
+  }
+}

+ 14 - 6
dashboard/src/lib/hooks/useGithubWorkflow.ts

@@ -1,11 +1,19 @@
+import { useCallback, useContext, useEffect, useMemo, useState } from "react";
 import { useQueries } from "@tanstack/react-query";
 import axios from "axios";
-import { PorterAppRecord } from "main/home/app-dashboard/app-view/AppView";
-import { useCallback, useContext, useEffect, useMemo, useState } from "react";
-import { Context } from "shared/Context";
-import api from "shared/api";
 import { z } from "zod";
 
+import { type PorterAppRecord } from "main/home/app-dashboard/app-view/AppView";
+
+import api from "shared/api";
+import { Context } from "shared/Context";
+
+type WorkflowResult = {
+  githubWorkflowFilename: string;
+  isLoading: boolean;
+  userHasGithubAccess: boolean;
+};
+
 export const useGithubWorkflow = ({
   porterApp,
   fileNames,
@@ -14,7 +22,7 @@ export const useGithubWorkflow = ({
   porterApp: PorterAppRecord;
   fileNames: string[];
   previouslyBuilt?: boolean;
-}) => {
+}): WorkflowResult => {
   const { currentProject, currentCluster } = useContext(Context);
   const [githubWorkflowFilename, setGithubWorkflowName] = useState<string>("");
   const [userHasGithubAccess, setUserHasGithubAccess] = useState<boolean>(true);
@@ -97,7 +105,7 @@ export const useGithubWorkflow = ({
         fn,
         previouslyBuilt,
       ],
-      queryFn: () => fetchGithubWorkflow(fn),
+      queryFn: async () => await fetchGithubWorkflow(fn),
       enabled,
       refetchInterval: 5000,
       retry: (_failureCount: number, error: unknown) => {

+ 40 - 19
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -25,6 +25,10 @@ import Link from "components/porter/Link";
 import Spacer from "components/porter/Spacer";
 import Tag from "components/porter/Tag";
 import TabSelector from "components/TabSelector";
+import {
+  runGithubWorkflow,
+  type GithubResultErrorCode,
+} from "lib/github/workflows";
 import { useAppAnalytics } from "lib/hooks/useAppAnalytics";
 import { useAppValidation } from "lib/hooks/useAppValidation";
 import { useIntercom } from "lib/hooks/useIntercom";
@@ -41,6 +45,7 @@ import alert from "assets/alert-warning.svg";
 import save from "assets/save-01.svg";
 
 import ConfirmRedeployModal from "./ConfirmRedeployModal";
+import { GithubErrorBanner } from "./GithubErrorBanner";
 import { useLatestRevision } from "./LatestRevisionContext";
 import Activity from "./tabs/Activity";
 import EventFocusView from "./tabs/activity-feed/events/focus-views/EventFocusView";
@@ -88,6 +93,8 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
   const history = useHistory();
   const queryClient = useQueryClient();
   const [confirmDeployModalOpen, setConfirmDeployModalOpen] = useState(false);
+  const [workflowRerunError, setWorkflowRerunError] =
+    useState<GithubResultErrorCode | null>(null);
 
   const { currentProject, user } = useContext(Context);
 
@@ -227,7 +234,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         return;
       }
 
-      if (currentProject?.beta_features_enabled && !needsRebuild) {
+      if (currentProject?.beta_features_enabled && !buildIsDirty) {
         const serviceDeletions = setServiceDeletions(data.app.services);
 
         await api.updateApp(
@@ -275,7 +282,12 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       }
 
       if (latestSource.type === "github" && needsRebuild) {
-        if (currentProject?.beta_features_enabled && validatedAppProto.build) {
+        // add a new revision with updated build settings only if they have changed
+        if (
+          currentProject?.beta_features_enabled &&
+          validatedAppProto.build &&
+          buildIsDirty
+        ) {
           await api.updateBuildSettings(
             "<token>",
             {
@@ -290,22 +302,27 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           );
         }
 
-        const res = await api.reRunGHWorkflow(
-          "<token>",
-          {},
-          {
-            project_id: projectId,
-            cluster_id: clusterId,
-            git_installation_id: latestSource.git_repo_id,
-            owner: latestSource.git_repo_name.split("/")[0],
-            name: latestSource.git_repo_name.split("/")[1],
-            branch: porterAppRecord.git_branch,
-            filename: "porter_stack_" + porterAppRecord.name + ".yml",
-          }
-        );
+        const [repoOwner, repoName] = z
+          .tuple([z.string(), z.string()])
+          .parse(latestSource.git_repo_name.split("/"));
+
+        const res = await runGithubWorkflow({
+          projectId,
+          clusterId,
+          gitInstallationId: latestSource.git_repo_id,
+          owner: repoOwner,
+          name: repoName,
+          branch: latestSource.git_branch,
+          filename: `porter_stack_${porterAppRecord.name}.yml`,
+        });
+
+        if (!res.success) {
+          setWorkflowRerunError(res.error);
+          return;
+        }
 
-        if (res.data != null) {
-          window.open(res.data, "_blank", "noreferrer");
+        if (res.url != null) {
+          window.open(res.url, "_blank", "noreferrer");
         }
       }
       await queryClient.invalidateQueries([
@@ -585,8 +602,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
                   status={isSubmitting ? "loading" : ""}
                   disabled={
                     isSubmitting ||
-                    latestRevision.status === "CREATED" ||
-                    latestRevision.status === "AWAITING_BUILD_ARTIFACT"
+                    latestRevision.status === "CREATED"
                   }
                   disabledTooltipMessage="Please wait for the deploy to complete before updating the app"
                   disabledTooltipPosition="bottom"
@@ -603,6 +619,11 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           </Banner>
           <Spacer y={1} />
         </AnimateHeight>
+        <GithubErrorBanner
+          appName={porterAppRecord.name}
+          workflowRerunError={workflowRerunError}
+          setWorkflowRerunError={setWorkflowRerunError}
+        />
         <TabSelector
           noBuffer
           options={tabs}

+ 2 - 1
dashboard/src/main/home/app-dashboard/app-view/AppHeader.tsx

@@ -34,7 +34,7 @@ const icons = [
   web,
 ];
 
-const HELLO_PORTER_PLACEHOLDER_TAG = "porter-initial-image";
+export const HELLO_PORTER_PLACEHOLDER_TAG = "porter-initial-image";
 
 const AppHeader: React.FC = () => {
   const { latestProto, porterApp, latestRevision, deploymentTarget } =
@@ -251,6 +251,7 @@ const AppHeader: React.FC = () => {
       </LatestDeployContainer>
       <Spacer y={0.5} />
       <GHStatusBanner />
+      <Spacer y={0.5} />
     </>
   );
 };

+ 88 - 0
dashboard/src/main/home/app-dashboard/app-view/GithubErrorBanner.tsx

@@ -0,0 +1,88 @@
+import React, { type Dispatch, type SetStateAction } from "react";
+import AnimateHeight from "react-animate-height";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import Banner from "components/porter/Banner";
+import Spacer from "components/porter/Spacer";
+import { type GithubResultErrorCode } from "lib/github/workflows";
+
+type Props = {
+  appName: string;
+  workflowRerunError: GithubResultErrorCode | null;
+  setWorkflowRerunError: Dispatch<SetStateAction<GithubResultErrorCode | null>>;
+};
+
+export const GithubErrorBanner: React.FC<Props> = ({
+  appName,
+  workflowRerunError,
+  setWorkflowRerunError,
+}) => {
+  return (
+    <AnimateHeight height={workflowRerunError ? "auto" : 0}>
+      <Banner
+        type="warning"
+        suffix={
+          <CloseButton
+            onClick={() => {
+              setWorkflowRerunError(null);
+            }}
+          >
+            <i className="material-icons">close</i>
+          </CloseButton>
+        }
+      >
+        <BannerContents>
+          <b>App updated but Github workflow could not run</b>
+          {match(workflowRerunError)
+            .with("FILE_NOT_FOUND", () => (
+              <>
+                Workflow file <Code>{`porter_stack_${appName}.yml`}</Code> not
+                found.
+              </>
+            ))
+            .with("NO_PERMISSION", () => (
+              <>
+                Make sure you have given Porter permission to access
+                repositories on your behalf. GitHub integrations can be viewed
+                under account settings.
+              </>
+            ))
+            .otherwise(() => null)}
+        </BannerContents>
+        <Spacer inline width="5px" />
+      </Banner>
+      <Spacer y={1} />
+    </AnimateHeight>
+  );
+};
+
+const Code = styled.span`
+  font-family: monospace;
+`;
+
+const BannerContents = styled.div`
+  display: flex;
+  flex-direction: column;
+  row-gap: 0.5rem;
+`;
+
+const CloseButton = styled.div`
+  display: block;
+  width: 40px;
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1;
+  border-radius: 50%;
+  cursor: pointer;
+  :hover {
+    background-color: #ffffff11;
+  }
+
+  > i {
+    font-size: 20px;
+    color: #aaaabb;
+  }
+`;

+ 1 - 2
dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettingsTab.tsx

@@ -40,8 +40,7 @@ const BuildSettingsTab: React.FC<Props> = ({ buttonStatus }) => {
               status={buttonStatus}
               disabled={
                 isSubmitting ||
-                latestRevision.status === "CREATED" ||
-                latestRevision.status === "AWAITING_BUILD_ARTIFACT"
+                latestRevision.status === "CREATED"
               }
               disabledTooltipMessage="Please wait for the build to complete before updating build settings"
             >

+ 1 - 2
dashboard/src/main/home/app-dashboard/app-view/tabs/Environment.tsx

@@ -72,8 +72,7 @@ const Environment: React.FC<Props> = ({ latestSource, buttonStatus }) => {
         loadingText={"Updating..."}
         disabled={
           isSubmitting ||
-          latestRevision.status === "CREATED" ||
-          latestRevision.status === "AWAITING_BUILD_ARTIFACT"
+          latestRevision.status === "CREATED"
         }
         disabledTooltipMessage="Please wait for the deploy to complete before updating environment variables"
       >

+ 0 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/HelmEditorTab.tsx

@@ -55,7 +55,6 @@ const HelmEditorTab: React.FC<Props> = ({ buttonStatus, featureFlagEnabled }) =>
         disabled={
           isSubmitting ||
           latestRevision.status === "CREATED" ||
-          latestRevision.status === "AWAITING_BUILD_ARTIFACT" ||
           error !== ""
         }
         disabledTooltipMessage={

+ 0 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/ImageSettingsTab.tsx

@@ -45,7 +45,6 @@ const ImageSettingsTab: React.FC<Props> = ({ buttonStatus }) => {
                     disabled={
                         isSubmitting
                         || latestRevision.status === "CREATED"
-                        || latestRevision.status === "AWAITING_BUILD_ARTIFACT"
                         || !source.image?.repository
                         || !source.image?.tag
                     }

+ 12 - 13
dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx

@@ -1,19 +1,22 @@
-import Spacer from "components/porter/Spacer";
-import Text from "components/porter/Text";
-import { PorterAppFormData } from "lib/porter-apps";
 import React from "react";
 import { useFormContext } from "react-hook-form";
-import ServiceList from "../../validate-apply/services-settings/ServiceList";
+
+import Button from "components/porter/Button";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { useAppStatus } from "lib/hooks/useAppStatus";
+import { type PorterAppFormData } from "lib/porter-apps";
 import {
   defaultSerialized,
   deserializeService,
 } from "lib/porter-apps/services";
-import Button from "components/porter/Button";
-import { useLatestRevision } from "../LatestRevisionContext";
-import { useAppStatus } from "lib/hooks/useAppStatus";
-import { ButtonStatus } from "../AppDataContainer";
+
 import { useClusterResources } from "shared/ClusterResourcesContext";
 
+import ServiceList from "../../validate-apply/services-settings/ServiceList";
+import { type ButtonStatus } from "../AppDataContainer";
+import { useLatestRevision } from "../LatestRevisionContext";
+
 type Props = {
   buttonStatus: ButtonStatus;
 };
@@ -80,11 +83,7 @@ const Overview: React.FC<Props> = ({ buttonStatus }) => {
         type="submit"
         status={buttonStatus}
         loadingText={"Updating..."}
-        disabled={
-          formState.isSubmitting ||
-          latestRevision.status === "CREATED" ||
-          latestRevision.status === "AWAITING_BUILD_ARTIFACT"
-        }
+        disabled={formState.isSubmitting || latestRevision.status === "CREATED"}
         disabledTooltipMessage="Please wait for the deploy to complete before updating services"
       >
         Update app

+ 10 - 0
dashboard/src/main/home/app-dashboard/validate-apply/revisions-list/GHStatusBanner.tsx

@@ -12,6 +12,7 @@ import { appRevisionValidator } from "lib/revisions/types";
 import api from "shared/api";
 import { Context } from "shared/Context";
 
+import { HELLO_PORTER_PLACEHOLDER_TAG } from "../../app-view/AppHeader";
 import { useLatestRevision } from "../../app-view/LatestRevisionContext";
 import GHABanner from "../../expanded-app/GHABanner";
 
@@ -24,6 +25,7 @@ const GHStatusBanner: React.FC = () => {
     latestRevision,
     appName,
     deploymentTarget,
+    latestProto,
   } = useLatestRevision();
 
   const { data: revisions = [], status } = useQuery(
@@ -58,6 +60,14 @@ const GHStatusBanner: React.FC = () => {
   );
 
   const previouslyBuilt = useMemo(() => {
+    if (revisions.length === 1) {
+      if (
+        revisions[0].status === "DEPLOYED" &&
+        latestProto.image?.tag === HELLO_PORTER_PLACEHOLDER_TAG
+      ) {
+        return false;
+      }
+    }
     return revisions.some((r) =>
       match(r.status)
         .with(