Browse Source

add manual job run button (#3933)

Co-authored-by: David Townley <davidtownley@Davids-MacBook-Air.local>
d-g-town 2 years ago
parent
commit
c7a964b3cd

+ 117 - 0
api/server/handlers/porter_app/app_run.go

@@ -0,0 +1,117 @@
+package porter_app
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/shared/requestutils"
+
+	"connectrpc.com/connect"
+
+	porterv1 "github.com/porter-dev/api-contracts/generated/go/porter/v1"
+
+	"github.com/porter-dev/porter/internal/telemetry"
+
+	"github.com/porter-dev/porter/api/server/handlers"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/server/shared/config"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+// AppRunHandler handles requests to the /apps/{porter_app_name}/run endpoint
+type AppRunHandler struct {
+	handlers.PorterHandlerReadWriter
+	authz.KubernetesAgentGetter
+}
+
+// NewAppRunHandler returns a new AppRunHandler
+func NewAppRunHandler(
+	config *config.Config,
+	decoderValidator shared.RequestDecoderValidator,
+	writer shared.ResultWriter,
+) *AppRunHandler {
+	return &AppRunHandler{
+		PorterHandlerReadWriter: handlers.NewDefaultPorterHandler(config, decoderValidator, writer),
+		KubernetesAgentGetter:   authz.NewOutOfClusterAgentGetter(config),
+	}
+}
+
+// AppRunRequest is the request object for the /apps/{porter_app_name}/run endpoint
+type AppRunRequest struct {
+	ServiceName        string `json:"service_name"`
+	DeploymentTargetID string `json:"deployment_target_id"`
+}
+
+// AppRunResponse is the response object for the /apps/{porter_app_name}/run endpoint
+type AppRunResponse struct {
+	JobRunID string `json:"job_run_id"`
+}
+
+// ServeHTTP runs a one-off command in the same environment as the provided service, app and deployment target
+func (c *AppRunHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	ctx, span := telemetry.NewSpan(r.Context(), "serve-app-run")
+	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
+	appName, reqErr := requestutils.GetURLParamString(r, types.URLParamPorterAppName)
+	if reqErr != nil {
+		e := telemetry.Error(ctx, span, reqErr, "error parsing app name from url")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(e, http.StatusBadRequest))
+		return
+	}
+
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "app-name", Value: appName})
+
+	request := &AppRunRequest{}
+	if ok := c.DecodeAndValidate(w, r, request); !ok {
+		err := telemetry.Error(ctx, span, nil, "error decoding request")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+
+	if request.ServiceName == "" {
+		err := telemetry.Error(ctx, span, nil, "service name is required")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "service-name", Value: request.ServiceName})
+
+	if request.DeploymentTargetID == "" {
+		err := telemetry.Error(ctx, span, nil, "deployment target id is required")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+		return
+	}
+	telemetry.WithAttributes(span, telemetry.AttributeKV{Key: "deployment-target-id", Value: request.DeploymentTargetID})
+
+	manualServiceRunReq := connect.NewRequest(&porterv1.ManualServiceRunRequest{
+		ProjectId:   int64(project.ID),
+		AppName:     appName,
+		ServiceName: request.ServiceName,
+		Command:     nil, // use default command for job
+		DeploymentTargetIdentifier: &porterv1.DeploymentTargetIdentifier{
+			Id: request.DeploymentTargetID,
+		},
+	})
+
+	serviceResp, err := c.Config().ClusterControlPlaneClient.ManualServiceRun(ctx, manualServiceRunReq)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting app helm values from cluster control plane client")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	if serviceResp == nil || serviceResp.Msg == nil {
+		err := telemetry.Error(ctx, span, err, "app helm values resp is nil")
+		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
+	response := AppRunResponse{
+		JobRunID: serviceResp.Msg.JobRunId,
+	}
+
+	c.WriteResult(w, r, response)
+}

+ 29 - 0
api/server/router/porter_app.go

@@ -1531,5 +1531,34 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
+	// POST /api/projects/{project_id}/clusters/{cluster_id}/apps/{app_name}/run -> porter_app.NewAppRunHandler
+	appRunEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbUpdate,
+			Method: types.HTTPVerbPost,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: fmt.Sprintf("%s/{%s}/run", relPathV2, types.URLParamPorterAppName),
+			},
+			Scopes: []types.PermissionScope{
+				types.UserScope,
+				types.ProjectScope,
+				types.ClusterScope,
+			},
+		},
+	)
+
+	appRunHandler := porter_app.NewAppRunHandler(
+		config,
+		factory.GetDecoderValidator(),
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &router.Route{
+		Endpoint: appRunEndpoint,
+		Handler:  appRunHandler,
+		Router:   r,
+	})
+
 	return routes, newPath
 }

+ 3 - 0
dashboard/src/assets/target.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M21.6 11.9999C21.6 17.3018 17.302 21.5999 12 21.5999M21.6 11.9999C21.6 6.69797 17.302 2.3999 12 2.3999M21.6 11.9999H18M12 21.5999C6.69809 21.5999 2.40002 17.3018 2.40002 11.9999M12 21.5999V17.9999M2.40002 11.9999C2.40002 6.69797 6.69809 2.3999 12 2.3999M2.40002 11.9999H6.00002M12 2.3999V5.9999" stroke="white" stroke-width="2" stroke-linecap="round"/>
+</svg>

+ 6 - 5
dashboard/src/main/home/app-dashboard/expanded-app/logs/types.ts

@@ -1,19 +1,19 @@
 import { z } from "zod";
-import { AnserJsonEntry } from "anser";
+import { type AnserJsonEntry } from "anser";
 
 export enum Direction {
     forward = "forward",
     backward = "backward",
 }
 
-export interface PorterLog {
+export type PorterLog = {
     line: AnserJsonEntry[];
     lineNumber: number;
     timestamp?: string;
     metadata?: z.infer<typeof agentLogMetadataValidator>;
 }
 
-export interface PaginationInfo {
+export type PaginationInfo = {
     previousCursor: string | null;
     nextCursor: string | null;
 }
@@ -25,6 +25,7 @@ const rawLabelsValidator = z.object({
     porter_run_app_revision_id: z.string().optional(),
     porter_run_service_name: z.string().optional(),
     porter_run_service_type: z.string().optional(),
+    controller_uid: z.string().optional(),
 });
 export type RawLabels = z.infer<typeof rawLabelsValidator>;
 
@@ -44,7 +45,7 @@ export const agentLogValidator = z.object({
 });
 export type AgentLog = z.infer<typeof agentLogValidator>;
 
-export interface GenericFilterOption {
+export type GenericFilterOption = {
     label: string;
     value: string;
 }
@@ -54,7 +55,7 @@ export const GenericFilterOption = {
     }
 }
 export type FilterName = 'revision' | 'output_stream' | 'pod_name' | 'service_name' | 'revision_id';
-export interface GenericFilter {
+export type GenericFilter = {
     name: FilterName;
     displayName: string;
     default: GenericFilterOption | undefined;

+ 3 - 1
dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobRunDetails.tsx

@@ -7,7 +7,7 @@ import loading from "assets/loading.gif";
 import Container from "components/porter/Container";
 import Logs from "main/home/app-dashboard/validate-apply/logs/Logs";
 import { useLatestRevision } from "main/home/app-dashboard/app-view/LatestRevisionContext";
-import { JobRun } from "lib/hooks/useJobs";
+import { type JobRun } from "lib/hooks/useJobs";
 import { match } from "ts-pattern";
 import { getStatusColor } from "../../app-view/tabs/activity-feed/events/utils";
 import { AppearingView } from "../../app-view/tabs/activity-feed/events/focus-views/EventFocusView";
@@ -75,6 +75,8 @@ const JobRunDetails: React.FC<Props> = ({
                     endTime: jobRun.status.completionTime != null ? dayjs(jobRun.status.completionTime).add(30, 'second') : undefined,
                 }}
                 appId={parseInt(jobRun.metadata.labels["porter.run/app-id"])}
+                defaultLatestRevision={false}
+                jobRunID={jobRun.metadata.uid}
             />
         </>
     );

+ 10 - 4
dashboard/src/main/home/app-dashboard/validate-apply/jobs/JobsSection.tsx

@@ -5,15 +5,16 @@ import history from "assets/history.png";
 import Text from "components/porter/Text";
 import Container from "components/porter/Container";
 import Spacer from "components/porter/Spacer";
-import { JobRun, useJobs } from "lib/hooks/useJobs";
+import { type JobRun, useJobs } from "lib/hooks/useJobs";
 import Table from "components/OldTable";
-import { CellProps, Column } from "react-table";
+import { type CellProps, type Column } from "react-table";
 import { relativeDate, timeFrom } from "shared/string_utils";
 import { useLocation } from "react-router";
 import SelectRow from "components/form-components/SelectRow";
 import Link from "components/porter/Link";
 import { ranFor } from "./utils";
 import JobRunDetails from "./JobRunDetails";
+import TriggerJobButton from "./TriggerJobButton";
 
 type Props = {
   appName: string;
@@ -59,7 +60,7 @@ const JobsSection: React.FC<Props> = ({
     return jobRuns.find((jr) => jr.metadata.uid === jobRunId);
   }, [jobRuns, jobRunId]);
 
-  const columns = useMemo<Column<JobRun>[]>(
+  const columns = useMemo<Array<Column<JobRun>>>(
     () => [
       {
         Header: "Started",
@@ -145,6 +146,7 @@ const JobsSection: React.FC<Props> = ({
       )}
       {!selectedJobRun && (
         <StyledExpandedApp>
+          <Container row spaced>
           <Container row>
             <Icon src={history} />
             <Text size={21}>Run history for</Text>
@@ -152,10 +154,14 @@ const JobsSection: React.FC<Props> = ({
               displayFlex={true}
               label=""
               value={selectedJobName}
-              setActiveValue={(x: string) => setSelectedJobName(x)}
+              setActiveValue={(x: string) => { setSelectedJobName(x); }}
               options={jobOptions}
               width="200px"
             />
+          </Container>
+            {selectedJobName !== "all" && (
+              <TriggerJobButton projectId={projectId} clusterId={clusterId} appName={appName} jobName={selectedJobName} deploymentTargetId={deploymentTargetId}/>
+            )}
           </Container>
           <Spacer y={1} />
           <Table

+ 89 - 0
dashboard/src/main/home/app-dashboard/validate-apply/jobs/TriggerJobButton.tsx

@@ -0,0 +1,89 @@
+import React, { useState } from "react";
+import { useHistory } from "react-router";
+
+import Button from "components/porter/Button";
+import Container from "components/porter/Container";
+import Error from "components/porter/Error";
+
+import Spacer from "components/porter/Spacer";
+import { useIntercom } from "lib/hooks/useIntercom";
+import api from "shared/api";
+import {z} from "zod";
+import target from "assets/target.svg";
+import Icon from "components/porter/Icon";
+
+type Props = {
+  projectId: number;
+  clusterId: number;
+  appName: string;
+  jobName: string;
+  deploymentTargetId: string;
+};
+
+const TriggerJobButton: React.FC<Props> = ({
+  projectId,
+  clusterId,
+  appName,
+  jobName,
+  deploymentTargetId,
+}) => {
+  const history = useHistory();
+  const { showIntercomWithMessage } = useIntercom();
+
+  const [errorMessage, setErrorMessage] = useState("");
+  const [status, setStatus] = useState("");
+
+  const triggerJobRun = async (): Promise<void> => {
+    setStatus("loading");
+    setErrorMessage("");
+
+    try {
+      const resp = await api.appRun(
+          "<token>",
+          {
+            deployment_target_id: deploymentTargetId,
+            service_name: jobName,
+          },
+          {
+            project_id: projectId,
+            cluster_id: clusterId,
+            porter_app_name: appName,
+          })
+
+      const parsed = await z.object({job_run_id: z.string()}).parseAsync(resp.data)
+
+      const jobRunID = parsed.job_run_id
+      history.push(
+          `/apps/${appName}/job-history?job_run_id=${jobRunID}&service=${jobName}`
+      );
+    } catch {
+      setStatus("");
+      setErrorMessage("Unable to run job");
+      showIntercomWithMessage({
+        message: "I am running into an issue running my job.",
+      });
+    }
+  };
+
+  return (
+    <Container row>
+      <Button
+        onClick={triggerJobRun}
+        loadingText={"Running..."}
+        status={status}
+        height={"33px"}
+      >
+        <Icon src={target} height={"15px"}/>
+        <Spacer inline x={.5}/>
+        Run once
+      </Button>
+      {errorMessage !== "" && (
+        <>
+          <Spacer x={1} inline /> <Error message={errorMessage} />
+        </>
+      )}
+    </Container>
+  );
+};
+
+export default TriggerJobButton;

+ 3 - 1
dashboard/src/main/home/app-dashboard/validate-apply/jobs/utils.ts

@@ -1,6 +1,8 @@
-import { JobRun } from "lib/hooks/useJobs";
+import { type JobRun } from "lib/hooks/useJobs";
 import { timeFrom } from "shared/string_utils";
 import { differenceInSeconds, intervalToDuration } from 'date-fns';
+import api from "shared/api";
+import {z} from "zod";
 
 export const ranFor = (start: string, end?: string | number) => {
     const duration = timeFrom(start, end);

+ 14 - 9
dashboard/src/main/home/app-dashboard/validate-apply/logs/Logs.tsx

@@ -8,8 +8,8 @@ import styled from "styled-components";
 import spinner from "assets/loading.gif";
 import api from "shared/api";
 import { useLogs } from "./utils";
-import { Direction, GenericFilterOption, GenericFilter, FilterName } from "../../expanded-app/logs/types";
-import dayjs, { Dayjs } from "dayjs";
+import { Direction, GenericFilterOption, GenericFilter, type FilterName } from "../../expanded-app/logs/types";
+import dayjs, { type Dayjs } from "dayjs";
 import Loading from "components/Loading";
 import _ from "lodash";
 import Banner from "components/porter/Banner";
@@ -43,6 +43,8 @@ type Props = {
     };
     filterPredeploy?: boolean;
     appId: number;
+    defaultLatestRevision?: boolean;
+    jobRunID?: string;
 };
 
 const DEFAULT_LOG_TIMEOUT_SECONDS = 60;
@@ -58,6 +60,8 @@ const Logs: React.FC<Props> = ({
     logFilterNames = ["service_name", "revision", "output_stream"], // these are the names of filters that will be displayed in the UI
     filterPredeploy = false,
     appId,
+    defaultLatestRevision = true,
+    jobRunID = ""
 }) => {
     const { search } = useLocation();
     const queryParams = new URLSearchParams(search);
@@ -101,7 +105,7 @@ const Logs: React.FC<Props> = ({
         if (version === "dev") {
             return true;
         }
-        //make sure version is above v3.1.3
+        // make sure version is above v3.1.3
         if (version == null) {
             return false;
         }
@@ -202,6 +206,7 @@ const Logs: React.FC<Props> = ({
         filterPredeploy,
         timeRange,
         appID: appId,
+        jobRunID
     });
 
     const { totalSeconds, isRunning, pause: pauseLogTimeout, restart: restartLogTimeout } = useTimer({
@@ -263,7 +268,7 @@ const Logs: React.FC<Props> = ({
             } as GenericFilter,
         ].filter((f: GenericFilter) => logFilterNames.includes(f.name)))
 
-        if (latestRevisionNumber && !logQueryParamOpts.revision && !logQueryParamOpts.revision_id) { // default to filter by latest revision number if no revision-related query params supplied
+        if (latestRevisionNumber && !logQueryParamOpts.revision && !logQueryParamOpts.revision_id && defaultLatestRevision) { // default to filter by latest revision number if no revision-related query params supplied
             setSelectedFilterValues({
                 ...selectedFilterValues,
                 revision: latestRevisionNumber.toString(),
@@ -319,7 +324,7 @@ const Logs: React.FC<Props> = ({
                             selectedFilterValues={selectedFilterValues}
                         />
                         <Spacer inline x={1} />
-                        <ScrollButton onClick={() => setScrollToBottomEnabled((s) => !s)}>
+                        <ScrollButton onClick={() => { setScrollToBottomEnabled((s) => !s); }}>
                             <Checkbox checked={scrollToBottomEnabled}>
                                 <i className="material-icons">done</i>
                             </Checkbox>
@@ -360,7 +365,7 @@ const Logs: React.FC<Props> = ({
                                 <LoadMoreButton
                                     active={selectedDate != null && logs.length !== 0}
                                     role="button"
-                                    onClick={() => moveCursor(Direction.forward)}
+                                    onClick={async () => { await moveCursor(Direction.forward); }}
                                 >
                                     Load more
                                 </LoadMoreButton>
@@ -369,7 +374,7 @@ const Logs: React.FC<Props> = ({
                         {!isLoading && logs.length === 0 && selectedDate != null && (
                             <Message>
                                 No logs found for this time range.
-                                <Highlight onClick={() => setSelectedDate(undefined)}>
+                                <Highlight onClick={() => { setSelectedDate(undefined); }}>
                                     <i className="material-icons">autorenew</i>
                                     Reset
                                 </Highlight>
@@ -415,7 +420,7 @@ const Logs: React.FC<Props> = ({
 
         const checkForAgentInterval = setInterval(checkForAgent, 3000);
 
-        return () => clearInterval(checkForAgentInterval);
+        return () => { clearInterval(checkForAgentInterval); };
     }, [isPorterAgentInstalling]);
 
     const checkForAgent = async () => {
@@ -473,7 +478,7 @@ const Logs: React.FC<Props> = ({
                 In order to use the Logs tab, you need to install the Porter agent.
             </Text>
             <Spacer y={1} />
-            <Button onClick={() => triggerInstall()}>
+            <Button onClick={() => { triggerInstall(); }}>
                 <I className="material-icons">add</I> Install Porter agent
             </Button>
         </Fieldset>

+ 13 - 7
dashboard/src/main/home/app-dashboard/validate-apply/logs/utils.ts

@@ -1,16 +1,16 @@
-import dayjs, { Dayjs } from "dayjs";
+import dayjs, { type Dayjs } from "dayjs";
 import _ from "lodash";
 import { useEffect, useRef, useState } from "react";
 import api from "shared/api";
 import Anser from "anser";
-import { useWebsockets, NewWebsocketOptions } from "shared/hooks/useWebsockets";
+import { useWebsockets, type NewWebsocketOptions } from "shared/hooks/useWebsockets";
 import {
-  AgentLog,
+  type AgentLog,
   agentLogValidator,
   Direction,
-  PorterLog,
-  PaginationInfo,
-  FilterName,
+  type PorterLog,
+  type PaginationInfo,
+  type FilterName,
   GenericFilter
 } from "../../expanded-app/logs/types";
 
@@ -56,6 +56,7 @@ export const useLogs = ({
   timeRange,
   filterPredeploy,
   appID,
+  jobRunID = ""
 }: {
   projectID: number,
   clusterID: number,
@@ -75,6 +76,7 @@ export const useLogs = ({
   },
   filterPredeploy: boolean,
   appID: number,
+  jobRunID?: string,
 }
 ) => {
   const [isLive, setIsLive] = useState<boolean>(!setDate && (timeRange?.startTime == null && timeRange?.endTime == null));
@@ -242,6 +244,10 @@ export const useLogs = ({
         return true;
       }
 
+      if (jobRunID !== "" && log.metadata.raw_labels?.controller_uid !== jobRunID) {
+        return false;
+      }
+
       if (selectedFilterValues.output_stream !== GenericFilter.getDefaultOption("output_stream").value &&
         log.metadata.output_stream !== selectedFilterValues.output_stream) {
         return false;
@@ -462,7 +468,7 @@ export const useLogs = ({
 
     const flushLogsBufferInterval = setInterval(flushLogsBuffer, 3000);
 
-    return () => clearInterval(flushLogsBufferInterval);
+    return () => { clearInterval(flushLogsBufferInterval); };
   }, []);
 
   useEffect(() => {

+ 8 - 4
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceStatusFooter.tsx

@@ -5,14 +5,16 @@ import Text from "components/porter/Text";
 import Container from "components/porter/Container";
 import Button from "components/porter/Button";
 
-import AnimateHeight, { Height } from "react-animate-height";
+import AnimateHeight, { type Height } from "react-animate-height";
 import _ from "lodash";
 import Link from "components/porter/Link";
-import { PorterAppVersionStatus } from "lib/hooks/useAppStatus";
+import { type PorterAppVersionStatus } from "lib/hooks/useAppStatus";
 import { match } from "ts-pattern";
 import { useLatestRevision } from "../../app-view/LatestRevisionContext";
+import TriggerJobButton from "../jobs/TriggerJobButton";
+import Spacer from "components/porter/Spacer";
 
-interface ServiceStatusFooterProps {
+type ServiceStatusFooterProps = {
     serviceName: string;
     status: PorterAppVersionStatus[];
     isJob: boolean,
@@ -23,7 +25,7 @@ const ServiceStatusFooter: React.FC<ServiceStatusFooterProps> = ({
     isJob
 }) => {
     const [expanded, setExpanded] = useState<boolean>(false);
-    const { latestProto } = useLatestRevision();
+    const { latestProto, projectId, clusterId, deploymentTarget, appName } = useLatestRevision();
     const [height, setHeight] = useState<Height>(0);
 
     if (isJob) {
@@ -49,6 +51,8 @@ const ServiceStatusFooter: React.FC<ServiceStatusFooterProps> = ({
                             History
                         </Button>
                     </Link>
+                    <Spacer inline x={1}/>
+                    <TriggerJobButton projectId={projectId} clusterId={clusterId} appName={appName} jobName={serviceName} deploymentTargetId={deploymentTarget.id} />
                 </Container>
 
             </StyledStatusFooter>

+ 15 - 0
dashboard/src/shared/api.tsx

@@ -1024,6 +1024,20 @@ const updateApp = baseApi<
   return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/update`;
 });
 
+const appRun = baseApi<
+    {
+        deployment_target_id: string;
+        service_name: string;
+    },
+    {
+        project_id: number;
+        cluster_id: number;
+        porter_app_name: string;
+    }
+>("POST", (pathParams) => {
+    return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/${pathParams.porter_app_name}/run`;
+});
+
 const updateBuildSettings = baseApi<
   {
     build_settings: {
@@ -3331,6 +3345,7 @@ export default {
   createApp,
   createAppTemplate,
   updateApp,
+  appRun,
   updateBuildSettings,
   applyApp,
   revertApp,

+ 1 - 1
go.mod

@@ -83,7 +83,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.2.40
+	github.com/porter-dev/api-contracts v0.2.41
 	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

@@ -1520,8 +1520,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.2.40 h1:K6rbdTBnh2lPM0U6GgIE2DUBOj7YyxQNqQE0DpquCFo=
-github.com/porter-dev/api-contracts v0.2.40/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.41 h1:b7S3txPw3CkpgYMSx3GT8i+qrQmOvZjCHQqExHKCwRI=
+github.com/porter-dev/api-contracts v0.2.41/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=