| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717 |
- import {
- Service,
- ServiceType,
- type PorterApp,
- } from "@porter-dev/api-contracts";
- import _ from "lodash";
- import { match } from "ts-pattern";
- import { z } from "zod";
- import { type BuildOptions } from "./build";
- import {
- autoscalingValidator,
- deserializeAutoscaling,
- deserializeHealthCheck,
- domainsValidator,
- healthcheckValidator,
- ingressAnnotationsValidator,
- serializeAutoscaling,
- serializeHealth,
- serviceBooleanValidator,
- ServiceField,
- serviceNumberValidator,
- serviceStringValidator,
- type SerializedAutoscaling,
- type SerializedHealthcheck,
- } from "./values";
- const LAUNCHER_PREFIX = "/cnb/lifecycle/launcher ";
- export type DetectedServices = {
- services: ClientService[];
- predeploy?: ClientService;
- build?: BuildOptions;
- previews?: {
- services: ClientService[];
- predeploy?: ClientService;
- variables?: Record<string, string>;
- };
- };
- type ClientServiceType = "web" | "worker" | "job" | "predeploy";
- type ClientWebService = ClientService & { config: ClientWebConfig };
- export const isClientWebService = (
- service: ClientService
- ): service is ClientWebService => {
- return service.config.type === "web";
- };
- type ClientWorkerService = ClientService & { config: ClientWorkerConfig };
- export const isClientWorkerService = (
- service: ClientService
- ): service is ClientWorkerService => {
- return service.config.type === "worker";
- };
- type ClientJobService = ClientService & { config: ClientJobConfig };
- export const isClientJobService = (
- service: ClientService
- ): service is ClientJobService => {
- return service.config.type === "job";
- };
- const webConfigValidator = z.object({
- type: z.literal("web"),
- autoscaling: autoscalingValidator.optional(),
- domains: domainsValidator,
- healthCheck: healthcheckValidator.optional(),
- private: serviceBooleanValidator.optional(),
- ingressAnnotations: ingressAnnotationsValidator.default([]),
- disableTls: serviceBooleanValidator.optional(),
- });
- export type ClientWebConfig = z.infer<typeof webConfigValidator>;
- const workerConfigValidator = z.object({
- type: z.literal("worker"),
- autoscaling: autoscalingValidator.optional(),
- healthCheck: healthcheckValidator.optional(),
- });
- export type ClientWorkerConfig = z.infer<typeof workerConfigValidator>;
- const jobConfigValidator = z.object({
- type: z.literal("job"),
- allowConcurrent: serviceBooleanValidator.optional(),
- cron: serviceStringValidator,
- suspendCron: serviceBooleanValidator.optional(),
- timeoutSeconds: serviceNumberValidator,
- });
- export type ClientJobConfig = z.infer<typeof jobConfigValidator>;
- const predeployConfigValidator = z.object({
- type: z.literal("predeploy"),
- });
- export type ClientPredeployConfig = z.infer<typeof predeployConfigValidator>;
- // 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({
- expanded: z.boolean().default(false).optional(),
- canDelete: z.boolean().default(true).optional(),
- name: serviceStringValidator,
- run: serviceStringValidator,
- instances: serviceNumberValidator,
- port: serviceNumberValidator,
- cpuCores: serviceNumberValidator,
- ramMegabytes: serviceNumberValidator,
- gpuCoresNvidia: serviceNumberValidator,
- gpu: z.object({
- enabled: serviceBooleanValidator,
- gpuCoresNvidia: serviceNumberValidator,
- }),
- smartOptimization: serviceBooleanValidator.optional(),
- terminationGracePeriodSeconds: serviceNumberValidator.optional(),
- config: z.discriminatedUnion("type", [
- webConfigValidator,
- workerConfigValidator,
- jobConfigValidator,
- predeployConfigValidator,
- ]),
- domainDeletions: z
- .object({
- name: z.string(),
- })
- .array()
- .default([]),
- ingressAnnotationDeletions: z
- .object({
- key: z.string(),
- })
- .array()
- .default([]),
- });
- 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 = {
- name: string;
- run: string;
- instances: number;
- port: number;
- cpuCores: number;
- ramMegabytes: number;
- smartOptimization?: boolean;
- gpuCoresNvidia: number;
- gpu?: {
- enabled: boolean;
- gpuCoresNvidia: number;
- };
- terminationGracePeriodSeconds?: number;
- config:
- | {
- type: "web";
- domains: Array<{
- name: string;
- }>;
- autoscaling?: SerializedAutoscaling;
- healthCheck?: SerializedHealthcheck;
- private?: boolean;
- disableTls?: boolean;
- ingressAnnotations: Record<string, string>;
- }
- | {
- type: "worker";
- autoscaling?: SerializedAutoscaling;
- healthCheck?: SerializedHealthcheck;
- }
- | {
- type: "job";
- allowConcurrent?: boolean;
- cron: string;
- suspendCron?: boolean;
- timeoutSeconds: number;
- }
- | {
- type: "predeploy";
- };
- };
- export function isPredeployService(
- service: SerializedService | ClientService
- ): boolean {
- return service.config.type === "predeploy";
- }
- export function prefixSubdomain(subdomain: string): string {
- if (subdomain.startsWith("https://") || subdomain.startsWith("http://")) {
- return subdomain;
- }
- return "https://" + subdomain;
- }
- export function uniqueServices(app: PorterApp): Service[] {
- if (app.serviceList?.length) {
- // dedupe services by name, favoring the first instance
- return _.uniqBy(app.serviceList, "name");
- }
- const servicesFromMap = Object.entries(app.services ?? {}).map(
- ([name, service]) => {
- return new Service({
- ...service,
- name,
- });
- }
- );
- return servicesFromMap;
- }
- export function defaultSerialized({
- name,
- type,
- defaultCPU = 0.1,
- defaultRAM = 256,
- }: {
- name: string;
- type: ClientServiceType;
- defaultCPU?: number;
- defaultRAM?: number;
- }): SerializedService {
- const baseService = {
- name,
- run: "",
- instances: 1,
- port: 3000,
- cpuCores: defaultCPU,
- ramMegabytes: defaultRAM,
- gpuCoresNvidia: 0,
- gpu: {
- enabled: false,
- gpuCoresNvidia: 0,
- },
- smartOptimization: false,
- };
- const defaultAutoscaling: SerializedAutoscaling = {
- enabled: false,
- minInstances: 1,
- maxInstances: 10,
- cpuThresholdPercent: 50,
- memoryThresholdPercent: 50,
- };
- const defaultWebHealthCheck: SerializedHealthcheck = {
- enabled: false,
- httpPath: "/healthz",
- };
- const defaultWorkerHealthCheck: SerializedHealthcheck = {
- enabled: false,
- command: "./healthz.sh",
- };
- return match(type)
- .with("web", () => ({
- ...baseService,
- config: {
- type: "web" as const,
- autoscaling: defaultAutoscaling,
- healthCheck: defaultWebHealthCheck,
- domains: [],
- private: false,
- ingressAnnotations: {},
- disableTls: false,
- },
- }))
- .with("worker", () => ({
- ...baseService,
- config: {
- type: "worker" as const,
- autoscaling: defaultAutoscaling,
- healthCheck: defaultWorkerHealthCheck,
- },
- }))
- .with("job", () => ({
- ...baseService,
- config: {
- type: "job" as const,
- allowConcurrent: false,
- cron: "",
- suspendCron: false,
- timeoutSeconds: 3600,
- },
- }))
- .with("predeploy", () => ({
- ...baseService,
- config: {
- type: "predeploy" as const,
- },
- }))
- .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
- export function serializeService(service: ClientService): SerializedService {
- return Object.freeze({
- name: service.name.value,
- run: service.run.value,
- instances: service.instances.value,
- port: service.port.value,
- cpuCores: service.cpuCores.value,
- ramMegabytes: Math.round(service.ramMegabytes.value), // RAM must be an integer
- smartOptimization: service.smartOptimization?.value,
- gpuCoresNvidia: service.gpuCoresNvidia.value,
- gpu: {
- enabled: service.gpu.enabled.value,
- gpuCoresNvidia: service.gpu.gpuCoresNvidia.value,
- },
- terminationGracePeriodSeconds: service.terminationGracePeriodSeconds?.value,
- config: match(service.config)
- .with({ type: "web" }, (config) =>
- Object.freeze({
- type: "web" as const,
- autoscaling: serializeAutoscaling({
- autoscaling: config.autoscaling,
- }),
- healthCheck: serializeHealth({ health: config.healthCheck }),
- domains: config.domains.map((domain) => ({
- name: domain.name.value
- .replace("https://", "")
- .replace("http://", ""),
- })),
- ingressAnnotations: Object.fromEntries(
- config.ingressAnnotations
- .filter((a) => a.key.length > 0 && a.value.length > 0)
- .map((annotation) => [annotation.key, annotation.value])
- ),
- private: config.private?.value,
- disableTls: config.disableTls?.value,
- })
- )
- .with({ type: "worker" }, (config) =>
- Object.freeze({
- type: "worker" as const,
- autoscaling: serializeAutoscaling({
- autoscaling: config.autoscaling,
- }),
- healthCheck: serializeHealth({ health: config.healthCheck }),
- })
- )
- .with({ type: "job" }, (config) =>
- Object.freeze({
- type: "job" as const,
- allowConcurrent: config.allowConcurrent?.value,
- cron: config.cron.value,
- suspendCron: config.suspendCron?.value,
- timeoutSeconds: config.timeoutSeconds.value,
- })
- )
- .with({ type: "predeploy" }, () =>
- Object.freeze({
- type: "predeploy" as const,
- })
- )
- .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,
- override,
- expanded,
- setDefaults = true,
- lockDeletions = false,
- }: {
- service: SerializedService;
- override?: SerializedService;
- expanded?: boolean;
- setDefaults?: boolean;
- lockDeletions?: boolean;
- }): ClientService {
- const baseService = {
- expanded,
- canDelete: !override && !lockDeletions,
- name: ServiceField.string(service.name, override?.name),
- 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),
- gpu: {
- enabled: ServiceField.boolean(
- service.gpu?.enabled,
- override?.gpu?.enabled
- ),
- gpuCoresNvidia: ServiceField.number(
- service.gpu?.gpuCoresNvidia ?? 0,
- override?.gpu?.gpuCoresNvidia
- ),
- },
- gpuCoresNvidia: ServiceField.number(
- service.gpuCoresNvidia,
- override?.gpuCoresNvidia
- ),
- ramMegabytes: ServiceField.number(
- service.ramMegabytes,
- override?.ramMegabytes
- ),
- smartOptimization: ServiceField.boolean(
- service.smartOptimization,
- override?.smartOptimization
- ),
- terminationGracePeriodSeconds: setDefaults
- ? ServiceField.number(
- service.terminationGracePeriodSeconds ?? 30, // if not set explicitly, assume the value is at default (30 seconds)
- override?.terminationGracePeriodSeconds
- )
- : undefined,
- domainDeletions: [],
- ingressAnnotationDeletions: [],
- };
- if (
- !baseService.run.readOnly &&
- baseService.run.value.startsWith(LAUNCHER_PREFIX)
- ) {
- // trim launcher prefix from run command
- baseService.run = ServiceField.string(
- baseService.run.value.substring(LAUNCHER_PREFIX.length),
- override?.run
- );
- }
- return match(service.config)
- .with({ type: "web" }, (config) => {
- const overrideWebConfig =
- override?.config.type === "web" ? override.config : undefined;
- const uniqueDomains = Array.from(
- new Set([
- ...config.domains.map((domain) => domain.name),
- ...(overrideWebConfig?.domains ?? []).map((domain) => domain.name),
- ])
- ).map((domain) => ({ name: domain }));
- const uniqueAnnotations = _.uniqBy(
- [
- ...Object.entries(overrideWebConfig?.ingressAnnotations ?? {}).map(
- (annotation) => {
- return {
- key: annotation[0],
- value: annotation[1],
- readOnly: true,
- };
- }
- ),
- ...Object.entries(config.ingressAnnotations).map((annotation) => {
- return {
- key: annotation[0],
- value: annotation[1],
- readOnly: false,
- };
- }),
- ],
- "key"
- );
- return {
- ...baseService,
- config: {
- type: "web" as const,
- autoscaling: deserializeAutoscaling({
- autoscaling: config.autoscaling,
- override: overrideWebConfig?.autoscaling,
- setDefaults,
- }),
- healthCheck: deserializeHealthCheck({
- health: config.healthCheck,
- override: overrideWebConfig?.healthCheck,
- setDefaults,
- }),
- domains: uniqueDomains.map((domain) => ({
- name: ServiceField.string(
- domain.name,
- overrideWebConfig?.domains.find(
- (overrideDomain) => overrideDomain.name === domain.name
- )?.name
- ),
- })),
- ingressAnnotations: uniqueAnnotations,
- private:
- typeof config.private === "boolean" ||
- typeof overrideWebConfig?.private === "boolean"
- ? ServiceField.boolean(config.private, overrideWebConfig?.private)
- : setDefaults
- ? ServiceField.boolean(false, undefined)
- : undefined,
- disableTls:
- typeof config.disableTls === "boolean" ||
- typeof overrideWebConfig?.disableTls === "boolean"
- ? ServiceField.boolean(
- config.disableTls,
- overrideWebConfig?.disableTls
- )
- : setDefaults
- ? ServiceField.boolean(false, undefined)
- : undefined,
- },
- };
- })
- .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,
- setDefaults,
- }),
- healthCheck: deserializeHealthCheck({
- health: config.healthCheck,
- override: overrideWorkerConfig?.healthCheck,
- setDefaults,
- }),
- },
- };
- })
- .with({ type: "job" }, (config) => {
- const overrideJobConfig =
- override?.config.type === "job" ? override.config : undefined;
- return {
- ...baseService,
- config: {
- type: "job" as const,
- allowConcurrent:
- typeof config.allowConcurrent === "boolean" ||
- typeof overrideJobConfig?.allowConcurrent === "boolean"
- ? ServiceField.boolean(
- config.allowConcurrent,
- overrideJobConfig?.allowConcurrent
- )
- : setDefaults
- ? ServiceField.boolean(false, undefined)
- : undefined,
- cron: ServiceField.string(config.cron, overrideJobConfig?.cron),
- suspendCron:
- typeof config.suspendCron === "boolean" ||
- typeof overrideJobConfig?.suspendCron === "boolean"
- ? ServiceField.boolean(
- config.suspendCron,
- overrideJobConfig?.suspendCron
- )
- : setDefaults
- ? ServiceField.boolean(false, undefined)
- : undefined,
- timeoutSeconds:
- config.timeoutSeconds !== 0
- ? ServiceField.number(
- config.timeoutSeconds,
- overrideJobConfig?.timeoutSeconds
- )
- : setDefaults
- ? ServiceField.number(3600, overrideJobConfig?.timeoutSeconds)
- : ServiceField.number(0, overrideJobConfig?.timeoutSeconds),
- },
- };
- })
- .with({ type: "predeploy" }, () => ({
- ...baseService,
- config: {
- type: "predeploy" as const,
- },
- }))
- .exhaustive();
- }
- // getServiceTypeEnumProto converts the type of a ClientService to the protobuf ServiceType enum
- export const serviceTypeEnumProto = (type: ClientServiceType): ServiceType => {
- return match(type)
- .with("web", () => ServiceType.WEB)
- .with("worker", () => ServiceType.WORKER)
- .with("job", () => ServiceType.JOB)
- .with("predeploy", () => 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,
- runOptional: service.run,
- instancesOptional: service.instances,
- type: serviceTypeEnumProto(config.type),
- config: {
- value: {
- ...config,
- },
- case: "webConfig",
- },
- })
- )
- .with(
- { type: "worker" },
- (config) =>
- new Service({
- ...service,
- runOptional: service.run,
- instancesOptional: service.instances,
- type: serviceTypeEnumProto(config.type),
- config: {
- value: {
- ...config,
- },
- case: "workerConfig",
- },
- })
- )
- .with(
- { type: "job" },
- (config) =>
- new Service({
- ...service,
- runOptional: service.run,
- instancesOptional: service.instances,
- type: serviceTypeEnumProto(config.type),
- config: {
- value: {
- ...config,
- allowConcurrentOptional: config.allowConcurrent,
- timeoutSeconds: BigInt(config.timeoutSeconds),
- },
- case: "jobConfig",
- },
- })
- )
- .with(
- { type: "predeploy" },
- (config) =>
- new Service({
- ...service,
- runOptional: service.run,
- instancesOptional: service.instances,
- type: serviceTypeEnumProto(config.type),
- config: {
- value: {},
- 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,
- isPredeploy,
- }: {
- service: Service;
- isPredeploy?: boolean;
- }): 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,
- run: service.runOptional ?? service.run,
- instances: service.instancesOptional ?? service.instances,
- config: {
- type: "web" as const,
- autoscaling: value.autoscaling ? value.autoscaling : undefined,
- healthCheck: value.healthCheck ? value.healthCheck : undefined,
- disableTls: value.disableTls ? value.disableTls : undefined,
- ...value,
- },
- }))
- .with({ case: "workerConfig" }, ({ value }) => ({
- ...service,
- run: service.runOptional ?? service.run,
- instances: service.instancesOptional ?? service.instances,
- config: {
- type: "worker" as const,
- autoscaling: value.autoscaling ? value.autoscaling : undefined,
- healthCheck: value.healthCheck ? value.healthCheck : undefined,
- ...value,
- },
- }))
- .with({ case: "jobConfig" }, ({ value }) =>
- isPredeploy
- ? {
- ...service,
- run: service.runOptional ?? service.run,
- instances: service.instancesOptional ?? service.instances,
- config: {
- type: "predeploy" as const,
- },
- }
- : {
- ...service,
- run: service.runOptional ?? service.run,
- instances: service.instancesOptional ?? service.instances,
- config: {
- type: "job" as const,
- ...value,
- allowConcurrent: value.allowConcurrentOptional,
- timeoutSeconds: Number(value.timeoutSeconds),
- },
- }
- )
- .exhaustive();
- }
|