diff --git a/package.json b/package.json index 0e4f145..6502062 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "cookie": "^0.6.0", "datastructures-js": "^13.0.0", "date-fns": "^3.0.6", + "deterministic-object-hash": "^2.0.2", "docker-cli-js": "^2.10.0", "docker-modem": "^5.0.3", "dockerode": "^4.0.2", @@ -59,6 +60,7 @@ "extensionless": "^1.9.6", "framer-motion": "^11.0.3", "ipaddr.js": "^2.1.0", + "jsondiffpatch": "^0.6.0", "lucide-react": "^0.298.0", "next": "14.0.4", "next-nprogress-bar": "^2.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c10acff..35995f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ dependencies: date-fns: specifier: ^3.0.6 version: 3.0.6 + deterministic-object-hash: + specifier: ^2.0.2 + version: 2.0.2 docker-cli-js: specifier: ^2.10.0 version: 2.10.0 @@ -131,6 +134,9 @@ dependencies: ipaddr.js: specifier: ^2.1.0 version: 2.1.0 + jsondiffpatch: + specifier: ^0.6.0 + version: 0.6.0 lucide-react: specifier: ^0.298.0 version: 0.298.0(react@18.2.0) @@ -2975,6 +2981,10 @@ packages: resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} dev: false + /@types/diff-match-patch@1.0.36: + resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} + dev: false + /@types/docker-modem@3.0.6: resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} dependencies: @@ -3679,6 +3689,10 @@ packages: /balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + /base-64@1.0.0: + resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} + dev: false + /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} dev: false @@ -4386,9 +4400,20 @@ packages: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} dev: false + /deterministic-object-hash@2.0.2: + resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} + engines: {node: '>=18'} + dependencies: + base-64: 1.0.0 + dev: false + /didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + /diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + dev: false + /difflib@0.2.4: resolution: {integrity: sha512-9YVwmMb0wQHQNr5J9m6BSj6fk4pfGITGQOOs+D9Fl+INODWFOfvhIU1hNv6GgR1RBoC/9NJcwu77zShxV0kT7w==} dependencies: @@ -6194,6 +6219,16 @@ packages: minimist: 1.2.8 dev: true + /jsondiffpatch@0.6.0: + resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + dependencies: + '@types/diff-match-patch': 1.0.36 + chalk: 5.3.0 + diff-match-patch: 1.0.5 + dev: false + /jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} diff --git a/src/server/api/middleware/logger.ts b/src/server/api/middleware/logger.ts index efd2fbf..44a91c4 100644 --- a/src/server/api/middleware/logger.ts +++ b/src/server/api/middleware/logger.ts @@ -3,6 +3,7 @@ import chalk from "chalk"; import logger from "~/server/utils/logger"; const log = logger.child({ module: "trpc:server" }); + export const loggerMiddleware = experimental_standaloneMiddleware().create( async ({ type, path, next }) => { const result = await next(); diff --git a/src/server/api/middleware/project.ts b/src/server/api/middleware/project.ts index 4fbd010..686ef5a 100644 --- a/src/server/api/middleware/project.ts +++ b/src/server/api/middleware/project.ts @@ -1,7 +1,6 @@ import { TRPCError, experimental_standaloneMiddleware } from "@trpc/server"; -import { eq, or } from "drizzle-orm"; import { type db } from "~/server/db"; -import { projects } from "~/server/db/schema"; +import ProjectManager from "~/server/managers/ProjectManager"; export type BasicProjectDetails = { id: string; @@ -21,21 +20,7 @@ export const projectMiddleware = experimental_standaloneMiddleware<{ }); } - const [project] = await ctx.db - .select({ - id: projects.id, - friendlyName: projects.friendlyName, - internalName: projects.internalName, - createdAt: projects.createdAt, - }) - .from(projects) - .where( - or( - eq(projects.id, input.projectId), - eq(projects.internalName, input.projectId), - ), - ) - .limit(1); + const project = await ProjectManager.findByNameOrId(input.projectId); if (!project) throw new TRPCError({ @@ -45,7 +30,7 @@ export const projectMiddleware = experimental_standaloneMiddleware<{ return next({ ctx: { - project: project as BasicProjectDetails, + project: project, }, }); }); diff --git a/src/server/api/middleware/service.ts b/src/server/api/middleware/service.ts index be55b0e..ce4d182 100644 --- a/src/server/api/middleware/service.ts +++ b/src/server/api/middleware/service.ts @@ -23,20 +23,16 @@ export const serviceMiddleware = experimental_standaloneMiddleware<{ }); } - const [serviceDetails] = await ctx.db - .select({ - id: service.id, - name: service.name, - createdAt: service.createdAt, - }) - .from(service) - .where( - and( - eq(service.projectId, ctx.project.id), - or(eq(service.name, input.serviceId), eq(service.id, input.serviceId)), - ), - ) - .limit(1); + const serviceDetails = await ctx.db.query.service.findFirst({ + where: and( + eq(service.projectId, ctx.project.id), + or(eq(service.name, input.serviceId), eq(service.id, input.serviceId)), + ), + + with: { + latestGeneration: true, + }, + }); if (!serviceDetails) throw new TRPCError({ diff --git a/src/server/api/routers/projects/deploy.ts b/src/server/api/routers/projects/deploy.ts index 7a7470f..58d674d 100644 --- a/src/server/api/routers/projects/deploy.ts +++ b/src/server/api/routers/projects/deploy.ts @@ -1,7 +1,7 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import { BuildManager } from "~/server/build/BuildManager"; -import { service } from "~/server/db/schema"; +import { serviceGeneration } from "~/server/db/schema"; import { buildDockerStackFile } from "~/server/docker/stack"; import logger from "~/server/utils/logger"; import { projectMiddleware } from "../../middleware/project"; @@ -23,7 +23,7 @@ export const deployProject = authenticatedProcedure .use(projectMiddleware) .mutation(async ({ ctx, input }) => { const services = await ctx.db.query.service.findMany({ - where: eq(service.projectId, input.projectId), + where: eq(serviceGeneration.serviceId, input.projectId), with: { domains: true, diff --git a/src/server/api/routers/projects/index.ts b/src/server/api/routers/projects/index.ts index 3d3bd59..348a64b 100644 --- a/src/server/api/routers/projects/index.ts +++ b/src/server/api/routers/projects/index.ts @@ -1,7 +1,7 @@ import assert from "assert"; import { eq } from "drizzle-orm"; import { z } from "zod"; -import { projects, service } from "~/server/db/schema"; +import { projects, serviceGeneration } from "~/server/db/schema"; import { authenticatedProcedure, createTRPCRouter } from "../../trpc"; import { deployProject } from "./deploy"; import { getProject } from "./project"; @@ -34,11 +34,11 @@ export const projectRouter = createTRPCRouter({ userProjects.map(async (project) => { const projServices = await ctx.db .select({ - id: service.id, - name: service.name, + id: serviceGeneration.id, + name: serviceGeneration.name, }) - .from(service) - .where(eq(service.projectId, project.id)); + .from(serviceGeneration) + .where(eq(serviceGeneration.serviceId, project.id)); return { ...project, diff --git a/src/server/api/routers/projects/project.ts b/src/server/api/routers/projects/project.ts index 19a5489..18f71bd 100644 --- a/src/server/api/routers/projects/project.ts +++ b/src/server/api/routers/projects/project.ts @@ -1,6 +1,6 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; -import { service } from "~/server/db/schema"; +import { serviceGeneration } from "~/server/db/schema"; import { projectMiddleware } from "../../middleware/project"; import { authenticatedProcedure } from "../../trpc"; @@ -18,11 +18,11 @@ export const getProject = authenticatedProcedure .query(async ({ ctx }) => { const projServices = await ctx.db .select({ - id: service.id, - name: service.name, + id: serviceGeneration.id, + name: serviceGeneration.name, }) - .from(service) - .where(eq(service.projectId, ctx.project.id)); + .from(serviceGeneration) + .where(eq(serviceGeneration.serviceId, ctx.project.id)); // get docker stats const stats = await ctx.docker.listServices({ diff --git a/src/server/api/routers/projects/service/index.ts b/src/server/api/routers/projects/service/index.ts index 5bb9172..b91e15d 100644 --- a/src/server/api/routers/projects/service/index.ts +++ b/src/server/api/routers/projects/service/index.ts @@ -6,7 +6,7 @@ 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 { serviceGeneration } from "~/server/db/schema"; import { DOCKER_DEPLOY_MODE_MAP, ServiceSource } from "~/server/db/types"; import { zDockerName } from "~/server/utils/zod"; import { getServiceContainers } from "./containers"; @@ -35,7 +35,7 @@ export const serviceRouter = createTRPCRouter({ .use(serviceMiddleware) .query(async ({ ctx }) => { const fullServiceData = await ctx.db.query.service.findFirst({ - where: eq(service.id, ctx.service.id), + where: eq(serviceGeneration.id, ctx.service.id), with: { domains: true, ports: true, @@ -71,7 +71,7 @@ export const serviceRouter = createTRPCRouter({ .use(projectMiddleware) .mutation(async ({ ctx, input }) => { const [data] = await ctx.db - .insert(service) + .insert(serviceGeneration) .values({ name: input.name, projectId: ctx.project.id, @@ -83,7 +83,7 @@ export const serviceRouter = createTRPCRouter({ dockerImage: "traefik/whoami", }) .returning({ - id: service.id, + id: serviceGeneration.id, }) .execute() .catch((err) => { @@ -108,8 +108,8 @@ export const serviceRouter = createTRPCRouter({ .use(projectMiddleware) .mutation(async ({ ctx, input }) => { await ctx.db - .delete(service) - .where(eq(service.id, input.serviceId)) + .delete(serviceGeneration) + .where(eq(serviceGeneration.id, input.serviceId)) .execute(); }), }); diff --git a/src/server/api/routers/projects/service/update.ts b/src/server/api/routers/projects/service/update.ts index feae598..4ce16d4 100644 --- a/src/server/api/routers/projects/service/update.ts +++ b/src/server/api/routers/projects/service/update.ts @@ -4,7 +4,7 @@ import { z } from "zod"; import { projectMiddleware } from "~/server/api/middleware/project"; import { serviceMiddleware } from "~/server/api/middleware/service"; import { authenticatedProcedure } from "~/server/api/trpc"; -import { service, serviceDomain } from "~/server/db/schema"; +import { serviceDomain, serviceGeneration } from "~/server/db/schema"; import { DockerDeployMode } from "~/server/db/types"; import { zDockerDuration, zDockerImage, zDomain } from "~/server/utils/zod"; @@ -27,7 +27,7 @@ export const updateServiceProcedure = authenticatedProcedure serviceId: z.string(), }) .merge( - createInsertSchema(service, { + createInsertSchema(serviceGeneration, { dockerImage: zDockerImage, gitUrl: (schema) => schema.gitUrl.regex( @@ -93,9 +93,9 @@ export const updateServiceProcedure = authenticatedProcedure delete queryUpdate.id; await ctx.db - .update(service) + .update(serviceGeneration) .set(queryUpdate) - .where(eq(service.id, ctx.service.id)) + .where(eq(serviceGeneration.id, ctx.service.id)) .execute(); return true; diff --git a/src/server/api/routers/system/index.ts b/src/server/api/routers/system/index.ts index 5af7887..0e73c42 100644 --- a/src/server/api/routers/system/index.ts +++ b/src/server/api/routers/system/index.ts @@ -1,14 +1,14 @@ -import { authenticatedProcedure, createTRPCRouter } from "../../trpc"; import { observable } from "@trpc/server/observable"; import { updateTraefik } from "~/server/docker/traefik"; -import { BasicServerStats, stats } from "~/server/modules/stats"; +import { stats, type BasicServerStats } from "~/server/modules/stats"; +import { authenticatedProcedure, createTRPCRouter } from "../../trpc"; export const systemRouter = createTRPCRouter({ - currentStats: authenticatedProcedure.query(async ({ ctx }) => { + currentStats: authenticatedProcedure.query(async () => { return stats.getCurrentStats(); }), - liveStats: authenticatedProcedure.subscription(async ({ ctx }) => { + liveStats: authenticatedProcedure.subscription(() => { return observable((observer) => { const update = observer.next.bind(observer); @@ -19,14 +19,14 @@ export const systemRouter = createTRPCRouter({ }); }), - history: authenticatedProcedure.query(async ({ ctx }) => { + history: authenticatedProcedure.query(async () => { return await stats.getStatsInRange( new Date(Date.now() - 1000 * 60 * 60 * 24), ); }), // core container options - redeployTraefik: authenticatedProcedure.mutation(async ({ ctx }) => { + redeployTraefik: authenticatedProcedure.mutation(() => { setTimeout(() => { void updateTraefik(); }, 200); diff --git a/src/server/auth/Session.ts b/src/server/auth/Session.ts index c9d90d2..b6b9f37 100644 --- a/src/server/auth/Session.ts +++ b/src/server/auth/Session.ts @@ -1,13 +1,12 @@ -import { eq } from "drizzle-orm"; -import { db } from "../db"; -import { users, sessions } from "../db/schema"; -import { randomBytes } from "crypto"; import assert from "assert"; -import { env } from "~/env"; +import crypto, { randomBytes } from "crypto"; +import { eq } from "drizzle-orm"; import { IncomingMessage } from "http"; +import { env } from "~/env"; import type { ExtendedRequest } from "../api/trpc"; +import { db } from "../db"; +import { sessions, users } from "../db/schema/schema"; import logger from "../utils/logger"; -import crypto from "crypto"; export type SessionUpdateData = Partial<{ ua: string; diff --git a/src/server/build/BuildManager.ts b/src/server/build/BuildManager.ts index 7a3e414..3e0cd0e 100644 --- a/src/server/build/BuildManager.ts +++ b/src/server/build/BuildManager.ts @@ -1,7 +1,7 @@ import assert from "assert"; import { Queue } from "datastructures-js"; import { db } from "../db"; -import { serviceDeployment } from "../db/schema"; +import { serviceDeployment } from "../db/schema/schema"; import { ServiceDeploymentStatus, ServiceSource } from "../db/types"; import { type Service } from "../docker/stack"; import logger from "../utils/logger"; diff --git a/src/server/build/BuildTask.ts b/src/server/build/BuildTask.ts index 21ac57d..0371f89 100644 --- a/src/server/build/BuildTask.ts +++ b/src/server/build/BuildTask.ts @@ -4,7 +4,7 @@ import { mkdirSync } from "fs"; import { rm, rmdir } from "fs/promises"; import path from "path"; import { db } from "../db"; -import { service, serviceDeployment } from "../db/schema"; +import { serviceDeployment, serviceGeneration } from "../db/schema/schema"; import { ServiceBuildMethod, ServiceDeploymentStatus, @@ -122,8 +122,8 @@ export default class BuildTask { private async fetchServiceDetails() { const [serviceDetails] = await db .select() - .from(service) - .where(eq(service.id, this.serviceId)); + .from(serviceGeneration) + .where(eq(serviceGeneration.id, this.serviceId)); assert(serviceDetails, "Service not found"); diff --git a/src/server/build/builders/BaseBuilder.ts b/src/server/build/builders/BaseBuilder.ts index cce3328..0f0b769 100644 --- a/src/server/build/builders/BaseBuilder.ts +++ b/src/server/build/builders/BaseBuilder.ts @@ -1,4 +1,4 @@ -import { type service } from "../../db/schema"; +import { type serviceGeneration } from "../../db/schema/schema"; import type BuilderLogger from "../utils/BuilderLogger"; export default class BaseBuilder { @@ -6,7 +6,7 @@ export default class BaseBuilder { public readonly configuration: { fileLogger: BuilderLogger; workDirectory: string; - serviceConfiguration: typeof service.$inferSelect; + serviceConfiguration: typeof serviceGeneration.$inferSelect; }, ) {} diff --git a/src/server/build/sources/BaseSource.ts b/src/server/build/sources/BaseSource.ts index 341a305..1ecec46 100644 --- a/src/server/build/sources/BaseSource.ts +++ b/src/server/build/sources/BaseSource.ts @@ -1,4 +1,4 @@ -import { type service } from "~/server/db/schema"; +import { type serviceGeneration } from "~/server/db/schema"; import type BuilderLogger from "../utils/BuilderLogger"; export default class BaseSource { @@ -6,7 +6,7 @@ export default class BaseSource { public readonly configuration: { fileLogger: BuilderLogger; workDirectory: string; - serviceConfiguration: typeof service.$inferSelect; + serviceConfiguration: typeof serviceGeneration.$inferSelect; }, ) {} diff --git a/src/server/db/schema/index.ts b/src/server/db/schema/index.ts new file mode 100644 index 0000000..df3382a --- /dev/null +++ b/src/server/db/schema/index.ts @@ -0,0 +1,2 @@ +export * from "./relations"; +export * from "./schema"; diff --git a/src/server/db/schema/relations.ts b/src/server/db/schema/relations.ts new file mode 100644 index 0000000..23dbe24 --- /dev/null +++ b/src/server/db/schema/relations.ts @@ -0,0 +1,121 @@ +import { relations } from "drizzle-orm"; +import { + projectDeployment, + projects, + service, + serviceDeployment, + serviceDomain, + serviceGeneration, + servicePort, + serviceSysctl, + serviceUlimit, + serviceVolume, + sessions, + users, +} from "./schema"; + +export const userRelations = relations(users, ({ many }) => ({ + sessions: many(sessions), +})); + +export const sessionRelations = relations(sessions, ({ one }) => ({ + user: one(users, { + fields: [sessions.userId], + references: [users.id], + }), +})); + +export const projectRelations = relations(projects, ({ one, many }) => ({ + owner: one(users, { + fields: [projects.ownerId], + references: [users.id], + }), + services: many(serviceGeneration), +})); + +export const serviceGenerationRelations = relations( + serviceGeneration, + ({ one, many }) => ({ + service: one(service, { + fields: [serviceGeneration.serviceId], + references: [service.id], + }), + + domains: many(serviceDomain), + ports: many(servicePort), + sysctls: many(serviceSysctl), + volumes: many(serviceVolume), + ulimits: many(serviceUlimit), + + deployment: one(serviceDeployment, { + fields: [serviceGeneration.deploymentId], + references: [serviceDeployment.id], + }), + }), +); + +export const serviceRelations = relations(service, ({ one, many }) => ({ + project: one(projects, { + fields: [service.projectId], + references: [projects.id], + }), + + generations: many(serviceGeneration), + + latestGeneration: one(serviceGeneration, { + fields: [service.latestGenerationId], + references: [serviceGeneration.id], + }), + + deployedGeneration: one(serviceGeneration, { + fields: [service.deployedGenerationId], + references: [serviceGeneration.id], + }), +})); + +export const projectDeploymentRelations = relations( + projectDeployment, + ({ one, many }) => ({ + project: one(projects, { + fields: [projectDeployment.projectId], + references: [projects.id], + }), + + serviceDeployments: many(serviceDeployment), + }), +); + +export const serviceDomainRelations = relations(serviceDomain, ({ one }) => ({ + service: one(serviceGeneration, { + fields: [serviceDomain.serviceId], + references: [serviceGeneration.id], + }), +})); + +export const servicePortRelations = relations(servicePort, ({ one }) => ({ + service: one(serviceGeneration, { + fields: [servicePort.serviceId], + references: [serviceGeneration.id], + }), +})); + +export const serviceSysctlRelations = relations(serviceSysctl, ({ one }) => ({ + service: one(serviceGeneration, { + fields: [serviceSysctl.serviceId], + references: [serviceGeneration.id], + }), +})); + +export const serviceVolumeRelations = relations(serviceVolume, ({ one }) => ({ + service: one(serviceGeneration, { + fields: [serviceVolume.serviceId], + references: [serviceGeneration.id], + }), +})); + +export const serviceUlimitRelations = relations(serviceUlimit, ({ one }) => ({ + service: one(serviceGeneration, { + fields: [serviceUlimit.serviceId], + references: [serviceGeneration.id], + }), +})); diff --git a/src/server/db/schema.ts b/src/server/db/schema/schema.ts similarity index 73% rename from src/server/db/schema.ts rename to src/server/db/schema/schema.ts index 999acde..55bd683 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema/schema.ts @@ -1,4 +1,4 @@ -import { relations, sql } from "drizzle-orm"; +import { sql } from "drizzle-orm"; import { blob, index, @@ -7,6 +7,7 @@ import { sqliteTable, text, unique, + type AnySQLiteColumn, } from "drizzle-orm/sqlite-core"; import { DockerDeployMode, @@ -16,12 +17,17 @@ import { type ServiceDeploymentStatus, type ServicePortType, type ServiceSource, -} from "./types"; +} from "../types"; // util const uuidv7 = sql`(uuid_generate_v7())`; const now = sql`CURRENT_TIMESTAMP`; +// overview of the project schema layout +// https://drawsql.app/teams/derock/diagrams/hostforge +// does not include all fields, just the main ones +// easy to see the relations between tables + /** * User table. * Represents a global user. @@ -41,11 +47,6 @@ export const users = sqliteTable( }), ); -export const userRelations = relations(users, ({ many }) => ({ - sessions: many(sessions), - mfaRequestSessions: many(MFARequestSessions), -})); - /** * User Session table. * Represents a user's session. @@ -54,6 +55,7 @@ export const sessions = sqliteTable("session", { token: text("token").primaryKey(), lastUA: text("last_useragent"), lastIP: text("last_ip"), + // NOT IN MILLISECONDS! lastAccessed: integer("last_accessed", { mode: "timestamp" }), createdAt: integer("created_at").default(now).notNull(), @@ -62,33 +64,6 @@ export const sessions = sqliteTable("session", { .references(() => users.id), }); -export const sessionRelations = relations(sessions, ({ one }) => ({ - user: one(users, { - fields: [sessions.userId], - references: [users.id], - }), -})); - -/** - * MFA Request Session. - * The intermediate between successful basic/oauth login and a full session for users with MFA enabled - */ -export const MFARequestSessions = sqliteTable("mfa_request_sessions", { - token: text("token").primaryKey(), - userId: text("user_id").references(() => users.id), - createdAt: integer("created_at").default(now), -}); - -export const MFARequestSessionRelations = relations( - MFARequestSessions, - ({ one }) => ({ - user: one(users, { - fields: [MFARequestSessions.userId], - references: [users.id], - }), - }), -); - /** * System Statistics * Historical data about the system's usage @@ -119,9 +94,35 @@ export const projects = sqliteTable("projects", { createdAt: integer("created_at").default(now).notNull(), ownerId: text("owner_id") .notNull() - .references(() => users.id), + .references(() => users.id, { + onDelete: "cascade", + }), }); +/** + * Aggregate of service deployments for a project + */ +export const projectDeployment = sqliteTable( + "project_deployment", + { + id: text("id").default(uuidv7).primaryKey(), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { + onDelete: "cascade", + }), + + deployedAt: integer("deployed_at").default(now).notNull(), + status: integer("status").$type().notNull(), + }, + (table) => ({ + proj_deployment_idx: index("proj_deployment_idx").on( + table.id, + table.projectId, + ), + }), +); + /** * Project services * Represents a service running in a project @@ -131,11 +132,49 @@ export const service = sqliteTable( "service", { id: text("id").default(uuidv7).primaryKey(), + name: text("name").notNull(), + projectId: text("project_id") .notNull() - .references(() => projects.id), + .references(() => projects.id, { + onDelete: "cascade", + }), - name: text("name").notNull(), + latestGenerationId: text("latest_generation_id") + .notNull() + .references(() => serviceGeneration.id), + + deployedGenerationId: text("deployed_generation_id") + .notNull() + .references(() => serviceGeneration.id), + + createdAt: integer("created_at").default(now).notNull(), + }, + (table) => ({ + name_project_idx: index("name_project_idx").on(table.name, table.projectId), + name_project_unq: unique("name_project_unq").on( + table.name, + table.projectId, + ), + }), +); + +/** + * Configuration at a point in time for a service + */ +export const serviceGeneration = sqliteTable( + "service_generation", + { + id: text("id").default(uuidv7).primaryKey(), + serviceId: text("service_id") + .notNull() + .references((): AnySQLiteColumn => service.id, { + onDelete: "cascade", + }), + + deploymentId: text("deployment_id") + .notNull() + .references((): AnySQLiteColumn => serviceDeployment.id), // service configuration source: integer("source").$type().notNull(), @@ -174,6 +213,7 @@ export const service = sqliteTable( .$type() .default(DockerDeployMode.Replicated) .notNull(), + zeroDowntime: integer("zero_downtime", { mode: "boolean" }) .default(false) .notNull(), @@ -213,48 +253,38 @@ export const service = sqliteTable( createdAt: integer("created_at").default(now).notNull(), }, (table) => ({ - name_project_idx: index("name_project_idx").on(table.name, table.projectId), - name_project_unq: unique("name_project_unq").on( - table.name, - table.projectId, + proj_generation_idx: index("proj_generation_idx").on( + table.id, + table.serviceId, ), }), ); -// relations -export const projectRelations = relations(projects, ({ one, many }) => ({ - owner: one(users, { - fields: [projects.ownerId], - references: [users.id], - }), - services: many(service), -})); - -export const serviceRelations = relations(service, ({ one, many }) => ({ - project: one(projects, { - fields: [service.projectId], - references: [projects.id], - }), - domains: many(serviceDomain), - ports: many(servicePort), - sysctls: many(serviceSysctl), - volumes: many(serviceVolume), - ulimits: many(serviceUlimit), - deployments: many(serviceDeployment), -})); - /** * Service deployments */ export const serviceDeployment = sqliteTable("service_deployment", { id: text("id").default(uuidv7).primaryKey(), + projectDeploymentId: text("project_deployment_id") + .notNull() + .references(() => projectDeployment.id, { + onDelete: "cascade", + }), + serviceId: text("service_id") .notNull() - .references(() => service.id), + .references(() => serviceGeneration.id, { + onDelete: "cascade", + }), - createdAt: integer("created_at").default(now).notNull(), + deployedAt: integer("created_at").default(now).notNull(), + deployedBy: text("deployed_by").references(() => users.id), + // logs will be pretty small, + // so we can store them as blobs + // https://www.sqlite.org/intern-v-extern-blob.html buildLogs: blob("build_logs"), // COMPRESSED! + status: integer("status").$type().notNull(), }); @@ -265,7 +295,7 @@ export const serviceDomain = sqliteTable("service_domain", { id: text("id").default(uuidv7).primaryKey(), serviceId: text("service_id") .notNull() - .references(() => service.id), + .references(() => serviceGeneration.id), domain: text("domain").notNull(), internalPort: integer("internal_port").notNull(), @@ -273,13 +303,6 @@ export const serviceDomain = sqliteTable("service_domain", { forceSSL: integer("force_ssl", { mode: "boolean" }).default(false).notNull(), }); -export const serviceDomainRelations = relations(serviceDomain, ({ one }) => ({ - service: one(service, { - fields: [serviceDomain.serviceId], - references: [service.id], - }), -})); - /** * Project exposed ports */ @@ -287,7 +310,7 @@ export const servicePort = sqliteTable("service_port", { id: text("id").default(uuidv7).primaryKey(), serviceId: text("service_id") .notNull() - .references(() => service.id), + .references(() => serviceGeneration.id), internalPort: integer("internal_port").notNull(), externalPort: integer("external_port").notNull(), @@ -295,13 +318,6 @@ export const servicePort = sqliteTable("service_port", { type: integer("type").$type().notNull(), }); -export const servicePortRelations = relations(servicePort, ({ one }) => ({ - service: one(service, { - fields: [servicePort.serviceId], - references: [service.id], - }), -})); - /** * Project sysctls */ @@ -309,19 +325,12 @@ export const serviceSysctl = sqliteTable("service_sysctl", { id: text("id").default(uuidv7).primaryKey(), serviceId: text("service_id") .notNull() - .references(() => service.id), + .references(() => serviceGeneration.id), key: text("key").notNull(), value: text("value").notNull(), }); -export const serviceSysctlRelations = relations(serviceSysctl, ({ one }) => ({ - service: one(service, { - fields: [serviceSysctl.serviceId], - references: [service.id], - }), -})); - /** * Project volumes */ @@ -329,20 +338,13 @@ export const serviceVolume = sqliteTable("service_volume", { id: text("id").default(uuidv7).primaryKey(), serviceId: text("service_id") .notNull() - .references(() => service.id), + .references(() => serviceGeneration.id), source: text("source"), target: text("target").notNull(), type: text("type").$type().notNull(), }); -export const serviceVolumeRelations = relations(serviceVolume, ({ one }) => ({ - service: one(service, { - fields: [serviceVolume.serviceId], - references: [service.id], - }), -})); - /** * Project ulimits */ @@ -350,16 +352,9 @@ export const serviceUlimit = sqliteTable("service_ulimit", { id: text("id").default(uuidv7).primaryKey(), serviceId: text("service_id") .notNull() - .references(() => service.id), + .references(() => serviceGeneration.id), name: text("name").notNull(), soft: integer("soft").notNull(), hard: integer("hard").notNull(), }); - -export const serviceUlimitRelations = relations(serviceUlimit, ({ one }) => ({ - service: one(service, { - fields: [serviceUlimit.serviceId], - references: [service.id], - }), -})); diff --git a/src/server/docker/stack.ts b/src/server/docker/stack.ts index 3af7c0a..839ea0a 100644 --- a/src/server/docker/stack.ts +++ b/src/server/docker/stack.ts @@ -1,12 +1,12 @@ import assert from "assert"; import { - type service, type serviceDomain, + type serviceGeneration, type servicePort, type serviceSysctl, type serviceUlimit, type serviceVolume, -} from "../db/schema"; +} from "../db/schema/schema"; import { DOCKER_DEPLOY_MODE_MAP, DOCKER_RESTART_CONDITION_MAP, @@ -19,7 +19,7 @@ import { type Ulimits, } from "./compose"; -export type Service = typeof service.$inferSelect & { +export type Service = typeof serviceGeneration.$inferSelect & { domains: (typeof serviceDomain.$inferSelect)[]; ports: (typeof servicePort.$inferSelect)[]; sysctls: (typeof serviceSysctl.$inferSelect)[]; diff --git a/src/server/managers/ProjectManager.ts b/src/server/managers/ProjectManager.ts new file mode 100644 index 0000000..b637678 --- /dev/null +++ b/src/server/managers/ProjectManager.ts @@ -0,0 +1,37 @@ +import { eq, or } from "drizzle-orm"; +import { db } from "../db"; +import { projects, service } from "../db/schema"; +import ServiceManager from "./ServiceManager"; + +export default class ProjectManager { + constructor(private projectData: typeof projects.$inferSelect) {} + + /** + * Finds a project by name or ID. + */ + static async findByNameOrId(nameOrId: string) { + const data = await db.query.projects.findFirst({ + where: or(eq(projects.internalName, nameOrId), eq(projects.id, nameOrId)), + }); + + return data ? new ProjectManager(data) : null; + } + + /** + * Returns the project data. + */ + public getData() { + return this.projectData; + } + + /** + * Returns the project services. + */ + public async getServices() { + const serviceData = await db.query.service.findMany({ + where: eq(service.projectId, this.projectData.id), + }); + + return serviceData.map((data) => new ServiceManager(data)); + } +} diff --git a/src/server/managers/ServiceManager.ts b/src/server/managers/ServiceManager.ts new file mode 100644 index 0000000..0cf01a6 --- /dev/null +++ b/src/server/managers/ServiceManager.ts @@ -0,0 +1,100 @@ +import { deterministicString } from "deterministic-object-hash"; +import { and, eq, or } from "drizzle-orm"; +import { create } from "jsondiffpatch"; +import { db } from "../db"; +import { service, serviceGeneration } from "../db/schema"; +import logger from "../utils/logger"; + +export default class ServiceManager { + private static LOGGER = logger.child({ + module: "ServiceManager", + }); + + private static JSON_DIFF = create({ + objectHash: (obj: unknown) => { + if (typeof obj !== "object" || obj === null) { + return deterministicString(obj); + } + + if ("id" in obj && typeof obj.id === "string") { + return obj.id; + } + + this.LOGGER.warn("Unexpected object in JSON diff.", { obj }); + return deterministicString(obj); + }, + }); + + constructor( + private serviceData: typeof service.$inferSelect & { + latestGeneration?: typeof serviceGeneration.$inferSelect; + deployedGeneration?: typeof serviceGeneration.$inferSelect; + }, + ) {} + + /** + * Finds a service by name or ID. + */ + static async findByNameOrId(nameOrId: string, projectId: string) { + const data = await db.query.service.findFirst({ + where: and( + eq(service.projectId, projectId), + or(eq(service.name, nameOrId), eq(service.id, nameOrId)), + ), + }); + + return data ? new ServiceManager(data) : null; + } + + /** + * Returns a list of changed parameters between the current deployed service and the latest generation. + */ + public async buildDeployDiff() { + if ( + this.serviceData.latestGenerationId === + this.serviceData.deployedGenerationId + ) { + return {}; + } + + // fetch the generations + const [latest, deployed] = await Promise.all([ + this.fetchLatestGeneration(), + this.fetchDeployedGeneration(), + ]); + + // compare the two + return ServiceManager.JSON_DIFF.diff(deployed, latest); + } + + /** + * Fetches the latest generation of the service. + * @param forceRefetch if true, will ignore what's cached internally + * @returns + */ + private async fetchLatestGeneration(forceRefetch?: boolean) { + if (!forceRefetch && this.serviceData.latestGeneration) { + return this.serviceData.latestGeneration; + } + + return (this.serviceData.latestGeneration = + await db.query.serviceGeneration.findFirst({ + where: eq(serviceGeneration.id, this.serviceData.latestGenerationId), + })); + } + + /** + * Fetches the deployed generation of the service. + * @param forceRefetch if true, will ignore what's cached internally + */ + private async fetchDeployedGeneration(forceRefetch?: boolean) { + if (!forceRefetch && this.serviceData.deployedGeneration) { + return this.serviceData.deployedGeneration; + } + + return (this.serviceData.deployedGeneration = + await db.query.serviceGeneration.findFirst({ + where: eq(serviceGeneration.id, this.serviceData.deployedGenerationId), + })); + } +}