Przeglądaj źródła

handle reverts and deletions on revisions (#3476)

ianedwards 2 lat temu
rodzic
commit
4e1c963338

+ 14 - 3
api/server/handlers/porter_app/validate.go

@@ -36,11 +36,18 @@ func NewValidatePorterAppHandler(
 	}
 }
 
+// Deletions are the names of services and env variables to delete
+type Deletions struct {
+	ServiceNames     []string `json:"service_names"`
+	EnvVariableNames []string `json:"env_variable_names"`
+}
+
 // ValidatePorterAppRequest is the request object for the /apps/validate endpoint
 type ValidatePorterAppRequest struct {
-	Base64AppProto     string `json:"b64_app_proto"`
-	DeploymentTargetId string `json:"deployment_target_id"`
-	CommitSHA          string `json:"commit_sha"`
+	Base64AppProto     string    `json:"b64_app_proto"`
+	DeploymentTargetId string    `json:"deployment_target_id"`
+	CommitSHA          string    `json:"commit_sha"`
+	Deletions          Deletions `json:"deletions"`
 }
 
 // ValidatePorterAppResponse is the response object for the /apps/validate endpoint
@@ -112,6 +119,10 @@ func (c *ValidatePorterAppHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
 		DeploymentTargetId: request.DeploymentTargetId,
 		CommitSha:          request.CommitSHA,
 		App:                appProto,
+		Deletions: &porterv1.Deletions{
+			ServiceNames:     request.Deletions.ServiceNames,
+			EnvVariableNames: request.Deletions.EnvVariableNames,
+		},
 	})
 	ccpResp, err := c.Config().ClusterControlPlaneClient.ValidatePorterApp(ctx, validateReq)
 	if err != nil {

+ 17 - 1
dashboard/src/lib/hooks/useAppValidation.ts

@@ -19,6 +19,13 @@ 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,
@@ -55,7 +62,7 @@ export const useAppValidation = ({
   };
 
   const validateApp = useCallback(
-    async (data: PorterAppFormData) => {
+    async (data: PorterAppFormData, prevRevision?: PorterApp) => {
       if (!currentProject || !currentCluster) {
         throw new Error("No project or cluster selected");
       }
@@ -64,6 +71,11 @@ export const useAppValidation = ({
         throw new Error("No deployment target selected");
       }
 
+      const envVariableDeletions = removedEnvKeys(
+        data.app.env,
+        prevRevision?.env || {}
+      );
+
       const proto = clientAppToProto(data);
       const commit_sha = await match(data.source)
         .with({ type: "github" }, async (src) => {
@@ -92,6 +104,10 @@ export const useAppValidation = ({
           ),
           deployment_target_id: deploymentTargetID,
           commit_sha,
+          deletions: {
+            service_names: data.deletions.serviceNames.map((s) => s.name),
+            env_variable_names: envVariableDeletions,
+          },
         },
         {
           project_id: currentProject.id,

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

@@ -56,6 +56,14 @@ export const sourceValidator = z.discriminatedUnion("type", [
 ]);
 export type SourceOptions = z.infer<typeof sourceValidator>;
 
+export const deletionValidator = z.object({
+  serviceNames: z
+    .object({
+      name: z.string(),
+    })
+    .array(),
+});
+
 // clientAppValidator is the representation of a Porter app on the client, and is used to validate inputs for app setting fields
 export const clientAppValidator = z.object({
   name: z.string().min(1),
@@ -69,6 +77,7 @@ export type ClientPorterApp = z.infer<typeof clientAppValidator>;
 export const porterAppFormValidator = z.object({
   app: clientAppValidator,
   source: sourceValidator,
+  deletions: deletionValidator,
 });
 export type PorterAppFormData = z.infer<typeof porterAppFormValidator>;
 

+ 10 - 1
dashboard/src/main/home/app-dashboard/app-view/AppDataContainer.tsx

@@ -61,6 +61,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     clusterId,
     deploymentTargetId,
     servicesFromYaml,
+    setPreviewRevision,
   } = useLatestRevision();
   const { validateApp } = useAppValidation({
     deploymentTargetID: deploymentTargetId,
@@ -101,6 +102,9 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
     defaultValues: {
       app: clientAppFromProto(latestProto, servicesFromYaml),
       source: latestSource,
+      deletions: {
+        serviceNames: [],
+      },
     },
   });
   const {
@@ -142,7 +146,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
 
   const onSubmit = handleSubmit(async (data) => {
     try {
-      const validatedAppProto = await validateApp(data);
+      const validatedAppProto = await validateApp(data, latestProto);
       await api.applyApp(
         "<token>",
         {
@@ -184,6 +188,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
         deploymentTargetId,
         porterApp.name,
       ]);
+      setPreviewRevision(null);
     } catch (err) {}
   });
 
@@ -192,6 +197,9 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
       reset({
         app: clientAppFromProto(latestProto, servicesFromYaml),
         source: latestSource,
+        deletions: {
+          serviceNames: [],
+        },
       });
     }
   }, [servicesFromYaml, currentTab, latestProto]);
@@ -206,6 +214,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
           clusterId={clusterId}
           appName={porterApp.name}
           latestSource={latestSource}
+          onSubmit={onSubmit}
         />
         <Spacer y={1} />
         <AnimateHeight height={isDirty && !onlyExpandedChanged ? "auto" : 0}>

+ 39 - 3
dashboard/src/main/home/app-dashboard/app-view/RevisionsList.tsx

@@ -1,6 +1,6 @@
 import { useQuery } from "@tanstack/react-query";
 import { AppRevision, appRevisionValidator } from "lib/revisions/types";
-import React, { useState } from "react";
+import React, { useCallback, useState } from "react";
 import api from "shared/api";
 import styled from "styled-components";
 import { match } from "ts-pattern";
@@ -16,6 +16,7 @@ import { readableDate } from "shared/string_utils";
 import Text from "components/porter/Text";
 import { useLatestRevision } from "./LatestRevisionContext";
 import { useFormContext } from "react-hook-form";
+import ConfirmOverlay from "components/porter/ConfirmOverlay";
 
 type Props = {
   deploymentTargetId: string;
@@ -24,6 +25,7 @@ type Props = {
   appName: string;
   latestSource: SourceOptions;
   latestRevisionNumber: number;
+  onSubmit: () => Promise<void>;
 };
 
 const RED = "#ff0000";
@@ -36,14 +38,19 @@ const RevisionsList: React.FC<Props> = ({
   clusterId,
   appName,
   latestSource,
+  onSubmit,
 }) => {
   const {
     previewRevision,
     setPreviewRevision,
     servicesFromYaml,
   } = useLatestRevision();
-  const { reset } = useFormContext<PorterAppFormData>();
+  const { reset, setValue } = useFormContext<PorterAppFormData>();
   const [expandRevisions, setExpandRevisions] = useState(false);
+  const [revertData, setRevertData] = useState<{
+    app: PorterApp;
+    revision: number;
+  } | null>(null);
 
   const res = useQuery(
     ["listAppRevisions", projectId, clusterId, latestRevisionNumber, appName],
@@ -122,6 +129,17 @@ const RevisionsList: React.FC<Props> = ({
     return numDeployed + 1;
   };
 
+  const onRevert = useCallback(async () => {
+    if (!revertData) {
+      return;
+    }
+
+    setValue("app", clientAppFromProto(revertData.app, servicesFromYaml));
+    setRevertData(null);
+
+    void onSubmit();
+  }, [onSubmit, setValue, revertData]);
+
   const renderContents = (revisions: AppRevision[]) => {
     const revisionsWithProto = revisions.map((revision) => {
       return {
@@ -245,7 +263,16 @@ const RevisionsList: React.FC<Props> = ({
                       <Td>
                         <RollbackButton
                           disabled={isLatestDeployedRevision}
-                          onClick={() => {}}
+                          onClick={() => {
+                            if (isLatestDeployedRevision) {
+                              return;
+                            }
+
+                            setRevertData({
+                              app: revision.app_proto,
+                              revision: revision.revision_number,
+                            });
+                          }}
                         >
                           {isLatestDeployedRevision ? "Current" : "Revert"}
                         </RollbackButton>
@@ -275,6 +302,15 @@ const RevisionsList: React.FC<Props> = ({
           renderContents(data.app_revisions)
         )
         .otherwise(() => null)}
+      {revertData ? (
+        <ConfirmOverlay
+          message={`Are you sure you want to revert to revision ${revertData?.revision}?`}
+          onYes={onRevert}
+          onNo={() => {
+            setRevertData(null);
+          }}
+        />
+      ) : null}
     </StyledRevisionSection>
   );
 };

+ 3 - 0
dashboard/src/main/home/app-dashboard/create-app/CreateApp.tsx

@@ -109,6 +109,9 @@ const CreateApp: React.FC<CreateAppProps> = ({ history }) => {
         git_branch: "",
         porter_yaml_path: "./porter.yaml",
       },
+      deletions: {
+        serviceNames: [],
+      }
     },
   });
   const {

+ 2 - 4
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceContainer.tsx

@@ -17,7 +17,7 @@ import { AWS_INSTANCE_LIMITS } from "./tabs/utils";
 import api from "shared/api";
 import StatusFooter from "../../expanded-app/StatusFooter";
 import { ClientService } from "lib/porter-apps/services";
-import { UseFieldArrayRemove, UseFieldArrayUpdate } from "react-hook-form";
+import { UseFieldArrayUpdate } from "react-hook-form";
 import { PorterAppFormData } from "lib/porter-apps";
 import { match } from "ts-pattern";
 import useResizeObserver from "lib/hooks/useResizeObserver";
@@ -26,16 +26,14 @@ interface ServiceProps {
   index: number;
   service: ClientService;
   chart?: any;
-  isPredeploy?: boolean;
   update: UseFieldArrayUpdate<PorterAppFormData, "app.services">;
-  remove: UseFieldArrayRemove;
+  remove: (index: number) => void;
 }
 
 const ServiceContainer: React.FC<ServiceProps> = ({
   index,
   service,
   chart,
-  isPredeploy,
   update,
   remove,
 }) => {

+ 25 - 1
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx

@@ -72,6 +72,14 @@ const ServiceList: React.FC<ServiceListProps> = ({
     control: appControl,
     name: "app.services",
   });
+  const {
+    append: appendDeletion,
+    remove: removeDeletion,
+    fields: deletedServices,
+  } = useFieldArray({
+    control: appControl,
+    name: "deletions.serviceNames",
+  });
 
   const serviceType = watch("type");
   const serviceName = watch("name");
@@ -122,6 +130,16 @@ const ServiceList: React.FC<ServiceListProps> = ({
   };
 
   const onSubmit = handleSubmit(async (data) => {
+    // if service was previously deleted, remove from deletions
+    // handle case such as pre-deploy (which always has the same name)
+    // being deleted and then re-added
+    const previouslyDeleted = deletedServices.findIndex(
+      (s) => s.name === data.name
+    );
+    if (previouslyDeleted !== -1) {
+      removeDeletion(previouslyDeleted);
+    }
+
     append(
       deserializeService({ service: defaultSerialized(data), expanded: true })
     );
@@ -129,6 +147,12 @@ const ServiceList: React.FC<ServiceListProps> = ({
     setShowAddServiceModal(false);
   });
 
+  const onRemove = (index: number) => {
+    const name = services[index].svc.name.value;
+    remove(index);
+    appendDeletion({ name });
+  };
+
   return (
     <>
       {services.length > 0 && (
@@ -140,7 +164,7 @@ const ServiceList: React.FC<ServiceListProps> = ({
                 key={svc.id}
                 service={svc}
                 update={update}
-                remove={remove}
+                remove={onRemove}
               />
             ) : null;
           })}

+ 3 - 2
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Main.tsx

@@ -15,7 +15,8 @@ type MainTabProps = {
 };
 
 const MainTab: React.FC<MainTabProps> = ({ index, service }) => {
-  const { register } = useFormContext<PorterAppFormData>();
+  const { register, watch } = useFormContext<PorterAppFormData>();
+  const cron = watch(`app.services.${index}.config.cron.value`);
 
   const getScheduleDescription = useCallback((cron: string) => {
     try {
@@ -61,7 +62,7 @@ const MainTab: React.FC<MainTabProps> = ({ index, service }) => {
             {...register(`app.services.${index}.config.cron.value`)}
           />
           <Spacer y={0.5} />
-          {getScheduleDescription(service.config.cron.value)}
+          {getScheduleDescription(cron)}
         </>
       )}
     </>

+ 59 - 42
dashboard/src/shared/api.tsx

@@ -11,7 +11,7 @@ import {
   CreateStackBody,
   SourceConfig,
 } from "main/home/cluster-dashboard/stacks/types";
-import { Contract, EnumCloudProvider, GKEPreflightValues, PreflightCheckRequest } from "@porter-dev/api-contracts";
+import { Contract, PreflightCheckRequest } from "@porter-dev/api-contracts";
 
 /**
  * Generic api call format
@@ -75,12 +75,12 @@ const getGitlabIntegration = baseApi<{}, { project_id: number }>(
   ({ project_id }) => `/api/projects/${project_id}/integrations/gitlab`
 );
 
-
-const preflightCheck = baseApi<PreflightCheckRequest,
-  { id: number }
->("POST", (pathParams) => {
-  return `/api/projects/${pathParams.id}/integrations/preflightcheck`;
-});
+const preflightCheck = baseApi<PreflightCheckRequest, { id: number }>(
+  "POST",
+  (pathParams) => {
+    return `/api/projects/${pathParams.id}/integrations/preflightcheck`;
+  }
+);
 
 const preflightCheckAWSUsage = baseApi<
   {
@@ -288,8 +288,9 @@ const getFeedEvents = baseApi<
   }
 >("GET", (pathParams) => {
   let { project_id, cluster_id, stack_name, page } = pathParams;
-  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${page || 1
-    }`;
+  return `/api/projects/${project_id}/clusters/${cluster_id}/applications/${stack_name}/events?page=${
+    page || 1
+  }`;
 });
 
 const createEnvironment = baseApi<
@@ -714,9 +715,11 @@ 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<
@@ -747,9 +750,11 @@ 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<
@@ -765,9 +770,11 @@ 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 getPorterYamlContents = baseApi<
@@ -783,9 +790,11 @@ const getPorterYamlContents = 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)}/porteryaml`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/porteryaml`;
 });
 
 const parsePorterYaml = baseApi<
@@ -821,9 +830,11 @@ const getBranchHead = 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)}/head`;
+  return `/api/projects/${pathParams.project_id}/gitrepos/${
+    pathParams.git_repo_id
+  }/repos/${pathParams.kind}/${pathParams.owner}/${
+    pathParams.name
+  }/${encodeURIComponent(pathParams.branch)}/head`;
 });
 
 const validatePorterApp = baseApi<
@@ -831,6 +842,10 @@ const validatePorterApp = baseApi<
     b64_app_proto: string;
     deployment_target_id: string;
     commit_sha: string;
+    deletions: {
+      service_names: string[];
+      env_variable_names: string[];
+    };
   },
   {
     project_id: number;
@@ -842,21 +857,21 @@ const validatePorterApp = baseApi<
 
 const createApp = baseApi<
   | {
-    name: string;
-    type: "github";
-    git_repo_id: number;
-    git_branch: string;
-    git_repo_name: string;
-    porter_yaml_path: string;
-  }
+      name: string;
+      type: "github";
+      git_repo_id: number;
+      git_branch: string;
+      git_repo_name: string;
+      porter_yaml_path: string;
+    }
   | {
-    name: string;
-    type: "docker-registry";
-    image: {
-      repository: string;
-      tag: string;
-    };
-  },
+      name: string;
+      type: "docker-registry";
+      image: {
+        repository: string;
+        tag: string;
+      };
+    },
   {
     project_id: number;
     cluster_id: number;
@@ -1767,9 +1782,11 @@ 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<
@@ -2826,7 +2843,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`);
 
 const createSecretAndOpenGitHubPullRequest = baseApi<
   {

+ 1 - 1
go.mod

@@ -79,7 +79,7 @@ require (
 	github.com/matryer/is v1.4.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.95
+	github.com/porter-dev/api-contracts v0.0.98
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 2
go.sum

@@ -1489,8 +1489,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.95 h1:at2td0mo5zEFJljAmDjBIiTZUvSqua41RU9q+jFCSNE=
-github.com/porter-dev/api-contracts v0.0.95/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.0.98 h1:pchO+C7HKhpWZzR2RPDKKJnH3Rx8Cy/Q1v9aTfR9jzk=
+github.com/porter-dev/api-contracts v0.0.98/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=