services.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. import { Service, ServiceType } from "@porter-dev/api-contracts";
  2. import { match } from "ts-pattern";
  3. import { z } from "zod";
  4. import { BuildOptions } from "./build";
  5. import {
  6. autoscalingValidator,
  7. deserializeAutoscaling,
  8. deserializeHealthCheck,
  9. domainsValidator,
  10. healthcheckValidator,
  11. ingressAnnotationsValidator,
  12. serializeAutoscaling,
  13. SerializedAutoscaling,
  14. SerializedHealthcheck,
  15. serializeHealth,
  16. serviceBooleanValidator,
  17. ServiceField,
  18. serviceNumberValidator,
  19. serviceStringValidator,
  20. } from "./values";
  21. import _ from "lodash";
  22. export type DetectedServices = {
  23. services: ClientService[];
  24. predeploy?: ClientService;
  25. build?: BuildOptions;
  26. previews?: {
  27. services: ClientService[];
  28. predeploy?: ClientService;
  29. variables?: Record<string, string>;
  30. };
  31. };
  32. type ClientServiceType = "web" | "worker" | "job" | "predeploy";
  33. const webConfigValidator = z.object({
  34. type: z.literal("web"),
  35. autoscaling: autoscalingValidator.optional(),
  36. domains: domainsValidator,
  37. healthCheck: healthcheckValidator.optional(),
  38. private: serviceBooleanValidator.optional(),
  39. ingressAnnotations: ingressAnnotationsValidator.default([]),
  40. });
  41. export type ClientWebConfig = z.infer<typeof webConfigValidator>;
  42. const workerConfigValidator = z.object({
  43. type: z.literal("worker"),
  44. autoscaling: autoscalingValidator.optional(),
  45. });
  46. export type ClientWorkerConfig = z.infer<typeof workerConfigValidator>;
  47. const jobConfigValidator = z.object({
  48. type: z.literal("job"),
  49. allowConcurrent: serviceBooleanValidator.optional(),
  50. cron: serviceStringValidator,
  51. suspendCron: serviceBooleanValidator.optional(),
  52. timeoutSeconds: serviceNumberValidator,
  53. });
  54. export type ClientJobConfig = z.infer<typeof jobConfigValidator>;
  55. const predeployConfigValidator = z.object({
  56. type: z.literal("predeploy"),
  57. });
  58. export type ClientPredeployConfig = z.infer<typeof predeployConfigValidator>;
  59. // serviceValidator is the validator for a ClientService
  60. // This is used to validate a service when creating or updating an app
  61. export const serviceValidator = z.object({
  62. expanded: z.boolean().default(false).optional(),
  63. canDelete: z.boolean().default(true).optional(),
  64. name: serviceStringValidator,
  65. run: serviceStringValidator,
  66. instances: serviceNumberValidator,
  67. port: serviceNumberValidator,
  68. cpuCores: serviceNumberValidator,
  69. ramMegabytes: serviceNumberValidator,
  70. smartOptimization: serviceBooleanValidator.optional(),
  71. config: z.discriminatedUnion("type", [
  72. webConfigValidator,
  73. workerConfigValidator,
  74. jobConfigValidator,
  75. predeployConfigValidator,
  76. ]),
  77. domainDeletions: z
  78. .object({
  79. name: z.string(),
  80. })
  81. .array()
  82. .default([]),
  83. });
  84. export type ClientService = z.infer<typeof serviceValidator>;
  85. // SerializedService is just the values of a Service without any override information
  86. // This is used as an intermediate step to convert a ClientService to a protobuf Service
  87. export type SerializedService = {
  88. name: string;
  89. run: string;
  90. instances: number;
  91. port: number;
  92. cpuCores: number;
  93. ramMegabytes: number;
  94. smartOptimization?: boolean;
  95. config:
  96. | {
  97. type: "web";
  98. domains: {
  99. name: string;
  100. }[];
  101. autoscaling?: SerializedAutoscaling;
  102. healthCheck?: SerializedHealthcheck;
  103. private?: boolean;
  104. ingressAnnotations: Record<string, string>;
  105. }
  106. | {
  107. type: "worker";
  108. autoscaling?: SerializedAutoscaling;
  109. }
  110. | {
  111. type: "job";
  112. allowConcurrent?: boolean;
  113. cron: string;
  114. suspendCron?: boolean;
  115. timeoutSeconds: number;
  116. }
  117. | {
  118. type: "predeploy";
  119. };
  120. };
  121. export function isPredeployService(service: SerializedService | ClientService) {
  122. return service.config.type == "predeploy";
  123. }
  124. export function prefixSubdomain(subdomain: string) {
  125. if (subdomain.startsWith("https://") || subdomain.startsWith("http://")) {
  126. return subdomain;
  127. }
  128. return "https://" + subdomain;
  129. }
  130. export function defaultSerialized({
  131. name,
  132. type,
  133. defaultCPU = 0.1,
  134. defaultRAM = 256,
  135. }: {
  136. name: string;
  137. type: ClientServiceType;
  138. defaultCPU?: number;
  139. defaultRAM?: number;
  140. }): SerializedService {
  141. const baseService = {
  142. name,
  143. run: "",
  144. instances: 1,
  145. port: 3000,
  146. cpuCores: defaultCPU,
  147. ramMegabytes: defaultRAM,
  148. smartOptimization: true,
  149. };
  150. const defaultAutoscaling: SerializedAutoscaling = {
  151. enabled: false,
  152. minInstances: 1,
  153. maxInstances: 10,
  154. cpuThresholdPercent: 50,
  155. memoryThresholdPercent: 50,
  156. };
  157. const defaultHealthCheck: SerializedHealthcheck = {
  158. enabled: false,
  159. httpPath: "/healthz",
  160. };
  161. return match(type)
  162. .with("web", () => ({
  163. ...baseService,
  164. config: {
  165. type: "web" as const,
  166. autoscaling: defaultAutoscaling,
  167. healthCheck: defaultHealthCheck,
  168. domains: [],
  169. private: false,
  170. ingressAnnotations: {},
  171. },
  172. }))
  173. .with("worker", () => ({
  174. ...baseService,
  175. config: {
  176. type: "worker" as const,
  177. autoscaling: defaultAutoscaling,
  178. },
  179. }))
  180. .with("job", () => ({
  181. ...baseService,
  182. config: {
  183. type: "job" as const,
  184. allowConcurrent: false,
  185. cron: "",
  186. suspendCron: false,
  187. timeoutSeconds: 3600,
  188. },
  189. }))
  190. .with("predeploy", () => ({
  191. ...baseService,
  192. config: {
  193. type: "predeploy" as const,
  194. },
  195. }))
  196. .exhaustive();
  197. }
  198. // serializeService converts a ClientService to a SerializedService
  199. // A SerializedService holds just the values of a ClientService
  200. // These values can be used to create a protobuf Service
  201. export function serializeService(service: ClientService): SerializedService {
  202. return match(service.config)
  203. .with({ type: "web" }, (config) =>
  204. Object.freeze({
  205. name: service.name.value,
  206. run: service.run.value,
  207. instances: service.instances.value,
  208. port: service.port.value,
  209. cpuCores: service.cpuCores.value,
  210. ramMegabytes: service.ramMegabytes.value,
  211. smartOptimization: service.smartOptimization?.value,
  212. config: {
  213. type: "web" as const,
  214. autoscaling: serializeAutoscaling({
  215. autoscaling: config.autoscaling,
  216. }),
  217. healthCheck: serializeHealth({ health: config.healthCheck }),
  218. domains: config.domains.map((domain) => ({
  219. name: domain.name.value,
  220. })),
  221. ingressAnnotations: Object.fromEntries(
  222. config.ingressAnnotations
  223. .filter((a) => a.key.length > 0 && a.value.length > 0)
  224. .map((annotation) => [annotation.key, annotation.value])
  225. ),
  226. private: config.private?.value,
  227. },
  228. })
  229. )
  230. .with({ type: "worker" }, (config) =>
  231. Object.freeze({
  232. name: service.name.value,
  233. run: service.run.value,
  234. instances: service.instances.value,
  235. port: service.port.value,
  236. cpuCores: service.cpuCores.value,
  237. ramMegabytes: service.ramMegabytes.value,
  238. smartOptimization: service.smartOptimization?.value,
  239. config: {
  240. type: "worker" as const,
  241. autoscaling: serializeAutoscaling({
  242. autoscaling: config.autoscaling,
  243. }),
  244. },
  245. })
  246. )
  247. .with({ type: "job" }, (config) =>
  248. Object.freeze({
  249. name: service.name.value,
  250. run: service.run.value,
  251. instances: service.instances.value,
  252. port: service.port.value,
  253. cpuCores: service.cpuCores.value,
  254. ramMegabytes: service.ramMegabytes.value,
  255. smartOptimization: service.smartOptimization?.value,
  256. config: {
  257. type: "job" as const,
  258. allowConcurrent: config.allowConcurrent?.value,
  259. cron: config.cron.value,
  260. suspendCron: config.suspendCron?.value,
  261. timeoutSeconds: config.timeoutSeconds.value,
  262. },
  263. })
  264. )
  265. .with({ type: "predeploy" }, () =>
  266. Object.freeze({
  267. name: service.name.value,
  268. run: service.run.value,
  269. instances: service.instances.value,
  270. port: service.port.value,
  271. cpuCores: service.cpuCores.value,
  272. smartOptimization: service.smartOptimization?.value,
  273. ramMegabytes: service.ramMegabytes.value,
  274. config: {
  275. type: "predeploy" as const,
  276. },
  277. })
  278. )
  279. .exhaustive();
  280. }
  281. // deserializeService converts a SerializedService to a ClientService
  282. // A deserialized ClientService represents the state of a service in the UI and which fields are editable
  283. export function deserializeService({
  284. service,
  285. override,
  286. expanded,
  287. setDefaults = true,
  288. }: {
  289. service: SerializedService;
  290. override?: SerializedService;
  291. expanded?: boolean;
  292. setDefaults?: boolean;
  293. }): ClientService {
  294. const baseService = {
  295. expanded,
  296. canDelete: !override,
  297. name: ServiceField.string(service.name, override?.name),
  298. run: ServiceField.string(service.run, override?.run),
  299. instances: ServiceField.number(service.instances, override?.instances),
  300. port: ServiceField.number(service.port, override?.port),
  301. cpuCores: ServiceField.number(service.cpuCores, override?.cpuCores),
  302. ramMegabytes: ServiceField.number(
  303. service.ramMegabytes,
  304. override?.ramMegabytes
  305. ),
  306. smartOptimization: ServiceField.boolean(
  307. service.smartOptimization,
  308. override?.smartOptimization
  309. ),
  310. domainDeletions: [],
  311. };
  312. return match(service.config)
  313. .with({ type: "web" }, (config) => {
  314. const overrideWebConfig =
  315. override?.config.type == "web" ? override.config : undefined;
  316. const uniqueDomains = Array.from(
  317. new Set([
  318. ...config.domains.map((domain) => domain.name),
  319. ...(overrideWebConfig?.domains ?? []).map((domain) => domain.name),
  320. ])
  321. ).map((domain) => ({ name: domain }));
  322. const uniqueAnnotations = _.uniqBy(
  323. [
  324. ...Object.entries(overrideWebConfig?.ingressAnnotations ?? {}).map(
  325. (annotation) => {
  326. return {
  327. key: annotation[0],
  328. value: annotation[1],
  329. readOnly: true,
  330. };
  331. }
  332. ),
  333. ...Object.entries(config.ingressAnnotations).map((annotation) => {
  334. return {
  335. key: annotation[0],
  336. value: annotation[1],
  337. readOnly: false,
  338. };
  339. }),
  340. ],
  341. "key"
  342. );
  343. return {
  344. ...baseService,
  345. config: {
  346. type: "web" as const,
  347. autoscaling: deserializeAutoscaling({
  348. autoscaling: config.autoscaling,
  349. override: overrideWebConfig?.autoscaling,
  350. setDefaults: setDefaults,
  351. }),
  352. healthCheck: deserializeHealthCheck({
  353. health: config.healthCheck,
  354. override: overrideWebConfig?.healthCheck,
  355. setDefaults: setDefaults,
  356. }),
  357. domains: uniqueDomains.map((domain) => ({
  358. name: ServiceField.string(
  359. domain.name,
  360. overrideWebConfig?.domains.find(
  361. (overrideDomain) => overrideDomain.name == domain.name
  362. )?.name
  363. ),
  364. })),
  365. ingressAnnotations: uniqueAnnotations,
  366. private:
  367. typeof config.private === "boolean" ||
  368. typeof overrideWebConfig?.private === "boolean"
  369. ? ServiceField.boolean(config.private, overrideWebConfig?.private)
  370. : setDefaults
  371. ? ServiceField.boolean(false, undefined)
  372. : undefined,
  373. },
  374. };
  375. })
  376. .with({ type: "worker" }, (config) => {
  377. const overrideWorkerConfig =
  378. override?.config.type == "worker" ? override.config : undefined;
  379. return {
  380. ...baseService,
  381. config: {
  382. type: "worker" as const,
  383. autoscaling: deserializeAutoscaling({
  384. autoscaling: config.autoscaling,
  385. override: overrideWorkerConfig?.autoscaling,
  386. setDefaults: setDefaults,
  387. }),
  388. },
  389. };
  390. })
  391. .with({ type: "job" }, (config) => {
  392. const overrideJobConfig =
  393. override?.config.type == "job" ? override.config : undefined;
  394. return {
  395. ...baseService,
  396. config: {
  397. type: "job" as const,
  398. allowConcurrent:
  399. typeof config.allowConcurrent === "boolean" ||
  400. typeof overrideJobConfig?.allowConcurrent === "boolean"
  401. ? ServiceField.boolean(
  402. config.allowConcurrent,
  403. overrideJobConfig?.allowConcurrent
  404. )
  405. : setDefaults
  406. ? ServiceField.boolean(false, undefined)
  407. : undefined,
  408. cron: ServiceField.string(config.cron, overrideJobConfig?.cron),
  409. suspendCron:
  410. typeof config.suspendCron === "boolean" ||
  411. typeof overrideJobConfig?.suspendCron === "boolean"
  412. ? ServiceField.boolean(
  413. config.suspendCron,
  414. overrideJobConfig?.suspendCron
  415. )
  416. : setDefaults
  417. ? ServiceField.boolean(false, undefined)
  418. : undefined,
  419. timeoutSeconds:
  420. config.timeoutSeconds != 0
  421. ? ServiceField.number(
  422. config.timeoutSeconds,
  423. overrideJobConfig?.timeoutSeconds
  424. )
  425. : setDefaults
  426. ? ServiceField.number(3600, overrideJobConfig?.timeoutSeconds)
  427. : ServiceField.number(0, overrideJobConfig?.timeoutSeconds),
  428. },
  429. };
  430. })
  431. .with({ type: "predeploy" }, () => ({
  432. ...baseService,
  433. config: {
  434. type: "predeploy" as const,
  435. },
  436. }))
  437. .exhaustive();
  438. }
  439. // getServiceTypeEnumProto converts the type of a ClientService to the protobuf ServiceType enum
  440. export const serviceTypeEnumProto = (type: ClientServiceType): ServiceType => {
  441. return match(type)
  442. .with("web", () => ServiceType.WEB)
  443. .with("worker", () => ServiceType.WORKER)
  444. .with("job", () => ServiceType.JOB)
  445. .with("predeploy", () => ServiceType.JOB)
  446. .exhaustive();
  447. };
  448. // serviceProto converts a SerializedService to the protobuf Service
  449. // This is used as an intermediate step to convert a ClientService to a protobuf Service
  450. export function serviceProto(service: SerializedService): Service {
  451. return match(service.config)
  452. .with(
  453. { type: "web" },
  454. (config) =>
  455. new Service({
  456. ...service,
  457. runOptional: service.run,
  458. type: serviceTypeEnumProto(config.type),
  459. config: {
  460. value: {
  461. ...config,
  462. },
  463. case: "webConfig",
  464. },
  465. })
  466. )
  467. .with(
  468. { type: "worker" },
  469. (config) =>
  470. new Service({
  471. ...service,
  472. runOptional: service.run,
  473. type: serviceTypeEnumProto(config.type),
  474. config: {
  475. value: {
  476. ...config,
  477. },
  478. case: "workerConfig",
  479. },
  480. })
  481. )
  482. .with(
  483. { type: "job" },
  484. (config) =>
  485. new Service({
  486. ...service,
  487. runOptional: service.run,
  488. type: serviceTypeEnumProto(config.type),
  489. config: {
  490. value: {
  491. ...config,
  492. allowConcurrentOptional: config.allowConcurrent,
  493. timeoutSeconds: BigInt(config.timeoutSeconds),
  494. },
  495. case: "jobConfig",
  496. },
  497. })
  498. )
  499. .with(
  500. { type: "predeploy" },
  501. (config) =>
  502. new Service({
  503. ...service,
  504. runOptional: service.run,
  505. type: serviceTypeEnumProto(config.type),
  506. config: {
  507. value: {},
  508. case: "jobConfig",
  509. },
  510. })
  511. )
  512. .exhaustive();
  513. }
  514. // serializedServiceFromProto converts a protobuf Service to a SerializedService
  515. // This is used as an intermediate step to convert a protobuf Service to a ClientService
  516. export function serializedServiceFromProto({
  517. service,
  518. name,
  519. isPredeploy,
  520. }: {
  521. service: Service;
  522. name: string;
  523. isPredeploy?: boolean;
  524. }): SerializedService {
  525. const config = service.config;
  526. if (!config.case) {
  527. throw new Error("No case found on service config");
  528. }
  529. return match(config)
  530. .with({ case: "webConfig" }, ({ value }) => ({
  531. ...service,
  532. name,
  533. run: service.runOptional ?? service.run,
  534. config: {
  535. type: "web" as const,
  536. autoscaling: value.autoscaling ? value.autoscaling : undefined,
  537. healthCheck: value.healthCheck ? value.healthCheck : undefined,
  538. ...value,
  539. },
  540. }))
  541. .with({ case: "workerConfig" }, ({ value }) => ({
  542. ...service,
  543. name,
  544. run: service.runOptional ?? service.run,
  545. config: {
  546. type: "worker" as const,
  547. autoscaling: value.autoscaling ? value.autoscaling : undefined,
  548. ...value,
  549. },
  550. }))
  551. .with({ case: "jobConfig" }, ({ value }) =>
  552. isPredeploy
  553. ? {
  554. ...service,
  555. name,
  556. run: service.runOptional ?? service.run,
  557. config: {
  558. type: "predeploy" as const,
  559. },
  560. }
  561. : {
  562. ...service,
  563. name,
  564. run: service.runOptional ?? service.run,
  565. config: {
  566. type: "job" as const,
  567. ...value,
  568. allowConcurrent: value.allowConcurrentOptional,
  569. timeoutSeconds: Number(value.timeoutSeconds),
  570. },
  571. }
  572. )
  573. .exhaustive();
  574. }