diff --git a/package.json b/package.json index e87482c..5b21936 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "dockerode": "^4.0.2", "dotenv": "^16.3.1", "drizzle-orm": "^0.29.3", + "drizzle-zod": "^0.5.1", "extensionless": "^1.9.6", "framer-motion": "^10.17.6", "ipaddr.js": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1704e30..aaa8adf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -107,6 +107,9 @@ dependencies: drizzle-orm: specifier: ^0.29.3 version: 0.29.3(@types/better-sqlite3@7.6.8)(@types/react@18.2.46)(better-sqlite3@9.2.2)(react@18.2.0) + drizzle-zod: + specifier: ^0.5.1 + version: 0.5.1(drizzle-orm@0.29.3)(zod@3.22.4) extensionless: specifier: ^1.9.6 version: 1.9.6 @@ -4287,6 +4290,16 @@ packages: react: 18.2.0 dev: false + /drizzle-zod@0.5.1(drizzle-orm@0.29.3)(zod@3.22.4): + resolution: {integrity: sha512-C/8bvzUH/zSnVfwdSibOgFjLhtDtbKYmkbPbUCq46QZyZCH6kODIMSOgZ8R7rVjoI+tCj3k06MRJMDqsIeoS4A==} + peerDependencies: + drizzle-orm: '>=0.23.13' + zod: '*' + dependencies: + drizzle-orm: 0.29.3(@types/better-sqlite3@7.6.8)(@types/react@18.2.46)(better-sqlite3@9.2.2)(react@18.2.0) + zod: 3.22.4 + dev: false + /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} diff --git a/src/server/api/routers/projects/service/index.ts b/src/server/api/routers/projects/service/index.ts index 01506a7..73c9b33 100644 --- a/src/server/api/routers/projects/service/index.ts +++ b/src/server/api/routers/projects/service/index.ts @@ -1,20 +1,15 @@ import assert from "assert"; import { randomBytes } from "crypto"; import { eq } from "drizzle-orm"; +import { createInsertSchema } from "drizzle-zod"; import { z } from "zod"; import { env } from "~/env"; import { projectMiddleware } from "~/server/api/middleware/project"; import { serviceMiddleware } from "~/server/api/middleware/service"; import { authenticatedProcedure, createTRPCRouter } from "~/server/api/trpc"; import { service } from "~/server/db/schema"; -import { - DOCKER_DEPLOY_MODE_MAP, - DockerDeployMode, - DockerRestartCondition, - ServiceBuildMethod, - ServiceSource, -} from "~/server/db/types"; -import { zDockerName } from "~/server/utils/zod"; +import { DOCKER_DEPLOY_MODE_MAP, ServiceSource } from "~/server/db/types"; +import { zDockerDuration, zDockerImage, zDockerName } from "~/server/utils/zod"; import { getServiceContainers } from "./containers"; export const serviceRouter = createTRPCRouter({ @@ -61,39 +56,44 @@ export const serviceRouter = createTRPCRouter({ }, }) .input( + // sometimes i forget how powerful zod is z .object({ projectId: z.string(), serviceId: z.string(), }) .merge( - z - .object({ - source: z.nativeEnum(ServiceSource), - environment: z.string(), - dockerImage: z.string(), - dockerRegistryUsername: z.string(), - dockerRegistryPassword: z.string(), - // TODO: restrict to valid github url - githubUrl: z.string().url(), - githubBranch: z.string(), - gitUrl: z.string(), - gitBranch: z.string(), - buildMethod: z.nativeEnum(ServiceBuildMethod), - buildPath: z.string(), - command: z.string(), - entrypoint: z.string(), - replicas: z.number(), - maxReplicasPerNode: z.number(), - deployMode: z.nativeEnum(DockerDeployMode), - zeroDowntime: z.boolean(), - max_cpu: z.number(), - max_memory: z.string(), - max_pids: z.number(), - restart: z.nativeEnum(DockerRestartCondition), + createInsertSchema(service, { + dockerImage: zDockerImage, + gitUrl: (schema) => + schema.gitUrl.regex( + // https://www.debuggex.com/r/fFggA8Uc4YYKjl34 from https://stackoverflow.com/a/22312124 + // /(?P(git@|https:\/\/)([\w\.@]+)(\/|:))(?P[\w,\-,\_]+)\/(?P[\w,\-,\_]+)(.git){0,1}((\/){0,1})/, + /(git@|https:\/\/)([\w\.@]+)(\/|:)([\w,\-,\_]+)\/([\w,\-,\_]+)(.git){0,1}(\/{0,1})/, + { + message: "Must be a valid git url. (Regex failed)", + }, + ), + zeroDowntime: z.boolean().transform((val) => (val ? 1 : 0)), + restartDelay: zDockerDuration, + healthcheckEnabled: z.boolean().transform((val) => (val ? 1 : 0)), + healthcheckInterval: zDockerDuration, + healthcheckTimeout: zDockerDuration, + healthcheckStartPeriod: zDockerDuration, + loggingMaxSize: (schema) => + schema.loggingMaxSize.regex(/^\d+[kmg]$/, { + message: "Must be an integer plus a modifier (k, m, or g).", + }), + loggingMaxFiles: (schema) => schema.loggingMaxFiles.positive(), + }) + .omit({ + id: true, + projectId: true, + name: true, }) .partial(), - ), + ) + .strict(), ) .use(projectMiddleware) .use(serviceMiddleware) @@ -101,12 +101,14 @@ export const serviceRouter = createTRPCRouter({ await ctx.db .update(service) .set({ - name: input.name, - zeroDowntime: input.zeroDowntime ? 1 : 0, - deployMode: input.deployMode, + ...input, + projectId: undefined, + id: undefined, }) .where(eq(service.id, ctx.service.id)) .execute(); + + return true; }), create: authenticatedProcedure diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 24643a3..1621f9f 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -10,7 +10,7 @@ import { } from "drizzle-orm/sqlite-core"; import { DockerDeployMode, - type DockerRestartCondition, + DockerRestartCondition, type DockerVolumeType, type ServiceBuildMethod, type ServicePortType, @@ -147,7 +147,9 @@ export const service = sqliteTable( dockerRegistryPassword: text("docker_registry_password"), // for github source - githubUrl: text("github_url"), + // https://github.com/{username}/{repo} + githubUsername: text("github_username"), + githubRepository: text("github_repository"), githubBranch: text("github_branch"), // for git source @@ -170,26 +172,34 @@ export const service = sqliteTable( zeroDowntime: integer("zero_downtime").default(0).notNull(), // deployment usage limits - max_cpu: real("max_cpu"), - max_memory: text("max_memory"), - max_pids: integer("max_pids"), + max_cpu: real("max_cpu").default(0).notNull(), + max_memory: text("max_memory").default("0").notNull(), + max_pids: integer("max_pids").default(0).notNull(), // restart policy - restart: integer("restart").$type(), - restartDelay: text("restart_delay"), - restartMaxAttempts: integer("restart_max_attempts"), + restart: integer("restart") + .$type() + .default(DockerRestartCondition.OnFailure) + .notNull(), + + // format: https://docs.docker.com/compose/compose-file/compose-file-v3/#specifying-durations + restartDelay: text("restart_delay").default("5s"), + restartMaxAttempts: integer("restart_max_attempts").default(-1).notNull(), // healthcheck - healtcheckEnabled: integer("healthcheck_enabled").default(0).notNull(), + healthcheckEnabled: integer("healthcheck_enabled").default(0).notNull(), healthcheckCommand: text("healthcheck_command"), - healthcheckInterval: text("healthcheck_interval"), - healthcheckTimeout: text("healthcheck_timeout"), - healthcheckRetries: integer("healthcheck_retries"), - healthcheckStartPeriod: text("healthcheck_start_period"), + healthcheckInterval: text("healthcheck_interval").default("30s").notNull(), + healthcheckTimeout: text("healthcheck_timeout").default("30s").notNull(), + healthcheckRetries: integer("healthcheck_retries").default(3).notNull(), + healthcheckStartPeriod: text("healthcheck_start_period") + .default("0s") + .notNull(), // logging - loggingMaxSize: text("logging_max_size"), - loggingMaxFiles: integer("logging_max_files"), + // https://docs.docker.com/config/containers/logging/json-file/#options + loggingMaxSize: text("logging_max_size").default("-1").notNull(), + loggingMaxFiles: integer("logging_max_files").default(1).notNull(), createdAt: integer("created_at").default(now).notNull(), }, diff --git a/src/server/utils/zod.ts b/src/server/utils/zod.ts index 04424fe..f59cb56 100644 --- a/src/server/utils/zod.ts +++ b/src/server/utils/zod.ts @@ -3,3 +3,18 @@ import z from "zod"; export const zDockerName = z.string().regex(/^[a-z0-9-]+$/, { message: "Must be lowercase alphanumeric with dashes.", }); + +export const zDockerImage = z + .string() + .regex( + /^(?:(?=[^:\/]{1,253})(?!-)[a-zA-Z0-9-]{1,63}(?