Selaa lähdekoodia

POR-1828 require CI rerun when build settings update (#3682)

ianedwards 2 vuotta sitten
vanhempi
sitoutus
1b0e2dc0f0

+ 10 - 8
dashboard/src/lib/hooks/useAppValidation.ts

@@ -10,6 +10,12 @@ import api from "shared/api";
 import { match } from "ts-pattern";
 import { z } from "zod";
 
+export type AppValidationResult = {
+  validatedAppProto: PorterApp;
+  variables: Record<string, string>;
+  secrets: Record<string, string>;
+};
+
 export const useAppValidation = ({
   deploymentTargetID,
   creating = false,
@@ -19,13 +25,6 @@ export const useAppValidation = ({
 }) => {
   const { currentProject, currentCluster } = useContext(Context);
 
-  const removedEnvKeys = (
-    current: Record<string, string>,
-    previous: Record<string, string>
-  ) => {
-    return Object.keys(previous).filter((key) => !current[key]);
-  };
-
   const getBranchHead = async ({
     projectID,
     source,
@@ -62,7 +61,10 @@ export const useAppValidation = ({
   };
 
   const validateApp = useCallback(
-    async (data: PorterAppFormData, prevRevision?: PorterApp) => {
+    async (
+      data: PorterAppFormData,
+      prevRevision?: PorterApp
+    ): Promise<AppValidationResult> => {
       if (!currentProject || !currentCluster) {
         throw new Error("No project or cluster selected");
       }

+ 1 - 0
dashboard/src/lib/porter-apps/index.ts

@@ -81,6 +81,7 @@ export const porterAppFormValidator = z.object({
   app: clientAppValidator,
   source: sourceValidator,
   deletions: deletionValidator,
+  redeployOnSave: z.boolean().default(false),
 });
 export type PorterAppFormData = z.infer<typeof porterAppFormValidator>;
 

+ 62 - 18
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState } from "react";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
 import { FormProvider, useForm } from "react-hook-form";
 import {
   PorterAppFormData,
@@ -13,7 +13,10 @@ import TabSelector from "components/TabSelector";
 import { useHistory } from "react-router";
 import { match } from "ts-pattern";
 import Overview from "./tabs/Overview";
-import { useAppValidation } from "lib/hooks/useAppValidation";
+import {
+  AppValidationResult,
+  useAppValidation,
+} from "lib/hooks/useAppValidation";
 import api from "shared/api";
 import { useQueryClient } from "@tanstack/react-query";
 import Settings from "./tabs/Settings";
@@ -32,6 +35,7 @@ import EventFocusView from "./tabs/activity-feed/events/focus-views/EventFocusVi
 import { z } from "zod";
 import { PorterApp } from "@porter-dev/api-contracts";
 import JobsTab from "./tabs/JobsTab";
+import ConfirmRedeployModal from "./ConfirmRedeployModal";
 
 // commented out tabs are not yet implemented
 // will be included as support is available based on data from app revisions rather than helm releases
@@ -58,7 +62,7 @@ type AppDataContainerProps = {
 const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
   const history = useHistory();
   const queryClient = useQueryClient();
-  const [redeployOnSave, setRedeployOnSave] = useState(false);
+  const [confirmDeployModalOpen, setConfirmDeployModalOpen] = useState(false);
 
   const {
     porterApp,
@@ -158,13 +162,26 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     return dirty.every((f) => f === "expanded" || f === "id");
   }, [isDirty, JSON.stringify(dirtyFields)]);
 
+  const buildIsDirty = useMemo(() => {
+    if (!isDirty) return false;
+
+    // get all entries in entire dirtyFields object that are true
+    const dirty = getAllDirtyFields(dirtyFields.app?.build ?? {});
+    return dirty.some((f) => f);
+  }, [isDirty, JSON.stringify(dirtyFields)]);
+
   const onSubmit = handleSubmit(async (data) => {
     try {
-      const { validatedAppProto, variables, secrets } = await validateApp(
+      const { variables, secrets, validatedAppProto } = await validateApp(
         data,
         latestProto
       );
 
+      if (buildIsDirty && !data.redeployOnSave) {
+        setConfirmDeployModalOpen(true);
+        return;
+      }
+
       // updates the default env group associated with this app to store app specific env vars
       const res = await api.updateEnvironmentGroupV2(
         "<token>",
@@ -213,11 +230,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         }
       );
 
-      if (
-        redeployOnSave &&
-        latestSource.type === "github" &&
-        dirtyFields.app?.build
-      ) {
+      if (latestSource.type === "github" && buildIsDirty) {
         const res = await api.reRunGHWorkflow(
           "<token>",
           {},
@@ -235,8 +248,6 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         if (res.data != null) {
           window.open(res.data, "_blank", "noreferrer");
         }
-
-        setRedeployOnSave(false);
       }
 
       await queryClient.invalidateQueries([
@@ -260,6 +271,31 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     } catch (err) {}
   });
 
+  const cancelRedeploy = useCallback(() => {
+    reset({
+      app: clientAppFromProto({
+        proto: previewRevision
+          ? PorterApp.fromJsonString(atob(previewRevision.b64_app_proto))
+          : latestProto,
+        overrides: servicesFromYaml,
+        variables: appEnv?.variables,
+        secrets: appEnv?.secret_variables,
+      }),
+      source: latestSource,
+      deletions: {
+        envGroupNames: [],
+        serviceNames: [],
+      },
+      redeployOnSave: false,
+    });
+    setConfirmDeployModalOpen(false);
+  }, [previewRevision, latestProto, servicesFromYaml, appEnv, latestSource]);
+
+  const finalizeDeploy = useCallback(() => {
+    setConfirmDeployModalOpen(false);
+    onSubmit();
+  }, [onSubmit, setConfirmDeployModalOpen]);
+
   useEffect(() => {
     reset({
       app: clientAppFromProto({
@@ -275,6 +311,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         envGroupNames: [],
         serviceNames: [],
       },
+      redeployOnSave: false,
     });
   }, [
     servicesFromYaml,
@@ -306,7 +343,12 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
                   loadingText={"Updating..."}
                   height={"10px"}
                   status={isSubmitting ? "loading" : ""}
-                  disabled={isSubmitting}
+                  disabled={
+                    isSubmitting ||
+                    latestRevision.status === "CREATED" ||
+                    latestRevision.status === "AWAITING_BUILD_ARTIFACT"
+                  }
+                  disabledTooltipMessage="Please wait for the build to complete before updating the app"
                 >
                   <Icon src={save} height={"13px"} />
                   <Spacer inline x={0.5} />
@@ -353,12 +395,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         {match(currentTab)
           .with("activity", () => <Activity />)
           .with("overview", () => <Overview />)
-          .with("build-settings", () => (
-            <BuildSettings
-              redeployOnSave={redeployOnSave}
-              setRedeployOnSave={setRedeployOnSave}
-            />
-          ))
+          .with("build-settings", () => <BuildSettings />)
           .with("environment", () => (
             <Environment latestSource={latestSource} />
           ))
@@ -370,6 +407,13 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           .otherwise(() => null)}
         <Spacer y={2} />
       </form>
+      {confirmDeployModalOpen ? (
+        <ConfirmRedeployModal
+          setOpen={setConfirmDeployModalOpen}
+          cancelRedeploy={cancelRedeploy}
+          finalizeDeploy={finalizeDeploy}
+        />
+      ) : null}
     </FormProvider>
   );
 };

+ 63 - 0
dashboard/src/main/home/app-dashboard/app-view/ConfirmRedeployModal.tsx

@@ -0,0 +1,63 @@
+import Button from "components/porter/Button";
+import Modal from "components/porter/Modal";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { PorterAppFormData } from "lib/porter-apps";
+import React, { Dispatch, SetStateAction } from "react";
+import { useFormContext } from "react-hook-form";
+import styled from "styled-components";
+
+type Props = {
+  cancelRedeploy: () => void;
+  setOpen: Dispatch<SetStateAction<boolean>>;
+  finalizeDeploy: () => void;
+};
+
+const ConfirmRedeployModal: React.FC<Props> = ({
+  cancelRedeploy,
+  setOpen,
+  finalizeDeploy,
+}) => {
+  const { setValue } = useFormContext<PorterAppFormData>();
+
+  return (
+    <Modal closeModal={() => setOpen(false)}>
+      <Text size={16}>Confirm deploy</Text>
+      <Spacer y={0.5} />
+      <Text color="helper">
+        A change to your application's build settings has been detected.
+        Confirming this change will trigger a rerun of your application's CI
+        pipeline.
+      </Text>
+      <Spacer y={0.5} />
+
+      <ButtonContainer>
+        <Button
+          onClick={() => {
+            cancelRedeploy();
+            setOpen(false);
+          }}
+          color="#b91133"
+        >
+          Cancel
+        </Button>
+        <Button
+          onClick={() => {
+            setValue("redeployOnSave", true);
+            finalizeDeploy();
+          }}
+        >
+          Continue
+        </Button>
+      </ButtonContainer>
+    </Modal>
+  );
+};
+
+export default ConfirmRedeployModal;
+
+const ButtonContainer = styled.div`
+  display: flex;
+  justify-content: flex-end;
+  column-gap: 0.5rem;
+`;

+ 3 - 19
dashboard/src/main/home/app-dashboard/app-view/tabs/BuildSettings.tsx

@@ -1,23 +1,13 @@
-import React, { Dispatch, SetStateAction, useMemo } from "react";
+import React, { useMemo } from "react";
 import RepoSettings from "../../create-app/RepoSettings";
 import { useFormContext } from "react-hook-form";
 import { PorterAppFormData } from "lib/porter-apps";
 import { useLatestRevision } from "../LatestRevisionContext";
 import Spacer from "components/porter/Spacer";
-import Checkbox from "components/porter/Checkbox";
-import Text from "components/porter/Text";
 import Button from "components/porter/Button";
 import Error from "components/porter/Error";
 
-type Props = {
-  redeployOnSave: boolean;
-  setRedeployOnSave: Dispatch<SetStateAction<boolean>>;
-};
-
-const BuildSettings: React.FC<Props> = ({
-  redeployOnSave,
-  setRedeployOnSave,
-}) => {
+const BuildSettings: React.FC = () => {
   const {
     watch,
     formState: { isSubmitting, errors },
@@ -52,13 +42,6 @@ const BuildSettings: React.FC<Props> = ({
         appExists
       />
       <Spacer y={1} />
-      <Checkbox
-        checked={redeployOnSave}
-        toggleChecked={() => setRedeployOnSave(!redeployOnSave)}
-      >
-        <Text>Re-run build and deploy on save</Text>
-      </Checkbox>
-      <Spacer y={1} />
       <Button
         type="submit"
         status={buttonStatus}
@@ -67,6 +50,7 @@ const BuildSettings: React.FC<Props> = ({
           latestRevision.status === "CREATED" ||
           latestRevision.status === "AWAITING_BUILD_ARTIFACT"
         }
+        disabledTooltipMessage="Please wait for the build to complete before updating build settings"
       >
         Save build settings
       </Button>

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

@@ -87,6 +87,7 @@ const Environment: React.FC<Props> = ({ latestSource }) => {
           latestRevision.status === "CREATED" ||
           latestRevision.status === "AWAITING_BUILD_ARTIFACT"
         }
+        disabledTooltipMessage="Please wait for the build to complete before updating environment variables"
       >
         Update app
       </Button>

+ 9 - 1
dashboard/src/main/home/app-dashboard/app-view/tabs/Overview.tsx

@@ -16,7 +16,14 @@ import { useAppStatus } from "lib/hooks/useAppStatus";
 
 const Overview: React.FC = () => {
   const { formState } = useFormContext<PorterAppFormData>();
-  const { porterApp, latestProto, latestRevision, projectId, clusterId, deploymentTarget } = useLatestRevision();
+  const {
+    porterApp,
+    latestProto,
+    latestRevision,
+    projectId,
+    clusterId,
+    deploymentTarget,
+  } = useLatestRevision();
 
   const { serviceVersionStatus } = useAppStatus({
     projectId,
@@ -77,6 +84,7 @@ const Overview: React.FC = () => {
           latestRevision.status === "CREATED" ||
           latestRevision.status === "AWAITING_BUILD_ARTIFACT"
         }
+        disabledTooltipMessage="Please wait for the build to complete before updating services"
       >
         Update app
       </Button>

+ 12 - 5
dashboard/src/main/home/app-dashboard/validate-apply/build-settings/buildpacks/BuildpackSettings.tsx

@@ -36,6 +36,9 @@ const BuildpackSettings: React.FC<Props> = ({
   source,
   autoDetectionDisabled,
 }) => {
+  const [enableAutoDetection, setEnableAutoDetection] = useState(
+    !autoDetectionDisabled
+  );
   const [stackOptions, setStackOptions] = useState<
     { label: string; value: string }[]
   >([]);
@@ -80,7 +83,7 @@ const BuildpackSettings: React.FC<Props> = ({
       return detectedBuildpacks;
     },
     {
-      enabled: !autoDetectionDisabled,
+      enabled: enableAutoDetection,
     }
   );
 
@@ -93,7 +96,7 @@ const BuildpackSettings: React.FC<Props> = ({
   );
 
   useEffect(() => {
-    if (autoDetectionDisabled) {
+    if (!enableAutoDetection) {
       // in this case, we are not detecting buildpacks, so we just populate based on the DB
       if (build.builder) {
         setValue("app.build.builder", build.builder);
@@ -154,7 +157,7 @@ const BuildpackSettings: React.FC<Props> = ({
         detectedBuilder = defaultBuilder.builders[0];
       }
 
-      if (!autoDetectionDisabled) {
+      if (enableAutoDetection) {
         setValue("app.build.builder", detectedBuilder);
         replace(
           defaultBuilder.detected.map((bp) => ({
@@ -193,7 +196,7 @@ const BuildpackSettings: React.FC<Props> = ({
           />
         </>
       )}
-      {!autoDetectionDisabled && status === "error" && (
+      {enableAutoDetection && status === "error" && (
         <>
           <Spacer y={1} />
           <Error
@@ -204,6 +207,7 @@ const BuildpackSettings: React.FC<Props> = ({
       <Spacer y={1} />
       <Button
         onClick={() => {
+          setEnableAutoDetection(true);
           setIsModalOpen(true);
         }}
       >
@@ -212,7 +216,10 @@ const BuildpackSettings: React.FC<Props> = ({
       {isModalOpen && (
         <BuildpackConfigurationModal
           build={build}
-          closeModal={() => setIsModalOpen(false)}
+          closeModal={() => {
+            setEnableAutoDetection(false);
+            setIsModalOpen(false);
+          }}
           sortedStackOptions={stackOptions}
           availableBuildpacks={availableBuildpacks}
           setAvailableBuildpacks={setAvailableBuildpacks}