| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748 |
- import {
- AutoRollback,
- Build,
- CloudSql,
- EFS,
- HelmOverrides,
- PorterApp,
- Service,
- } from "@porter-dev/api-contracts";
- import { match, P } from "ts-pattern";
- import { z } from "zod";
- import { BUILDPACK_TO_NAME } from "main/home/app-dashboard/types/buildpack";
- import { type KeyValueType } from "main/home/cluster-dashboard/env-groups/EnvGroupArray";
- import { buildValidator, type BuildOptions } from "./build";
- import {
- defaultSerialized,
- deserializeService,
- serializedServiceFromProto,
- serializeService,
- serviceProto,
- serviceValidator,
- uniqueServices,
- type DetectedServices,
- } from "./services";
- // 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().min(1),
- git_branch: z.string().min(1),
- git_repo_name: z.string().min(1),
- porter_yaml_path: z.string().default("./porter.yaml"),
- }),
- z.object({
- type: z.literal("local"),
- git_branch: z.undefined(),
- git_repo_name: z.undefined(),
- }),
- z.object({
- type: z.literal("docker-registry"),
- // add branch and repo as undefined to allow for easy checks on changes to the source type
- // (i.e. we want to remove the services if any source fields change)
- git_branch: z.undefined(),
- git_repo_name: z.undefined(),
- image: z.object({
- repository: z.string().min(1),
- tag: z.string().default("latest"),
- }),
- }),
- ]);
- export type SourceOptions = z.infer<typeof sourceValidator>;
- export const deletionValidator = z.object({
- serviceNames: z
- .object({
- name: z.string(),
- })
- .array(),
- predeploy: z
- .object({
- name: z.string(),
- })
- .array(),
- initialDeploy: z
- .object({
- name: z.string(),
- })
- .array(),
- envGroupNames: z
- .object({
- name: z.string(),
- })
- .array(),
- });
- // clientAppValidator is the representation of a Porter app on the client, and is used to validate inputs for app setting fields
- export const clientAppValidator = z.object({
- name: z.object({
- readOnly: z.boolean(),
- value: z
- .string()
- .min(1, { message: "Name must be at least 1 character" })
- .max(31, { message: "Name must be 31 characters or less" })
- .regex(/^[a-z0-9-]{1,61}$/, {
- message: 'Lowercase letters, numbers, and "-" only.',
- }),
- }),
- efsStorage: z.object({
- enabled: z.boolean(),
- readOnly: z.boolean().optional(),
- }),
- cloudSql: z
- .object({
- enabled: z.boolean(),
- connectionName: z.string(),
- dbPort: z.coerce.number(),
- serviceAccountJsonSecret: z.string(),
- })
- .default({
- enabled: false,
- connectionName: "",
- dbPort: 5432,
- serviceAccountJsonSecret: "",
- }),
- envGroups: z
- .object({ name: z.string(), version: z.bigint() })
- .array()
- .default([]),
- services: serviceValidator.array(),
- predeploy: serviceValidator.array().optional(),
- initialDeploy: serviceValidator.array().optional(),
- env: z
- .object({
- key: z.string(),
- value: z.string(),
- hidden: z.boolean(),
- locked: z.boolean(),
- deleted: z.boolean(),
- })
- .array()
- .default([]),
- build: buildValidator,
- helmOverrides: z.string().optional(),
- requiredApps: z.object({ name: z.string() }).array().default([]),
- autoRollback: z
- .object({
- enabled: z.boolean(),
- readOnly: z.boolean().optional(),
- })
- .default({ enabled: false, readOnly: false }),
- });
- export type ClientPorterApp = z.infer<typeof clientAppValidator>;
- export const basePorterAppFormValidator = z.object({
- app: clientAppValidator,
- source: sourceValidator,
- deletions: deletionValidator,
- redeployOnSave: z.boolean().default(false),
- });
- // porterAppFormValidator is used to validate inputs when creating + updating an app
- export const porterAppFormValidator = basePorterAppFormValidator
- .refine(
- ({ app, source }) => {
- if (source.type !== "docker-registry" && app.build.method === "pack") {
- return app.services.every((svc) => svc.run.value.length > 0);
- }
- return true;
- },
- {
- message:
- "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",
- path: ["app", "services"],
- }
- )
- .refine(
- ({ app, source }) => {
- if (source.type === "docker-registry" || app.build.method === "docker") {
- return app.services.every(
- (svc) => !svc.run.value.startsWith("docker run")
- );
- }
- return true;
- },
- {
- message:
- "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",
- path: ["app", "services"],
- }
- )
- .refine(
- ({ app }) => {
- return app.services.length !== 0;
- },
- {
- message: "app must have at least one service",
- path: ["app", "services"],
- }
- )
- .refine(
- ({ app: { env } }) => {
- return env.every((e) => e.key.length > 0 && /^[A-Za-z]/.test(e.key));
- },
- {
- message: "All environment variables keys must start with a letter",
- path: ["app", "env"],
- }
- );
- export type PorterAppFormData = z.infer<typeof porterAppFormValidator>;
- // serviceOverrides is used to generate the services overrides for an app from porter.yaml
- // this method is only called when a porter.yaml is present and has services defined
- export function serviceOverrides({
- overrides,
- useDefaults = true,
- defaultCPU = 0.1,
- defaultRAM = 256,
- }: {
- overrides: PorterApp;
- useDefaults?: boolean;
- defaultCPU?: number;
- defaultRAM?: number;
- }): DetectedServices {
- const services = uniqueServices(overrides)
- .map((service) => serializedServiceFromProto({ service }))
- .map((svc) => {
- if (useDefaults) {
- return deserializeService({
- service: defaultSerialized({
- name: svc.name,
- type: svc.config.type,
- defaultCPU,
- defaultRAM,
- }),
- override: svc,
- expanded: true,
- setDefaults: false,
- });
- }
- return deserializeService({ service: svc, setDefaults: false });
- });
- const validatedBuild = buildValidator
- .default({
- method: "pack",
- context: "./",
- buildpacks: [],
- builder: "",
- })
- .parse(overrides.build);
- if (!overrides.predeploy) {
- return {
- build: validatedBuild,
- services,
- };
- }
- const predeploy = match({
- predeployOverride: overrides.predeploy,
- useDefaults,
- })
- .with(
- {
- predeployOverride: P.nullish,
- },
- () => undefined
- )
- .with(
- {
- useDefaults: true,
- },
- ({ predeployOverride }) =>
- deserializeService({
- service: defaultSerialized({
- name: "pre-deploy",
- type: "predeploy",
- defaultCPU,
- defaultRAM,
- }),
- override: serializedServiceFromProto({
- service: new Service({
- ...predeployOverride,
- name: "pre-deploy",
- }),
- isPredeploy: true,
- }),
- expanded: true,
- })
- )
- .otherwise(({ predeployOverride }) =>
- deserializeService({
- service: serializedServiceFromProto({
- service: new Service({
- ...predeployOverride,
- name: "pre-deploy",
- }),
- isPredeploy: true,
- }),
- })
- );
- const initialDeploy = match({
- initialDeployOverride: overrides.initialDeploy,
- useDefaults,
- })
- .with(
- {
- initialDeployOverride: P.nullish,
- },
- () => undefined
- )
- .with(
- {
- useDefaults: true,
- initialDeployOverride: P.not(P.nullish),
- },
- ({ initialDeployOverride }) =>
- deserializeService({
- service: defaultSerialized({
- name: "initdeploy",
- type: "initdeploy",
- defaultCPU,
- defaultRAM,
- }),
- override: serializedServiceFromProto({
- service: new Service({
- ...initialDeployOverride,
- name: "initdeploy",
- }),
- isPredeploy: false,
- isInitdeploy: true,
- }),
- expanded: true,
- })
- )
- .otherwise(({ initialDeployOverride }) =>
- deserializeService({
- service: serializedServiceFromProto({
- service: new Service({
- ...(initialDeployOverride ?? {}),
- name: "initdeploy",
- }),
- isInitdeploy: true,
- }),
- })
- );
- return {
- build: validatedBuild,
- services,
- predeploy,
- initialDeploy,
- };
- }
- const clientBuildToProto = (build: BuildOptions): Build => {
- return match(build)
- .with(
- { method: "pack" },
- (b) =>
- new Build({
- method: "pack",
- context: b.context,
- buildpacks: b.buildpacks.map((b) => b.buildpack),
- builder: b.builder,
- })
- )
- .with(
- { method: "docker" },
- (b) =>
- new Build({
- method: "docker",
- context: b.context,
- dockerfile: b.dockerfile,
- })
- )
- .exhaustive();
- };
- export function clientAppToProto(data: PorterAppFormData): PorterApp {
- const { app, source } = data;
- const services = app.services.reduce((acc: Record<string, Service>, svc) => {
- const serialized = serializeService(svc);
- const proto = serviceProto(serialized);
- acc[svc.name.value] = proto;
- return acc;
- }, {});
- // filter out predeploy if its start command is empty
- const predeploy = app.predeploy?.[0]?.run.value
- ? app.predeploy[0]
- : undefined;
- const initialDeploy = app.initialDeploy?.[0]?.run.value
- ? app.initialDeploy[0]
- : undefined;
- const proto = match(source)
- .with(
- { type: "github" },
- { type: "local" },
- () =>
- new PorterApp({
- name: app.name.value,
- services,
- envGroups: app.envGroups.map((eg) => ({
- name: eg.name,
- version: eg.version,
- })),
- build: clientBuildToProto(app.build),
- ...(predeploy && {
- predeploy: serviceProto(serializeService(predeploy)),
- }),
- ...(initialDeploy && {
- initialDeploy: serviceProto(serializeService(initialDeploy)),
- }),
- helmOverrides:
- app.helmOverrides != null
- ? new HelmOverrides({ b64Values: btoa(app.helmOverrides) })
- : undefined,
- efsStorage: new EFS({
- enabled: app.efsStorage.enabled,
- }),
- cloudSql: new CloudSql({
- enabled: app.cloudSql.enabled,
- connectionName: app.cloudSql?.connectionName ?? "",
- serviceAccountJsonSecret:
- app.cloudSql?.serviceAccountJsonSecret ?? "",
- dbPort: app.cloudSql?.dbPort ?? 5432,
- }),
- requiredApps: app.requiredApps.map((app) => ({
- name: app.name,
- })),
- autoRollback: new AutoRollback({
- enabled: app.autoRollback.enabled,
- }),
- })
- )
- .with(
- { type: "docker-registry" },
- (src) =>
- new PorterApp({
- name: app.name.value,
- services,
- envGroups: app.envGroups.map((eg) => ({
- name: eg.name,
- version: eg.version,
- })),
- image: {
- repository: src.image.repository,
- tag: src.image.tag,
- },
- ...(predeploy && {
- predeploy: serviceProto(serializeService(predeploy)),
- }),
- helmOverrides:
- app.helmOverrides != null
- ? new HelmOverrides({ b64Values: btoa(app.helmOverrides) })
- : undefined,
- efsStorage: new EFS({
- enabled: app.efsStorage.enabled,
- }),
- cloudSql: new CloudSql({
- enabled: app.cloudSql.enabled,
- connectionName: app.cloudSql?.connectionName ?? "",
- serviceAccountJsonSecret:
- app.cloudSql?.serviceAccountJsonSecret ?? "",
- dbPort: app.cloudSql?.dbPort ?? 5432,
- }),
- requiredApps: app.requiredApps.map((app) => ({
- name: app.name,
- })),
- autoRollback: new AutoRollback({
- enabled: app.autoRollback.enabled,
- }),
- })
- )
- .exhaustive();
- return proto;
- }
- const clientBuildFromProto = (proto?: Build): BuildOptions | undefined => {
- if (!proto) {
- return;
- }
- const buildValidation = z
- .discriminatedUnion("method", [
- z.object({
- method: z.literal("pack"),
- context: z.string(),
- buildpacks: z.array(z.string()).default([]),
- builder: z.string(),
- }),
- z.object({
- method: z.literal("docker"),
- context: z.string(),
- dockerfile: z.string(),
- }),
- ])
- .safeParse(proto);
- if (!buildValidation.success) {
- return;
- }
- const build = buildValidation.data;
- return match(build)
- .with({ method: "pack" }, (b) =>
- Object.freeze({
- method: b.method,
- context: b.context,
- buildpacks: b.buildpacks.map((b) => ({
- name: BUILDPACK_TO_NAME[b] ?? b,
- buildpack: b,
- })),
- builder: b.builder,
- })
- )
- .with({ method: "docker" }, (b) =>
- Object.freeze({
- method: b.method,
- context: b.context,
- dockerfile: b.dockerfile,
- })
- )
- .exhaustive();
- };
- export function clientAppFromProto({
- proto,
- overrides,
- variables = {},
- secrets = {},
- lockServiceDeletions = false,
- }: {
- proto: PorterApp;
- overrides: DetectedServices | null;
- variables?: Record<string, string>;
- secrets?: Record<string, string>;
- lockServiceDeletions?: boolean;
- }): ClientPorterApp {
- const services = uniqueServices(proto)
- .map((service) => serializedServiceFromProto({ service }))
- .map((svc) => {
- const override = overrides?.services.find(
- (s) => s.name.value === svc.name
- );
- if (override) {
- const ds = deserializeService({
- service: svc,
- override: serializeService(override),
- });
- return ds;
- }
- return deserializeService({
- service: svc,
- lockDeletions: lockServiceDeletions,
- });
- });
- const predeployList = (proto.predeploy ? [proto.predeploy] : [])
- .map((service) =>
- serializedServiceFromProto({ service, isPredeploy: true })
- )
- .map((svc) => {
- const override = overrides?.predeploy;
- if (override) {
- return deserializeService({
- service: svc,
- override: serializeService(override),
- });
- }
- return deserializeService({
- service: svc,
- lockDeletions: lockServiceDeletions,
- });
- });
- const initialDeployList = (proto.initialDeploy ? [proto.initialDeploy] : [])
- .map((service) =>
- serializedServiceFromProto({ service, isInitdeploy: true })
- )
- .map((svc) => {
- const override = overrides?.initialDeploy;
- if (override) {
- return deserializeService({
- service: svc,
- override: serializeService(override),
- });
- }
- return deserializeService({
- service: svc,
- lockDeletions: lockServiceDeletions,
- });
- });
- const parsedEnv: KeyValueType[] = [
- ...Object.entries(variables).map(([key, value]) => ({
- key,
- value,
- hidden: false,
- locked: false,
- deleted: false,
- })),
- ...Object.entries(secrets).map(([key, value]) => ({
- key,
- value,
- hidden: true,
- locked: true,
- deleted: false,
- })),
- ];
- const helmOverrides =
- proto.helmOverrides == null ? "" : atob(proto.helmOverrides.b64Values);
- return {
- name: {
- readOnly: true,
- value: proto.name,
- },
- services,
- predeploy: predeployList.length ? predeployList : undefined,
- initialDeploy: initialDeployList.length ? initialDeployList : undefined,
- env: parsedEnv,
- envGroups: proto.envGroups.map((eg) => ({
- name: eg.name,
- version: eg.version,
- })),
- build: clientBuildFromProto(proto.build) ?? {
- method: "pack",
- context: "./",
- buildpacks: [],
- builder: "",
- },
- helmOverrides,
- efsStorage: { enabled: proto.efsStorage?.enabled ?? false },
- cloudSql: {
- enabled: proto.cloudSql?.enabled ?? false,
- connectionName: proto.cloudSql?.connectionName ?? "",
- serviceAccountJsonSecret: proto.cloudSql?.serviceAccountJsonSecret ?? "",
- dbPort: proto.cloudSql?.dbPort ?? 5432,
- },
- requiredApps: proto.requiredApps.map((app) => ({
- name: app.name,
- })),
- autoRollback: {
- enabled: proto.autoRollback?.enabled ?? true, // enabled by default if not found in proto
- readOnly: false, // TODO: detect autorollback from porter.yaml
- },
- };
- }
- export function applyPreviewOverrides({
- app,
- overrides,
- }: {
- app: ClientPorterApp;
- overrides?: DetectedServices["previews"];
- }): ClientPorterApp {
- const services = app.services.map((svc) => {
- const override = overrides?.services.find(
- (s) => s.name.value === svc.name.value
- );
- if (override) {
- const ds = deserializeService({
- service: serializeService(svc),
- override: serializeService(override),
- });
- if (ds.config.type === "web") {
- return {
- ...ds,
- config: {
- ...ds.config,
- domains: [],
- },
- };
- }
- return ds;
- }
- if (svc.config.type === "web") {
- return {
- ...svc,
- config: {
- ...svc.config,
- domains: [],
- },
- };
- }
- return svc;
- });
- const additionalServices =
- overrides?.services
- .filter(
- (s) => !app.services.find((svc) => svc.name.value === s.name.value)
- )
- .map((svc) => deserializeService({ service: serializeService(svc) })) ??
- [];
- app.services = [...services, ...additionalServices];
- if (app.predeploy) {
- const predeployOverride = overrides?.predeploy;
- if (predeployOverride) {
- app.predeploy = [
- deserializeService({
- service: serializeService(app.predeploy[0]),
- override: serializeService(predeployOverride),
- }),
- ];
- }
- }
- if (app.initialDeploy) {
- const initialDeployOverride = overrides?.initialDeploy;
- if (initialDeployOverride) {
- app.initialDeploy = [
- deserializeService({
- service: serializeService(app.initialDeploy[0]),
- override: serializeService(initialDeployOverride),
- }),
- ];
- }
- }
- const envOverrides = overrides?.variables;
- const env = app.env.map((e) => {
- const override = envOverrides?.[e.key];
- if (override) {
- return {
- ...e,
- locked: true,
- value: override,
- };
- }
- return e;
- });
- const additionalEnv = Object.entries(envOverrides ?? {})
- .filter(([key]) => !app.env.find((e) => e.key === key))
- .map(([key, value]) => ({
- key,
- value,
- hidden: false,
- locked: true,
- deleted: false,
- }));
- app.env = [...env, ...additionalEnv];
- return app;
- }
|