Quellcode durchsuchen

support initial deploy job in activity feed and yaml (#4446)

ianedwards vor 2 Jahren
Ursprung
Commit
c49c90a324

+ 25 - 0
api/server/handlers/porter_app/yaml_from_revision.go

@@ -477,6 +477,31 @@ func zeroOutValues(app v2.PorterApp) v2.PorterApp {
 		app.Predeploy.TimeoutSeconds = 0
 	}
 
+	if app.InitialDeploy != nil {
+		// remove name
+		app.InitialDeploy.Name = ""
+		// remove type
+		app.InitialDeploy.Type = ""
+		// remove smart optimization
+		app.InitialDeploy.SmartOptimization = nil
+		// remove launcher
+		if app.InitialDeploy.Run != nil {
+			launcherLess := strings.TrimPrefix(*app.InitialDeploy.Run, "launcher ")
+			launcherLess = strings.TrimPrefix(launcherLess, "/cnb/lifecycle/launcher ")
+			app.InitialDeploy.Run = &launcherLess
+		}
+		// remove port
+		app.InitialDeploy.Port = 0
+		// remove instances
+		app.InitialDeploy.Instances = nil
+		// remove suspendCron
+		app.InitialDeploy.SuspendCron = nil
+		// remove allowConcurrency
+		app.InitialDeploy.AllowConcurrent = nil
+		// remove timeout
+		app.InitialDeploy.TimeoutSeconds = 0
+	}
+
 	return app
 }
 

+ 1 - 0
dashboard/src/assets/seed.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="1" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sprout"><path d="M7 20h10"/><path d="M10 20c5.5-2.5.8-6.4 3-10"/><path d="M9.5 9.4c1.1.8 1.8 2.2 2.3 3.7-2 .4-3.5.4-4.8-.3-1.2-.6-2.3-1.9-3-4.2 2.8-.5 4.4 0 5.5.8z"/><path d="M14.1 6a7 7 0 0 0-1.1 4c1.9-.1 3.3-.6 4.3-1.4 1-1 1.6-2.3 1.7-4.6-2.7.1-4 1-4.9 2z"/></svg>

+ 44 - 1
dashboard/src/lib/porter-apps/services.ts

@@ -39,7 +39,7 @@ export type DetectedServices = {
     variables?: Record<string, string>;
   };
 };
-type ClientServiceType = "web" | "worker" | "job" | "predeploy";
+type ClientServiceType = "web" | "worker" | "job" | "predeploy" | "initdeploy";
 
 type ClientWebService = ClientService & { config: ClientWebConfig };
 export const isClientWebService = (
@@ -92,6 +92,13 @@ const predeployConfigValidator = z.object({
 });
 export type ClientPredeployConfig = z.infer<typeof predeployConfigValidator>;
 
+const initialDeployConfigValidator = z.object({
+  type: z.literal("initdeploy"),
+});
+export type ClientInitialDeployConfig = z.infer<
+  typeof initialDeployConfigValidator
+>;
+
 // serviceValidator is the validator for a ClientService
 // This is used to validate a service when creating or updating an app
 export const serviceValidator = z.object({
@@ -116,6 +123,7 @@ export const serviceValidator = z.object({
     workerConfigValidator,
     jobConfigValidator,
     predeployConfigValidator,
+    initialDeployConfigValidator,
   ]),
   domainDeletions: z
     .object({
@@ -176,6 +184,9 @@ export type SerializedService = {
       }
     | {
         type: "predeploy";
+      }
+    | {
+        type: "initdeploy";
       };
 };
 
@@ -295,6 +306,12 @@ export function defaultSerialized({
         type: "predeploy" as const,
       },
     }))
+    .with("initdeploy", () => ({
+      ...baseService,
+      config: {
+        type: "initdeploy" as const,
+      },
+    }))
     .exhaustive();
 }
 
@@ -362,6 +379,11 @@ export function serializeService(service: ClientService): SerializedService {
           type: "predeploy" as const,
         })
       )
+      .with({ type: "initdeploy" }, () =>
+        Object.freeze({
+          type: "initdeploy" as const,
+        })
+      )
       .exhaustive(),
   });
 }
@@ -579,6 +601,12 @@ export function deserializeService({
         type: "predeploy" as const,
       },
     }))
+    .with({ type: "initdeploy" }, () => ({
+      ...baseService,
+      config: {
+        type: "initdeploy" as const,
+      },
+    }))
     .exhaustive();
 }
 
@@ -589,6 +617,7 @@ export const serviceTypeEnumProto = (type: ClientServiceType): ServiceType => {
     .with("worker", () => ServiceType.WORKER)
     .with("job", () => ServiceType.JOB)
     .with("predeploy", () => ServiceType.JOB)
+    .with("initdeploy", () => ServiceType.JOB)
     .exhaustive();
 };
 
@@ -662,6 +691,20 @@ export function serviceProto(service: SerializedService): Service {
           },
         })
     )
+    .with(
+      { type: "initdeploy" },
+      (config) =>
+        new Service({
+          ...service,
+          runOptional: service.run,
+          instancesOptional: service.instances,
+          type: serviceTypeEnumProto(config.type),
+          config: {
+            value: {},
+            case: "jobConfig",
+          },
+        })
+    )
     .exhaustive();
 }
 

+ 12 - 2
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/EventCard.tsx

@@ -7,6 +7,7 @@ import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisi
 import { type PorterAppEvent } from "../types";
 import BuildEventCard from "./BuildEventCard";
 import DeployEventCard from "./DeployEventCard";
+import InitialDeployEventCard from "./InitialDeployEventCard";
 import PreDeployEventCard from "./PreDeployEventCard";
 
 type Props = {
@@ -43,7 +44,7 @@ const EventCard: React.FC<Props> = ({
             : ""
         )
         // TODO: remove check for commit_sha when update flow is GA'd
-        .with({ type: "PRE_DEPLOY" }, (event) =>
+        .with({ type: "PRE_DEPLOY" }, { type: "INITIAL_DEPLOY" }, (event) =>
           event.metadata.commit_sha
             ? `https://www.github.com/${porterApp.repo_name}/commit/${event.metadata.commit_sha}`
             : event.metadata.image_tag
@@ -77,7 +78,7 @@ const EventCard: React.FC<Props> = ({
           event.metadata.commit_sha ? event.metadata.commit_sha.slice(0, 7) : ""
         )
         // TODO: remove check for commit_sha when update flow is GA'd
-        .with({ type: "PRE_DEPLOY" }, (event) =>
+        .with({ type: "PRE_DEPLOY" }, { type: "INITIAL_DEPLOY" }, (event) =>
           event.metadata.commit_sha
             ? event.metadata.commit_sha.slice(0, 7)
             : event.metadata.image_tag
@@ -129,6 +130,15 @@ const EventCard: React.FC<Props> = ({
         displayCommitSha={displayCommitSha}
       />
     ))
+    .with({ type: "INITIAL_DEPLOY" }, (ev) => (
+      <InitialDeployEventCard
+        event={ev}
+        projectId={projectId}
+        clusterId={clusterId}
+        gitCommitUrl={gitCommitUrl}
+        displayCommitSha={displayCommitSha}
+      />
+    ))
     .with({ type: "AUTO_ROLLBACK" }, () => null)
     .exhaustive();
 };

+ 189 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/cards/InitialDeployEventCard.tsx

@@ -0,0 +1,189 @@
+import React, { useMemo } from "react";
+import styled from "styled-components";
+import { match } from "ts-pattern";
+
+import Container from "components/porter/Container";
+import Icon from "components/porter/Icon";
+import Link from "components/porter/Link";
+import Spacer from "components/porter/Spacer";
+import Tag from "components/porter/Tag";
+import Text from "components/porter/Text";
+import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
+import { isClientServiceNotification } from "lib/porter-apps/notification";
+
+import alert from "assets/alert-warning.svg";
+import document from "assets/document.svg";
+import pull_request_icon from "assets/pull_request_icon.svg";
+import refresh from "assets/refresh.png";
+import run_for from "assets/run_for.png";
+import seed from "assets/seed.svg";
+import tag_icon from "assets/tag.png";
+
+import { type PorterAppInitialDeployEvent } from "../types";
+import {
+  getDuration,
+  getStatusColor,
+  getStatusIcon,
+  triggerWorkflow,
+} from "../utils";
+import {
+  Code,
+  CommitIcon,
+  ImageTagContainer,
+  StyledEventCard,
+  TagContainer,
+} from "./EventCard";
+
+type Props = {
+  event: PorterAppInitialDeployEvent;
+  projectId: number;
+  clusterId: number;
+  gitCommitUrl: string;
+  displayCommitSha: string;
+};
+
+const InitialDeployEventCard: React.FC<Props> = ({
+  event,
+  projectId,
+  clusterId,
+  gitCommitUrl,
+  displayCommitSha,
+}) => {
+  const { porterApp, latestClientNotifications, tabUrlGenerator } =
+    useLatestRevision();
+
+  const renderStatusText = (
+    event: PorterAppInitialDeployEvent
+  ): JSX.Element => {
+    const color = getStatusColor(event.status);
+    const text = match(event.status)
+      .with("SUCCESS", () => "Initial deploy job successful")
+      .with("FAILED", () => "Initial deploy job failed")
+      .with("CANCELED", () => "Initial deploy job canceled")
+      .otherwise(() => "Initial deploy job in progress...");
+    return <Text color={color}>{text}</Text>;
+  };
+
+  const initialDeployNotificationsExist = useMemo(() => {
+    return latestClientNotifications
+      .filter(isClientServiceNotification)
+      .some((notification) => {
+        return (
+          notification.service.config.type === "initdeploy" &&
+          notification.appRevisionId === event.metadata.app_revision_id
+        );
+      });
+  }, [JSON.stringify(latestClientNotifications)]);
+
+  return (
+    <StyledEventCard>
+      <Container row spaced>
+        <Container row>
+          <Icon height="18px" src={seed} />
+          <Spacer inline width="10px" />
+          <Text>Application initial deploy</Text>
+          {gitCommitUrl && displayCommitSha ? (
+            <>
+              <Spacer inline x={0.5} />
+              <ImageTagContainer>
+                <Link
+                  to={gitCommitUrl}
+                  target="_blank"
+                  showTargetBlankIcon={false}
+                >
+                  <CommitIcon src={pull_request_icon} />
+                  <Code>{displayCommitSha}</Code>
+                </Link>
+              </ImageTagContainer>
+            </>
+          ) : event.metadata.image_tag ? (
+            <>
+              <Spacer inline x={0.5} />
+              <ImageTagContainer hoverable={false}>
+                <TagContainer>
+                  <CommitIcon src={tag_icon} />
+                  <Code>{event.metadata.image_tag}</Code>
+                </TagContainer>
+              </ImageTagContainer>
+            </>
+          ) : null}
+        </Container>
+        <Container row>
+          <Icon height="14px" src={run_for} />
+          <Spacer inline width="6px" />
+          <Text color="helper">{getDuration(event)}</Text>
+        </Container>
+      </Container>
+      <Spacer y={0.5} />
+      <Container row spaced>
+        <Container row>
+          <Icon height="12px" src={getStatusIcon(event.status)} />
+          <Spacer inline width="10px" />
+          {renderStatusText(event)}
+          <Spacer inline x={1} />
+          <Tag>
+            <Link
+              to={tabUrlGenerator({
+                tab: "events",
+                queryParams: {
+                  event_id: event.id,
+                  service: "initialDeploy",
+                  revision_id: event.metadata.app_revision_id,
+                },
+              })}
+            >
+              <TagIcon src={document} />
+              Logs
+            </Link>
+          </Tag>
+          {/* retry is not supported for docker initialDeploy atm */}
+          {event.status !== "SUCCESS" && gitCommitUrl && (
+            <>
+              <Spacer inline x={0.5} />
+              <Tag>
+                <Link
+                  onClick={async () => {
+                    await triggerWorkflow({
+                      projectId,
+                      clusterId,
+                      porterApp,
+                    });
+                  }}
+                >
+                  <TagIcon src={refresh} />
+                  Retry
+                </Link>
+              </Tag>
+            </>
+          )}
+          {initialDeployNotificationsExist && (
+            <>
+              <Spacer inline x={0.5} />
+              <Container row>
+                <Tag borderColor="#FFBF00">
+                  <Link
+                    to={tabUrlGenerator({
+                      tab: "notifications",
+                      queryParams: {},
+                    })}
+                    color={"#FFBF00"}
+                  >
+                    <TagIcon src={alert} />
+                    Notifications
+                  </Link>
+                </Tag>
+              </Container>
+            </>
+          )}
+        </Container>
+      </Container>
+    </StyledEventCard>
+  );
+};
+
+export default InitialDeployEventCard;
+
+const TagIcon = styled.img`
+  height: 12px;
+  margin-right: 3px;
+`;

+ 13 - 0
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/types.ts

@@ -173,6 +173,16 @@ export const porterAppEventValidator = z.discriminatedUnion("type", [
     porter_app_id: z.number(),
     metadata: porterAppPreDeployEventMetadataValidator,
   }),
+  z.object({
+    id: z.string(),
+    created_at: z.string(),
+    updated_at: z.string(),
+    status: z.string().optional().default(""),
+    type: z.literal("INITIAL_DEPLOY"),
+    type_external_source: z.string().optional().default(""),
+    porter_app_id: z.number(),
+    metadata: porterAppPreDeployEventMetadataValidator,
+  }),
   z.object({
     id: z.string(),
     created_at: z.string(),
@@ -211,6 +221,9 @@ export type PorterAppEvent = z.infer<typeof porterAppEventValidator>;
 export type PorterAppBuildEvent = PorterAppEvent & { type: "BUILD" };
 export type PorterAppDeployEvent = PorterAppEvent & { type: "DEPLOY" };
 export type PorterAppPreDeployEvent = PorterAppEvent & { type: "PRE_DEPLOY" };
+export type PorterAppInitialDeployEvent = PorterAppEvent & {
+  type: "INITIAL_DEPLOY";
+};
 export type PorterAppAppEvent = PorterAppEvent & { type: "APP_EVENT" };
 export type PorterAppNotificationEvent = PorterAppEvent & {
   type: "NOTIFICATION";

+ 107 - 85
dashboard/src/main/home/app-dashboard/app-view/tabs/activity-feed/events/utils.ts

@@ -1,101 +1,123 @@
-import healthy from "assets/status-healthy.png";
+import { differenceInSeconds, intervalToDuration } from "date-fns";
+import { match } from "ts-pattern";
+
+import api from "shared/api";
+import canceled from "assets/canceled.svg";
 import failure from "assets/failure.svg";
 import loading from "assets/loading.gif";
-import canceled from "assets/canceled.svg"
-import api from "shared/api";
-import { PorterAppBuildEvent, PorterAppDeployEvent, PorterAppPreDeployEvent } from "./types";
-import { PorterAppRecord } from "../../../AppView";
-import { match } from "ts-pattern";
-import { differenceInSeconds, intervalToDuration } from 'date-fns';
+import healthy from "assets/status-healthy.png";
+
+import { type PorterAppRecord } from "../../../AppView";
+import {
+  type PorterAppBuildEvent,
+  type PorterAppDeployEvent,
+  type PorterAppInitialDeployEvent,
+  type PorterAppPreDeployEvent,
+} from "./types";
 
 const ZERO_TIME = "0001-01-01T00:00:00Z";
 
-export const getDuration = (event: PorterAppPreDeployEvent | PorterAppBuildEvent | PorterAppDeployEvent): string => {
-    const startTimeStamp = match(event)
-        .with({ type: "BUILD" }, (ev) => new Date(ev.created_at).getTime())
-        .with({ type: "DEPLOY" }, (ev) => new Date(ev.created_at).getTime())
-        .with({ type: "PRE_DEPLOY" }, (ev) => new Date(ev.metadata.start_time).getTime())
-        .exhaustive();
+export const getDuration = (
+  event:
+    | PorterAppPreDeployEvent
+    | PorterAppBuildEvent
+    | PorterAppDeployEvent
+    | PorterAppInitialDeployEvent
+): string => {
+  const startTimeStamp = match(event)
+    .with({ type: "BUILD" }, (ev) => new Date(ev.created_at).getTime())
+    .with({ type: "DEPLOY" }, (ev) => new Date(ev.created_at).getTime())
+    .with({ type: "PRE_DEPLOY" }, (ev) =>
+      new Date(ev.metadata.start_time).getTime()
+    )
+    .with({ type: "INITIAL_DEPLOY" }, (ev) =>
+      new Date(ev.metadata.start_time).getTime()
+    )
+    .exhaustive();
 
-    const endTimeStamp = event.metadata.end_time && event.metadata.end_time !== ZERO_TIME
-        ? new Date(event.metadata.end_time).getTime() 
-        : Date.now();
+  const endTimeStamp =
+    event.metadata.end_time && event.metadata.end_time !== ZERO_TIME
+      ? new Date(event.metadata.end_time).getTime()
+      : Date.now();
 
-    const timeDifferenceInSeconds = differenceInSeconds(endTimeStamp, startTimeStamp);
-    const duration = intervalToDuration({ start: 0, end: timeDifferenceInSeconds * 1000 });
-    if (duration.weeks) {
-        return `${duration.weeks}w ${duration.days}d ${duration.hours}h`
-    } else if (duration.days) {
-        return `${duration.days}d ${duration.hours}h ${duration.minutes}m`
-    } else if (duration.hours) {
-        return `${duration.hours}h ${duration.minutes}m ${duration.seconds}s`
-    } else if (duration.minutes) {
-        return `${duration.minutes}m ${duration.seconds}s`
-    } else {
-        return `${duration.seconds}s`
-    }
+  const timeDifferenceInSeconds = differenceInSeconds(
+    endTimeStamp,
+    startTimeStamp
+  );
+  const duration = intervalToDuration({
+    start: 0,
+    end: timeDifferenceInSeconds * 1000,
+  });
+  if (duration.weeks) {
+    return `${duration.weeks}w ${duration.days}d ${duration.hours}h`;
+  } else if (duration.days) {
+    return `${duration.days}d ${duration.hours}h ${duration.minutes}m`;
+  } else if (duration.hours) {
+    return `${duration.hours}h ${duration.minutes}m ${duration.seconds}s`;
+  } else if (duration.minutes) {
+    return `${duration.minutes}m ${duration.seconds}s`;
+  } else {
+    return `${duration.seconds}s`;
+  }
 };
 
-export const getStatusIcon = (status: string) => {
-    switch (status) {
-        case "SUCCESS":
-            return healthy;
-        case "FAILED":
-            return failure;
-        case "PROGRESSING":
-            return loading;
-        case "CANCELED":
-            return canceled;
-        default:
-            return loading;
-    }
+export const getStatusIcon = (status: string): React.ReactNode => {
+  switch (status) {
+    case "SUCCESS":
+      return healthy;
+    case "FAILED":
+      return failure;
+    case "PROGRESSING":
+      return loading;
+    case "CANCELED":
+      return canceled;
+    default:
+      return loading;
+  }
 };
 
-export const getStatusColor = (status: string) => {
-    switch (status) {
-        case "SUCCESS":
-            return "#68BF8B";
-        case "FAILED":
-            return "#FF6060";
-        case "PROGRESSING":
-            return "#6e9df5";
-        case "CANCELED":
-            return "#FFBF00";
-        default:
-            return "#6e9df5";
-    }
+export const getStatusColor = (status: string): string => {
+  switch (status) {
+    case "SUCCESS":
+      return "#68BF8B";
+    case "FAILED":
+      return "#FF6060";
+    case "PROGRESSING":
+      return "#6e9df5";
+    case "CANCELED":
+      return "#FFBF00";
+    default:
+      return "#6e9df5";
+  }
 };
 
 export const triggerWorkflow = async ({
-    projectId,
-    clusterId,
-    porterApp,
+  projectId,
+  clusterId,
+  porterApp,
 }: {
-    projectId: number;
-    clusterId: number;
-    porterApp: PorterAppRecord;
-}) => {
-    if (porterApp.git_repo_id != null && porterApp.repo_name != null) {
-        try {
-            const res = await api.reRunGHWorkflow(
-                "<token>",
-                {},
-                {
-                    project_id: projectId,
-                    cluster_id: clusterId,
-                    git_installation_id: porterApp.git_repo_id ?? 0,
-                    owner: porterApp.repo_name.split("/")[0],
-                    name: porterApp.repo_name.split("/")[1],
-                    branch: porterApp.git_branch,
-                    filename: "porter_stack_" + porterApp.name + ".yml",
-                }
-            );
-            if (res.data != null) {
-                window.open(res.data, "_blank", "noreferrer");
-            }
-
-        } catch (error) {
-            console.log(error);
+  projectId: number;
+  clusterId: number;
+  porterApp: PorterAppRecord;
+}): Promise<void> => {
+  if (porterApp.git_repo_id != null && porterApp.repo_name != null) {
+    try {
+      const res = await api.reRunGHWorkflow(
+        "<token>",
+        {},
+        {
+          project_id: projectId,
+          cluster_id: clusterId,
+          git_installation_id: porterApp.git_repo_id ?? 0,
+          owner: porterApp.repo_name.split("/")[0],
+          name: porterApp.repo_name.split("/")[1],
+          branch: porterApp.git_branch,
+          filename: "porter_stack_" + porterApp.name + ".yml",
         }
-    }
-};
+      );
+      if (res.data != null) {
+        window.open(res.data, "_blank", "noreferrer");
+      }
+    } catch (error) {}
+  }
+};

+ 23 - 5
internal/porter_app/v2/yaml.go

@@ -105,11 +105,12 @@ type PorterApp struct {
 	Build    *Build    `yaml:"build,omitempty"`
 	Env      Env       `yaml:"env,omitempty"`
 
-	Predeploy    *Service      `yaml:"predeploy,omitempty"`
-	EnvGroups    []string      `yaml:"envGroups,omitempty"`
-	EfsStorage   *EfsStorage   `yaml:"efsStorage,omitempty"`
-	RequiredApps []RequiredApp `yaml:"requiredApps,omitempty"`
-	AutoRollback *AutoRollback `yaml:"autoRollback,omitempty"`
+	Predeploy     *Service      `yaml:"predeploy,omitempty"`
+	InitialDeploy *Service      `yaml:"initialDeploy,omitempty"`
+	EnvGroups     []string      `yaml:"envGroups,omitempty"`
+	EfsStorage    *EfsStorage   `yaml:"efsStorage,omitempty"`
+	RequiredApps  []RequiredApp `yaml:"requiredApps,omitempty"`
+	AutoRollback  *AutoRollback `yaml:"autoRollback,omitempty"`
 }
 
 // PorterAppWithAddons is the definition of a porter app in a Porter YAML file with addons
@@ -273,6 +274,14 @@ func ProtoFromApp(ctx context.Context, porterApp PorterApp) (*porterv1.PorterApp
 		appProto.Predeploy = predeployProto
 	}
 
+	if porterApp.InitialDeploy != nil {
+		initialDeployProto, err := serviceProtoFromConfig(*porterApp.InitialDeploy, porterv1.ServiceType_SERVICE_TYPE_JOB)
+		if err != nil {
+			return appProto, nil, telemetry.Error(ctx, span, err, "error casting initial deploy config")
+		}
+		appProto.InitialDeploy = initialDeployProto
+	}
+
 	for _, envGroup := range porterApp.EnvGroups {
 		appProto.EnvGroups = append(appProto.EnvGroups, &porterv1.EnvGroup{
 			Name:    envGroup,
@@ -549,6 +558,15 @@ func AppFromProto(appProto *porterv1.PorterApp) (PorterApp, error) {
 		porterApp.Predeploy = &appPredeploy
 	}
 
+	if appProto.InitialDeploy != nil {
+		appInitialDeploy, err := appServiceFromProto(appProto.InitialDeploy)
+		if err != nil {
+			return porterApp, err
+		}
+
+		porterApp.InitialDeploy = &appInitialDeploy
+	}
+
 	for _, envGroup := range appProto.EnvGroups {
 		if envGroup != nil {
 			porterApp.EnvGroups = append(porterApp.EnvGroups, fmt.Sprintf("%s:v%d", envGroup.Name, envGroup.Version))