Sfoglia il codice sorgente

types and conversions for porter app def (#3378) (#3380)

ianedwards 2 anni fa
parent
commit
b8433f4221

+ 39 - 7
dashboard/package-lock.json

@@ -12,7 +12,7 @@
         "@loadable/component": "^5.15.2",
         "@material-ui/core": "^4.11.3",
         "@material-ui/lab": "^4.0.0-alpha.61",
-        "@porter-dev/api-contracts": "^0.0.75",
+        "@porter-dev/api-contracts": "^0.0.85",
         "@sentry/react": "^6.13.2",
         "@sentry/tracing": "^6.13.2",
         "@tanstack/react-query": "^4.13.0",
@@ -59,6 +59,7 @@
         "react-diff-viewer": "^3.1.1",
         "react-dom": "^18.0.0",
         "react-error-boundary": "^3.1.3",
+        "react-hook-form": "^7.45.4",
         "react-hot-toast": "^2.4.0",
         "react-infinite-scroll-component": "^6.1.0",
         "react-modal": "^3.11.2",
@@ -71,6 +72,7 @@
         "stacktrace-js": "^2.0.2",
         "styled-components": "^5.2.0",
         "traverse": "^0.6.7",
+        "ts-pattern": "^5.0.5",
         "uuid": "^9.0.0",
         "valtio": "^1.2.4",
         "zod": "^3.20.2"
@@ -2438,9 +2440,9 @@
       }
     },
     "node_modules/@porter-dev/api-contracts": {
-      "version": "0.0.75",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.75.tgz",
-      "integrity": "sha512-IHyLUCRPWyEwH0fljC6iu6134yGGki4qXHHa7j8wYOSEWMOed/r86OIpXY0tdNpYhDKAcPaoxlDwPbbeV6Wj0Q==",
+      "version": "0.0.85",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.85.tgz",
+      "integrity": "sha512-usSfoZQljk/LVIdsGnD022NEpKktmK1hNHH3wfx4VCC+G/yC0GsPcZM/7HOct5y3zh5slIZ6raqvdB3cvZ9LsQ==",
       "dependencies": {
         "@bufbuild/protobuf": "^1.1.0"
       }
@@ -10950,6 +10952,21 @@
       "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.1.tgz",
       "integrity": "sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg=="
     },
+    "node_modules/react-hook-form": {
+      "version": "7.45.4",
+      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.4.tgz",
+      "integrity": "sha512-HGDV1JOOBPZj10LB3+OZgfDBTn+IeEsNOKiq/cxbQAIbKaiJUe/KV8DBUzsx0Gx/7IG/orWqRRm736JwOfUSWQ==",
+      "engines": {
+        "node": ">=12.22.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/react-hook-form"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17 || ^18"
+      }
+    },
     "node_modules/react-hot-toast": {
       "version": "2.4.0",
       "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.0.tgz",
@@ -13024,6 +13041,11 @@
         "node": ">=8"
       }
     },
+    "node_modules/ts-pattern": {
+      "version": "5.0.5",
+      "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.5.tgz",
+      "integrity": "sha512-tL0w8U/pgaacOmkb9fRlYzWEUDCfVjjv9dD4wHTgZ61MjhuMt46VNWTG747NqW6vRzoWIKABVhFSOJ82FvXrfA=="
+    },
     "node_modules/tslib": {
       "version": "1.14.1",
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@@ -16676,9 +16698,9 @@
       "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
     },
     "@porter-dev/api-contracts": {
-      "version": "0.0.75",
-      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.75.tgz",
-      "integrity": "sha512-IHyLUCRPWyEwH0fljC6iu6134yGGki4qXHHa7j8wYOSEWMOed/r86OIpXY0tdNpYhDKAcPaoxlDwPbbeV6Wj0Q==",
+      "version": "0.0.85",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.85.tgz",
+      "integrity": "sha512-usSfoZQljk/LVIdsGnD022NEpKktmK1hNHH3wfx4VCC+G/yC0GsPcZM/7HOct5y3zh5slIZ6raqvdB3cvZ9LsQ==",
       "requires": {
         "@bufbuild/protobuf": "^1.1.0"
       }
@@ -23642,6 +23664,11 @@
       "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.1.tgz",
       "integrity": "sha512-xTYf9zFim2pEif/Fw16dBiXpe0hoy5PxcD8+OwBnTtNLfIm3g6WxhKNurY+6OmdH1u6Ta/W/Vl6vjbYP1MFnDg=="
     },
+    "react-hook-form": {
+      "version": "7.45.4",
+      "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.45.4.tgz",
+      "integrity": "sha512-HGDV1JOOBPZj10LB3+OZgfDBTn+IeEsNOKiq/cxbQAIbKaiJUe/KV8DBUzsx0Gx/7IG/orWqRRm736JwOfUSWQ=="
+    },
     "react-hot-toast": {
       "version": "2.4.0",
       "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.0.tgz",
@@ -25295,6 +25322,11 @@
         }
       }
     },
+    "ts-pattern": {
+      "version": "5.0.5",
+      "resolved": "https://registry.npmjs.org/ts-pattern/-/ts-pattern-5.0.5.tgz",
+      "integrity": "sha512-tL0w8U/pgaacOmkb9fRlYzWEUDCfVjjv9dD4wHTgZ61MjhuMt46VNWTG747NqW6vRzoWIKABVhFSOJ82FvXrfA=="
+    },
     "tslib": {
       "version": "1.14.1",
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",

+ 3 - 1
dashboard/package.json

@@ -7,7 +7,7 @@
     "@loadable/component": "^5.15.2",
     "@material-ui/core": "^4.11.3",
     "@material-ui/lab": "^4.0.0-alpha.61",
-    "@porter-dev/api-contracts": "^0.0.75",
+    "@porter-dev/api-contracts": "^0.0.85",
     "@sentry/react": "^6.13.2",
     "@sentry/tracing": "^6.13.2",
     "@tanstack/react-query": "^4.13.0",
@@ -54,6 +54,7 @@
     "react-diff-viewer": "^3.1.1",
     "react-dom": "^18.0.0",
     "react-error-boundary": "^3.1.3",
+    "react-hook-form": "^7.45.4",
     "react-hot-toast": "^2.4.0",
     "react-infinite-scroll-component": "^6.1.0",
     "react-modal": "^3.11.2",
@@ -66,6 +67,7 @@
     "stacktrace-js": "^2.0.2",
     "styled-components": "^5.2.0",
     "traverse": "^0.6.7",
+    "ts-pattern": "^5.0.5",
     "uuid": "^9.0.0",
     "valtio": "^1.2.4",
     "zod": "^3.20.2"

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

@@ -0,0 +1,117 @@
+import {
+  BUILDPACK_TO_NAME,
+  Buildpack,
+  buildpackSchema,
+} from "main/home/app-dashboard/types/buildpack";
+import { z } from "zod";
+import {
+  deserializeService,
+  serializedServiceFromProto,
+  serviceValidator,
+} from "./services";
+import { PorterApp } from "@porter-dev/api-contracts";
+
+// buildValidator is used to validate inputs for build setting fields
+export const buildValidator = z.object({
+  context: z.string().default("./"),
+  method: z.enum(["pack", "docker", "registry"]),
+  buildpacks: z.array(buildpackSchema),
+  builder: z.string(),
+  dockerfile: z.string(),
+});
+export type BuildOptions = z.infer<typeof buildValidator>;
+
+// sourceValidator is used to validate inputs for source setting fields
+export const sourceValidator = z.discriminatedUnion("type", [
+  z.object({
+    type: z.literal("github"),
+    git_repo_id: z.number(),
+    git_branch: z.string(),
+    git_repo_name: z.string(),
+    porter_yaml_path: z.string(),
+  }),
+  z.object({
+    type: z.literal("docker-registry"),
+    image_repo_uri: z.string(),
+  }),
+]);
+export type SourceOptions = z.infer<typeof sourceValidator>;
+
+// porterAppValidator is the representation of a Porter app on the client, and is used to validate inputs for app setting fields
+export const porterAppValidator = z.object({
+  name: z.string(),
+  services: z.record(z.string(), serviceValidator),
+  env: z.record(z.string(), z.string()),
+  build: buildValidator,
+  predeploy: serviceValidator.optional(),
+  image: z
+    .object({
+      repository: z.string(),
+      tag: z.string(),
+    })
+    .optional(),
+});
+export type ClientPorterApp = z.infer<typeof porterAppValidator>;
+
+// porterAppFormValidator is used to validate inputs when creating + updating an app
+export const porterAppFormValidator = z.object({
+  app: porterAppValidator,
+  source: sourceValidator,
+});
+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.fromEntries(
+    Object.entries(proto.services).map(([name, service]) => [
+      name,
+      deserializeService(serializedServiceFromProto(service)),
+    ])
+  );
+
+  const { name, env, build, predeploy, image } = proto;
+
+  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 app = {
+    name,
+    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(predeploy)),
+    }),
+    image,
+  };
+
+  return app;
+}

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

@@ -0,0 +1,356 @@
+import { match } from "ts-pattern";
+import { z } from "zod";
+import {
+  SerializedAutoscaling,
+  SerializedHealthcheck,
+  autoscalingValidator,
+  healthcheckValidator,
+  deserializeAutoscaling,
+  deserializeHealthCheck,
+  serializeAutoscaling,
+  serializeHealth,
+} 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({
+  run: serviceStringValidator,
+  instances: serviceNumberValidator,
+  port: serviceNumberValidator,
+  cpuCores: serviceNumberValidator,
+  ramMegabytes: serviceNumberValidator,
+  config: z.discriminatedUnion("type", [
+    z.object({
+      type: z.literal("web"),
+      autoscaling: autoscalingValidator.optional(),
+      domains: z.array(
+        z.object({
+          name: serviceStringValidator,
+        })
+      ),
+      healthCheck: healthcheckValidator.optional(),
+    }),
+    z.object({
+      type: z.literal("worker"),
+      autoscaling: autoscalingValidator.optional(),
+    }),
+    z.object({
+      type: z.literal("job"),
+      allowConcurrent: serviceBooleanValidator,
+      cron: serviceStringValidator,
+    }),
+  ]),
+});
+
+export type ClientService = z.infer<typeof serviceValidator>;
+
+// SerializedService is just the values of a Service without any override information
+// This is used as an intermediate step to convert a ClientService to a protobuf Service
+export type SerializedService = {
+  run: string;
+  instances: number;
+  port: number;
+  cpuCores: number;
+  ramMegabytes: number;
+  config:
+    | {
+        type: "web";
+        domains: {
+          name: string;
+        }[];
+        autoscaling?: SerializedAutoscaling;
+        healthCheck?: SerializedHealthcheck;
+      }
+    | {
+        type: "worker";
+        autoscaling?: SerializedAutoscaling;
+      }
+    | {
+        type: "job";
+        allowConcurrent: boolean;
+        cron: string;
+      };
+};
+
+// 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
+export function serializeService(service: ClientService): SerializedService {
+  return match(service.config)
+    .with({ type: "web" }, (config) =>
+      Object.freeze({
+        run: service.run.value,
+        instances: service.instances.value,
+        port: service.port.value,
+        cpuCores: service.cpuCores.value,
+        ramMegabytes: service.ramMegabytes.value,
+        config: {
+          type: "web" as const,
+          autoscaling: serializeAutoscaling({
+            autoscaling: config.autoscaling,
+          }),
+          healthCheck: serializeHealth({ health: config.healthCheck }),
+          domains: config.domains.map((domain) => ({
+            name: domain.name.value,
+          })),
+        },
+      })
+    )
+    .with({ type: "worker" }, (config) =>
+      Object.freeze({
+        run: service.run.value,
+        instances: service.instances.value,
+        port: service.port.value,
+        cpuCores: service.cpuCores.value,
+        ramMegabytes: service.ramMegabytes.value,
+        config: {
+          type: "worker" as const,
+          autoscaling: serializeAutoscaling({
+            autoscaling: config.autoscaling,
+          }),
+        },
+      })
+    )
+    .with({ type: "job" }, (config) =>
+      Object.freeze({
+        run: service.run.value,
+        instances: service.instances.value,
+        port: service.port.value,
+        cpuCores: service.cpuCores.value,
+        ramMegabytes: service.ramMegabytes.value,
+        config: {
+          type: "job" as const,
+          allowConcurrent: config.allowConcurrent.value,
+          cron: config.cron.value,
+        },
+      })
+    )
+    .exhaustive();
+}
+
+// deserializeService converts a SerializedService to a ClientService
+// A deserialized ClientService represents the state of a service in the UI and which fields are editable
+export function deserializeService(
+  service: SerializedService,
+  override?: SerializedService
+): ClientService {
+  const baseService = {
+    run: ServiceField.string(service.run, override?.run),
+    instances: ServiceField.number(service.instances, override?.instances),
+    port: ServiceField.number(service.port, override?.port),
+    cpuCores: ServiceField.number(service.cpuCores, override?.cpuCores),
+    ramMegabytes: ServiceField.number(
+      service.ramMegabytes,
+      override?.ramMegabytes
+    ),
+  };
+
+  return match(service.config)
+    .with({ type: "web" }, (config) => {
+      const overrideWebConfig =
+        override?.config.type == "web" ? override.config : undefined;
+
+      return {
+        ...baseService,
+        config: {
+          type: "web" as const,
+          autoscaling: deserializeAutoscaling({
+            autoscaling: config.autoscaling,
+            override: overrideWebConfig?.autoscaling,
+          }),
+          healthCheck: deserializeHealthCheck({
+            health: config.healthCheck,
+            override: overrideWebConfig?.healthCheck,
+          }),
+          domains: config.domains.map((domain) => ({
+            name: ServiceField.string(
+              domain.name,
+              overrideWebConfig?.domains.find(
+                (overrideDomain) => overrideDomain.name == domain.name
+              )?.name
+            ),
+          })),
+        },
+      };
+    })
+    .with({ type: "worker" }, (config) => {
+      const overrideWorkerConfig =
+        override?.config.type == "worker" ? override.config : undefined;
+
+      return {
+        ...baseService,
+        config: {
+          type: "worker" as const,
+          autoscaling: deserializeAutoscaling({
+            autoscaling: config.autoscaling,
+            override: overrideWorkerConfig?.autoscaling,
+          }),
+        },
+      };
+    })
+    .with({ type: "job" }, (config) => {
+      const overrideJobConfig =
+        override?.config.type == "job" ? override.config : undefined;
+
+      return {
+        ...baseService,
+        config: {
+          type: "job" as const,
+          allowConcurrent: ServiceField.boolean(
+            config.allowConcurrent,
+            overrideJobConfig?.allowConcurrent
+          ),
+          cron: ServiceField.string(config.cron, overrideJobConfig?.cron),
+        },
+      };
+    })
+    .exhaustive();
+}
+
+// getServiceTypeEnumProto converts the type of a ClientService to the protobuf ServiceType enum
+export const serviceTypeEnumProto = (
+  type: "web" | "worker" | "job"
+): ServiceType => {
+  return match(type)
+    .with("web", () => ServiceType.WEB)
+    .with("worker", () => ServiceType.WORKER)
+    .with("job", () => ServiceType.JOB)
+    .exhaustive();
+};
+
+// serviceProto converts a SerializedService to the protobuf Service
+// This is used as an intermediate step to convert a ClientService to a protobuf Service
+export function serviceProto(service: SerializedService): Service {
+  return match(service.config)
+    .with(
+      { type: "web" },
+      (config) =>
+        new Service({
+          ...service,
+          type: serviceTypeEnumProto(config.type),
+          config: {
+            value: {
+              ...config,
+            },
+            case: "webConfig",
+          },
+        })
+    )
+    .with(
+      { type: "worker" },
+      (config) =>
+        new Service({
+          ...service,
+          type: serviceTypeEnumProto(config.type),
+          config: {
+            value: {
+              ...config,
+            },
+            case: "workerConfig",
+          },
+        })
+    )
+    .with(
+      { type: "job" },
+      (config) =>
+        new Service({
+          ...service,
+          type: serviceTypeEnumProto(config.type),
+          config: {
+            value: {
+              ...config,
+            },
+            case: "jobConfig",
+          },
+        })
+    )
+    .exhaustive();
+}
+
+// serializedServiceFromProto converts a protobuf Service to a SerializedService
+// This is used as an intermediate step to convert a protobuf Service to a ClientService
+export function serializedServiceFromProto(
+  service: Service
+): SerializedService {
+  const config = service.config;
+  if (!config.case) {
+    throw new Error("No case found on service config");
+  }
+
+  return match(config)
+    .with({ case: "webConfig" }, ({ value }) => ({
+      ...service,
+      config: {
+        type: "web" as const,
+        ...value,
+      },
+    }))
+    .with({ case: "workerConfig" }, ({ value }) => ({
+      ...service,
+      config: {
+        type: "worker" as const,
+        ...value,
+      },
+    }))
+    .with({ case: "jobConfig" }, ({ value }) => ({
+      ...service,
+      config: {
+        type: "job" as const,
+        ...value,
+      },
+    }))
+    .exhaustive();
+}

+ 112 - 0
dashboard/src/lib/porter-apps/values.ts

@@ -0,0 +1,112 @@
+import { z } from "zod";
+import {
+  ServiceField,
+  serviceBooleanValidator,
+  serviceNumberValidator,
+  serviceStringValidator,
+} from "./services";
+
+// Autoscaling
+export const autoscalingValidator = z.object({
+  enabled: serviceBooleanValidator,
+  minInstances: serviceNumberValidator,
+  maxInstances: serviceNumberValidator,
+  cpuThresholdPercent: serviceNumberValidator,
+  memoryThresholdPercent: serviceNumberValidator,
+});
+export type ClientAutoscaling = z.infer<typeof autoscalingValidator>;
+export type SerializedAutoscaling = {
+  enabled: boolean;
+  minInstances: number;
+  maxInstances: number;
+  cpuThresholdPercent: number;
+  memoryThresholdPercent: number;
+};
+
+export function serializeAutoscaling({
+  autoscaling,
+}: {
+  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;
+  override?: SerializedAutoscaling;
+}): ClientAutoscaling | undefined {
+  if (!autoscaling) {
+    return undefined;
+  }
+
+  return {
+    enabled: ServiceField.boolean(autoscaling.enabled, override?.enabled),
+    minInstances: ServiceField.number(
+      autoscaling.minInstances,
+      override?.minInstances
+    ),
+    maxInstances: ServiceField.number(
+      autoscaling.maxInstances,
+      override?.maxInstances
+    ),
+    cpuThresholdPercent: ServiceField.number(
+      autoscaling.cpuThresholdPercent,
+      override?.cpuThresholdPercent
+    ),
+    memoryThresholdPercent: ServiceField.number(
+      autoscaling.memoryThresholdPercent,
+      override?.memoryThresholdPercent
+    ),
+  };
+}
+
+// Health Check
+export const healthcheckValidator = z.object({
+  enabled: serviceBooleanValidator,
+  httpPath: serviceStringValidator,
+});
+export type ClientHealthCheck = z.infer<typeof healthcheckValidator>;
+export type SerializedHealthcheck = {
+  enabled: boolean;
+  httpPath: string;
+};
+
+export function serializeHealth({
+  health,
+}: {
+  health?: ClientHealthCheck;
+}): SerializedHealthcheck | undefined {
+  return (
+    health && {
+      enabled: health.enabled.value,
+      httpPath: health.httpPath.value,
+    }
+  );
+}
+export function deserializeHealthCheck({
+  health,
+  override,
+}: {
+  health?: SerializedHealthcheck;
+  override?: SerializedHealthcheck;
+}) {
+  if (!health) {
+    return undefined;
+  }
+
+  return {
+    enabled: ServiceField.boolean(health.enabled, override?.enabled),
+    httpPath: ServiceField.string(health.httpPath, override?.httpPath),
+  };
+}

+ 31 - 27
dashboard/src/main/home/app-dashboard/types/buildpack.ts

@@ -1,32 +1,36 @@
-export type BuildConfig = {
-    builder: string;
-    buildpacks: string[];
-    config: null | {
-        [key: string]: string;
-    };
-};
-export type Buildpack = {
-    name: string;
-    buildpack: string;
-    config: {
-        [key: string]: string;
-    };
-};
-export type DetectedBuildpack = {
-    name: string;
-    builders: string[];
-    detected: Buildpack[];
-    others: Buildpack[];
-    buildConfig: BuildConfig;
-};
+import { z } from "zod";
+
+export const buildConfigSchema = z.object({
+  builder: z.string(),
+  buildpacks: z.array(z.string()),
+  config: z.record(z.any()).optional(),
+});
+export type BuildConfig = z.infer<typeof buildConfigSchema>;
+
+export const buildpackSchema = z.object({
+  name: z.string(),
+  buildpack: z.string(),
+  config: z.record(z.any()).nullish(),
+});
+export type Buildpack = z.infer<typeof buildpackSchema>;
+
+export const detectedBuildpackSchema = z.object({
+  name: z.string(),
+  builders: z.array(z.string()),
+  detected: z.array(buildpackSchema),
+  others: z.array(buildpackSchema),
+  buildConfig: buildConfigSchema.optional(),
+});
+export type DetectedBuildpack = z.infer<typeof detectedBuildpackSchema>;
+
 export const DEFAULT_BUILDER_NAME = "heroku";
 export const DEFAULT_PAKETO_STACK = "paketobuildpacks/builder:full";
 export const DEFAULT_HEROKU_STACK = "heroku/buildpacks:20";
 
 export const BUILDPACK_TO_NAME: { [key: string]: string } = {
-    "heroku/nodejs": "NodeJS",
-    "heroku/python": "Python",
-    "heroku/java": "Java",
-    "heroku/ruby": "Ruby",
-    "heroku/go": "Go",
-};
+  "heroku/nodejs": "NodeJS",
+  "heroku/python": "Python",
+  "heroku/java": "Java",
+  "heroku/ruby": "Ruby",
+  "heroku/go": "Go",
+};

+ 25 - 0
package-lock.json

@@ -0,0 +1,25 @@
+{
+  "name": "porter",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "dependencies": {
+        "@porter-dev/api-contracts": "^0.0.85"
+      }
+    },
+    "node_modules/@bufbuild/protobuf": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.3.0.tgz",
+      "integrity": "sha512-G372ods0pLt46yxVRsnP/e2btVPuuzArcMPFpIDeIwiGPuuglEs9y75iG0HMvZgncsj5TvbYRWqbVyOe3PLCWQ=="
+    },
+    "node_modules/@porter-dev/api-contracts": {
+      "version": "0.0.85",
+      "resolved": "https://registry.npmjs.org/@porter-dev/api-contracts/-/api-contracts-0.0.85.tgz",
+      "integrity": "sha512-usSfoZQljk/LVIdsGnD022NEpKktmK1hNHH3wfx4VCC+G/yC0GsPcZM/7HOct5y3zh5slIZ6raqvdB3cvZ9LsQ==",
+      "dependencies": {
+        "@bufbuild/protobuf": "^1.1.0"
+      }
+    }
+  }
+}

+ 5 - 0
package.json

@@ -0,0 +1,5 @@
+{
+  "dependencies": {
+    "@porter-dev/api-contracts": "^0.0.85"
+  }
+}