Sfoglia il codice sorgente

Support termination grace period seconds on the frontend (#4003)

Feroze Mohideen 2 anni fa
parent
commit
7f48233de1

+ 3 - 1
dashboard/src/components/porter/Button.tsx

@@ -21,6 +21,7 @@ type Props = {
   alt?: boolean;
   type?: React.ButtonHTMLAttributes<HTMLButtonElement>["type"];
   disabledTooltipMessage?: string;
+  disabledTooltipPosition?: "top" | "right" | "bottom" | "left";
 };
 
 const Button: React.FC<Props> = ({
@@ -40,6 +41,7 @@ const Button: React.FC<Props> = ({
   alt,
   type = "button",
   disabledTooltipMessage,
+  disabledTooltipPosition = "right",
 }) => {
   const renderStatus = () => {
     switch (status) {
@@ -69,7 +71,7 @@ const Button: React.FC<Props> = ({
   };
 
   return disabled && disabledTooltipMessage ? (
-    <Tooltip content={disabledTooltipMessage} position="right">
+    <Tooltip content={disabledTooltipMessage} position={disabledTooltipPosition}>
       <Wrapper>
         <StyledButton
           disabled={disabled}

+ 21 - 17
dashboard/src/lib/porter-apps/index.ts

@@ -1,9 +1,9 @@
 import {
+  Build,
   EFS,
   HelmOverrides,
   PorterApp,
   Service,
-  type Build,
 } from "@porter-dev/api-contracts";
 import { match } from "ts-pattern";
 import { z } from "zod";
@@ -246,22 +246,26 @@ export function serviceOverrides({
   };
 }
 
-const clientBuildToProto = (build: BuildOptions) => {
+const clientBuildToProto = (build: BuildOptions): Build => {
   return match(build)
-    .with({ method: "pack" }, (b) =>
-      Object.freeze({
-        method: "pack",
-        context: b.context,
-        buildpacks: b.buildpacks.map((b) => b.buildpack),
-        builder: b.builder,
-      })
+    .with(
+      { method: "pack" },
+      (b) =>
+        new Build({
+          method: "pack",
+          context: b.context,
+          buildpacks: b.buildpacks.map((b) => b.buildpack),
+          builder: b.builder,
+        })
     )
-    .with({ method: "docker" }, (b) =>
-      Object.freeze({
-        method: "docker",
-        context: b.context,
-        dockerfile: b.dockerfile,
-      })
+    .with(
+      { method: "docker" },
+      (b) =>
+        new Build({
+          method: "docker",
+          context: b.context,
+          dockerfile: b.dockerfile,
+        })
     )
     .exhaustive();
 };
@@ -529,7 +533,7 @@ export function applyPreviewOverrides({
         override: serializeService(override),
       });
 
-      if (ds.config.type == "web") {
+      if (ds.config.type === "web") {
         return {
           ...ds,
           config: {
@@ -541,7 +545,7 @@ export function applyPreviewOverrides({
       return ds;
     }
 
-    if (svc.config.type == "web") {
+    if (svc.config.type === "web") {
       return {
         ...svc,
         config: {

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

@@ -83,6 +83,7 @@ export const serviceValidator = z.object({
   ramMegabytes: serviceNumberValidator,
   gpuCoresNvidia: serviceNumberValidator,
   smartOptimization: serviceBooleanValidator.optional(),
+  terminationGracePeriodSeconds: serviceNumberValidator.optional(),
   config: z.discriminatedUnion("type", [
     webConfigValidator,
     workerConfigValidator,
@@ -116,6 +117,7 @@ export type SerializedService = {
   ramMegabytes: number;
   smartOptimization?: boolean;
   gpuCoresNvidia: number;
+  terminationGracePeriodSeconds?: number;
   config:
     | {
         type: "web";
@@ -262,6 +264,7 @@ export function serializeService(service: ClientService): SerializedService {
     ramMegabytes: Math.round(service.ramMegabytes.value), // RAM must be an integer
     smartOptimization: service.smartOptimization?.value,
     gpuCoresNvidia: service.gpuCoresNvidia.value,
+    terminationGracePeriodSeconds: service.terminationGracePeriodSeconds?.value,
     config: match(service.config)
       .with({ type: "web" }, (config) =>
         Object.freeze({
@@ -345,6 +348,12 @@ export function deserializeService({
       service.smartOptimization,
       override?.smartOptimization
     ),
+    terminationGracePeriodSeconds: setDefaults
+      ? ServiceField.number(
+          service.terminationGracePeriodSeconds ?? 30, // if not set explicitly, assume the value is at default (30 seconds)
+          override?.terminationGracePeriodSeconds
+        )
+      : undefined,
     domainDeletions: [],
     ingressAnnotationDeletions: [],
   };

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

@@ -589,6 +589,7 @@ const AppDataContainer: React.FC<AppDataContainerProps> = ({ tabParam }) => {
                     latestRevision.status === "AWAITING_BUILD_ARTIFACT"
                   }
                   disabledTooltipMessage="Please wait for the deploy to complete before updating the app"
+                  disabledTooltipPosition="bottom"
                 >
                   <Icon src={save} height={"13px"} />
                   <Spacer inline x={0.5} />

+ 47 - 0
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Advanced.tsx

@@ -0,0 +1,47 @@
+import React from "react";
+import { useFormContext } from "react-hook-form";
+
+import Container from "components/porter/Container";
+import { ControlledInput } from "components/porter/ControlledInput";
+import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
+import { type PorterAppFormData } from "lib/porter-apps";
+
+type AdvancedProps = {
+  index: number;
+};
+
+const Advanced: React.FC<AdvancedProps> = ({ index }) => {
+  const { register } = useFormContext<PorterAppFormData>();
+
+  return (
+    <>
+      <Text>Termination grace period seconds</Text>
+      <Spacer y={0.25} />
+      <Container style={{ width: "400px" }}>
+        <Text color="helper">
+          Specify how much time service processes are given to gracefully shut
+          down when they receive SIGTERM
+          <a
+            href="https://docs.porter.run/standard/deploying-applications/zero-downtime-deployments#graceful-shutdown"
+            target="_blank"
+            rel="noreferrer"
+          >
+            &nbsp;(?)
+          </a>
+        </Text>
+      </Container>
+      <Spacer y={0.25} />
+      <ControlledInput
+        type="text"
+        placeholder="ex: 30"
+        {...register(
+          `app.services.${index}.terminationGracePeriodSeconds.value`
+        )}
+      />
+      <Spacer y={0.5} />
+    </>
+  );
+};
+
+export default Advanced;

+ 7 - 13
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/Health.tsx

@@ -1,23 +1,17 @@
+import React from "react";
+import { Controller, useFormContext } from "react-hook-form";
+
 import Checkbox from "components/porter/Checkbox";
 import { ControlledInput } from "components/porter/ControlledInput";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
-import { PorterAppFormData } from "lib/porter-apps";
-import { ClientService } from "lib/porter-apps/services";
-import React from "react";
-import AnimateHeight from "react-animate-height";
-import { Controller, useFormContext } from "react-hook-form";
+import { type PorterAppFormData } from "lib/porter-apps";
 
 type HealthProps = {
   index: number;
-  service: ClientService & {
-    config: {
-      type: "web";
-    };
-  };
 };
 
-const Health: React.FC<HealthProps> = ({ index, service }) => {
+const Health: React.FC<HealthProps> = ({ index }) => {
   const { register, control, watch } = useFormContext<PorterAppFormData>();
 
   const healthCheckEnabled = watch(
@@ -27,12 +21,13 @@ const Health: React.FC<HealthProps> = ({ index, service }) => {
   return (
     <>
       <Spacer y={1} />
-      <Text color="helper">
+      <Text>
         <>
           <span>Health checks</span>
           <a
             href="https://docs.porter.run/enterprise/deploying-applications/zero-downtime-deployments#health-checks"
             target="_blank"
+            rel="noreferrer"
           >
             &nbsp;(?)
           </a>
@@ -71,7 +66,6 @@ const Health: React.FC<HealthProps> = ({ index, service }) => {
               `app.services.${index}.config.healthCheck.httpPath.value`
             )}
           />
-          <Spacer y={0.5} />
         </>
       )}
     </>

+ 66 - 54
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/JobTabs.tsx

@@ -1,31 +1,31 @@
 import React from "react";
-import Text from "components/porter/Text";
+import { Controller, useFormContext } from "react-hook-form";
+import { match } from "ts-pattern";
+
+import Checkbox from "components/porter/Checkbox";
+import { ControlledInput } from "components/porter/ControlledInput";
 import Spacer from "components/porter/Spacer";
+import Text from "components/porter/Text";
 import TabSelector from "components/TabSelector";
-import Checkbox from "components/porter/Checkbox";
-import { Height } from "react-animate-height";
+import { type PorterAppFormData } from "lib/porter-apps";
+import { type ClientService } from "lib/porter-apps/services";
 
-import { ClientService } from "lib/porter-apps/services";
-import { match } from "ts-pattern";
+import Advanced from "./Advanced";
 import MainTab from "./Main";
 import Resources from "./Resources";
-import { Controller, useFormContext } from "react-hook-form";
-import { PorterAppFormData } from "lib/porter-apps";
-import { ControlledInput } from "components/porter/ControlledInput";
 
-interface Props {
+type Props = {
   index: number;
   service: ClientService & {
     config: {
       type: "job" | "predeploy";
     };
   };
-  chart?: any;
   maxRAM: number;
   maxCPU: number;
   clusterContainsGPUNodes: boolean;
   isPredeploy?: boolean;
-}
+};
 
 const JobTabs: React.FC<Props> = ({
   index,
@@ -42,14 +42,14 @@ const JobTabs: React.FC<Props> = ({
 
   const tabs = isPredeploy
     ? [
-      { label: "Main", value: "main" as const },
-      { label: "Resources", value: "resources" as const },
-    ]
+        { label: "Main", value: "main" as const },
+        { label: "Resources", value: "resources" as const },
+      ]
     : [
-      { label: "Main", value: "main" as const },
-      { label: "Resources", value: "resources" as const },
-      { label: "Advanced", value: "advanced" as const },
-    ];
+        { label: "Main", value: "main" as const },
+        { label: "Resources", value: "resources" as const },
+        { label: "Advanced", value: "advanced" as const },
+      ];
 
   return (
     <>
@@ -59,7 +59,9 @@ const JobTabs: React.FC<Props> = ({
         setCurrentTab={setCurrentTab}
       />
       {match(currentTab)
-        .with("main", () => <MainTab index={index} service={service} isPredeploy={isPredeploy} />)
+        .with("main", () => (
+          <MainTab index={index} service={service} isPredeploy={isPredeploy} />
+        ))
         .with("resources", () => (
           <Resources
             index={index}
@@ -70,44 +72,54 @@ const JobTabs: React.FC<Props> = ({
             isPredeploy={isPredeploy}
           />
         ))
-        .with("advanced", () => (
-          <>
-            <Spacer y={1} />
-            <Controller
-              name={`app.services.${index}.config.allowConcurrent`}
-              control={control}
-              render={({ field: { value, onChange } }) => (
-                <Checkbox
-                  checked={value.value}
-                  toggleChecked={() => {
-                    onChange({
-                      ...value,
-                      value: !value.value,
-                    });
-                  }}
-                  disabled={value.readOnly}
+        .with(
+          "advanced",
+          () =>
+            service.config.type === "job" ? (
+              <>
+                <Spacer y={1} />
+                <Controller
+                  name={`app.services.${index}.config.allowConcurrent`}
+                  control={control}
+                  render={({ field: { value, onChange } }) => (
+                    <Checkbox
+                      checked={value?.value ?? false}
+                      toggleChecked={() => {
+                        onChange({
+                          ...value,
+                          value: !value?.value,
+                        });
+                      }}
+                      disabled={value?.readOnly}
+                      disabledTooltip={
+                        "You may only edit this field in your porter.yaml."
+                      }
+                    >
+                      <Text color="helper">
+                        Allow jobs to execute concurrently
+                      </Text>
+                    </Checkbox>
+                  )}
+                />
+                <Spacer y={1} />
+                <ControlledInput
+                  type="text"
+                  label="Timeout (seconds)"
+                  placeholder="ex: 3600"
+                  width="300px"
+                  disabled={service.config.timeoutSeconds.readOnly}
                   disabledTooltip={
                     "You may only edit this field in your porter.yaml."
                   }
-                >
-                  <Text color="helper">Allow jobs to execute concurrently</Text>
-                </Checkbox>
-              )}
-            />
-            <Spacer y={1} />
-            <ControlledInput
-              type="text"
-              label="Timeout (seconds)"
-              placeholder="ex: 3600"
-              width="300px"
-              disabled={service.config.timeoutSeconds.readOnly}
-              disabledTooltip={
-                "You may only edit this field in your porter.yaml."
-              }
-              {...register(`app.services.${index}.config.timeoutSeconds.value`)}
-            />
-          </>
-        ))
+                  {...register(
+                    `app.services.${index}.config.timeoutSeconds.value`
+                  )}
+                />
+                <Spacer y={1} />
+                <Advanced index={index} />
+              </>
+            ) : null // we do not display this tab for predeploy jobs anyway
+        )
         .exhaustive()}
     </>
   );

+ 9 - 1
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/WebTabs.tsx

@@ -1,9 +1,11 @@
 import React from "react";
 import { match } from "ts-pattern";
 
+import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
 import { type ClientService } from "lib/porter-apps/services";
 
+import Advanced from "./Advanced";
 import Health from "./Health";
 import MainTab from "./Main";
 import Networking from "./Networking";
@@ -73,7 +75,13 @@ const WebTabs: React.FC<Props> = ({
             service={service}
           />
         ))
-        .with("advanced", () => <Health index={index} service={service} />)
+        .with("advanced", () => (
+          <>
+            <Health index={index} />
+            <Spacer y={1} />
+            <Advanced index={index} />
+          </>
+        ))
         .exhaustive()}
     </>
   );

+ 24 - 9
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/tabs/WorkerTabs.tsx

@@ -1,28 +1,36 @@
 import React from "react";
+import { match } from "ts-pattern";
+
+import Spacer from "components/porter/Spacer";
 import TabSelector from "components/TabSelector";
+import { type ClientService } from "lib/porter-apps/services";
 
-import { ClientService } from "lib/porter-apps/services";
-import { match } from "ts-pattern";
+import Advanced from "./Advanced";
 import MainTab from "./Main";
 import Resources from "./Resources";
 
-interface Props {
+type Props = {
   index: number;
   service: ClientService & {
     config: {
       type: "worker";
     };
   };
-  chart?: any;
   maxRAM: number;
   maxCPU: number;
   clusterContainsGPUNodes: boolean;
-}
+};
 
-const WorkerTabs: React.FC<Props> = ({ index, service, maxCPU, maxRAM, clusterContainsGPUNodes }) => {
-  const [currentTab, setCurrentTab] = React.useState<"main" | "resources">(
-    "main"
-  );
+const WorkerTabs: React.FC<Props> = ({
+  index,
+  service,
+  maxCPU,
+  maxRAM,
+  clusterContainsGPUNodes,
+}) => {
+  const [currentTab, setCurrentTab] = React.useState<
+    "main" | "resources" | "advanced"
+  >("main");
 
   return (
     <>
@@ -30,6 +38,7 @@ const WorkerTabs: React.FC<Props> = ({ index, service, maxCPU, maxRAM, clusterCo
         options={[
           { label: "Main", value: "main" },
           { label: "Resources", value: "resources" },
+          { label: "Advanced", value: "advanced" },
         ]}
         currentTab={currentTab}
         setCurrentTab={setCurrentTab}
@@ -45,6 +54,12 @@ const WorkerTabs: React.FC<Props> = ({ index, service, maxCPU, maxRAM, clusterCo
             clusterContainsGPUNodes={clusterContainsGPUNodes}
           />
         ))
+        .with("advanced", () => (
+          <>
+            <Spacer y={1} />
+            <Advanced index={index} />
+          </>
+        ))
         .exhaustive()}
     </>
   );

+ 39 - 36
internal/porter_app/v2/yaml.go

@@ -129,25 +129,26 @@ type Image struct {
 
 // Service represents a single service in a porter app
 type Service struct {
-	Name               string            `yaml:"name,omitempty"`
-	Run                *string           `yaml:"run,omitempty"`
-	Type               ServiceType       `yaml:"type,omitempty" validate:"required, oneof=web worker job"`
-	Instances          *int32            `yaml:"instances,omitempty"`
-	CpuCores           float32           `yaml:"cpuCores,omitempty"`
-	RamMegabytes       int               `yaml:"ramMegabytes,omitempty"`
-	GpuCoresNvidia     float32           `yaml:"gpuCoresNvidia,omitempty"`
-	SmartOptimization  *bool             `yaml:"smartOptimization,omitempty"`
-	Port               int               `yaml:"port,omitempty"`
-	Autoscaling        *AutoScaling      `yaml:"autoscaling,omitempty" validate:"excluded_if=Type job"`
-	Domains            []Domains         `yaml:"domains,omitempty" validate:"excluded_unless=Type web"`
-	HealthCheck        *HealthCheck      `yaml:"healthCheck,omitempty" validate:"excluded_unless=Type web"`
-	AllowConcurrent    *bool             `yaml:"allowConcurrent,omitempty" validate:"excluded_unless=Type job"`
-	Cron               string            `yaml:"cron,omitempty" validate:"excluded_unless=Type job"`
-	SuspendCron        *bool             `yaml:"suspendCron,omitempty" validate:"excluded_unless=Type job"`
-	TimeoutSeconds     int               `yaml:"timeoutSeconds,omitempty" validate:"excluded_unless=Type job"`
-	Private            *bool             `yaml:"private,omitempty" validate:"excluded_unless=Type web"`
-	IngressAnnotations map[string]string `yaml:"ingressAnnotations,omitempty" validate:"excluded_unless=Type web"`
-	DisableTLS         *bool             `yaml:"disableTLS,omitempty" validate:"excluded_unless=Type web"`
+	Name                          string            `yaml:"name,omitempty"`
+	Run                           *string           `yaml:"run,omitempty"`
+	Type                          ServiceType       `yaml:"type,omitempty" validate:"required, oneof=web worker job"`
+	Instances                     *int32            `yaml:"instances,omitempty"`
+	CpuCores                      float32           `yaml:"cpuCores,omitempty"`
+	RamMegabytes                  int               `yaml:"ramMegabytes,omitempty"`
+	GpuCoresNvidia                float32           `yaml:"gpuCoresNvidia,omitempty"`
+	SmartOptimization             *bool             `yaml:"smartOptimization,omitempty"`
+	TerminationGracePeriodSeconds *int32            `yaml:"terminationGracePeriodSeconds,omitempty"`
+	Port                          int               `yaml:"port,omitempty"`
+	Autoscaling                   *AutoScaling      `yaml:"autoscaling,omitempty" validate:"excluded_if=Type job"`
+	Domains                       []Domains         `yaml:"domains,omitempty" validate:"excluded_unless=Type web"`
+	HealthCheck                   *HealthCheck      `yaml:"healthCheck,omitempty" validate:"excluded_unless=Type web"`
+	AllowConcurrent               *bool             `yaml:"allowConcurrent,omitempty" validate:"excluded_unless=Type job"`
+	Cron                          string            `yaml:"cron,omitempty" validate:"excluded_unless=Type job"`
+	SuspendCron                   *bool             `yaml:"suspendCron,omitempty" validate:"excluded_unless=Type job"`
+	TimeoutSeconds                int               `yaml:"timeoutSeconds,omitempty" validate:"excluded_unless=Type job"`
+	Private                       *bool             `yaml:"private,omitempty" validate:"excluded_unless=Type web"`
+	IngressAnnotations            map[string]string `yaml:"ingressAnnotations,omitempty" validate:"excluded_unless=Type web"`
+	DisableTLS                    *bool             `yaml:"disableTLS,omitempty" validate:"excluded_unless=Type web"`
 }
 
 // AutoScaling represents the autoscaling settings for web services
@@ -292,15 +293,16 @@ func protoEnumFromType(name string, service Service) porterv1.ServiceType {
 
 func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (*porterv1.Service, error) {
 	serviceProto := &porterv1.Service{
-		Name:              service.Name,
-		RunOptional:       service.Run,
-		InstancesOptional: service.Instances,
-		CpuCores:          service.CpuCores,
-		RamMegabytes:      int32(service.RamMegabytes),
-		GpuCoresNvidia:    service.GpuCoresNvidia,
-		Port:              int32(service.Port),
-		SmartOptimization: service.SmartOptimization,
-		Type:              serviceType,
+		Name:                          service.Name,
+		RunOptional:                   service.Run,
+		InstancesOptional:             service.Instances,
+		CpuCores:                      service.CpuCores,
+		RamMegabytes:                  int32(service.RamMegabytes),
+		GpuCoresNvidia:                service.GpuCoresNvidia,
+		Port:                          int32(service.Port),
+		SmartOptimization:             service.SmartOptimization,
+		Type:                          serviceType,
+		TerminationGracePeriodSeconds: service.TerminationGracePeriodSeconds,
 	}
 
 	switch serviceType {
@@ -453,14 +455,15 @@ func AppFromProto(appProto *porterv1.PorterApp) (PorterApp, error) {
 
 func appServiceFromProto(service *porterv1.Service) (Service, error) {
 	appService := Service{
-		Name:              service.Name,
-		Run:               service.RunOptional,
-		Instances:         service.InstancesOptional,
-		CpuCores:          service.CpuCores,
-		RamMegabytes:      int(service.RamMegabytes),
-		GpuCoresNvidia:    service.GpuCoresNvidia,
-		Port:              int(service.Port),
-		SmartOptimization: service.SmartOptimization,
+		Name:                          service.Name,
+		Run:                           service.RunOptional,
+		Instances:                     service.InstancesOptional,
+		CpuCores:                      service.CpuCores,
+		RamMegabytes:                  int(service.RamMegabytes),
+		GpuCoresNvidia:                service.GpuCoresNvidia, // nolint:staticcheck // https://linear.app/porter/issue/POR-2137/support-new-gpu-field-in-porteryaml
+		Port:                          int(service.Port),
+		SmartOptimization:             service.SmartOptimization,
+		TerminationGracePeriodSeconds: service.TerminationGracePeriodSeconds,
 	}
 
 	switch service.Type {