index.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748
  1. import {
  2. AutoRollback,
  3. Build,
  4. CloudSql,
  5. EFS,
  6. HelmOverrides,
  7. PorterApp,
  8. Service,
  9. } from "@porter-dev/api-contracts";
  10. import { match, P } from "ts-pattern";
  11. import { z } from "zod";
  12. import { BUILDPACK_TO_NAME } from "main/home/app-dashboard/types/buildpack";
  13. import { type KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
  14. import { buildValidator, type BuildOptions } from "./build";
  15. import {
  16. defaultSerialized,
  17. deserializeService,
  18. serializedServiceFromProto,
  19. serializeService,
  20. serviceProto,
  21. serviceValidator,
  22. uniqueServices,
  23. type DetectedServices,
  24. } from "./services";
  25. // sourceValidator is used to validate inputs for source setting fields
  26. export const sourceValidator = z.discriminatedUnion("type", [
  27. z.object({
  28. type: z.literal("github"),
  29. git_repo_id: z.number().min(1),
  30. git_branch: z.string().min(1),
  31. git_repo_name: z.string().min(1),
  32. porter_yaml_path: z.string().default("./porter.yaml"),
  33. }),
  34. z.object({
  35. type: z.literal("local"),
  36. git_branch: z.undefined(),
  37. git_repo_name: z.undefined(),
  38. }),
  39. z.object({
  40. type: z.literal("docker-registry"),
  41. // add branch and repo as undefined to allow for easy checks on changes to the source type
  42. // (i.e. we want to remove the services if any source fields change)
  43. git_branch: z.undefined(),
  44. git_repo_name: z.undefined(),
  45. image: z.object({
  46. repository: z.string().min(1),
  47. tag: z.string().default("latest"),
  48. }),
  49. }),
  50. ]);
  51. export type SourceOptions = z.infer<typeof sourceValidator>;
  52. export const deletionValidator = z.object({
  53. serviceNames: z
  54. .object({
  55. name: z.string(),
  56. })
  57. .array(),
  58. predeploy: z
  59. .object({
  60. name: z.string(),
  61. })
  62. .array(),
  63. initialDeploy: z
  64. .object({
  65. name: z.string(),
  66. })
  67. .array(),
  68. envGroupNames: z
  69. .object({
  70. name: z.string(),
  71. })
  72. .array(),
  73. });
  74. // clientAppValidator is the representation of a Porter app on the client, and is used to validate inputs for app setting fields
  75. export const clientAppValidator = z.object({
  76. name: z.object({
  77. readOnly: z.boolean(),
  78. value: z
  79. .string()
  80. .min(1, { message: "Name must be at least 1 character" })
  81. .max(31, { message: "Name must be 31 characters or less" })
  82. .regex(/^[a-z0-9-]{1,61}$/, {
  83. message: 'Lowercase letters, numbers, and "-" only.',
  84. }),
  85. }),
  86. efsStorage: z.object({
  87. enabled: z.boolean(),
  88. readOnly: z.boolean().optional(),
  89. }),
  90. cloudSql: z
  91. .object({
  92. enabled: z.boolean(),
  93. connectionName: z.string(),
  94. dbPort: z.coerce.number(),
  95. serviceAccountJsonSecret: z.string(),
  96. })
  97. .default({
  98. enabled: false,
  99. connectionName: "",
  100. dbPort: 5432,
  101. serviceAccountJsonSecret: "",
  102. }),
  103. envGroups: z
  104. .object({ name: z.string(), version: z.bigint() })
  105. .array()
  106. .default([]),
  107. services: serviceValidator.array(),
  108. predeploy: serviceValidator.array().optional(),
  109. initialDeploy: serviceValidator.array().optional(),
  110. env: z
  111. .object({
  112. key: z.string(),
  113. value: z.string(),
  114. hidden: z.boolean(),
  115. locked: z.boolean(),
  116. deleted: z.boolean(),
  117. })
  118. .array()
  119. .default([]),
  120. build: buildValidator,
  121. helmOverrides: z.string().optional(),
  122. requiredApps: z.object({ name: z.string() }).array().default([]),
  123. autoRollback: z
  124. .object({
  125. enabled: z.boolean(),
  126. readOnly: z.boolean().optional(),
  127. })
  128. .default({ enabled: false, readOnly: false }),
  129. });
  130. export type ClientPorterApp = z.infer<typeof clientAppValidator>;
  131. export const basePorterAppFormValidator = z.object({
  132. app: clientAppValidator,
  133. source: sourceValidator,
  134. deletions: deletionValidator,
  135. redeployOnSave: z.boolean().default(false),
  136. });
  137. // porterAppFormValidator is used to validate inputs when creating + updating an app
  138. export const porterAppFormValidator = basePorterAppFormValidator
  139. .refine(
  140. ({ app, source }) => {
  141. if (source.type !== "docker-registry" && app.build.method === "pack") {
  142. return app.services.every((svc) => svc.run.value.length > 0);
  143. }
  144. return true;
  145. },
  146. {
  147. message:
  148. "if building with buildpacks, all services must include a run command. Make sure all services contain a run command or change your build method to Docker in build settings",
  149. path: ["app", "services"],
  150. }
  151. )
  152. .refine(
  153. ({ app, source }) => {
  154. if (source.type === "docker-registry" || app.build.method === "docker") {
  155. return app.services.every(
  156. (svc) => !svc.run.value.startsWith("docker run")
  157. );
  158. }
  159. return true;
  160. },
  161. {
  162. message:
  163. "if using Docker registry or building via a Dockerfile, service must not include `docker run` in its start command; instead, leave the start command empty",
  164. path: ["app", "services"],
  165. }
  166. )
  167. .refine(
  168. ({ app }) => {
  169. return app.services.length !== 0;
  170. },
  171. {
  172. message: "app must have at least one service",
  173. path: ["app", "services"],
  174. }
  175. )
  176. .refine(
  177. ({ app: { env } }) => {
  178. return env.every((e) => e.key.length > 0 && /^[A-Za-z]/.test(e.key));
  179. },
  180. {
  181. message: "All environment variables keys must start with a letter",
  182. path: ["app", "env"],
  183. }
  184. );
  185. export type PorterAppFormData = z.infer<typeof porterAppFormValidator>;
  186. // serviceOverrides is used to generate the services overrides for an app from porter.yaml
  187. // this method is only called when a porter.yaml is present and has services defined
  188. export function serviceOverrides({
  189. overrides,
  190. useDefaults = true,
  191. defaultCPU = 0.1,
  192. defaultRAM = 256,
  193. }: {
  194. overrides: PorterApp;
  195. useDefaults?: boolean;
  196. defaultCPU?: number;
  197. defaultRAM?: number;
  198. }): DetectedServices {
  199. const services = uniqueServices(overrides)
  200. .map((service) => serializedServiceFromProto({ service }))
  201. .map((svc) => {
  202. if (useDefaults) {
  203. return deserializeService({
  204. service: defaultSerialized({
  205. name: svc.name,
  206. type: svc.config.type,
  207. defaultCPU,
  208. defaultRAM,
  209. }),
  210. override: svc,
  211. expanded: true,
  212. setDefaults: false,
  213. });
  214. }
  215. return deserializeService({ service: svc, setDefaults: false });
  216. });
  217. const validatedBuild = buildValidator
  218. .default({
  219. method: "pack",
  220. context: "./",
  221. buildpacks: [],
  222. builder: "",
  223. })
  224. .parse(overrides.build);
  225. if (!overrides.predeploy) {
  226. return {
  227. build: validatedBuild,
  228. services,
  229. };
  230. }
  231. const predeploy = match({
  232. predeployOverride: overrides.predeploy,
  233. useDefaults,
  234. })
  235. .with(
  236. {
  237. predeployOverride: P.nullish,
  238. },
  239. () => undefined
  240. )
  241. .with(
  242. {
  243. useDefaults: true,
  244. },
  245. ({ predeployOverride }) =>
  246. deserializeService({
  247. service: defaultSerialized({
  248. name: "pre-deploy",
  249. type: "predeploy",
  250. defaultCPU,
  251. defaultRAM,
  252. }),
  253. override: serializedServiceFromProto({
  254. service: new Service({
  255. ...predeployOverride,
  256. name: "pre-deploy",
  257. }),
  258. isPredeploy: true,
  259. }),
  260. expanded: true,
  261. })
  262. )
  263. .otherwise(({ predeployOverride }) =>
  264. deserializeService({
  265. service: serializedServiceFromProto({
  266. service: new Service({
  267. ...predeployOverride,
  268. name: "pre-deploy",
  269. }),
  270. isPredeploy: true,
  271. }),
  272. })
  273. );
  274. const initialDeploy = match({
  275. initialDeployOverride: overrides.initialDeploy,
  276. useDefaults,
  277. })
  278. .with(
  279. {
  280. initialDeployOverride: P.nullish,
  281. },
  282. () => undefined
  283. )
  284. .with(
  285. {
  286. useDefaults: true,
  287. initialDeployOverride: P.not(P.nullish),
  288. },
  289. ({ initialDeployOverride }) =>
  290. deserializeService({
  291. service: defaultSerialized({
  292. name: "initdeploy",
  293. type: "initdeploy",
  294. defaultCPU,
  295. defaultRAM,
  296. }),
  297. override: serializedServiceFromProto({
  298. service: new Service({
  299. ...initialDeployOverride,
  300. name: "initdeploy",
  301. }),
  302. isPredeploy: false,
  303. isInitdeploy: true,
  304. }),
  305. expanded: true,
  306. })
  307. )
  308. .otherwise(({ initialDeployOverride }) =>
  309. deserializeService({
  310. service: serializedServiceFromProto({
  311. service: new Service({
  312. ...(initialDeployOverride ?? {}),
  313. name: "initdeploy",
  314. }),
  315. isInitdeploy: true,
  316. }),
  317. })
  318. );
  319. return {
  320. build: validatedBuild,
  321. services,
  322. predeploy,
  323. initialDeploy,
  324. };
  325. }
  326. const clientBuildToProto = (build: BuildOptions): Build => {
  327. return match(build)
  328. .with(
  329. { method: "pack" },
  330. (b) =>
  331. new Build({
  332. method: "pack",
  333. context: b.context,
  334. buildpacks: b.buildpacks.map((b) => b.buildpack),
  335. builder: b.builder,
  336. })
  337. )
  338. .with(
  339. { method: "docker" },
  340. (b) =>
  341. new Build({
  342. method: "docker",
  343. context: b.context,
  344. dockerfile: b.dockerfile,
  345. })
  346. )
  347. .exhaustive();
  348. };
  349. export function clientAppToProto(data: PorterAppFormData): PorterApp {
  350. const { app, source } = data;
  351. const services = app.services.reduce((acc: Record<string, Service>, svc) => {
  352. const serialized = serializeService(svc);
  353. const proto = serviceProto(serialized);
  354. acc[svc.name.value] = proto;
  355. return acc;
  356. }, {});
  357. // filter out predeploy if its start command is empty
  358. const predeploy = app.predeploy?.[0]?.run.value
  359. ? app.predeploy[0]
  360. : undefined;
  361. const initialDeploy = app.initialDeploy?.[0]?.run.value
  362. ? app.initialDeploy[0]
  363. : undefined;
  364. const proto = match(source)
  365. .with(
  366. { type: "github" },
  367. { type: "local" },
  368. () =>
  369. new PorterApp({
  370. name: app.name.value,
  371. services,
  372. envGroups: app.envGroups.map((eg) => ({
  373. name: eg.name,
  374. version: eg.version,
  375. })),
  376. build: clientBuildToProto(app.build),
  377. ...(predeploy && {
  378. predeploy: serviceProto(serializeService(predeploy)),
  379. }),
  380. ...(initialDeploy && {
  381. initialDeploy: serviceProto(serializeService(initialDeploy)),
  382. }),
  383. helmOverrides:
  384. app.helmOverrides != null
  385. ? new HelmOverrides({ b64Values: btoa(app.helmOverrides) })
  386. : undefined,
  387. efsStorage: new EFS({
  388. enabled: app.efsStorage.enabled,
  389. }),
  390. cloudSql: new CloudSql({
  391. enabled: app.cloudSql.enabled,
  392. connectionName: app.cloudSql?.connectionName ?? "",
  393. serviceAccountJsonSecret:
  394. app.cloudSql?.serviceAccountJsonSecret ?? "",
  395. dbPort: app.cloudSql?.dbPort ?? 5432,
  396. }),
  397. requiredApps: app.requiredApps.map((app) => ({
  398. name: app.name,
  399. })),
  400. autoRollback: new AutoRollback({
  401. enabled: app.autoRollback.enabled,
  402. }),
  403. })
  404. )
  405. .with(
  406. { type: "docker-registry" },
  407. (src) =>
  408. new PorterApp({
  409. name: app.name.value,
  410. services,
  411. envGroups: app.envGroups.map((eg) => ({
  412. name: eg.name,
  413. version: eg.version,
  414. })),
  415. image: {
  416. repository: src.image.repository,
  417. tag: src.image.tag,
  418. },
  419. ...(predeploy && {
  420. predeploy: serviceProto(serializeService(predeploy)),
  421. }),
  422. helmOverrides:
  423. app.helmOverrides != null
  424. ? new HelmOverrides({ b64Values: btoa(app.helmOverrides) })
  425. : undefined,
  426. efsStorage: new EFS({
  427. enabled: app.efsStorage.enabled,
  428. }),
  429. cloudSql: new CloudSql({
  430. enabled: app.cloudSql.enabled,
  431. connectionName: app.cloudSql?.connectionName ?? "",
  432. serviceAccountJsonSecret:
  433. app.cloudSql?.serviceAccountJsonSecret ?? "",
  434. dbPort: app.cloudSql?.dbPort ?? 5432,
  435. }),
  436. requiredApps: app.requiredApps.map((app) => ({
  437. name: app.name,
  438. })),
  439. autoRollback: new AutoRollback({
  440. enabled: app.autoRollback.enabled,
  441. }),
  442. })
  443. )
  444. .exhaustive();
  445. return proto;
  446. }
  447. const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
  448. if (!proto) {
  449. return;
  450. }
  451. const buildValidation = z
  452. .discriminatedUnion("method", [
  453. z.object({
  454. method: z.literal("pack"),
  455. context: z.string(),
  456. buildpacks: z.array(z.string()).default([]),
  457. builder: z.string(),
  458. }),
  459. z.object({
  460. method: z.literal("docker"),
  461. context: z.string(),
  462. dockerfile: z.string(),
  463. }),
  464. ])
  465. .safeParse(proto);
  466. if (!buildValidation.success) {
  467. return;
  468. }
  469. const build = buildValidation.data;
  470. return match(build)
  471. .with({ method: "pack" }, (b) =>
  472. Object.freeze({
  473. method: b.method,
  474. context: b.context,
  475. buildpacks: b.buildpacks.map((b) => ({
  476. name: BUILDPACK_TO_NAME[b] ?? b,
  477. buildpack: b,
  478. })),
  479. builder: b.builder,
  480. })
  481. )
  482. .with({ method: "docker" }, (b) =>
  483. Object.freeze({
  484. method: b.method,
  485. context: b.context,
  486. dockerfile: b.dockerfile,
  487. })
  488. )
  489. .exhaustive();
  490. };
  491. export function clientAppFromProto({
  492. proto,
  493. overrides,
  494. variables = {},
  495. secrets = {},
  496. lockServiceDeletions = false,
  497. }: {
  498. proto: PorterApp;
  499. overrides: DetectedServices | null;
  500. variables?: Record<string, string>;
  501. secrets?: Record<string, string>;
  502. lockServiceDeletions?: boolean;
  503. }): ClientPorterApp {
  504. const services = uniqueServices(proto)
  505. .map((service) => serializedServiceFromProto({ service }))
  506. .map((svc) => {
  507. const override = overrides?.services.find(
  508. (s) => s.name.value === svc.name
  509. );
  510. if (override) {
  511. const ds = deserializeService({
  512. service: svc,
  513. override: serializeService(override),
  514. });
  515. return ds;
  516. }
  517. return deserializeService({
  518. service: svc,
  519. lockDeletions: lockServiceDeletions,
  520. });
  521. });
  522. const predeployList = (proto.predeploy ? [proto.predeploy] : [])
  523. .map((service) =>
  524. serializedServiceFromProto({ service, isPredeploy: true })
  525. )
  526. .map((svc) => {
  527. const override = overrides?.predeploy;
  528. if (override) {
  529. return deserializeService({
  530. service: svc,
  531. override: serializeService(override),
  532. });
  533. }
  534. return deserializeService({
  535. service: svc,
  536. lockDeletions: lockServiceDeletions,
  537. });
  538. });
  539. const initialDeployList = (proto.initialDeploy ? [proto.initialDeploy] : [])
  540. .map((service) =>
  541. serializedServiceFromProto({ service, isInitdeploy: true })
  542. )
  543. .map((svc) => {
  544. const override = overrides?.initialDeploy;
  545. if (override) {
  546. return deserializeService({
  547. service: svc,
  548. override: serializeService(override),
  549. });
  550. }
  551. return deserializeService({
  552. service: svc,
  553. lockDeletions: lockServiceDeletions,
  554. });
  555. });
  556. const parsedEnv: KeyValueType[] = [
  557. ...Object.entries(variables).map(([key, value]) => ({
  558. key,
  559. value,
  560. hidden: false,
  561. locked: false,
  562. deleted: false,
  563. })),
  564. ...Object.entries(secrets).map(([key, value]) => ({
  565. key,
  566. value,
  567. hidden: true,
  568. locked: true,
  569. deleted: false,
  570. })),
  571. ];
  572. const helmOverrides =
  573. proto.helmOverrides == null ? "" : atob(proto.helmOverrides.b64Values);
  574. return {
  575. name: {
  576. readOnly: true,
  577. value: proto.name,
  578. },
  579. services,
  580. predeploy: predeployList.length ? predeployList : undefined,
  581. initialDeploy: initialDeployList.length ? initialDeployList : undefined,
  582. env: parsedEnv,
  583. envGroups: proto.envGroups.map((eg) => ({
  584. name: eg.name,
  585. version: eg.version,
  586. })),
  587. build: clientBuildFromProto(proto.build) ?? {
  588. method: "pack",
  589. context: "./",
  590. buildpacks: [],
  591. builder: "",
  592. },
  593. helmOverrides,
  594. efsStorage: { enabled: proto.efsStorage?.enabled ?? false },
  595. cloudSql: {
  596. enabled: proto.cloudSql?.enabled ?? false,
  597. connectionName: proto.cloudSql?.connectionName ?? "",
  598. serviceAccountJsonSecret: proto.cloudSql?.serviceAccountJsonSecret ?? "",
  599. dbPort: proto.cloudSql?.dbPort ?? 5432,
  600. },
  601. requiredApps: proto.requiredApps.map((app) => ({
  602. name: app.name,
  603. })),
  604. autoRollback: {
  605. enabled: proto.autoRollback?.enabled ?? true, // enabled by default if not found in proto
  606. readOnly: false, // TODO: detect autorollback from porter.yaml
  607. },
  608. };
  609. }
  610. export function applyPreviewOverrides({
  611. app,
  612. overrides,
  613. }: {
  614. app: ClientPorterApp;
  615. overrides?: DetectedServices["previews"];
  616. }): ClientPorterApp {
  617. const services = app.services.map((svc) => {
  618. const override = overrides?.services.find(
  619. (s) => s.name.value === svc.name.value
  620. );
  621. if (override) {
  622. const ds = deserializeService({
  623. service: serializeService(svc),
  624. override: serializeService(override),
  625. });
  626. if (ds.config.type === "web") {
  627. return {
  628. ...ds,
  629. config: {
  630. ...ds.config,
  631. domains: [],
  632. },
  633. };
  634. }
  635. return ds;
  636. }
  637. if (svc.config.type === "web") {
  638. return {
  639. ...svc,
  640. config: {
  641. ...svc.config,
  642. domains: [],
  643. },
  644. };
  645. }
  646. return svc;
  647. });
  648. const additionalServices =
  649. overrides?.services
  650. .filter(
  651. (s) => !app.services.find((svc) => svc.name.value === s.name.value)
  652. )
  653. .map((svc) => deserializeService({ service: serializeService(svc) })) ??
  654. [];
  655. app.services = [...services, ...additionalServices];
  656. if (app.predeploy) {
  657. const predeployOverride = overrides?.predeploy;
  658. if (predeployOverride) {
  659. app.predeploy = [
  660. deserializeService({
  661. service: serializeService(app.predeploy[0]),
  662. override: serializeService(predeployOverride),
  663. }),
  664. ];
  665. }
  666. }
  667. if (app.initialDeploy) {
  668. const initialDeployOverride = overrides?.initialDeploy;
  669. if (initialDeployOverride) {
  670. app.initialDeploy = [
  671. deserializeService({
  672. service: serializeService(app.initialDeploy[0]),
  673. override: serializeService(initialDeployOverride),
  674. }),
  675. ];
  676. }
  677. }
  678. const envOverrides = overrides?.variables;
  679. const env = app.env.map((e) => {
  680. const override = envOverrides?.[e.key];
  681. if (override) {
  682. return {
  683. ...e,
  684. locked: true,
  685. value: override,
  686. };
  687. }
  688. return e;
  689. });
  690. const additionalEnv = Object.entries(envOverrides ?? {})
  691. .filter(([key]) => !app.env.find((e) => e.key === key))
  692. .map(([key, value]) => ({
  693. key,
  694. value,
  695. hidden: false,
  696. locked: true,
  697. deleted: false,
  698. }));
  699. app.env = [...env, ...additionalEnv];
  700. return app;
  701. }