services.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459
  1. import { match } from "ts-pattern";
  2. import { z } from "zod";
  3. import {
  4. SerializedAutoscaling,
  5. SerializedHealthcheck,
  6. autoscalingValidator,
  7. healthcheckValidator,
  8. deserializeAutoscaling,
  9. deserializeHealthCheck,
  10. serializeAutoscaling,
  11. serializeHealth,
  12. domainsValidator,
  13. serviceStringValidator,
  14. serviceNumberValidator,
  15. serviceBooleanValidator,
  16. ServiceField,
  17. } from "./values";
  18. import { Service, ServiceType } from "@porter-dev/api-contracts";
  19. export type DetectedServices = {
  20. services: ClientService[];
  21. predeploy?: ClientService;
  22. };
  23. type ClientServiceType = "web" | "worker" | "job" | "predeploy";
  24. // serviceValidator is the validator for a ClientService
  25. // This is used to validate a service when creating or updating an app
  26. export const serviceValidator = z.object({
  27. expanded: z.boolean().default(false).optional(),
  28. canDelete: z.boolean().default(true).optional(),
  29. name: serviceStringValidator,
  30. run: serviceStringValidator,
  31. instances: serviceNumberValidator,
  32. port: serviceNumberValidator,
  33. cpuCores: serviceNumberValidator,
  34. ramMegabytes: serviceNumberValidator,
  35. config: z.discriminatedUnion("type", [
  36. z.object({
  37. type: z.literal("web"),
  38. autoscaling: autoscalingValidator.optional(),
  39. domains: domainsValidator,
  40. healthCheck: healthcheckValidator.optional(),
  41. private: serviceBooleanValidator.default({
  42. value: false,
  43. readOnly: false,
  44. }),
  45. }),
  46. z.object({
  47. type: z.literal("worker"),
  48. autoscaling: autoscalingValidator.optional(),
  49. }),
  50. z.object({
  51. type: z.literal("job"),
  52. allowConcurrent: serviceBooleanValidator,
  53. cron: serviceStringValidator,
  54. }),
  55. z.object({
  56. type: z.literal("predeploy"),
  57. }),
  58. ]),
  59. });
  60. export type ClientService = z.infer<typeof serviceValidator>;
  61. // SerializedService is just the values of a Service without any override information
  62. // This is used as an intermediate step to convert a ClientService to a protobuf Service
  63. export type SerializedService = {
  64. name: string;
  65. run: string;
  66. instances: number;
  67. port: number;
  68. cpuCores: number;
  69. ramMegabytes: number;
  70. config:
  71. | {
  72. type: "web";
  73. domains: {
  74. name: string;
  75. }[];
  76. autoscaling?: SerializedAutoscaling;
  77. healthCheck?: SerializedHealthcheck;
  78. private: boolean;
  79. }
  80. | {
  81. type: "worker";
  82. autoscaling?: SerializedAutoscaling;
  83. }
  84. | {
  85. type: "job";
  86. allowConcurrent: boolean;
  87. cron: string;
  88. }
  89. | {
  90. type: "predeploy";
  91. };
  92. };
  93. export function isPredeployService(service: SerializedService | ClientService) {
  94. return service.config.type == "predeploy";
  95. }
  96. export function prefixSubdomain(subdomain: string) {
  97. if (subdomain.startsWith("https://") || subdomain.startsWith("http://")) {
  98. return subdomain;
  99. }
  100. return "https://" + subdomain;
  101. }
  102. export function defaultSerialized({
  103. name,
  104. type,
  105. }: {
  106. name: string;
  107. type: ClientServiceType;
  108. }): SerializedService {
  109. const baseService = {
  110. name,
  111. run: "",
  112. instances: 1,
  113. port: 3000,
  114. cpuCores: 0.1,
  115. ramMegabytes: 256,
  116. };
  117. const defaultAutoscaling: SerializedAutoscaling = {
  118. enabled: false,
  119. minInstances: 1,
  120. maxInstances: 10,
  121. cpuThresholdPercent: 50,
  122. memoryThresholdPercent: 50,
  123. };
  124. const defaultHealthCheck: SerializedHealthcheck = {
  125. enabled: false,
  126. httpPath: "/healthz",
  127. };
  128. return match(type)
  129. .with("web", () => ({
  130. ...baseService,
  131. config: {
  132. type: "web" as const,
  133. autoscaling: defaultAutoscaling,
  134. healthCheck: defaultHealthCheck,
  135. domains: [],
  136. private: false,
  137. },
  138. }))
  139. .with("worker", () => ({
  140. ...baseService,
  141. config: {
  142. type: "worker" as const,
  143. autoscaling: defaultAutoscaling,
  144. },
  145. }))
  146. .with("job", () => ({
  147. ...baseService,
  148. config: {
  149. type: "job" as const,
  150. allowConcurrent: false,
  151. cron: "",
  152. },
  153. }))
  154. .with("predeploy", () => ({
  155. ...baseService,
  156. config: {
  157. type: "predeploy" as const,
  158. },
  159. }))
  160. .exhaustive();
  161. }
  162. // serializeService converts a ClientService to a SerializedService
  163. // A SerializedService holds just the values of a ClientService
  164. // These values can be used to create a protobuf Service
  165. export function serializeService(service: ClientService): SerializedService {
  166. return match(service.config)
  167. .with({ type: "web" }, (config) =>
  168. Object.freeze({
  169. name: service.name.value,
  170. run: service.run.value,
  171. instances: service.instances.value,
  172. port: service.port.value,
  173. cpuCores: service.cpuCores.value,
  174. ramMegabytes: service.ramMegabytes.value,
  175. config: {
  176. type: "web" as const,
  177. autoscaling: serializeAutoscaling({
  178. autoscaling: config.autoscaling,
  179. }),
  180. healthCheck: serializeHealth({ health: config.healthCheck }),
  181. domains: config.domains.map((domain) => ({
  182. name: domain.name.value,
  183. })),
  184. private: config.private.value,
  185. },
  186. })
  187. )
  188. .with({ type: "worker" }, (config) =>
  189. Object.freeze({
  190. name: service.name.value,
  191. run: service.run.value,
  192. instances: service.instances.value,
  193. port: service.port.value,
  194. cpuCores: service.cpuCores.value,
  195. ramMegabytes: service.ramMegabytes.value,
  196. config: {
  197. type: "worker" as const,
  198. autoscaling: serializeAutoscaling({
  199. autoscaling: config.autoscaling,
  200. }),
  201. },
  202. })
  203. )
  204. .with({ type: "job" }, (config) =>
  205. Object.freeze({
  206. name: service.name.value,
  207. run: service.run.value,
  208. instances: service.instances.value,
  209. port: service.port.value,
  210. cpuCores: service.cpuCores.value,
  211. ramMegabytes: service.ramMegabytes.value,
  212. config: {
  213. type: "job" as const,
  214. allowConcurrent: config.allowConcurrent.value,
  215. cron: config.cron.value,
  216. },
  217. })
  218. )
  219. .with({ type: "predeploy" }, () =>
  220. Object.freeze({
  221. name: service.name.value,
  222. run: service.run.value,
  223. instances: service.instances.value,
  224. port: service.port.value,
  225. cpuCores: service.cpuCores.value,
  226. ramMegabytes: service.ramMegabytes.value,
  227. config: {
  228. type: "predeploy" as const,
  229. },
  230. })
  231. )
  232. .exhaustive();
  233. }
  234. // deserializeService converts a SerializedService to a ClientService
  235. // A deserialized ClientService represents the state of a service in the UI and which fields are editable
  236. export function deserializeService({
  237. service,
  238. override,
  239. expanded,
  240. }: {
  241. service: SerializedService;
  242. override?: SerializedService;
  243. expanded?: boolean;
  244. }): ClientService {
  245. const baseService = {
  246. expanded,
  247. canDelete: !override,
  248. name: ServiceField.string(service.name, override?.name),
  249. run: ServiceField.string(service.run, override?.run),
  250. instances: ServiceField.number(service.instances, override?.instances),
  251. port: ServiceField.number(service.port, override?.port),
  252. cpuCores: ServiceField.number(service.cpuCores, override?.cpuCores),
  253. ramMegabytes: ServiceField.number(
  254. service.ramMegabytes,
  255. override?.ramMegabytes
  256. ),
  257. };
  258. return match(service.config)
  259. .with({ type: "web" }, (config) => {
  260. const overrideWebConfig =
  261. override?.config.type == "web" ? override.config : undefined;
  262. return {
  263. ...baseService,
  264. config: {
  265. type: "web" as const,
  266. autoscaling: deserializeAutoscaling({
  267. autoscaling: config.autoscaling,
  268. override: overrideWebConfig?.autoscaling,
  269. }),
  270. healthCheck: deserializeHealthCheck({
  271. health: config.healthCheck,
  272. override: overrideWebConfig?.healthCheck,
  273. }),
  274. domains: config.domains.map((domain) => ({
  275. name: ServiceField.string(
  276. domain.name,
  277. overrideWebConfig?.domains.find(
  278. (overrideDomain) => overrideDomain.name == domain.name
  279. )?.name
  280. ),
  281. })),
  282. private: ServiceField.boolean(
  283. config.private,
  284. overrideWebConfig?.private
  285. ),
  286. },
  287. };
  288. })
  289. .with({ type: "worker" }, (config) => {
  290. const overrideWorkerConfig =
  291. override?.config.type == "worker" ? override.config : undefined;
  292. return {
  293. ...baseService,
  294. config: {
  295. type: "worker" as const,
  296. autoscaling: deserializeAutoscaling({
  297. autoscaling: config.autoscaling,
  298. override: overrideWorkerConfig?.autoscaling,
  299. }),
  300. },
  301. };
  302. })
  303. .with({ type: "job" }, (config) => {
  304. const overrideJobConfig =
  305. override?.config.type == "job" ? override.config : undefined;
  306. return {
  307. ...baseService,
  308. config: {
  309. type: "job" as const,
  310. allowConcurrent: ServiceField.boolean(
  311. config.allowConcurrent,
  312. overrideJobConfig?.allowConcurrent
  313. ),
  314. cron: ServiceField.string(config.cron, overrideJobConfig?.cron),
  315. },
  316. };
  317. })
  318. .with({ type: "predeploy" }, () => ({
  319. ...baseService,
  320. config: {
  321. type: "predeploy" as const,
  322. },
  323. }))
  324. .exhaustive();
  325. }
  326. // getServiceTypeEnumProto converts the type of a ClientService to the protobuf ServiceType enum
  327. export const serviceTypeEnumProto = (type: ClientServiceType): ServiceType => {
  328. return match(type)
  329. .with("web", () => ServiceType.WEB)
  330. .with("worker", () => ServiceType.WORKER)
  331. .with("job", () => ServiceType.JOB)
  332. .with("predeploy", () => ServiceType.JOB)
  333. .exhaustive();
  334. };
  335. // serviceProto converts a SerializedService to the protobuf Service
  336. // This is used as an intermediate step to convert a ClientService to a protobuf Service
  337. export function serviceProto(service: SerializedService): Service {
  338. return match(service.config)
  339. .with(
  340. { type: "web" },
  341. (config) =>
  342. new Service({
  343. ...service,
  344. type: serviceTypeEnumProto(config.type),
  345. config: {
  346. value: {
  347. ...config,
  348. },
  349. case: "webConfig",
  350. },
  351. })
  352. )
  353. .with(
  354. { type: "worker" },
  355. (config) =>
  356. new Service({
  357. ...service,
  358. type: serviceTypeEnumProto(config.type),
  359. config: {
  360. value: {
  361. ...config,
  362. },
  363. case: "workerConfig",
  364. },
  365. })
  366. )
  367. .with(
  368. { type: "job" },
  369. (config) =>
  370. new Service({
  371. ...service,
  372. type: serviceTypeEnumProto(config.type),
  373. config: {
  374. value: {
  375. ...config,
  376. },
  377. case: "jobConfig",
  378. },
  379. })
  380. )
  381. .with(
  382. { type: "predeploy" },
  383. (config) =>
  384. new Service({
  385. ...service,
  386. type: serviceTypeEnumProto(config.type),
  387. config: {
  388. value: {},
  389. case: "jobConfig",
  390. },
  391. })
  392. )
  393. .exhaustive();
  394. }
  395. // serializedServiceFromProto converts a protobuf Service to a SerializedService
  396. // This is used as an intermediate step to convert a protobuf Service to a ClientService
  397. export function serializedServiceFromProto({
  398. service,
  399. name,
  400. isPredeploy,
  401. }: {
  402. service: Service;
  403. name: string;
  404. isPredeploy?: boolean;
  405. }): SerializedService {
  406. const config = service.config;
  407. if (!config.case) {
  408. throw new Error("No case found on service config");
  409. }
  410. return match(config)
  411. .with({ case: "webConfig" }, ({ value }) => ({
  412. ...service,
  413. name,
  414. config: {
  415. type: "web" as const,
  416. autoscaling: value.autoscaling ? value.autoscaling : undefined,
  417. healthCheck: value.healthCheck ? value.healthCheck : undefined,
  418. ...value,
  419. },
  420. }))
  421. .with({ case: "workerConfig" }, ({ value }) => ({
  422. ...service,
  423. name,
  424. config: {
  425. type: "worker" as const,
  426. autoscaling: value.autoscaling ? value.autoscaling : undefined,
  427. ...value,
  428. },
  429. }))
  430. .with({ case: "jobConfig" }, ({ value }) => ({
  431. ...service,
  432. name,
  433. config: {
  434. type: isPredeploy ? ("predeploy" as const) : ("job" as const),
  435. ...value,
  436. },
  437. }))
  438. .exhaustive();
  439. }