Przeglądaj źródła

load in service overrides from porter yaml (#3402)

ianedwards 2 lat temu
rodzic
commit
2a7feaec18

+ 1 - 1
api/client/porter_app.go

@@ -161,7 +161,7 @@ func (c *Client) ParseYAML(
 		B64Yaml: b64Yaml,
 	}
 
-	err := c.getRequest(
+	err := c.postRequest(
 		fmt.Sprintf(
 			"/projects/%d/clusters/%d/apps/parse",
 			projectID, clusterID,

+ 20 - 1
api/server/handlers/gitinstallation/get_porter_yaml.go

@@ -15,6 +15,7 @@ import (
 	"github.com/porter-dev/porter/api/server/shared/commonutils"
 	"github.com/porter-dev/porter/api/server/shared/config"
 	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/telemetry"
 	"gopkg.in/yaml.v2"
 )
@@ -37,6 +38,9 @@ func NewGithubGetPorterYamlHandler(
 func (c *GithubGetPorterYamlHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 	ctx, span := telemetry.NewSpan(r.Context(), "serve-get-porter-yaml")
 	defer span.End()
+
+	project, _ := ctx.Value(types.ProjectScope).(*models.Project)
+
 	request := &types.GetPorterYamlRequest{}
 	ok := c.DecodeAndValidate(w, r, request)
 	if !ok {
@@ -97,8 +101,23 @@ func (c *GithubGetPorterYamlHandler) ServeHTTP(w http.ResponseWriter, r *http.Re
 		c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
 		return
 	}
+
+	if project.ValidateApplyV2 {
+		if parsed.Version == nil {
+			err = telemetry.Error(ctx, span, nil, "v2 porter yaml is required")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+
+		if *parsed.Version != "v2" {
+			err = telemetry.Error(ctx, span, nil, "porter YAML version is not supported")
+			c.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusBadRequest))
+			return
+		}
+	}
+
 	// backwards compatibility so that old porter yamls are no longer valid
-	if parsed.Version != nil {
+	if !project.ValidateApplyV2 && parsed.Version != nil {
 		version := *parsed.Version
 		if version != "v1stack" {
 			err = telemetry.Error(ctx, span, nil, "porter YAML version is not supported")

+ 1 - 1
api/server/handlers/porter_app/apply.go

@@ -20,7 +20,7 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 )
 
-// ApplyPorterAppHandler is the handler for the /app/parse endpoint
+// ApplyPorterAppHandler is the handler for the /apps/parse endpoint
 type ApplyPorterAppHandler struct {
 	handlers.PorterHandlerReadWriter
 }

+ 3 - 3
api/server/handlers/porter_app/parse_yaml.go

@@ -18,7 +18,7 @@ import (
 	"github.com/porter-dev/porter/internal/models"
 )
 
-// ParsePorterYAMLToProtoHandler is the handler for the /app/parse endpoint
+// ParsePorterYAMLToProtoHandler is the handler for the /apps/parse endpoint
 type ParsePorterYAMLToProtoHandler struct {
 	handlers.PorterHandlerReadWriter
 }
@@ -34,12 +34,12 @@ func NewParsePorterYAMLToProtoHandler(
 	}
 }
 
-// ParsePorterYAMLToProtoRequest is the request object for the /app/parse endpoint
+// ParsePorterYAMLToProtoRequest is the request object for the /apps/parse endpoint
 type ParsePorterYAMLToProtoRequest struct {
 	B64Yaml string `json:"b64_yaml"`
 }
 
-// ParsePorterYAMLToProtoResponse is the response object for the /app/parse endpoint
+// ParsePorterYAMLToProtoResponse is the response object for the /apps/parse endpoint
 type ParsePorterYAMLToProtoResponse struct {
 	B64AppProto string `json:"b64_app_proto"`
 }

+ 2 - 2
api/server/router/porter_app.go

@@ -542,11 +542,11 @@ func getPorterAppRoutes(
 		Router:   r,
 	})
 
-	// GET /api/projects/{project_id}/clusters/{cluster_id}/app/parse -> porter_app.NewParsePorterYAMLToProtoHandler
+	// GET /api/projects/{project_id}/clusters/{cluster_id}/apps/parse -> porter_app.NewParsePorterYAMLToProtoHandler
 	parsePorterYAMLToProtoEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbGet,
-			Method: types.HTTPVerbGet,
+			Method: types.HTTPVerbPost,
 			Path: &types.Path{
 				Parent:       basePath,
 				RelativePath: "/apps/parse",

+ 38 - 54
dashboard/src/lib/porter-apps/index.ts

@@ -1,10 +1,8 @@
-import {
-  BUILDPACK_TO_NAME,
-  Buildpack,
-  buildpackSchema,
-} from "main/home/app-dashboard/types/buildpack";
+import { buildpackSchema } from "main/home/app-dashboard/types/buildpack";
 import { z } from "zod";
 import {
+  ClientService,
+  defaultSerialized,
   deserializeService,
   serializedServiceFromProto,
   serviceValidator,
@@ -64,57 +62,43 @@ export const porterAppFormValidator = z.object({
 });
 export type PorterAppFormData = z.infer<typeof porterAppFormValidator>;
 
-// porterClientAppFromProto converts a PorterApp proto object to a ClientPorterApp
-export function porterClientAppFromProto(
-  proto: PorterApp,
-  buildpacks?: Buildpack[]
-): ClientPorterApp {
-  const services = Object.entries(proto.services).map(([name, service]) =>
-    deserializeService(serializedServiceFromProto({ name, service }))
-  );
-
-  const { name, env, build, predeploy, image } = proto;
+// defaultServicesWithOverrides is used to generate the default services for an app from porter.yaml
+// this method is only called when a porter.yaml is present and has services defined
+export function defaultServicesWithOverrides({
+  overrides,
+}: {
+  overrides: PorterApp;
+}): {
+  services: ClientService[];
+  predeploy?: ClientService;
+} {
+  const services = Object.entries(overrides.services)
+    .map(([name, service]) => serializedServiceFromProto({ name, service }))
+    .map((svc) =>
+      deserializeService(
+        defaultSerialized({
+          name: svc.name,
+          type: svc.config.type,
+        }),
+        svc
+      )
+    );
 
-  const validBuildpacks =
-    build?.buildpacks.map((bp) => {
-      const buildpack = buildpacks?.find((b) => b.buildpack === bp);
-      return buildpack
-        ? buildpack
-        : {
-            name: BUILDPACK_TO_NAME[bp],
-            buildpack: bp,
-          };
-    }) ?? [];
+  const predeploy = overrides.predeploy
+    ? deserializeService(
+        defaultSerialized({
+          name: "pre-deploy",
+          type: "job",
+        }),
+        serializedServiceFromProto({
+          name: "pre-deploy",
+          service: overrides.predeploy,
+        })
+      )
+    : undefined;
 
-  const app = {
-    name,
+  return {
     services,
-    env,
-    ...(build
-      ? {
-          build: {
-            ...build,
-            buildpacks: validBuildpacks,
-            method:
-              build.method === "pack" ? ("pack" as const) : ("docker" as const),
-          },
-        }
-      : {
-          build: {
-            context: "./",
-            method: "pack" as const,
-            buildpacks: [],
-            builder: "",
-            dockerfile: "",
-          },
-        }),
-    ...(predeploy && {
-      predeploy: deserializeService(
-        serializedServiceFromProto({ name: "predeploy", service: predeploy })
-      ),
-    }),
-    image,
+    predeploy,
   };
-
-  return app;
 }

+ 72 - 61
dashboard/src/lib/porter-apps/services.ts

@@ -10,61 +10,13 @@ import {
   serializeAutoscaling,
   serializeHealth,
   domainsValidator,
+  serviceStringValidator,
+  serviceNumberValidator,
+  serviceBooleanValidator,
+  ServiceField,
 } from "./values";
 import { Service, ServiceType } from "@porter-dev/api-contracts";
 
-// ServiceString is a string value in a service that can be read-only or editable
-export const serviceStringValidator = z.object({
-  readOnly: z.boolean(),
-  value: z.string(),
-});
-export type ServiceString = z.infer<typeof serviceStringValidator>;
-
-// ServiceNumber is a number value in a service that can be read-only or editable
-export const serviceNumberValidator = z.object({
-  readOnly: z.boolean(),
-  value: z.number(),
-});
-export type ServiceNumber = z.infer<typeof serviceNumberValidator>;
-
-// ServiceBoolean is a boolean value in a service that can be read-only or editable
-export const serviceBooleanValidator = z.object({
-  readOnly: z.boolean(),
-  value: z.boolean(),
-});
-export type ServiceBoolean = z.infer<typeof serviceBooleanValidator>;
-
-// ServiceArray is an array of ServiceStrings
-const serviceArrayValidator = z.array(
-  z.object({
-    key: z.string(),
-    value: serviceStringValidator,
-  })
-);
-export type ServiceArray = z.infer<typeof serviceArrayValidator>;
-
-// ServiceField is a helper to create a ServiceString, ServiceNumber, or ServiceBoolean
-export const ServiceField = {
-  string: (defaultValue: string, overrideValue?: string): ServiceString => {
-    return {
-      readOnly: !!overrideValue,
-      value: overrideValue ?? defaultValue,
-    };
-  },
-  number: (defaultValue: number, overrideValue?: number): ServiceNumber => {
-    return {
-      readOnly: !!overrideValue,
-      value: overrideValue ?? defaultValue,
-    };
-  },
-  boolean: (defaultValue: boolean, overrideValue?: boolean): ServiceBoolean => {
-    return {
-      readOnly: !!overrideValue,
-      value: overrideValue ?? defaultValue,
-    };
-  },
-};
-
 // 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({
@@ -79,14 +31,14 @@ export const serviceValidator = z.object({
   config: z.discriminatedUnion("type", [
     z.object({
       type: z.literal("web"),
-      autoscaling: autoscalingValidator,
+      autoscaling: autoscalingValidator.optional(),
       ingressEnabled: z.boolean().default(false).optional(),
       domains: domainsValidator,
-      healthCheck: healthcheckValidator,
+      healthCheck: healthcheckValidator.optional(),
     }),
     z.object({
       type: z.literal("worker"),
-      autoscaling: autoscalingValidator,
+      autoscaling: autoscalingValidator.optional(),
     }),
     z.object({
       type: z.literal("job"),
@@ -113,12 +65,12 @@ export type SerializedService = {
         domains: {
           name: string;
         }[];
-        autoscaling: SerializedAutoscaling;
-        healthCheck: SerializedHealthcheck;
+        autoscaling?: SerializedAutoscaling;
+        healthCheck?: SerializedHealthcheck;
       }
     | {
         type: "worker";
-        autoscaling: SerializedAutoscaling;
+        autoscaling?: SerializedAutoscaling;
       }
     | {
         type: "job";
@@ -127,6 +79,63 @@ export type SerializedService = {
       };
 };
 
+export function defaultSerialized({
+  name,
+  type,
+}: {
+  name: string;
+  type: "web" | "worker" | "job";
+}): SerializedService {
+  const baseService = {
+    name,
+    run: "",
+    instances: 1,
+    port: 3000,
+    cpuCores: 0.1,
+    ramMegabytes: 256,
+  };
+
+  const defaultAutoscaling: SerializedAutoscaling = {
+    enabled: false,
+    minInstances: 1,
+    maxInstances: 10,
+    cpuThresholdPercent: 50,
+    memoryThresholdPercent: 50,
+  };
+
+  const defaultHealthCheck: SerializedHealthcheck = {
+    enabled: false,
+    httpPath: "/healthz",
+  };
+
+  return match(type)
+    .with("web", () => ({
+      ...baseService,
+      config: {
+        type: "web" as const,
+        autoscaling: defaultAutoscaling,
+        healthCheck: defaultHealthCheck,
+        domains: [],
+      },
+    }))
+    .with("worker", () => ({
+      ...baseService,
+      config: {
+        type: "worker" as const,
+        autoscaling: defaultAutoscaling,
+      },
+    }))
+    .with("job", () => ({
+      ...baseService,
+      config: {
+        type: "job" as const,
+        allowConcurrent: false,
+        cron: "",
+      },
+    }))
+    .exhaustive();
+}
+
 // serializeService converts a ClientService to a SerializedService
 // A SerializedService holds just the values of a ClientService
 // These values can be used to create a protobuf Service
@@ -193,6 +202,8 @@ export function deserializeService(
   override?: SerializedService
 ): ClientService {
   const baseService = {
+    expanded: true,
+    canDelete: !override,
     name: ServiceField.string(service.name, override?.name),
     run: ServiceField.string(service.run, override?.run),
     instances: ServiceField.number(service.instances, override?.instances),
@@ -346,8 +357,8 @@ export function serializedServiceFromProto({
       name,
       config: {
         type: "web" as const,
-        autoscaling: value.autoscaling ? value.autoscaling : { enabled: false },
-        healthCheck: value.healthCheck ? value.healthCheck : { enabled: false },
+        autoscaling: value.autoscaling ? value.autoscaling : undefined,
+        healthCheck: value.healthCheck ? value.healthCheck : undefined,
         ...value,
       },
     }))
@@ -356,7 +367,7 @@ export function serializedServiceFromProto({
       name,
       config: {
         type: "worker" as const,
-        autoscaling: value.autoscaling ? value.autoscaling : { enabled: false },
+        autoscaling: value.autoscaling ? value.autoscaling : undefined,
         ...value,
       },
     }))

+ 127 - 53
dashboard/src/lib/porter-apps/values.ts

@@ -1,10 +1,76 @@
 import { z } from "zod";
-import {
-  ServiceField,
-  serviceBooleanValidator,
-  serviceNumberValidator,
-  serviceStringValidator,
-} from "./services";
+
+// ServiceString is a string value in a service that can be read-only or editable
+export const serviceStringValidator = z.object({
+  readOnly: z.boolean(),
+  value: z.string(),
+});
+export type ServiceString = z.infer<typeof serviceStringValidator>;
+
+// ServiceNumber is a number value in a service that can be read-only or editable
+export const serviceNumberValidator = z.object({
+  readOnly: z.boolean(),
+  value: z.number(),
+});
+export type ServiceNumber = z.infer<typeof serviceNumberValidator>;
+
+// ServiceBoolean is a boolean value in a service that can be read-only or editable
+export const serviceBooleanValidator = z.object({
+  readOnly: z.boolean(),
+  value: z.boolean(),
+});
+export type ServiceBoolean = z.infer<typeof serviceBooleanValidator>;
+
+// ServiceArray is an array of ServiceStrings
+const serviceArrayValidator = z.array(
+  z.object({
+    key: z.string(),
+    value: serviceStringValidator,
+  })
+);
+export type ServiceArray = z.infer<typeof serviceArrayValidator>;
+
+const getNumericValue = (
+  defaultValue: number,
+  overrideValue?: number,
+  validAsZero = false
+) => {
+  if (!overrideValue) {
+    return defaultValue;
+  }
+
+  if (!validAsZero && overrideValue === 0) {
+    return defaultValue;
+  }
+
+  return overrideValue;
+};
+
+// ServiceField is a helper to create a ServiceString, ServiceNumber, or ServiceBoolean
+export const ServiceField = {
+  string: (defaultValue: string, overrideValue?: string): ServiceString => {
+    return {
+      readOnly: !!overrideValue,
+      value: overrideValue ?? defaultValue,
+    };
+  },
+  number: (
+    defaultValue: number,
+    overrideValue?: number,
+    validAsZero = false
+  ): ServiceNumber => {
+    return {
+      readOnly: !!overrideValue || (validAsZero && overrideValue === 0),
+      value: getNumericValue(defaultValue, overrideValue, validAsZero),
+    };
+  },
+  boolean: (defaultValue: boolean, overrideValue?: boolean): ServiceBoolean => {
+    return {
+      readOnly: overrideValue != null,
+      value: overrideValue ?? defaultValue,
+    };
+  },
+};
 
 // Autoscaling
 export const autoscalingValidator = z.object({
@@ -26,45 +92,49 @@ export type SerializedAutoscaling = {
 export function serializeAutoscaling({
   autoscaling,
 }: {
-  autoscaling: ClientAutoscaling;
-}): SerializedAutoscaling {
-  return {
-    enabled: autoscaling.enabled.value,
-    minInstances: autoscaling.minInstances?.value,
-    maxInstances: autoscaling.maxInstances?.value,
-    cpuThresholdPercent: autoscaling.cpuThresholdPercent?.value,
-    memoryThresholdPercent: autoscaling.memoryThresholdPercent?.value,
-  };
+  autoscaling?: ClientAutoscaling;
+}): SerializedAutoscaling | undefined {
+  return (
+    autoscaling && {
+      enabled: autoscaling.enabled.value,
+      minInstances: autoscaling.minInstances?.value,
+      maxInstances: autoscaling.maxInstances?.value,
+      cpuThresholdPercent: autoscaling.cpuThresholdPercent?.value,
+      memoryThresholdPercent: autoscaling.memoryThresholdPercent?.value,
+    }
+  );
 }
 
 export function deserializeAutoscaling({
   autoscaling,
   override,
 }: {
-  autoscaling: SerializedAutoscaling;
+  autoscaling?: SerializedAutoscaling;
   override?: SerializedAutoscaling;
-}): ClientAutoscaling {
-  return {
-    enabled: ServiceField.boolean(autoscaling.enabled, override?.enabled),
-    minInstances: autoscaling.minInstances
-      ? ServiceField.number(autoscaling.minInstances, override?.minInstances)
-      : undefined,
-    maxInstances: autoscaling.maxInstances
-      ? ServiceField.number(autoscaling.maxInstances, override?.maxInstances)
-      : undefined,
-    cpuThresholdPercent: autoscaling.cpuThresholdPercent
-      ? ServiceField.number(
-          autoscaling.cpuThresholdPercent,
-          override?.cpuThresholdPercent
-        )
-      : undefined,
-    memoryThresholdPercent: autoscaling.memoryThresholdPercent
-      ? ServiceField.number(
-          autoscaling.memoryThresholdPercent,
-          override?.memoryThresholdPercent
-        )
-      : undefined,
-  };
+}): ClientAutoscaling | undefined {
+  return (
+    autoscaling && {
+      enabled: ServiceField.boolean(autoscaling.enabled, override?.enabled),
+      minInstances: autoscaling.minInstances
+        ? ServiceField.number(autoscaling.minInstances, override?.minInstances)
+        : undefined,
+      maxInstances: autoscaling.maxInstances
+        ? ServiceField.number(autoscaling.maxInstances, override?.maxInstances)
+        : undefined,
+      cpuThresholdPercent: autoscaling.cpuThresholdPercent
+        ? ServiceField.number(
+            autoscaling.cpuThresholdPercent,
+            override?.cpuThresholdPercent
+          )
+        : undefined,
+      memoryThresholdPercent: autoscaling.memoryThresholdPercent
+        ? ServiceField.number(
+            autoscaling.memoryThresholdPercent,
+            override?.memoryThresholdPercent
+          )
+        : undefined,
+    }
+  );
 }
 
 // Health Check
@@ -81,26 +151,30 @@ export type SerializedHealthcheck = {
 export function serializeHealth({
   health,
 }: {
-  health: ClientHealthCheck;
-}): SerializedHealthcheck {
-  return {
-    enabled: health.enabled.value,
-    httpPath: health.httpPath?.value,
-  };
+  health?: ClientHealthCheck;
+}): SerializedHealthcheck | undefined {
+  return (
+    health && {
+      enabled: health.enabled.value,
+      httpPath: health.httpPath?.value,
+    }
+  );
 }
 export function deserializeHealthCheck({
   health,
   override,
 }: {
-  health: SerializedHealthcheck;
+  health?: SerializedHealthcheck;
   override?: SerializedHealthcheck;
-}) {
-  return {
-    enabled: ServiceField.boolean(health.enabled, override?.enabled),
-    httpPath: health.httpPath
-      ? ServiceField.string(health.httpPath, override?.httpPath)
-      : undefined,
-  };
+}): ClientHealthCheck | undefined {
+  return (
+    health && {
+      enabled: ServiceField.boolean(health.enabled, override?.enabled),
+      httpPath: health.httpPath
+        ? ServiceField.string(health.httpPath, override?.httpPath)
+        : undefined,
+    }
+  );
 }
 
 // Domains
@@ -109,4 +183,4 @@ export const domainsValidator = z.array(
     name: serviceStringValidator,
   })
 );
-export type ClientDomains = z.infer<typeof domainsValidator>;
+export type ClientDomains = z.infer<typeof domainsValidator>;

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

@@ -1,4 +1,4 @@
-import React, { useContext, useEffect, useMemo } from "react";
+import React, { useCallback, useContext, useEffect, useMemo } from "react";
 import { RouteComponentProps, withRouter } from "react-router";
 import web from "assets/web.png";
 import AnimateHeight from "react-animate-height";
@@ -13,7 +13,10 @@ import { ControlledInput } from "components/porter/ControlledInput";
 import Link from "components/porter/Link";
 
 import { Context } from "shared/Context";
-import { PorterAppFormData } from "lib/porter-apps";
+import {
+  PorterAppFormData,
+  defaultServicesWithOverrides,
+} from "lib/porter-apps";
 import DashboardHeader from "main/home/cluster-dashboard/DashboardHeader";
 import SourceSelector from "../new-app-flow/SourceSelector";
 import Button from "components/porter/Button";
@@ -21,12 +24,20 @@ import RepoSettings from "./RepoSettings";
 import ImageSettings from "./ImageSettings";
 import Container from "components/porter/Container";
 import ServiceList from "../validate-apply/services-settings/ServiceList";
+import { useQuery } from "@tanstack/react-query";
+import api from "shared/api";
+import { z } from "zod";
+import { PorterApp } from "@porter-dev/api-contracts";
 
 type CreateAppProps = {} & RouteComponentProps;
 
 const CreateApp: React.FC<CreateAppProps> = ({}) => {
-  const { currentProject } = useContext(Context);
+  const { currentProject, currentCluster } = useContext(Context);
   const [step, setStep] = React.useState(0);
+  const [detectedServices, setDetectedServices] = React.useState<{
+    detected: boolean;
+    count: number;
+  }>({ detected: false, count: 0 });
 
   const porterAppFormMethods = useForm<PorterAppFormData>({
     reValidateMode: "onSubmit",
@@ -61,6 +72,93 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
   const build = watch("app.build");
   const image = watch("app.image");
 
+  const { data } = useQuery(
+    [
+      "getPorterYamlContents",
+      currentProject?.id,
+      source.git_branch,
+      source.git_repo_name,
+    ],
+    async () => {
+      if (!currentProject) {
+        return;
+      }
+      if (source.type !== "github") {
+        return;
+      }
+      const res = await api.getPorterYamlContents(
+        "<token>",
+        {
+          path: source.porter_yaml_path,
+        },
+        {
+          project_id: currentProject.id,
+          git_repo_id: source.git_repo_id,
+          kind: "github",
+          owner: source.git_repo_name.split("/")[0],
+          name: source.git_repo_name.split("/")[1],
+          branch: source.git_branch,
+        }
+      );
+
+      return z.string().parseAsync(res.data);
+    },
+    {
+      enabled:
+        source.type === "github" &&
+        Boolean(source.git_repo_name) &&
+        Boolean(source.git_branch),
+    }
+  );
+
+  const detectServices = useCallback(
+    async ({
+      b64Yaml,
+      projectId,
+      clusterId,
+    }: {
+      b64Yaml: string;
+      projectId: number;
+      clusterId: number;
+    }) => {
+      try {
+        const res = await api.parsePorterYaml(
+          "<token>",
+          { b64_yaml: b64Yaml },
+          {
+            project_id: projectId,
+            cluster_id: clusterId,
+          }
+        );
+
+        const data = await z
+          .object({
+            b64_app_proto: z.string(),
+          })
+          .parseAsync(res.data);
+        const proto = PorterApp.fromJsonString(atob(data.b64_app_proto));
+        const { services, predeploy } = defaultServicesWithOverrides({
+          overrides: proto,
+        });
+
+        if (services.length) {
+          setValue("app.services", services);
+          setDetectedServices({
+            detected: true,
+            count: services.length,
+          });
+        }
+
+        if (predeploy) {
+          setValue("app.predeploy", predeploy);
+        }
+      } catch (err) {
+        // silent failure for now
+      }
+    },
+    []
+  );
+
   useEffect(() => {
     // set step to 1 if name is filled out
     if (name) {
@@ -91,8 +189,26 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
   // reset services when source changes
   useEffect(() => {
     setValue("app.services", []);
+    setDetectedServices({
+      detected: false,
+      count: 0,
+    });
   }, [source?.type, source?.git_repo_name, source?.git_branch, image?.tag]);
 
+  useEffect(() => {
+    if (!currentProject || !currentCluster) {
+      return;
+    }
+
+    if (data) {
+      detectServices({
+        b64Yaml: data,
+        projectId: currentProject.id,
+        clusterId: currentCluster.id,
+      });
+    }
+  }, [data]);
+
   if (!currentProject) {
     return null;
   }
@@ -171,6 +287,30 @@ const CreateApp: React.FC<CreateAppProps> = ({}) => {
                 <>
                   <Container row>
                     <Text size={16}>Application services</Text>
+                    {detectedServices.detected && (
+                      <AppearingDiv
+                        color={
+                          detectedServices.detected ? "#8590ff" : "#fcba03"
+                        }
+                      >
+                        {detectedServices.count > 0 ? (
+                          <I className="material-icons">check</I>
+                        ) : (
+                          <I className="material-icons">error</I>
+                        )}
+                        <Text
+                          color={
+                            detectedServices.detected ? "#8590ff" : "#fcba03"
+                          }
+                        >
+                          {detectedServices.count > 0
+                            ? `Detected ${detectedServices.count} service${
+                                detectedServices.count > 1 ? "s" : ""
+                              } from porter.yaml.`
+                            : `Could not detect any services from porter.yaml. Make sure it exists in the root of your repo.`}
+                        </Text>
+                      </AppearingDiv>
+                    )}
                   </Container>
                   <Spacer y={0.5} />
                   <ServiceList
@@ -244,3 +384,27 @@ const Icon = styled.img`
     }
   }
 `;
+
+const AppearingDiv = styled.div<{ color?: string }>`
+  animation: floatIn 0.5s;
+  animation-fill-mode: forwards;
+  display: flex;
+  align-items: center;
+  color: ${(props) => props.color || "#ffffff44"};
+  margin-left: 10px;
+  @keyframes floatIn {
+    from {
+      opacity: 0;
+      transform: translateY(20px);
+    }
+    to {
+      opacity: 1;
+      transform: translateY(0px);
+    }
+  }
+`;
+
+const I = styled.i`
+  font-size: 18px;
+  margin-right: 5px;
+`;

+ 7 - 71
dashboard/src/main/home/app-dashboard/validate-apply/services-settings/ServiceList.tsx

@@ -12,8 +12,12 @@ import web from "assets/web.png";
 import worker from "assets/worker.png";
 import job from "assets/job.png";
 import { z } from "zod";
-import { ClientPorterApp, PorterAppFormData } from "lib/porter-apps";
-import { ClientService } from "lib/porter-apps/services";
+import { PorterAppFormData } from "lib/porter-apps";
+import {
+  ClientService,
+  defaultSerialized,
+  deserializeService,
+} from "lib/porter-apps/services";
 import {
   Controller,
   useFieldArray,
@@ -106,75 +110,7 @@ const ServiceList: React.FC<ServiceListProps> = ({
   };
 
   const onSubmit = handleSubmit(async (data) => {
-    const config: ClientService["config"] = match(data.type)
-      .with("web", () => ({
-        type: "web" as const,
-        domains: [],
-        autoscaling: {
-          enabled: {
-            readOnly: false,
-            value: false,
-          },
-        },
-        healthCheck: {
-          enabled: {
-            readOnly: false,
-            value: false,
-          },
-        },
-      }))
-      .with("worker", () => ({
-        type: "worker" as const,
-        autoscaling: {
-          enabled: {
-            readOnly: false,
-            value: false,
-          },
-        },
-      }))
-      .with("job", () => ({
-        type: "job" as const,
-        allowConcurrent: {
-          readOnly: false,
-          value: true,
-        },
-        cron: {
-          readOnly: false,
-          value: "",
-        },
-      }))
-      .exhaustive();
-
-    append({
-      expanded: true,
-      canDelete: true,
-      name: {
-        readOnly: false,
-        value: data.name,
-      },
-      run: {
-        readOnly: false,
-        value: "",
-      },
-      instances: {
-        readOnly: false,
-        value: 1,
-      },
-      cpuCores: {
-        readOnly: false,
-        value: 0.1,
-      },
-      ramMegabytes: {
-        readOnly: false,
-        value: 256,
-      },
-      port: {
-        readOnly: false,
-        value: 3000,
-      },
-      config,
-    });
-
+    append(deserializeService(defaultSerialized(data)));
     reset();
     setShowAddServiceModal(false);
   });

+ 48 - 20
dashboard/src/shared/api.tsx

@@ -2,7 +2,11 @@ import { PolicyDocType } from "./auth/types";
 import { PullRequest } from "main/home/cluster-dashboard/preview-environments/types";
 import { baseApi } from "./baseApi";
 
-import { BuildConfig, FullActionConfigType, CreateUpdatePorterAppOptions } from "./types";
+import {
+  BuildConfig,
+  FullActionConfigType,
+  CreateUpdatePorterAppOptions,
+} from "./types";
 import {
   CreateStackBody,
   SourceConfig,
@@ -268,7 +272,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<
@@ -693,9 +699,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<
@@ -726,9 +734,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<
@@ -744,9 +754,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<
@@ -762,9 +774,23 @@ 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<
+  {
+    b64_yaml: string;
+  },
+  {
+    project_id: number;
+    cluster_id: number;
+  }
+>("POST", (pathParams) => {
+  return `/api/projects/${pathParams.project_id}/clusters/${pathParams.cluster_id}/apps/parse`;
 });
 
 const getGitlabProcfileContents = baseApi<
@@ -1629,9 +1655,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<
@@ -1797,7 +1825,6 @@ const deleteNewEnvGroup = baseApi<
   return `/api/projects/${pathParams.id}/clusters/${pathParams.cluster_id}/environment-groups`;
 });
 
-
 const deleteConfigMap = baseApi<
   {
     name: string;
@@ -2689,7 +2716,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<
   {
@@ -2832,6 +2859,7 @@ export default {
   getPodEvents,
   getProcfileContents,
   getPorterYamlContents,
+  parsePorterYaml,
   getGitlabProcfileContents,
   getProjectClusters,
   getProjectRegistries,
@@ -2952,4 +2980,4 @@ export default {
 
   // STATUS
   getGithubStatus,
-};
+};

+ 1 - 7
internal/porter_app/parse_test.go

@@ -60,13 +60,7 @@ var result_nobuild = &porterv1.PorterApp{
 			RamMegabytes: 256,
 			Config: &porterv1.Service_WorkerConfig{
 				WorkerConfig: &porterv1.WorkerServiceConfig{
-					Autoscaling: &porterv1.Autoscaling{
-						Enabled:                false,
-						MinInstances:           0,
-						MaxInstances:           0,
-						CpuThresholdPercent:    0,
-						MemoryThresholdPercent: 0,
-					},
+					Autoscaling: nil,
 				},
 			},
 			Type: 2,

+ 40 - 30
internal/porter_app/v2/yaml.go

@@ -102,17 +102,17 @@ type Build struct {
 
 // Service represents a single service in a porter app
 type Service struct {
-	Run             string      `yaml:"run"`
-	Type            string      `yaml:"type" validate:"required, oneof=web worker job"`
-	Instances       int         `yaml:"instances"`
-	CpuCores        float32     `yaml:"cpuCores"`
-	RamMegabytes    int         `yaml:"ramMegabytes"`
-	Port            int         `yaml:"port"`
-	Autoscaling     AutoScaling `yaml:"autoscaling" validate:"excluded_if=Type job"`
-	Domains         []Domains   `yaml:"domains" validate:"excluded_unless=Type web"`
-	HealthCheck     HealthCheck `yaml:"healthCheck" validate:"excluded_unless=Type web"`
-	AllowConcurrent bool        `yaml:"allowConcurrent" validate:"excluded_unless=Type job"`
-	Cron            string      `yaml:"cron" validate:"excluded_unless=Type job"`
+	Run             string       `yaml:"run"`
+	Type            string       `yaml:"type" validate:"required, oneof=web worker job"`
+	Instances       int          `yaml:"instances"`
+	CpuCores        float32      `yaml:"cpuCores"`
+	RamMegabytes    int          `yaml:"ramMegabytes"`
+	Port            int          `yaml:"port"`
+	Autoscaling     *AutoScaling `yaml:"autoscaling,omitempty" validate:"excluded_if=Type job"`
+	Domains         []Domains    `yaml:"domains" validate:"excluded_unless=Type web"`
+	HealthCheck     *HealthCheck `yaml:"healthCheck,omitempty" validate:"excluded_unless=Type web"`
+	AllowConcurrent bool         `yaml:"allowConcurrent" validate:"excluded_unless=Type job"`
+	Cron            string       `yaml:"cron" validate:"excluded_unless=Type job"`
 }
 
 // AutoScaling represents the autoscaling settings for web services
@@ -189,22 +189,28 @@ func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (
 	case porterv1.ServiceType_SERVICE_TYPE_UNSPECIFIED:
 		return nil, errors.New("Service type unspecified")
 	case porterv1.ServiceType_SERVICE_TYPE_WEB:
-		webConfig := &porterv1.WebServiceConfig{
-			HealthCheck: &porterv1.HealthCheck{
-				Enabled:  service.HealthCheck.Enabled,
-				HttpPath: service.HealthCheck.HttpPath,
-			},
+		webConfig := &porterv1.WebServiceConfig{}
+
+		var autoscaling *porterv1.Autoscaling
+		if service.Autoscaling != nil {
+			autoscaling = &porterv1.Autoscaling{
+				Enabled:                service.Autoscaling.Enabled,
+				MinInstances:           int32(service.Autoscaling.MinInstances),
+				MaxInstances:           int32(service.Autoscaling.MaxInstances),
+				CpuThresholdPercent:    int32(service.Autoscaling.CpuThresholdPercent),
+				MemoryThresholdPercent: int32(service.Autoscaling.MemoryThresholdPercent),
+			}
 		}
+		webConfig.Autoscaling = autoscaling
 
-		autoscaling := &porterv1.Autoscaling{
-			Enabled:                service.Autoscaling.Enabled,
-			MinInstances:           int32(service.Autoscaling.MinInstances),
-			MaxInstances:           int32(service.Autoscaling.MaxInstances),
-			CpuThresholdPercent:    int32(service.Autoscaling.CpuThresholdPercent),
-			MemoryThresholdPercent: int32(service.Autoscaling.MemoryThresholdPercent),
+		var healthCheck *porterv1.HealthCheck
+		if service.HealthCheck != nil {
+			healthCheck = &porterv1.HealthCheck{
+				Enabled:  service.HealthCheck.Enabled,
+				HttpPath: service.HealthCheck.HttpPath,
+			}
 		}
-
-		webConfig.Autoscaling = autoscaling
+		webConfig.HealthCheck = healthCheck
 
 		domains := make([]*porterv1.Domain, 0)
 		for _, domain := range service.Domains {
@@ -219,12 +225,16 @@ func serviceProtoFromConfig(service Service, serviceType porterv1.ServiceType) (
 		}
 	case porterv1.ServiceType_SERVICE_TYPE_WORKER:
 		workerConfig := &porterv1.WorkerServiceConfig{}
-		autoscaling := &porterv1.Autoscaling{
-			Enabled:                service.Autoscaling.Enabled,
-			MinInstances:           int32(service.Autoscaling.MinInstances),
-			MaxInstances:           int32(service.Autoscaling.MaxInstances),
-			CpuThresholdPercent:    int32(service.Autoscaling.CpuThresholdPercent),
-			MemoryThresholdPercent: int32(service.Autoscaling.MemoryThresholdPercent),
+
+		var autoscaling *porterv1.Autoscaling
+		if service.Autoscaling != nil {
+			autoscaling = &porterv1.Autoscaling{
+				Enabled:                service.Autoscaling.Enabled,
+				MinInstances:           int32(service.Autoscaling.MinInstances),
+				MaxInstances:           int32(service.Autoscaling.MaxInstances),
+				CpuThresholdPercent:    int32(service.Autoscaling.CpuThresholdPercent),
+				MemoryThresholdPercent: int32(service.Autoscaling.MemoryThresholdPercent),
+			}
 		}
 		workerConfig.Autoscaling = autoscaling