Merge branch 'canary' into fix/openapi-bigint-serialization

This commit is contained in:
Mauricio Siu
2026-04-04 23:06:07 -06:00
560 changed files with 210245 additions and 31666 deletions

View File

@@ -1,27 +0,0 @@
# Debug build OOM orden para probar
Ejecuta desde `packages/server` (o `pnpm --filter=@dokploy/server run <script>` desde la raíz).
1. **`pnpm run build:debug:noEmit`**
Solo typecheck, no escribe archivos.
- Si hace **OOM** → el problema es el análisis de tipos (ej. zod u otras libs).
- Si **pasa** → el problema está en emit (JS o `.d.ts`).
2. **`pnpm run build:debug:noEmit:8gb`**
Mismo que el anterior pero con 8GB de heap.
- Si con 8GB **pasa** y sin 8GB **no** → el typecheck necesita más memoria.
3. **`pnpm run build:debug:noDecl`**
Compila solo JS (sin `declaration`).
- Si hace **OOM** → el problema es emitir JS.
- Si **pasa** → el problema es generar `.d.ts`.
4. **`pnpm run build:debug:declOnly`**
Solo genera declaraciones (`.d.ts`).
- Si hace **OOM** → el cuello de botella son las declaraciones.
5. **`pnpm run build:debug:full`**
Build completo con `--extendedDiagnostics` (imprime estadísticas al final).
- Para ver en qué paso se va la memoria si no has localizado antes.
Con eso sabes si el OOM viene de: typecheck, emit JS o emit declarations, y puedes elegir fix (más memoria, esbuild para JS, o no emitir declarations).

View File

@@ -1,274 +1,299 @@
// import { relations } from "drizzle-orm";
// import {
// pgTable,
// text,
// timestamp,
// boolean,
// integer,
// index,
// uniqueIndex,
// } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import {
boolean,
index,
integer,
pgTable,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
// export const user = pgTable("user", {
// id: text("id").primaryKey(),
// firstName: text("first_name").notNull(),
// email: text("email").notNull().unique(),
// emailVerified: boolean("email_verified").default(false).notNull(),
// image: text("image"),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// updatedAt: timestamp("updated_at")
// .defaultNow()
// .$onUpdate(() => /* @__PURE__ */ new Date())
// .notNull(),
// twoFactorEnabled: boolean("two_factor_enabled").default(false),
// role: text("role"),
// ownerId: text("owner_id"),
// allowImpersonation: boolean("allow_impersonation").default(false),
// lastName: text("last_name").default(""),
// });
export const user = pgTable("user", {
id: text("id").primaryKey(),
firstName: text("first_name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
twoFactorEnabled: boolean("two_factor_enabled").default(false),
role: text("role"),
ownerId: text("owner_id"),
allowImpersonation: boolean("allow_impersonation").default(false),
lastName: text("last_name").default(""),
enableEnterpriseFeatures: boolean("enable_enterprise_features"),
isValidEnterpriseLicense: boolean("is_valid_enterprise_license"),
});
// export const session = pgTable(
// "session",
// {
// id: text("id").primaryKey(),
// expiresAt: timestamp("expires_at").notNull(),
// token: text("token").notNull().unique(),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// updatedAt: timestamp("updated_at")
// .$onUpdate(() => /* @__PURE__ */ new Date())
// .notNull(),
// ipAddress: text("ip_address"),
// userAgent: text("user_agent"),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// activeOrganizationId: text("active_organization_id"),
// },
// (table) => [index("session_userId_idx").on(table.userId)],
// );
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
activeOrganizationId: text("active_organization_id"),
},
(table) => [index("session_userId_idx").on(table.userId)],
);
// export const account = pgTable(
// "account",
// {
// id: text("id").primaryKey(),
// accountId: text("account_id").notNull(),
// providerId: text("provider_id").notNull(),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// accessToken: text("access_token"),
// refreshToken: text("refresh_token"),
// idToken: text("id_token"),
// accessTokenExpiresAt: timestamp("access_token_expires_at"),
// refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
// scope: text("scope"),
// password: text("password"),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// updatedAt: timestamp("updated_at")
// .$onUpdate(() => /* @__PURE__ */ new Date())
// .notNull(),
// },
// (table) => [index("account_userId_idx").on(table.userId)],
// );
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);
// export const verification = pgTable(
// "verification",
// {
// id: text("id").primaryKey(),
// identifier: text("identifier").notNull(),
// value: text("value").notNull(),
// expiresAt: timestamp("expires_at").notNull(),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// updatedAt: timestamp("updated_at")
// .defaultNow()
// .$onUpdate(() => /* @__PURE__ */ new Date())
// .notNull(),
// },
// (table) => [index("verification_identifier_idx").on(table.identifier)],
// );
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);
// export const apikey = pgTable(
// "apikey",
// {
// id: text("id").primaryKey(),
// name: text("name"),
// start: text("start"),
// prefix: text("prefix"),
// key: text("key").notNull(),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// refillInterval: integer("refill_interval"),
// refillAmount: integer("refill_amount"),
// lastRefillAt: timestamp("last_refill_at"),
// enabled: boolean("enabled").default(true),
// rateLimitEnabled: boolean("rate_limit_enabled").default(true),
// rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
// rateLimitMax: integer("rate_limit_max").default(10),
// requestCount: integer("request_count").default(0),
// remaining: integer("remaining"),
// lastRequest: timestamp("last_request"),
// expiresAt: timestamp("expires_at"),
// createdAt: timestamp("created_at").notNull(),
// updatedAt: timestamp("updated_at").notNull(),
// permissions: text("permissions"),
// metadata: text("metadata"),
// },
// (table) => [
// index("apikey_key_idx").on(table.key),
// index("apikey_userId_idx").on(table.userId),
// ],
// );
export const apikey = pgTable(
"apikey",
{
id: text("id").primaryKey(),
configId: text("config_id").default("default").notNull(),
name: text("name"),
start: text("start"),
referenceId: text("reference_id").notNull(),
prefix: text("prefix"),
key: text("key").notNull(),
refillInterval: integer("refill_interval"),
refillAmount: integer("refill_amount"),
lastRefillAt: timestamp("last_refill_at"),
enabled: boolean("enabled").default(true),
rateLimitEnabled: boolean("rate_limit_enabled").default(true),
rateLimitTimeWindow: integer("rate_limit_time_window").default(86400000),
rateLimitMax: integer("rate_limit_max").default(10),
requestCount: integer("request_count").default(0),
remaining: integer("remaining"),
lastRequest: timestamp("last_request"),
expiresAt: timestamp("expires_at"),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
permissions: text("permissions"),
metadata: text("metadata"),
},
(table) => [
index("apikey_configId_idx").on(table.configId),
index("apikey_referenceId_idx").on(table.referenceId),
index("apikey_key_idx").on(table.key),
],
);
// export const ssoProvider = pgTable("sso_provider", {
// id: text("id").primaryKey(),
// issuer: text("issuer").notNull(),
// oidcConfig: text("oidc_config"),
// samlConfig: text("saml_config"),
// userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
// providerId: text("provider_id").notNull().unique(),
// organizationId: text("organization_id"),
// domain: text("domain").notNull(),
// });
export const ssoProvider = pgTable("sso_provider", {
id: text("id").primaryKey(),
issuer: text("issuer").notNull(),
oidcConfig: text("oidc_config"),
samlConfig: text("saml_config"),
userId: text("user_id").references(() => user.id, { onDelete: "cascade" }),
providerId: text("provider_id").notNull().unique(),
organizationId: text("organization_id"),
domain: text("domain").notNull(),
});
// export const twoFactor = pgTable(
// "two_factor",
// {
// id: text("id").primaryKey(),
// secret: text("secret").notNull(),
// backupCodes: text("backup_codes").notNull(),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// },
// (table) => [
// index("twoFactor_secret_idx").on(table.secret),
// index("twoFactor_userId_idx").on(table.userId),
// ],
// );
export const twoFactor = pgTable(
"two_factor",
{
id: text("id").primaryKey(),
secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("twoFactor_secret_idx").on(table.secret),
index("twoFactor_userId_idx").on(table.userId),
],
);
// export const organization = pgTable(
// "organization",
// {
// id: text("id").primaryKey(),
// name: text("name").notNull(),
// slug: text("slug").notNull().unique(),
// logo: text("logo"),
// createdAt: timestamp("created_at").notNull(),
// metadata: text("metadata"),
// },
// (table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
// );
export const organization = pgTable(
"organization",
{
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull().unique(),
logo: text("logo"),
createdAt: timestamp("created_at").notNull(),
metadata: text("metadata"),
},
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
);
// export const member = pgTable(
// "member",
// {
// id: text("id").primaryKey(),
// organizationId: text("organization_id")
// .notNull()
// .references(() => organization.id, { onDelete: "cascade" }),
// userId: text("user_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// role: text("role").default("member").notNull(),
// createdAt: timestamp("created_at").notNull(),
// },
// (table) => [
// index("member_organizationId_idx").on(table.organizationId),
// index("member_userId_idx").on(table.userId),
// ],
// );
export const organizationRole = pgTable(
"organization_role",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
role: text("role").notNull(),
permission: text("permission").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate(
() => /* @__PURE__ */ new Date(),
),
},
(table) => [
index("organizationRole_organizationId_idx").on(table.organizationId),
index("organizationRole_role_idx").on(table.role),
],
);
// export const invitation = pgTable(
// "invitation",
// {
// id: text("id").primaryKey(),
// organizationId: text("organization_id")
// .notNull()
// .references(() => organization.id, { onDelete: "cascade" }),
// email: text("email").notNull(),
// role: text("role"),
// status: text("status").default("pending").notNull(),
// expiresAt: timestamp("expires_at").notNull(),
// createdAt: timestamp("created_at").defaultNow().notNull(),
// inviterId: text("inviter_id")
// .notNull()
// .references(() => user.id, { onDelete: "cascade" }),
// },
// (table) => [
// index("invitation_organizationId_idx").on(table.organizationId),
// index("invitation_email_idx").on(table.email),
// ],
// );
export const member = pgTable(
"member",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").default("member").notNull(),
createdAt: timestamp("created_at").notNull(),
},
(table) => [
index("member_organizationId_idx").on(table.organizationId),
index("member_userId_idx").on(table.userId),
],
);
// export const userRelations = relations(user, ({ many }) => ({
// sessions: many(session),
// accounts: many(account),
// apikeys: many(apikey),
// ssoProviders: many(ssoProvider),
// twoFactors: many(twoFactor),
// members: many(member),
// invitations: many(invitation),
// }));
export const invitation = pgTable(
"invitation",
{
id: text("id").primaryKey(),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role"),
status: text("status").default("pending").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
inviterId: text("inviter_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [
index("invitation_organizationId_idx").on(table.organizationId),
index("invitation_email_idx").on(table.email),
],
);
// export const sessionRelations = relations(session, ({ one }) => ({
// user: one(user, {
// fields: [session.userId],
// references: [user.id],
// }),
// }));
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
ssoProviders: many(ssoProvider),
twoFactors: many(twoFactor),
members: many(member),
invitations: many(invitation),
}));
// export const accountRelations = relations(account, ({ one }) => ({
// user: one(user, {
// fields: [account.userId],
// references: [user.id],
// }),
// }));
export const sessionRelations = relations(session, ({ one }) => ({
user: one(user, {
fields: [session.userId],
references: [user.id],
}),
}));
// export const apikeyRelations = relations(apikey, ({ one }) => ({
// user: one(user, {
// fields: [apikey.userId],
// references: [user.id],
// }),
// }));
export const accountRelations = relations(account, ({ one }) => ({
user: one(user, {
fields: [account.userId],
references: [user.id],
}),
}));
// export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
// user: one(user, {
// fields: [ssoProvider.userId],
// references: [user.id],
// }),
// }));
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({
user: one(user, {
fields: [ssoProvider.userId],
references: [user.id],
}),
}));
// export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
// user: one(user, {
// fields: [twoFactor.userId],
// references: [user.id],
// }),
// }));
export const twoFactorRelations = relations(twoFactor, ({ one }) => ({
user: one(user, {
fields: [twoFactor.userId],
references: [user.id],
}),
}));
// export const organizationRelations = relations(organization, ({ many }) => ({
// members: many(member),
// invitations: many(invitation),
// }));
export const organizationRelations = relations(organization, ({ many }) => ({
organizationRoles: many(organizationRole),
members: many(member),
invitations: many(invitation),
}));
// export const memberRelations = relations(member, ({ one }) => ({
// organization: one(organization, {
// fields: [member.organizationId],
// references: [organization.id],
// }),
// user: one(user, {
// fields: [member.userId],
// references: [user.id],
// }),
// }));
export const organizationRoleRelations = relations(
organizationRole,
({ one }) => ({
organization: one(organization, {
fields: [organizationRole.organizationId],
references: [organization.id],
}),
}),
);
// export const invitationRelations = relations(invitation, ({ one }) => ({
// organization: one(organization, {
// fields: [invitation.organizationId],
// references: [organization.id],
// }),
// user: one(user, {
// fields: [invitation.inviterId],
// references: [user.id],
// }),
// }));
export const memberRelations = relations(member, ({ one }) => ({
organization: one(organization, {
fields: [member.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [member.userId],
references: [user.id],
}),
}));
export const invitationRelations = relations(invitation, ({ one }) => ({
organization: one(organization, {
fields: [invitation.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [invitation.inviterId],
references: [user.id],
}),
}));

View File

@@ -19,15 +19,15 @@
}
},
"scripts": {
"build": "npm run switch:prod && rm -rf ./dist && tsc --project tsconfig.server.json && tsc-alias -p tsconfig.server.json",
"build": "npm run switch:prod && rimraf dist && tsc --project tsconfig.server.json && tsc-alias -p tsconfig.server.json",
"build:types": "tsc --emitDeclarationOnly --experimenta-dts",
"switch:dev": "node scripts/switchToSrc.js",
"switch:prod": "node scripts/switchToDist.js",
"dev": "rm -rf ./dist && pnpm esbuild && tsc --emitDeclarationOnly --outDir dist -p tsconfig.server.json",
"dev": "rimraf dist && pnpm esbuild && tsc --emitDeclarationOnly --outDir dist -p tsconfig.server.json",
"esbuild": "tsx ./esbuild.config.ts && tsc --project tsconfig.server.json --emitDeclarationOnly ",
"typecheck": "tsc --noEmit",
"dbml:generate": "npx tsx src/db/schema/dbml.ts",
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth.ts"
"generate:drizzle": "pnpm dlx @better-auth/cli generate --output auth-schema2.ts --config src/lib/auth-cli.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.44",
@@ -37,29 +37,30 @@
"@ai-sdk/mistral": "^3.0.20",
"@ai-sdk/openai": "^3.0.29",
"@ai-sdk/openai-compatible": "^2.0.30",
"@better-auth/utils": "0.3.0",
"@better-auth/api-key": "1.5.4",
"@better-auth/sso": "1.5.4",
"@better-auth/utils": "0.3.1",
"@faker-js/faker": "^8.4.1",
"@octokit/auth-app": "^6.1.3",
"@octokit/rest": "^20.1.2",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@react-email/components": "^0.0.21",
"@better-auth/sso": "1.4.18",
"@trpc/server": "^10.45.2",
"@trpc/server": "11.10.0",
"adm-zip": "^0.5.16",
"ai": "^6.0.86",
"ai-sdk-ollama": "^3.7.0",
"bcrypt": "5.1.1",
"better-auth": "1.4.18",
"better-auth": "1.5.4",
"better-call": "2.0.2",
"bl": "6.0.11",
"boxen": "^7.1.1",
"date-fns": "3.6.0",
"dockerode": "4.0.2",
"dotenv": "16.4.5",
"drizzle-dbml-generator": "0.10.0",
"drizzle-orm": "^0.41.0",
"drizzle-orm": "0.45.1",
"drizzle-zod": "0.5.1",
"yaml": "2.8.1",
"lodash": "4.17.21",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
@@ -76,17 +77,17 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"resend": "^6.0.2",
"semver": "7.7.3",
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"ssh2": "1.15.0",
"toml": "3.0.0",
"ws": "8.16.0",
"zod": "^3.25.76",
"semver": "7.7.3"
"yaml": "2.8.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@better-auth/cli": "1.4.18",
"@types/semver": "7.7.1",
"@better-auth/cli": "1.4.21",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
"@types/dockerode": "3.3.23",
@@ -98,6 +99,7 @@
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/semver": "7.7.1",
"@types/shell-quote": "^1.7.5",
"@types/ssh2": "1.15.1",
"@types/ws": "8.5.10",
@@ -105,6 +107,7 @@
"esbuild": "0.20.2",
"esbuild-plugin-alias": "0.2.1",
"postcss": "^8.5.3",
"rimraf": "6.1.3",
"tailwindcss": "^3.4.17",
"tsc-alias": "1.8.10",
"tsx": "^4.16.2",

View File

@@ -1,12 +1,90 @@
import fs from "node:fs";
import path from "node:path";
import Docker from "dockerode";
export const IS_CLOUD = process.env.IS_CLOUD === "true";
export const DOKPLOY_DOCKER_API_VERSION =
process.env.DOKPLOY_DOCKER_API_VERSION;
export const DOKPLOY_DOCKER_HOST = process.env.DOKPLOY_DOCKER_HOST;
export const DOKPLOY_DOCKER_PORT = process.env.DOKPLOY_DOCKER_PORT
? Number(process.env.DOKPLOY_DOCKER_PORT)
: undefined;
export const CLEANUP_CRON_JOB = "50 23 * * *";
export const docker = new Docker();
type DockerSocketCandidate = {
label: string;
path: string;
};
const getDockerConfig = (): Docker => {
const versionOption = DOKPLOY_DOCKER_API_VERSION
? { version: DOKPLOY_DOCKER_API_VERSION }
: {};
// Explicit remote Docker host configuration
if (DOKPLOY_DOCKER_HOST) {
console.info(
`Using remote Docker host: ${DOKPLOY_DOCKER_HOST}${DOKPLOY_DOCKER_PORT ? `:${DOKPLOY_DOCKER_PORT}` : ""}`,
);
return new Docker({
host: DOKPLOY_DOCKER_HOST,
...(DOKPLOY_DOCKER_PORT && { port: DOKPLOY_DOCKER_PORT }),
...versionOption,
});
}
// Local socket auto-detection (Rancher Desktop, Colima, standard Docker)
const dockerSocketCandidates: Array<DockerSocketCandidate> = [];
if (process.env.DOCKER_HOST) {
dockerSocketCandidates.push({
label: "DOCKER_HOST environment variable",
path: process.env.DOCKER_HOST.replace("unix://", ""),
});
}
if (process.env.HOME) {
dockerSocketCandidates.push({
label: "Rancher Desktop socket",
path: `${process.env.HOME}/.rd/docker.sock`,
});
}
dockerSocketCandidates.push({
label: "Standard Docker socket",
path: "/var/run/docker.sock",
});
for (const candidate of dockerSocketCandidates) {
try {
if (candidate.path && fs.existsSync(candidate.path)) {
console.info(
`Using Docker socket (${candidate.label}): ${candidate.path}`,
);
return new Docker({
socketPath: candidate.path,
...versionOption,
});
}
} catch (e) {
console.info(
`Docker socket initialization failed for ${candidate.label} (${candidate.path}): ${e instanceof Error ? e.message : "Unknown error"}`,
);
}
}
console.info(
"Using default Docker configuration. You can set the DOCKER_HOST environment variable to specify a custom Docker socket path.",
);
return new Docker({ ...versionOption });
};
export const docker = getDockerConfig();
// When not set, use the legacy default so 2FA remains working for users who
// enabled it before BETTER_AUTH_SECRET was introduced .
// enabled it before BETTER_AUTH_SECRET was introduced.
export const BETTER_AUTH_SECRET =
process.env.BETTER_AUTH_SECRET || "better-auth-secret-123456789";

View File

@@ -1,24 +1,40 @@
import { and, eq } from "drizzle-orm";
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { dbUrl } from "./constants";
import * as schema from "./schema";
declare global {
var db: PostgresJsDatabase<typeof schema> | undefined;
}
export { and, eq };
export * from "./schema";
type Database = PostgresJsDatabase<typeof schema>;
/**
* Evita problemas de redeclaración global en monorepos.
* No usamos `declare global`.
*/
const globalForDb = globalThis as unknown as {
db?: Database;
};
let dbConnection: Database;
export let db: PostgresJsDatabase<typeof schema>;
if (process.env.NODE_ENV === "production") {
db = drizzle(postgres(dbUrl), {
// En producción no usamos global cache
dbConnection = drizzle(postgres(dbUrl), {
schema,
});
} else {
if (!global.db)
global.db = drizzle(postgres(dbUrl), {
// En desarrollo reutilizamos conexión para evitar múltiples conexiones
if (!globalForDb.db) {
globalForDb.db = drizzle(postgres(dbUrl), {
schema,
});
}
db = global.db;
dbConnection = globalForDb.db;
}
export const db: Database = dbConnection;
export { dbUrl };

View File

@@ -1,6 +1,7 @@
import { relations, sql } from "drizzle-orm";
import {
boolean,
index,
integer,
pgTable,
text,
@@ -69,6 +70,36 @@ export const organization = pgTable("organization", {
.references(() => user.id, { onDelete: "cascade" }),
});
export const organizationRole = pgTable(
"organization_role",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
organizationId: text("organization_id")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
role: text("role").notNull(),
permission: text("permission").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").$onUpdate(() => new Date()),
},
(table) => [
index("organizationRole_organizationId_idx").on(table.organizationId),
index("organizationRole_role_idx").on(table.role),
],
);
export const organizationRoleRelations = relations(
organizationRole,
({ one }) => ({
organization: one(organization, {
fields: [organizationRole.organizationId],
references: [organization.id],
}),
}),
);
export const organizationRelations = relations(
organization,
({ one, many }) => ({
@@ -80,6 +111,7 @@ export const organizationRelations = relations(
projects: many(projects),
members: many(member),
ssoProviders: many(ssoProvider),
roles: many(organizationRole),
}),
);
@@ -93,7 +125,9 @@ export const member = pgTable("member", {
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
role: text("role").notNull().$type<"owner" | "member" | "admin">(),
role: text("role")
.notNull()
.$type<"owner" | "member" | "admin" | (string & {})>(),
createdAt: timestamp("created_at").notNull(),
teamId: text("team_id"),
isDefault: boolean("is_default").notNull().default(false),
@@ -129,6 +163,10 @@ export const member = pgTable("member", {
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
accessedGitProviders: text("accessedGitProviders")
.array()
.notNull()
.default(sql`ARRAY[]::text[]`),
});
export const memberRelations = relations(member, ({ one }) => ({
@@ -148,7 +186,7 @@ export const invitation = pgTable("invitation", {
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
email: text("email").notNull(),
role: text("role").$type<"owner" | "member" | "admin">(),
role: text("role").$type<"owner" | "member" | "admin" | (string & {})>(),
status: text("status").notNull(),
expiresAt: timestamp("expires_at").notNull(),
inviterId: text("inviter_id")
@@ -180,7 +218,8 @@ export const apikey = pgTable("apikey", {
start: text("start"),
prefix: text("prefix"),
key: text("key").notNull(),
userId: text("user_id")
configId: text("config_id").default("default").notNull(),
referenceId: text("reference_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
refillInterval: integer("refill_interval"),
@@ -202,7 +241,7 @@ export const apikey = pgTable("apikey", {
export const apikeyRelations = relations(apikey, ({ one }) => ({
user: one(user, {
fields: [apikey.userId],
fields: [apikey.referenceId],
references: [user.id],
}),
}));

View File

@@ -115,6 +115,7 @@ export const applications = pgTable("application", {
subtitle: text("subtitle"),
command: text("command"),
args: text("args").array(),
icon: text("icon"),
refreshToken: text("refreshToken").$defaultFn(() => nanoid()),
sourceType: sourceType("sourceType").notNull().default("github"),
cleanCache: boolean("cleanCache").default(false),
@@ -331,6 +332,7 @@ const createSchema = createInsertSchema(applications, {
sourceType: z
.enum(["github", "docker", "git", "gitlab", "bitbucket", "gitea", "drop"])
.optional(),
triggerType: z.enum(["push", "tag"]).optional(),
applicationStatus: z.enum(["idle", "running", "done", "error"]),
buildType: z.enum([
"dockerfile",
@@ -364,12 +366,18 @@ const createSchema = createInsertSchema(applications, {
previewPath: z.string().optional(),
previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
previewRequireCollaboratorPermissions: z.boolean().optional(),
watchPaths: z.array(z.string()).optional(),
watchPaths: z.array(z.string()).optional().optional(),
previewLabels: z.array(z.string()).optional(),
cleanCache: z.boolean().optional(),
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
enableSubmodules: z.boolean().optional(),
icon: z
.string()
.max(2 * 1024 * 1024, "Icon must be less than 2MB")
.nullable()
.optional(),
});
export const apiCreateApplication = createSchema.pick({
@@ -380,11 +388,9 @@ export const apiCreateApplication = createSchema.pick({
serverId: true,
});
export const apiFindOneApplication = createSchema
.pick({
applicationId: true,
})
.required();
export const apiFindOneApplication = z.object({
applicationId: z.string().min(1),
});
export const apiDeployApplication = createSchema
.pick({
@@ -434,13 +440,13 @@ export const apiSaveGithubProvider = createSchema
owner: true,
buildPath: true,
githubId: true,
watchPaths: true,
enableSubmodules: true,
})
.required()
.extend({
triggerType: z.enum(["push", "tag"]).default("push"),
});
})
.required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
export const apiSaveGitlabProvider = createSchema
.pick({
@@ -452,10 +458,9 @@ export const apiSaveGitlabProvider = createSchema
gitlabId: true,
gitlabProjectId: true,
gitlabPathNamespace: true,
watchPaths: true,
enableSubmodules: true,
})
.required();
.required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
export const apiSaveBitbucketProvider = createSchema
.pick({
@@ -466,10 +471,9 @@ export const apiSaveBitbucketProvider = createSchema
bitbucketRepositorySlug: true,
bitbucketId: true,
applicationId: true,
watchPaths: true,
enableSubmodules: true,
})
.required();
.required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
export const apiSaveGiteaProvider = createSchema
.pick({
@@ -479,10 +483,9 @@ export const apiSaveGiteaProvider = createSchema
giteaOwner: true,
giteaRepository: true,
giteaId: true,
watchPaths: true,
enableSubmodules: true,
})
.required();
.required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
export const apiSaveDockerProvider = createSchema
.pick({
@@ -507,6 +510,7 @@ export const apiSaveGitProvider = createSchema
.merge(
createSchema.pick({
customGitSSHKeyId: true,
enableSubmodules: true,
}),
);
@@ -520,11 +524,9 @@ export const apiSaveEnvironmentVariables = createSchema
})
.required();
export const apiFindMonitoringStats = createSchema
.pick({
appName: true,
})
.required();
export const apiFindMonitoringStats = z.object({
appName: z.string().min(1),
});
export const apiUpdateApplication = createSchema
.partial()

View File

@@ -0,0 +1,94 @@
import { relations } from "drizzle-orm";
import { index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { organization } from "./account";
import { user } from "./user";
export const auditLog = pgTable(
"audit_log",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
organizationId: text("organization_id").references(() => organization.id, {
onDelete: "set null",
}),
userId: text("user_id").references(() => user.id, { onDelete: "set null" }),
userEmail: text("user_email").notNull(),
userRole: text("user_role").notNull(),
action: text("action").notNull(),
resourceType: text("resource_type").notNull(),
resourceId: text("resource_id"),
resourceName: text("resource_name"),
metadata: text("metadata"),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(t) => ({
orgIdx: index("auditLog_organizationId_idx").on(t.organizationId),
userIdx: index("auditLog_userId_idx").on(t.userId),
createdAtIdx: index("auditLog_createdAt_idx").on(t.createdAt),
}),
);
export const auditLogRelations = relations(auditLog, ({ one }) => ({
organization: one(organization, {
fields: [auditLog.organizationId],
references: [organization.id],
}),
user: one(user, {
fields: [auditLog.userId],
references: [user.id],
}),
}));
export type AuditLog = typeof auditLog.$inferSelect;
export type NewAuditLog = typeof auditLog.$inferInsert;
export type AuditAction =
| "create"
| "update"
| "delete"
| "deploy"
| "cancel"
| "redeploy"
| "login"
| "logout"
| "restore"
| "run"
| "start"
| "stop"
| "reload"
| "rebuild"
| "move";
export type AuditResourceType =
| "project"
| "service"
| "environment"
| "deployment"
| "user"
| "customRole"
| "domain"
| "certificate"
| "registry"
| "server"
| "sshKey"
| "gitProvider"
| "destination"
| "notification"
| "settings"
| "session"
| "port"
| "redirect"
| "security"
| "schedule"
| "backup"
| "volumeBackup"
| "docker"
| "swarm"
| "previewDeployment"
| "organization"
| "cluster"
| "mount"
| "application"
| "compose";

View File

@@ -15,6 +15,7 @@ import { generateAppName } from ".";
import { compose } from "./compose";
import { deployments } from "./deployment";
import { destinations } from "./destination";
import { libsql } from "./libsql";
import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { mysql } from "./mysql";
@@ -26,6 +27,7 @@ export const databaseType = pgEnum("databaseType", [
"mysql",
"mongo",
"web-server",
"libsql",
]);
export const backupType = pgEnum("backupType", ["database", "compose"]);
@@ -74,6 +76,9 @@ export const backups = pgTable("backup", {
mongoId: text("mongoId").references((): AnyPgColumn => mongo.mongoId, {
onDelete: "cascade",
}),
libsqlId: text("libsqlId").references((): AnyPgColumn => libsql.libsqlId, {
onDelete: "cascade",
}),
userId: text("userId").references(() => user.id),
// Only for compose backups
metadata: jsonb("metadata").$type<
@@ -118,6 +123,10 @@ export const backupsRelations = relations(backups, ({ one, many }) => ({
fields: [backups.mongoId],
references: [mongo.mongoId],
}),
libsql: one(libsql, {
fields: [backups.libsqlId],
references: [libsql.libsqlId],
}),
user: one(user, {
fields: [backups.userId],
references: [user.id],
@@ -137,11 +146,19 @@ const createSchema = createInsertSchema(backups, {
database: z.string().min(1),
schedule: z.string(),
keepLatestCount: z.number().optional(),
databaseType: z.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"]),
databaseType: z.enum([
"postgres",
"mariadb",
"mysql",
"mongo",
"web-server",
"libsql",
]),
postgresId: z.string().optional(),
mariadbId: z.string().optional(),
mysqlId: z.string().optional(),
mongoId: z.string().optional(),
libsqlId: z.string().optional(),
userId: z.string().optional(),
metadata: z.any().optional(),
});
@@ -157,6 +174,7 @@ export const apiCreateBackup = createSchema.pick({
mysqlId: true,
postgresId: true,
mongoId: true,
libsqlId: true,
databaseType: true,
userId: true,
backupType: true,
@@ -165,11 +183,9 @@ export const apiCreateBackup = createSchema.pick({
metadata: true,
});
export const apiFindOneBackup = createSchema
.pick({
backupId: true,
})
.required();
export const apiFindOneBackup = z.object({
backupId: z.string().min(1),
});
export const apiRemoveBackup = createSchema
.pick({
@@ -194,7 +210,14 @@ export const apiUpdateBackup = createSchema
export const apiRestoreBackup = z.object({
databaseId: z.string(),
databaseType: z.enum(["postgres", "mysql", "mariadb", "mongo", "web-server"]),
databaseType: z.enum([
"postgres",
"mysql",
"mariadb",
"mongo",
"web-server",
"libsql",
]),
backupType: z.enum(["database", "compose"]),
databaseName: z.string().min(1),
backupFile: z.string().min(1),

View File

@@ -56,7 +56,6 @@ export const apiUpdateCertificate = z.object({
name: z.string().min(1).optional(),
certificateData: z.string().min(1).optional(),
privateKey: z.string().min(1).optional(),
autoRenew: z.boolean().optional(),
});
export const apiDeleteCertificate = z.object({

View File

@@ -164,6 +164,11 @@ const createSchema = createInsertSchema(compose, {
composePath: z.string().min(1),
composeType: z.enum(["docker-compose", "stack"]).optional(),
watchPaths: z.array(z.string()).optional(),
sourceType: z
.enum(["git", "github", "gitlab", "bitbucket", "gitea", "raw"])
.optional(),
triggerType: z.enum(["push", "tag"]).optional(),
composeStatus: z.enum(["idle", "running", "done", "error"]).optional(),
});
export const apiCreateCompose = createSchema.pick({

View File

@@ -209,44 +209,27 @@ export const apiCreateDeploymentVolumeBackup = schema
volumeBackupId: z.string().min(1),
});
export const apiFindAllByApplication = schema
.pick({
applicationId: true,
})
.extend({
applicationId: z.string().min(1),
})
.required();
export const apiFindAllByApplication = z.object({
applicationId: z.string().min(1),
});
export const apiFindAllByCompose = schema
.pick({
composeId: true,
})
.extend({
composeId: z.string().min(1),
})
.required();
export const apiFindAllByCompose = z.object({
composeId: z.string().min(1),
});
export const apiFindAllByServer = schema
.pick({
serverId: true,
})
.extend({
serverId: z.string().min(1),
})
.required();
export const apiFindAllByServer = z.object({
serverId: z.string().min(1),
});
export const apiFindAllByType = z
.object({
id: z.string().min(1),
type: z.enum([
"application",
"compose",
"server",
"schedule",
"previewDeployment",
"backup",
"volumeBackup",
]),
})
.required();
export const apiFindAllByType = z.object({
id: z.string().min(1),
type: z.enum([
"application",
"compose",
"server",
"schedule",
"previewDeployment",
"backup",
"volumeBackup",
]),
});

View File

@@ -3,6 +3,10 @@ import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import {
ADDITIONAL_FLAG_ERROR,
ADDITIONAL_FLAG_REGEX,
} from "../validations/destination";
import { organization } from "./account";
import { backups } from "./backups";
@@ -18,6 +22,7 @@ export const destinations = pgTable("destination", {
bucket: text("bucket").notNull(),
region: text("region").notNull(),
endpoint: text("endpoint").notNull(),
additionalFlags: text("additionalFlags").array(),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
@@ -44,6 +49,9 @@ const createSchema = createInsertSchema(destinations, {
endpoint: z.string(),
secretAccessKey: z.string(),
region: z.string(),
additionalFlags: z
.array(z.string().regex(ADDITIONAL_FLAG_REGEX, ADDITIONAL_FLAG_ERROR))
.default([]),
});
export const apiCreateDestination = createSchema
@@ -55,17 +63,16 @@ export const apiCreateDestination = createSchema
region: true,
endpoint: true,
secretAccessKey: true,
additionalFlags: true,
})
.required()
.extend({
serverId: z.string().optional(),
});
export const apiFindOneDestination = createSchema
.pick({
destinationId: true,
})
.required();
export const apiFindOneDestination = z.object({
destinationId: z.string().min(1),
});
export const apiRemoveDestination = createSchema
.pick({
@@ -83,6 +90,7 @@ export const apiUpdateDestination = createSchema
secretAccessKey: true,
destinationId: true,
provider: true,
additionalFlags: true,
})
.required()
.extend({

View File

@@ -1,4 +1,4 @@
import { relations } from "drizzle-orm";
import { relations, sql } from "drizzle-orm";
import {
type AnyPgColumn,
boolean,
@@ -31,6 +31,7 @@ export const domains = pgTable("domain", {
host: text("host").notNull(),
https: boolean("https").notNull().default(false),
port: integer("port").default(3000),
customEntrypoint: text("customEntrypoint"),
path: text("path").default("/"),
serviceName: text("serviceName"),
domainType: domainType("domainType").default("application"),
@@ -53,6 +54,7 @@ export const domains = pgTable("domain", {
certificateType: certificateType("certificateType").notNull().default("none"),
internalPath: text("internalPath").default("/"),
stripPath: boolean("stripPath").notNull().default(false),
middlewares: text("middlewares").array().default(sql`ARRAY[]::text[]`),
});
export const domainsRelations = relations(domains, ({ one }) => ({
@@ -70,12 +72,17 @@ export const domainsRelations = relations(domains, ({ one }) => ({
}),
}));
const createSchema = createInsertSchema(domains, domain._def.schema.shape);
const createSchema = createInsertSchema(domains, {
...domain.shape,
// Override pgEnum so Zod 4 infers only string literals, not numeric enum index
domainType: z.enum(["compose", "application", "preview"]).optional(),
});
export const apiCreateDomain = createSchema.pick({
host: true,
path: true,
port: true,
customEntrypoint: true,
https: true,
applicationId: true,
certificateType: true,
@@ -86,13 +93,12 @@ export const apiCreateDomain = createSchema.pick({
previewDeploymentId: true,
internalPath: true,
stripPath: true,
middlewares: true,
});
export const apiFindDomain = createSchema
.pick({
domainId: true,
})
.required();
export const apiFindDomain = z.object({
domainId: z.string().min(1),
});
export const apiFindDomainByApplication = createSchema.pick({
applicationId: true,
@@ -111,6 +117,7 @@ export const apiUpdateDomain = createSchema
host: true,
path: true,
port: true,
customEntrypoint: true,
https: true,
certificateType: true,
customCertResolver: true,
@@ -118,5 +125,6 @@ export const apiUpdateDomain = createSchema
domainType: true,
internalPath: true,
stripPath: true,
middlewares: true,
})
.merge(createSchema.pick({ domainId: true }).required());

View File

@@ -1,10 +1,10 @@
import { relations } from "drizzle-orm";
import { boolean, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { applications } from "./application";
import { compose } from "./compose";
import { libsql } from "./libsql";
import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { mysql } from "./mysql";
@@ -37,55 +37,40 @@ export const environmentRelations = relations(
references: [projects.projectId],
}),
applications: many(applications),
mariadb: many(mariadb),
postgres: many(postgres),
mysql: many(mysql),
redis: many(redis),
mongo: many(mongo),
compose: many(compose),
libsql: many(libsql),
mariadb: many(mariadb),
mongo: many(mongo),
mysql: many(mysql),
postgres: many(postgres),
redis: many(redis),
}),
);
const createSchema = createInsertSchema(environments, {
export const apiCreateEnvironment = z.object({
name: z.string().min(1),
description: z.string().optional(),
projectId: z.string().min(1),
});
export const apiFindOneEnvironment = z.object({
environmentId: z.string().min(1),
});
export const apiRemoveEnvironment = z.object({
environmentId: z.string().min(1),
});
export const apiUpdateEnvironment = z.object({
environmentId: z.string().min(1),
name: z.string().min(1).optional(),
description: z.string().optional(),
projectId: z.string().optional(),
env: z.string().optional(),
});
export const apiDuplicateEnvironment = z.object({
environmentId: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
});
export const apiCreateEnvironment = createSchema.pick({
name: true,
description: true,
projectId: true,
});
export const apiFindOneEnvironment = createSchema
.pick({
environmentId: true,
})
.required();
export const apiRemoveEnvironment = createSchema
.pick({
environmentId: true,
})
.required();
export const apiUpdateEnvironment = createSchema
.partial()
.extend({
environmentId: z.string().min(1),
})
.omit({
isDefault: true,
});
export const apiDuplicateEnvironment = createSchema
.pick({
environmentId: true,
name: true,
description: true,
})
.required({
environmentId: true,
name: true,
});

View File

@@ -1,6 +1,5 @@
import { relations } from "drizzle-orm";
import { pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { boolean, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
@@ -33,6 +32,9 @@ export const gitProvider = pgTable("git_provider", {
userId: text("userId")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
sharedWithOrganization: boolean("sharedWithOrganization")
.notNull()
.default(false),
});
export const gitProviderRelations = relations(gitProvider, ({ one }) => ({
@@ -62,10 +64,11 @@ export const gitProviderRelations = relations(gitProvider, ({ one }) => ({
}),
}));
const createSchema = createInsertSchema(gitProvider);
export const apiRemoveGitProvider = z.object({
gitProviderId: z.string().min(1),
});
export const apiRemoveGitProvider = createSchema
.extend({
gitProviderId: z.string().min(1),
})
.pick({ gitProviderId: true });
export const apiToggleShareGitProvider = z.object({
gitProviderId: z.string().min(1),
sharedWithOrganization: z.boolean(),
});

View File

@@ -1,6 +1,5 @@
import { relations } from "drizzle-orm";
import { integer, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { gitProvider } from "./git-provider";
@@ -29,8 +28,7 @@ export const githubProviderRelations = relations(github, ({ one }) => ({
}),
}));
const createSchema = createInsertSchema(github);
export const apiCreateGithub = createSchema.extend({
export const apiCreateGithub = z.object({
githubAppName: z.string().optional(),
githubAppId: z.number().optional(),
githubClientId: z.string().optional(),
@@ -48,13 +46,11 @@ export const apiFindGithubBranches = z.object({
githubId: z.string().optional(),
});
export const apiFindOneGithub = createSchema
.extend({
githubId: z.string().min(1),
})
.pick({ githubId: true });
export const apiFindOneGithub = z.object({
githubId: z.string().min(1),
});
export const apiUpdateGithub = createSchema.extend({
export const apiUpdateGithub = z.object({
githubId: z.string().min(1),
name: z.string().min(1),
gitProviderId: z.string().min(1),

View File

@@ -1,6 +1,5 @@
import { relations } from "drizzle-orm";
import { integer, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { gitProvider } from "./git-provider";
@@ -31,9 +30,7 @@ export const gitlabProviderRelations = relations(gitlab, ({ one }) => ({
}),
}));
const createSchema = createInsertSchema(gitlab);
export const apiCreateGitlab = createSchema.extend({
export const apiCreateGitlab = z.object({
applicationId: z.string().optional(),
secret: z.string().optional(),
groupName: z.string().optional(),
@@ -45,17 +42,14 @@ export const apiCreateGitlab = createSchema.extend({
gitlabInternalUrl: z.string().optional().nullable(),
});
export const apiFindOneGitlab = createSchema
.extend({
gitlabId: z.string().min(1),
})
.pick({ gitlabId: true });
export const apiFindOneGitlab = z.object({
gitlabId: z.string().min(1),
});
export const apiGitlabTestConnection = createSchema
.extend({
groupName: z.string().optional(),
})
.pick({ gitlabId: true, groupName: true });
export const apiGitlabTestConnection = z.object({
gitlabId: z.string().min(1),
groupName: z.string().optional(),
});
export const apiFindGitlabBranches = z.object({
id: z.number().optional(),
@@ -64,7 +58,7 @@ export const apiFindGitlabBranches = z.object({
gitlabId: z.string().optional(),
});
export const apiUpdateGitlab = createSchema.extend({
export const apiUpdateGitlab = z.object({
applicationId: z.string().optional(),
secret: z.string().optional(),
groupName: z.string().optional(),
@@ -72,5 +66,6 @@ export const apiUpdateGitlab = createSchema.extend({
name: z.string().min(1),
gitlabId: z.string().min(1),
gitlabUrl: z.string().min(1),
gitProviderId: z.string().min(1),
gitlabInternalUrl: z.string().optional().nullable(),
});

View File

@@ -1,6 +1,7 @@
export * from "./account";
export * from "./ai";
export * from "./application";
export * from "./audit-log";
export * from "./backups";
export * from "./bitbucket";
export * from "./certificate";
@@ -13,6 +14,7 @@ export * from "./git-provider";
export * from "./gitea";
export * from "./github";
export * from "./gitlab";
export * from "./libsql";
export * from "./mariadb";
export * from "./mongo";
export * from "./mount";
@@ -34,6 +36,7 @@ export * from "./session";
export * from "./shared";
export * from "./ssh-key";
export * from "./sso";
export * from "./tag";
export * from "./user";
export * from "./utils";
export * from "./volume-backups";

View File

@@ -0,0 +1,248 @@
import { relations } from "drizzle-orm";
import {
bigint,
boolean,
integer,
json,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { backups } from "./backups";
import { environments } from "./environment";
import { mounts } from "./mount";
import { server } from "./server";
import {
applicationStatus,
type EndpointSpecSwarm,
EndpointSpecSwarmSchema,
type HealthCheckSwarm,
HealthCheckSwarmSchema,
type LabelsSwarm,
LabelsSwarmSchema,
type NetworkSwarm,
NetworkSwarmSchema,
type PlacementSwarm,
PlacementSwarmSchema,
type RestartPolicySwarm,
RestartPolicySwarmSchema,
type ServiceModeSwarm,
ServiceModeSwarmSchema,
sqldNode,
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import {
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
generateAppName,
} from "./utils";
export const libsql = pgTable("libsql", {
libsqlId: text("libsqlId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
appName: text("appName")
.notNull()
.$defaultFn(() => generateAppName("libsql"))
.unique(),
description: text("description"),
databaseUser: text("databaseUser").notNull(),
databasePassword: text("databasePassword").notNull(),
sqldNode: sqldNode("sqldNode").notNull().default("primary"),
sqldPrimaryUrl: text("sqldPrimaryUrl"),
enableNamespaces: boolean("enableNamespaces").notNull().default(false),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
env: text("env"),
// RESOURCES
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),
cpuReservation: text("cpuReservation"),
cpuLimit: text("cpuLimit"),
//
externalPort: integer("externalPort"),
externalGRPCPort: integer("externalGRPCPort"),
externalAdminPort: integer("externalAdminPort"),
applicationStatus: applicationStatus("applicationStatus")
.notNull()
.default("idle"),
healthCheckSwarm: json("healthCheckSwarm").$type<HealthCheckSwarm>(),
restartPolicySwarm: json("restartPolicySwarm").$type<RestartPolicySwarm>(),
placementSwarm: json("placementSwarm").$type<PlacementSwarm>(),
updateConfigSwarm: json("updateConfigSwarm").$type<UpdateConfigSwarm>(),
rollbackConfigSwarm: json("rollbackConfigSwarm").$type<UpdateConfigSwarm>(),
modeSwarm: json("modeSwarm").$type<ServiceModeSwarm>(),
labelsSwarm: json("labelsSwarm").$type<LabelsSwarm>(),
networkSwarm: json("networkSwarm").$type<NetworkSwarm[]>(),
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "bigint" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
replicas: integer("replicas").default(1).notNull(),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
environmentId: text("environmentId")
.notNull()
.references(() => environments.environmentId, { onDelete: "cascade" }),
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
});
export const libsqlRelations = relations(libsql, ({ one, many }) => ({
environment: one(environments, {
fields: [libsql.environmentId],
references: [environments.environmentId],
}),
backups: many(backups),
mounts: many(mounts),
server: one(server, {
fields: [libsql.serverId],
references: [server.serverId],
}),
}));
const createSchema = createInsertSchema(libsql, {
libsqlId: z.string(),
name: z.string().min(1),
appName: z.string().min(1),
createdAt: z.string(),
databaseUser: z.string().min(1),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
}),
sqldNode: z.enum(sqldNode.enumValues),
sqldPrimaryUrl: z.string().nullable(),
enableNamespaces: z.boolean().default(false),
dockerImage: z
.string()
.default("ghcr.io/tursodatabase/libsql-server:v0.24.32"),
command: z.string().optional(),
env: z.string().optional(),
memoryReservation: z.string().optional(),
memoryLimit: z.string().optional(),
cpuReservation: z.string().optional(),
cpuLimit: z.string().optional(),
environmentId: z.string(),
applicationStatus: z.enum(["idle", "running", "done", "error"]),
externalPort: z.number(),
externalGRPCPort: z.number(),
externalAdminPort: z.number(),
description: z.string().optional(),
serverId: z.string().optional(),
healthCheckSwarm: HealthCheckSwarmSchema.nullable(),
restartPolicySwarm: RestartPolicySwarmSchema.nullable(),
placementSwarm: PlacementSwarmSchema.nullable(),
updateConfigSwarm: UpdateConfigSwarmSchema.nullable(),
rollbackConfigSwarm: UpdateConfigSwarmSchema.nullable(),
modeSwarm: ServiceModeSwarmSchema.nullable(),
labelsSwarm: LabelsSwarmSchema.nullable(),
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.bigint().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
});
export const apiCreateLibsql = createSchema
.pick({
name: true,
appName: true,
dockerImage: true,
environmentId: true,
description: true,
databaseUser: true,
databasePassword: true,
sqldNode: true,
sqldPrimaryUrl: true,
enableNamespaces: true,
serverId: true,
})
.required()
.superRefine((data, ctx) => {
if (data.sqldNode === "replica" && !data.sqldPrimaryUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sqldPrimaryUrl"],
message: "sqldPrimaryUrl is required when sqldNode is 'replica'.",
});
}
if (data.sqldNode !== "replica" && data.sqldPrimaryUrl) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["sqldPrimaryUrl"],
message:
"sqldPrimaryUrl should not be provided when sqldNode is not 'replica'.",
});
}
});
export const apiFindOneLibsql = z.object({
libsqlId: z.string().min(1),
});
export const apiChangeLibsqlStatus = createSchema
.pick({
libsqlId: true,
applicationStatus: true,
})
.required();
export const apiSaveEnvironmentVariablesLibsql = createSchema
.pick({
libsqlId: true,
env: true,
})
.required();
export const apiSaveExternalPortsLibsql = createSchema
.pick({
libsqlId: true,
externalPort: true,
externalGRPCPort: true,
externalAdminPort: true,
})
.required({ libsqlId: true })
.superRefine((data, ctx) => {
if (
data.externalPort === null &&
data.externalGRPCPort === null &&
data.externalAdminPort === null
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
"Either externalPort, externalGRPCPort or externalAdminPort must be provided.",
path: ["externalPort", "externalGRPCPort", "externalAdminPort"],
});
}
});
export const apiDeployLibsql = createSchema
.pick({
libsqlId: true,
})
.required();
export const apiResetLibsql = createSchema
.pick({
libsqlId: true,
appName: true,
})
.required();
export const apiUpdateLibsql = createSchema
.partial()
.extend({
libsqlId: z.string().min(1),
})
.omit({ serverId: true });
export const apiRebuildLibsql = createSchema
.pick({
libsqlId: true,
})
.required();

View File

@@ -28,7 +28,13 @@ import {
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
import {
APP_NAME_MESSAGE,
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
generateAppName,
} from "./utils";
export const mariadb = pgTable("mariadb", {
mariadbId: text("mariadbId")
@@ -108,17 +114,13 @@ const createSchema = createInsertSchema(mariadb, {
createdAt: z.string(),
databaseName: z.string().min(1),
databaseUser: z.string().min(1),
databasePassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
}),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
}),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
.regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
})
.optional(),
dockerImage: z.string().default("mariadb:6"),
@@ -160,11 +162,9 @@ export const apiCreateMariaDB = createSchema.pick({
serverId: true,
});
export const apiFindOneMariaDB = createSchema
.pick({
mariadbId: true,
})
.required();
export const apiFindOneMariaDB = z.object({
mariadbId: z.string().min(1),
});
export const apiChangeMariaDBStatus = createSchema
.pick({
@@ -204,6 +204,7 @@ export const apiUpdateMariaDB = createSchema
.partial()
.extend({
mariadbId: z.string().min(1),
dockerImage: z.string().optional(),
})
.omit({ serverId: true });

View File

@@ -35,7 +35,13 @@ import {
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
import {
APP_NAME_MESSAGE,
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
generateAppName,
} from "./utils";
export const mongo = pgTable("mongo", {
mongoId: text("mongoId")
@@ -50,7 +56,7 @@ export const mongo = pgTable("mongo", {
description: text("description"),
databaseUser: text("databaseUser").notNull(),
databasePassword: text("databasePassword").notNull(),
dockerImage: text("dockerImage").notNull(),
dockerImage: text("dockerImage").notNull().default("mongo:8"),
command: text("command"),
args: text("args").array(),
env: text("env"),
@@ -110,12 +116,9 @@ const createSchema = createInsertSchema(mongo, {
createdAt: z.string(),
mongoId: z.string(),
name: z.string().min(1),
databasePassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
}),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
}),
databaseUser: z.string().min(1),
dockerImage: z.string().default("mongo:15"),
command: z.string().optional(),
@@ -156,11 +159,9 @@ export const apiCreateMongo = createSchema.pick({
replicaSets: true,
});
export const apiFindOneMongo = createSchema
.pick({
mongoId: true,
})
.required();
export const apiFindOneMongo = z.object({
mongoId: z.string().min(1),
});
export const apiChangeMongoStatus = createSchema
.pick({
@@ -193,6 +194,7 @@ export const apiUpdateMongo = createSchema
.partial()
.extend({
mongoId: z.string().min(1),
dockerImage: z.string().optional(),
})
.omit({ serverId: true });

View File

@@ -5,6 +5,7 @@ import { nanoid } from "nanoid";
import { z } from "zod";
import { applications } from "./application";
import { compose } from "./compose";
import { libsql } from "./libsql";
import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { mysql } from "./mysql";
@@ -19,8 +20,11 @@ export const serviceType = pgEnum("serviceType", [
"mongo",
"redis",
"compose",
"libsql",
]);
export type ServiceType = (typeof serviceType.enumValues)[number];
export const mountType = pgEnum("mountType", ["bind", "volume", "file"]);
export const mounts = pgTable("mount", {
@@ -39,7 +43,10 @@ export const mounts = pgTable("mount", {
() => applications.applicationId,
{ onDelete: "cascade" },
),
postgresId: text("postgresId").references(() => postgres.postgresId, {
composeId: text("composeId").references(() => compose.composeId, {
onDelete: "cascade",
}),
libsqlId: text("libsqlId").references(() => libsql.libsqlId, {
onDelete: "cascade",
}),
mariadbId: text("mariadbId").references(() => mariadb.mariadbId, {
@@ -51,10 +58,10 @@ export const mounts = pgTable("mount", {
mysqlId: text("mysqlId").references(() => mysql.mysqlId, {
onDelete: "cascade",
}),
redisId: text("redisId").references(() => redis.redisId, {
postgresId: text("postgresId").references(() => postgres.postgresId, {
onDelete: "cascade",
}),
composeId: text("composeId").references(() => compose.composeId, {
redisId: text("redisId").references(() => redis.redisId, {
onDelete: "cascade",
}),
});
@@ -64,9 +71,13 @@ export const MountssRelations = relations(mounts, ({ one }) => ({
fields: [mounts.applicationId],
references: [applications.applicationId],
}),
postgres: one(postgres, {
fields: [mounts.postgresId],
references: [postgres.postgresId],
compose: one(compose, {
fields: [mounts.composeId],
references: [compose.composeId],
}),
libsql: one(libsql, {
fields: [mounts.libsqlId],
references: [libsql.libsqlId],
}),
mariadb: one(mariadb, {
fields: [mounts.mariadbId],
@@ -80,14 +91,14 @@ export const MountssRelations = relations(mounts, ({ one }) => ({
fields: [mounts.mysqlId],
references: [mysql.mysqlId],
}),
postgres: one(postgres, {
fields: [mounts.postgresId],
references: [postgres.postgresId],
}),
redis: one(redis, {
fields: [mounts.redisId],
references: [redis.redisId],
}),
compose: one(compose, {
fields: [mounts.composeId],
references: [compose.composeId],
}),
}));
const createSchema = createInsertSchema(mounts, {
@@ -99,23 +110,18 @@ const createSchema = createInsertSchema(mounts, {
mountPath: z.string().min(1),
mountId: z.string().optional(),
filePath: z.string().optional(),
serviceType: z
.enum([
"application",
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
"compose",
])
.default("application"),
serviceType: z.enum([
"application",
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
"compose",
"libsql",
]),
});
export type ServiceType = NonNullable<
z.infer<typeof createSchema>["serviceType"]
>;
export const apiCreateMount = createSchema
.pick({
type: true,
@@ -123,18 +129,16 @@ export const apiCreateMount = createSchema
volumeName: true,
content: true,
mountPath: true,
serviceType: true,
filePath: true,
serviceType: true,
})
.extend({
serviceId: z.string().min(1),
});
export const apiFindOneMount = createSchema
.pick({
mountId: true,
})
.required();
export const apiFindOneMount = z.object({
mountId: z.string().min(1),
});
export const apiRemoveMount = createSchema
.pick({
@@ -145,15 +149,13 @@ export const apiRemoveMount = createSchema
// })
.required();
export const apiFindMountByApplicationId = createSchema
.extend({
serviceId: z.string().min(1),
})
.pick({
serviceId: true,
serviceType: true,
})
.required();
export const apiFindMountByApplicationId = z.object({
serviceType: z
.string()
.min(1)
.transform((val) => val as ServiceType),
serviceId: z.string().min(1),
});
export const apiUpdateMount = createSchema.partial().extend({
mountId: z.string().min(1),

View File

@@ -28,7 +28,13 @@ import {
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
import {
APP_NAME_MESSAGE,
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
generateAppName,
} from "./utils";
export const mysql = pgTable("mysql", {
mysqlId: text("mysqlId")
@@ -106,17 +112,13 @@ const createSchema = createInsertSchema(mysql, {
name: z.string().min(1),
databaseName: z.string().min(1),
databaseUser: z.string().min(1),
databasePassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
}),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
}),
databaseRootPassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
.regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
})
.optional(),
dockerImage: z.string().default("mysql:8"),
@@ -157,11 +159,9 @@ export const apiCreateMySql = createSchema.pick({
serverId: true,
});
export const apiFindOneMySql = createSchema
.pick({
mysqlId: true,
})
.required();
export const apiFindOneMySql = z.object({
mysqlId: z.string().min(1),
});
export const apiChangeMySqlStatus = createSchema
.pick({
@@ -201,6 +201,7 @@ export const apiUpdateMySql = createSchema
.partial()
.extend({
mysqlId: z.string().min(1),
dockerImage: z.string().optional(),
})
.omit({ serverId: true });

View File

@@ -20,6 +20,7 @@ export const notificationType = pgEnum("notificationType", [
"resend",
"gotify",
"ntfy",
"mattermost",
"pushover",
"custom",
"lark",
@@ -37,6 +38,7 @@ export const notifications = pgTable("notification", {
databaseBackup: boolean("databaseBackup").notNull().default(false),
volumeBackup: boolean("volumeBackup").notNull().default(false),
dokployRestart: boolean("dokployRestart").notNull().default(false),
dokployBackup: boolean("dokployBackup").notNull().default(false),
dockerCleanup: boolean("dockerCleanup").notNull().default(false),
serverThreshold: boolean("serverThreshold").notNull().default(false),
notificationType: notificationType("notificationType").notNull(),
@@ -64,6 +66,9 @@ export const notifications = pgTable("notification", {
ntfyId: text("ntfyId").references(() => ntfy.ntfyId, {
onDelete: "cascade",
}),
mattermostId: text("mattermostId").references(() => mattermost.mattermostId, {
onDelete: "cascade",
}),
customId: text("customId").references(() => custom.customId, {
onDelete: "cascade",
}),
@@ -154,6 +159,16 @@ export const ntfy = pgTable("ntfy", {
priority: integer("priority").notNull().default(3),
});
export const mattermost = pgTable("mattermost", {
mattermostId: text("mattermostId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
webhookUrl: text("webhookUrl").notNull(),
channel: text("channel"),
username: text("username"),
});
export const custom = pgTable("custom", {
customId: text("customId")
.notNull()
@@ -220,6 +235,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.ntfyId],
references: [ntfy.ntfyId],
}),
mattermost: one(mattermost, {
fields: [notifications.mattermostId],
references: [mattermost.mattermostId],
}),
custom: one(custom, {
fields: [notifications.customId],
references: [custom.customId],
@@ -248,6 +267,7 @@ export const apiCreateSlack = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -276,6 +296,7 @@ export const apiCreateTelegram = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -306,6 +327,7 @@ export const apiCreateDiscord = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -337,6 +359,7 @@ export const apiCreateEmail = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -373,6 +396,7 @@ export const apiCreateResend = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -403,6 +427,7 @@ export const apiCreateGotify = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -437,6 +462,7 @@ export const apiCreateNtfy = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -464,16 +490,62 @@ export const apiTestNtfyConnection = apiCreateNtfy.pick({
priority: true,
});
export const apiFindOneNotification = notificationsSchema
export const apiCreateMattermost = notificationsSchema
.pick({
notificationId: true,
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.required();
.extend({
webhookUrl: z.string().url(),
channel: z.string().optional(),
username: z.string().optional(),
})
.required({
name: true,
webhookUrl: true,
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
});
export const apiUpdateMattermost = apiCreateMattermost.partial().extend({
notificationId: z.string().min(1),
mattermostId: z.string().min(1),
organizationId: z.string().optional(),
});
export const apiTestMattermostConnection = apiCreateMattermost
.pick({
webhookUrl: true,
channel: true,
username: true,
})
.extend({
channel: z.string().optional(),
username: z.string().optional(),
});
export const apiFindOneNotification = z.object({
notificationId: z.string().min(1),
});
export const apiCreateCustom = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -483,7 +555,7 @@ export const apiCreateCustom = notificationsSchema
})
.extend({
endpoint: z.string().min(1),
headers: z.record(z.string()).optional(),
headers: z.record(z.string(), z.string()).optional(),
});
export const apiUpdateCustom = apiCreateCustom.partial().extend({
@@ -494,13 +566,14 @@ export const apiUpdateCustom = apiCreateCustom.partial().extend({
export const apiTestCustomConnection = z.object({
endpoint: z.string().min(1),
headers: z.record(z.string()).optional(),
headers: z.record(z.string(), z.string()).optional(),
});
export const apiCreateLark = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -527,6 +600,7 @@ export const apiCreateTeams = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -553,6 +627,7 @@ export const apiCreatePushover = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployBackup: true,
volumeBackup: true,
dokployRestart: true,
name: true,
@@ -587,6 +662,7 @@ export const apiUpdatePushover = z.object({
expire: z.number().min(1).max(10800).nullish(),
appBuildError: z.boolean().optional(),
databaseBackup: z.boolean().optional(),
dokployBackup: z.boolean().optional(),
volumeBackup: z.boolean().optional(),
dokployRestart: z.boolean().optional(),
name: z.string().optional(),

View File

@@ -49,11 +49,9 @@ export const apiCreatePort = createSchema
})
.required();
export const apiFindOnePort = createSchema
.pick({
portId: true,
})
.required();
export const apiFindOnePort = z.object({
portId: z.string().min(1),
});
export const apiUpdatePort = createSchema
.pick({

View File

@@ -28,7 +28,13 @@ import {
type UpdateConfigSwarm,
UpdateConfigSwarmSchema,
} from "./shared";
import { APP_NAME_MESSAGE, APP_NAME_REGEX, generateAppName } from "./utils";
import {
APP_NAME_MESSAGE,
APP_NAME_REGEX,
DATABASE_PASSWORD_MESSAGE,
DATABASE_PASSWORD_REGEX,
generateAppName,
} from "./utils";
export const postgres = pgTable("postgres", {
postgresId: text("postgresId")
@@ -103,12 +109,9 @@ const createSchema = createInsertSchema(postgres, {
.max(63)
.regex(APP_NAME_REGEX, APP_NAME_MESSAGE)
.optional(),
databasePassword: z
.string()
.regex(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
message:
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
}),
databasePassword: z.string().regex(DATABASE_PASSWORD_REGEX, {
message: DATABASE_PASSWORD_MESSAGE,
}),
databaseName: z.string().min(1),
databaseUser: z.string().min(1),
dockerImage: z.string().default("postgres:18"),
@@ -150,11 +153,9 @@ export const apiCreatePostgres = createSchema.pick({
serverId: true,
});
export const apiFindOnePostgres = createSchema
.pick({
postgresId: true,
})
.required();
export const apiFindOnePostgres = z.object({
postgresId: z.string().min(1),
});
export const apiChangePostgresStatus = createSchema
.pick({
@@ -194,6 +195,7 @@ export const apiUpdatePostgres = createSchema
.partial()
.extend({
postgresId: z.string().min(1),
dockerImage: z.string().optional(),
})
.omit({ serverId: true });

View File

@@ -58,17 +58,12 @@ export const createSchema = createInsertSchema(previewDeployments, {
applicationId: z.string(),
});
export const apiCreatePreviewDeployment = createSchema
.pick({
applicationId: true,
domainId: true,
branch: true,
pullRequestId: true,
pullRequestNumber: true,
pullRequestURL: true,
pullRequestTitle: true,
})
.extend({
applicationId: z.string().min(1),
// deploymentId: z.string().min(1),
});
export const apiCreatePreviewDeployment = z.object({
applicationId: z.string().min(1),
domainId: z.string().optional(),
branch: z.string().min(1),
pullRequestId: z.string().min(1),
pullRequestNumber: z.string().min(1),
pullRequestURL: z.string().min(1),
pullRequestTitle: z.string().min(1),
});

View File

@@ -5,6 +5,7 @@ import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { environments } from "./environment";
import { projectTags } from "./tag";
export const projects = pgTable("project", {
projectId: text("projectId")
@@ -25,6 +26,7 @@ export const projects = pgTable("project", {
export const projectRelations = relations(projects, ({ many, one }) => ({
environments: many(environments),
projectTags: many(projectTags),
organization: one(organization, {
fields: [projects.organizationId],
references: [organization.id],
@@ -43,12 +45,9 @@ export const apiCreateProject = createSchema.pick({
env: true,
});
export const apiFindOneProject = createSchema
.pick({
projectId: true,
})
.required();
export const apiFindOneProject = z.object({
projectId: z.string().min(1),
});
export const apiRemoveProject = createSchema
.pick({
projectId: true,

View File

@@ -35,11 +35,9 @@ const createSchema = createInsertSchema(redirects, {
permanent: z.boolean().optional(),
});
export const apiFindOneRedirect = createSchema
.pick({
redirectId: true,
})
.required();
export const apiFindOneRedirect = z.object({
redirectId: z.string().min(1),
});
export const apiCreateRedirect = createSchema
.pick({

View File

@@ -136,11 +136,9 @@ export const apiCreateRedis = createSchema.pick({
serverId: true,
});
export const apiFindOneRedis = createSchema
.pick({
redisId: true,
})
.required();
export const apiFindOneRedis = z.object({
redisId: z.string().min(1),
});
export const apiChangeRedisStatus = createSchema
.pick({
@@ -180,6 +178,7 @@ export const apiUpdateRedis = createSchema
.partial()
.extend({
redisId: z.string().min(1),
dockerImage: z.string().optional(),
})
.omit({ serverId: true });

View File

@@ -94,11 +94,9 @@ export const apiRemoveRegistry = createSchema
})
.required();
export const apiFindOneRegistry = createSchema
.pick({
registryId: true,
})
.required();
export const apiFindOneRegistry = z.object({
registryId: z.string().min(1),
});
export const apiUpdateRegistry = createSchema.partial().extend({
registryId: z.string().min(1),

View File

@@ -38,11 +38,9 @@ const createSchema = createInsertSchema(security, {
password: z.string().min(1),
});
export const apiFindOneSecurity = createSchema
.pick({
securityId: true,
})
.required();
export const apiFindOneSecurity = z.object({
securityId: z.string().min(1),
});
export const apiCreateSecurity = createSchema
.pick({

View File

@@ -15,6 +15,7 @@ import { applications } from "./application";
import { certificates } from "./certificate";
import { compose } from "./compose";
import { deployments } from "./deployment";
import { libsql } from "./libsql";
import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { mysql } from "./mysql";
@@ -116,6 +117,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
relationName: "applicationBuildServer",
}),
compose: many(compose),
libsql: many(libsql),
redis: many(redis),
mariadb: many(mariadb),
mongo: many(mongo),
@@ -133,6 +135,7 @@ const createSchema = createInsertSchema(server, {
serverId: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
serverType: z.enum(["deploy", "build"]).optional(),
});
export const apiCreateServer = createSchema
@@ -147,11 +150,9 @@ export const apiCreateServer = createSchema
})
.required();
export const apiFindOneServer = createSchema
.pick({
serverId: true,
})
.required();
export const apiFindOneServer = z.object({
serverId: z.string().min(1),
});
export const apiRemoveServer = createSchema
.pick({

View File

@@ -2,7 +2,7 @@ import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { user } from "./user";
// OLD TABLE
export const session = pgTable("session_temp", {
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),

View File

@@ -16,6 +16,8 @@ export const certificateType = pgEnum("certificateType", [
export const triggerType = pgEnum("triggerType", ["push", "tag"]);
export const sqldNode = pgEnum("sqldNode", ["primary", "replica"]);
export interface HealthCheckSwarm {
Test?: string[] | undefined;
Interval?: number | undefined;
@@ -175,12 +177,12 @@ export const NetworkSwarmSchema = z.array(
.object({
Target: z.string().optional(),
Aliases: z.array(z.string()).optional(),
DriverOpts: z.record(z.string()).optional(),
DriverOpts: z.record(z.string(), z.string()).optional(),
})
.strict(),
);
export const LabelsSwarmSchema = z.record(z.string());
export const LabelsSwarmSchema = z.record(z.string(), z.string());
export const EndpointPortConfigSwarmSchema = z
.object({

View File

@@ -2,6 +2,7 @@ import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { sshKeyCreate, sshKeyType } from "../validations";
import { organization } from "./account";
import { applications } from "./application";
@@ -52,11 +53,9 @@ export const apiCreateSshKey = createSchema
})
.merge(sshKeyCreate.pick({ privateKey: true }));
export const apiFindOneSshKey = createSchema
.pick({
sshKeyId: true,
})
.required();
export const apiFindOneSshKey = z.object({
sshKeyId: z.string().min(1),
});
export const apiGenerateSSHKey = sshKeyType;

View File

@@ -0,0 +1,99 @@
import { relations } from "drizzle-orm";
import { pgTable, text, unique } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { projects } from "./project";
export const tags = pgTable(
"tag",
{
tagId: text("tagId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
color: text("color"),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
},
(table) => ({
// Unique index on (organizationId, name) to prevent duplicate tag names per organization
uniqueOrgName: unique("unique_org_tag_name").on(
table.organizationId,
table.name,
),
}),
);
export const projectTags = pgTable(
"project_tag",
{
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
projectId: text("projectId")
.notNull()
.references(() => projects.projectId, { onDelete: "cascade" }),
tagId: text("tagId")
.notNull()
.references(() => tags.tagId, { onDelete: "cascade" }),
},
(table) => ({
// Unique constraint to prevent duplicate project-tag associations
uniqueProjectTag: unique("unique_project_tag").on(
table.projectId,
table.tagId,
),
}),
);
export const tagRelations = relations(tags, ({ one, many }) => ({
organization: one(organization, {
fields: [tags.organizationId],
references: [organization.id],
}),
projectTags: many(projectTags),
}));
export const projectTagRelations = relations(projectTags, ({ one }) => ({
project: one(projects, {
fields: [projectTags.projectId],
references: [projects.projectId],
}),
tag: one(tags, {
fields: [projectTags.tagId],
references: [tags.tagId],
}),
}));
const createSchema = createInsertSchema(tags, {
tagId: z.string().min(1),
name: z.string().min(1),
color: z.string().optional(),
});
export const apiCreateTag = createSchema.pick({
name: true,
color: true,
});
export const apiFindOneTag = z.object({
tagId: z.string().min(1),
});
export const apiRemoveTag = createSchema
.pick({
tagId: true,
})
.required();
export const apiUpdateTag = createSchema.partial().extend({
tagId: z.string().min(1),
});

View File

@@ -1,5 +1,5 @@
import { paths } from "@dokploy/server/constants";
import { relations } from "drizzle-orm";
import { relations, sql } from "drizzle-orm";
import {
boolean,
integer,
@@ -66,6 +66,9 @@ export const user = pgTable("user", {
stripeSubscriptionId: text("stripeSubscriptionId"),
serversQuantity: integer("serversQuantity").notNull().default(0),
trustedOrigins: text("trustedOrigins").array(),
bookmarkedTemplates: text("bookmarkedTemplates")
.array()
.default(sql`ARRAY[]::text[]`),
});
export const usersRelations = relations(user, ({ one, many }) => ({
@@ -87,6 +90,7 @@ const createSchema = createInsertSchema(user, {
}).omit({
role: true,
trustedOrigins: true,
bookmarkedTemplates: true,
isValidEnterpriseLicense: true,
});
@@ -126,6 +130,7 @@ export const apiAssignPermissions = createSchema
accessedProjects: z.array(z.string()).optional(),
accessedEnvironments: z.array(z.string()).optional(),
accessedServices: z.array(z.string()).optional(),
accessedGitProviders: z.array(z.string()).optional(),
canCreateProjects: z.boolean().optional(),
canCreateServices: z.boolean().optional(),
canDeleteProjects: z.boolean().optional(),

View File

@@ -12,6 +12,13 @@ export const APP_NAME_REGEX = /^[a-zA-Z0-9._-]+$/;
export const APP_NAME_MESSAGE =
"App name can only contain letters, numbers, dots, underscores and hyphens";
/** Database password: blocks shell-dangerous characters like $ ! ' " \ / and spaces. */
export const DATABASE_PASSWORD_REGEX =
/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/;
export const DATABASE_PASSWORD_MESSAGE =
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility";
export const generateAppName = (type: string) => {
const verb = faker.hacker.verb().replace(/ /g, "-");
const adjective = faker.hacker.adjective().replace(/ /g, "-");

View File

@@ -7,6 +7,7 @@ import { applications } from "./application";
import { compose } from "./compose";
import { deployments } from "./deployment";
import { destinations } from "./destination";
import { libsql } from "./libsql";
import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { serviceType } from "./mount";
@@ -53,6 +54,9 @@ export const volumeBackups = pgTable("volume_backup", {
redisId: text("redisId").references(() => redis.redisId, {
onDelete: "cascade",
}),
libsqlId: text("libsqlId").references(() => libsql.libsqlId, {
onDelete: "cascade",
}),
composeId: text("composeId").references(() => compose.composeId, {
onDelete: "cascade",
}),
@@ -93,6 +97,10 @@ export const volumeBackupsRelations = relations(
fields: [volumeBackups.redisId],
references: [redis.redisId],
}),
libsql: one(libsql, {
fields: [volumeBackups.libsqlId],
references: [libsql.libsqlId],
}),
compose: one(compose, {
fields: [volumeBackups.composeId],
references: [compose.composeId],

View File

@@ -66,6 +66,36 @@ export const webServerSettings = pgTable("webServerSettings", {
},
},
}),
// Whitelabeling Configuration (Enterprise / Proprietary)
whitelabelingConfig: jsonb("whitelabelingConfig")
.$type<{
appName: string | null;
appDescription: string | null;
logoUrl: string | null;
faviconUrl: string | null;
customCss: string | null;
loginLogoUrl: string | null;
supportUrl: string | null;
docsUrl: string | null;
errorPageTitle: string | null;
errorPageDescription: string | null;
metaTitle: string | null;
footerText: string | null;
}>()
.default({
appName: null,
appDescription: null,
logoUrl: null,
faviconUrl: null,
customCss: null,
loginLogoUrl: null,
supportUrl: null,
docsUrl: null,
errorPageTitle: null,
errorPageDescription: null,
metaTitle: null,
footerText: null,
}),
// Cache Cleanup Configuration
cleanupCacheApplications: boolean("cleanupCacheApplications")
.notNull()
@@ -154,6 +184,33 @@ export const apiUpdateDockerCleanup = z.object({
serverId: z.string().optional(),
});
// Whitelabeling validation schemas
const safeUrl = z
.string()
.refine((url) => /^https?:\/\//i.test(url), {
message: "Only http:// and https:// URLs are allowed",
})
.nullable();
export const whitelabelingConfigSchema = z.object({
appName: z.string().nullable(),
appDescription: z.string().nullable(),
logoUrl: safeUrl,
faviconUrl: safeUrl,
customCss: z.string().nullable(),
loginLogoUrl: safeUrl,
supportUrl: safeUrl,
docsUrl: safeUrl,
errorPageTitle: z.string().nullable(),
errorPageDescription: z.string().nullable(),
metaTitle: z.string().nullable(),
footerText: z.string().nullable(),
});
export const apiUpdateWhitelabeling = z.object({
whitelabelingConfig: whitelabelingConfigSchema,
});
export const apiUpdateWebServerMonitoring = z.object({
metricsConfig: z
.object({

View File

@@ -0,0 +1,3 @@
export const ADDITIONAL_FLAG_REGEX = /^--[a-zA-Z0-9-]+(=[a-zA-Z0-9._:/@-]+)?$/;
export const ADDITIONAL_FLAG_ERROR =
"Invalid flag format. Must start with -- (e.g. --s3-sign-accept-encoding=false)";

View File

@@ -20,6 +20,7 @@ export const domain = z
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string(),
middlewares: z.array(z.string()).optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {
@@ -83,6 +84,7 @@ export const domainCompose = z
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string(),
serviceName: z.string().min(1, { message: "Service name is required" }),
middlewares: z.array(z.string()).optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {

View File

@@ -14,7 +14,7 @@ import {
export type TemplateProps = {
projectName: string;
applicationName: string;
databaseType: "postgres" | "mysql" | "mongodb" | "mariadb";
databaseType: "postgres" | "mysql" | "mongodb" | "mariadb" | "libsql";
type: "error" | "success";
errorMessage?: string;
date: string;

View File

@@ -0,0 +1,106 @@
import {
Body,
Container,
Head,
Heading,
Html,
Img,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
export type TemplateProps = {
type: "error" | "success";
errorMessage?: string;
date: string;
backupSize?: string;
};
export const DokployBackupEmail = ({
type = "success",
errorMessage,
date = "2023-05-01T00:00:00.000Z",
backupSize,
}: TemplateProps) => {
const previewText = `Dokploy instance backup was ${type === "success" ? "successful ✅" : "failed ❌"}`;
return (
<Html>
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
brand: "#007291",
},
},
},
}}
>
<Head />
<Body className="bg-white my-auto mx-auto font-sans px-2">
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
<Section className="mt-[32px]">
<Img
src={
"https://raw.githubusercontent.com/Dokploy/dokploy/refs/heads/canary/apps/dokploy/logo.png"
}
width="100"
height="50"
alt="Dokploy"
className="my-0 mx-auto"
/>
</Section>
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
Dokploy Instance Backup
</Heading>
<Text className="text-black text-[14px] leading-[24px]">
Hello,
</Text>
<Text className="text-black text-[14px] leading-[24px]">
Your Dokploy instance backup was{" "}
{type === "success"
? "successful ✅"
: "failed. Please check the error message below. ❌"}
.
</Text>
<Section className="flex text-black text-[14px] leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
<Text className="!leading-3 font-bold">Details: </Text>
<Text className="!leading-3">
Backup Type: <strong>Complete Dokploy Instance</strong>
</Text>
<Text className="!leading-3">
Content: <strong>/etc/dokploy + PostgreSQL Database</strong>
</Text>
{backupSize && (
<Text className="!leading-3">
Backup Size: <strong>{backupSize}</strong>
</Text>
)}
<Text className="!leading-3">
Date: <strong>{date}</strong>
</Text>
<Text className="!leading-3">
Status:{" "}
<strong>{type === "success" ? "Successful" : "Failed"}</strong>
</Text>
</Section>
{type === "error" && errorMessage ? (
<Section className="flex text-black text-[14px] mt-4 leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
<Text className="!leading-3 font-bold">Reason: </Text>
<Text className="text-[12px] leading-[24px]">
{errorMessage || "Error message not provided"}
</Text>
</Section>
) : null}
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default DokployBackupEmail;

View File

@@ -22,7 +22,8 @@ export type TemplateProps = {
| "mongodb"
| "mariadb"
| "redis"
| "compose";
| "compose"
| "libsql";
type: "error" | "success";
errorMessage?: string;
backupSize?: string;

View File

@@ -1,6 +1,7 @@
export * from "./auth/random-password";
export * from "./constants/index";
export * from "./db/constants";
export * from "./db/validations/destination";
export * from "./db/validations/domain";
export * from "./db/validations/index";
export * from "./lib/auth";
@@ -22,6 +23,7 @@ export * from "./services/git-provider";
export * from "./services/gitea";
export * from "./services/github";
export * from "./services/gitlab";
export * from "./services/libsql";
export * from "./services/mariadb";
export * from "./services/mongo";
export * from "./services/mount";
@@ -67,6 +69,7 @@ export * from "./utils/access-log/types";
export * from "./utils/access-log/utils";
export * from "./utils/backups/compose";
export * from "./utils/backups/index";
export * from "./utils/backups/libsql";
export * from "./utils/backups/mariadb";
export * from "./utils/backups/mongo";
export * from "./utils/backups/mysql";
@@ -118,7 +121,7 @@ export * from "./utils/providers/raw";
export * from "./utils/schedules/index";
export * from "./utils/schedules/utils";
export * from "./utils/servers/remote-docker";
export * from "./utils/startup/cancell-deployments";
export * from "./utils/startup/cancel-deployments";
export * from "./utils/tracking/hubspot";
export * from "./utils/traefik/application";
export * from "./utils/traefik/domain";

View File

@@ -0,0 +1,195 @@
import { createAccessControl } from "better-auth/plugins/access";
/**
* Dokploy Access Control Statements
*
* Defines all resources and their possible actions across the platform.
* The first 5 (organization, member, invitation, team, ac) are better-auth defaults
* used internally by the organization plugin.
* The rest are Dokploy-specific resources.
*
* Enterprise-only resources (only assignable via custom roles):
* deployment, envVars, server, registry, certificate, backup, domain, logs, monitoring
*/
export const statements = {
// better-auth organization plugin defaults
organization: ["update", "delete"],
member: ["read", "create", "update", "delete"],
invitation: ["create", "cancel"],
team: ["create", "update", "delete"],
ac: ["create", "read", "update", "delete"],
// Dokploy core resources (free tier)
project: ["create", "delete"],
service: ["create", "read", "delete"],
environment: ["create", "read", "delete"],
docker: ["read"],
sshKeys: ["read", "create", "delete"],
gitProviders: ["read", "create", "delete"],
traefikFiles: ["read", "write"],
api: ["read"],
// Enterprise-only resources (custom roles only)
volume: ["read", "create", "delete"],
deployment: ["read", "create", "cancel"],
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"],
domain: ["read", "create", "delete"],
destination: ["read", "create", "delete"],
notification: ["read", "create", "update", "delete"],
tag: ["read", "create", "update", "delete"],
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
} as const;
/**
* Enterprise-only resources. For static roles (owner/admin/member),
* permission checks on these resources are bypassed — they only apply
* when using custom roles with an enterprise license.
*/
export const enterpriseOnlyResources = new Set<string>([
"volume",
"deployment",
"envVars",
"projectEnvVars",
"environmentEnvVars",
"server",
"registry",
"certificate",
"backup",
"volumeBackup",
"schedule",
"domain",
"destination",
"notification",
"tag",
"logs",
"monitoring",
"auditLog",
]);
export const ac = createAccessControl(statements);
/**
* Owner role — full access to everything
*/
export const ownerRole = ac.newRole({
organization: ["update", "delete"],
member: ["read", "create", "update", "delete"],
invitation: ["create", "cancel"],
team: ["create", "update", "delete"],
ac: ["create", "read", "update", "delete"],
project: ["create", "delete"],
service: ["create", "read", "delete"],
environment: ["create", "read", "delete"],
docker: ["read"],
sshKeys: ["read", "create", "delete"],
gitProviders: ["read", "create", "delete"],
traefikFiles: ["read", "write"],
api: ["read"],
volume: ["read", "create", "delete"],
deployment: ["read", "create", "cancel"],
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"],
domain: ["read", "create", "delete"],
destination: ["read", "create", "delete"],
notification: ["read", "create", "update", "delete"],
tag: ["read", "create", "update", "delete"],
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
});
/**
* Admin role — same as owner but cannot delete the organization
*/
export const adminRole = ac.newRole({
organization: ["update"],
member: ["read", "create", "update", "delete"],
invitation: ["create", "cancel"],
team: ["create", "update", "delete"],
ac: ["create", "read", "update", "delete"],
project: ["create", "delete"],
service: ["create", "read", "delete"],
environment: ["create", "read", "delete"],
docker: ["read"],
sshKeys: ["read", "create", "delete"],
gitProviders: ["read", "create", "delete"],
traefikFiles: ["read", "write"],
api: ["read"],
volume: ["read", "create", "delete"],
deployment: ["read", "create", "cancel"],
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
server: ["read", "create", "delete"],
registry: ["read", "create", "delete"],
certificate: ["read", "create", "update", "delete"],
backup: ["read", "create", "update", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"],
domain: ["read", "create", "delete"],
destination: ["read", "create", "delete"],
notification: ["read", "create", "update", "delete"],
tag: ["read", "create", "update", "delete"],
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
});
/**
* Member role (free tier) — read-only base permissions.
* Members can read projects/services/environments they have access to,
* but cannot create, delete, or access admin resources.
* Enterprise resources are not available to the base member role.
*/
export const memberRole = ac.newRole({
organization: [],
member: [],
invitation: [],
team: [],
ac: ["read"],
project: [],
service: ["read"],
environment: ["read"],
docker: [],
sshKeys: [],
gitProviders: [],
traefikFiles: [],
api: [],
// Service-level enterprise resources — member can do everything within services they have access to
volume: ["read", "create", "delete"],
deployment: ["read", "create", "cancel"],
envVars: ["read", "write"],
projectEnvVars: ["read", "write"],
environmentEnvVars: ["read", "write"],
backup: ["read", "create", "update", "delete", "restore"],
volumeBackup: ["read", "create", "update", "delete", "restore"],
schedule: ["read", "create", "update", "delete"],
domain: ["read", "create", "delete"],
logs: ["read"],
monitoring: ["read"],
// Org-level enterprise resources — member cannot manage these
server: [],
registry: [],
certificate: [],
destination: [],
notification: [],
tag: ["read"],
auditLog: [],
});

View File

@@ -1,15 +1,21 @@
import type { IncomingMessage } from "node:http";
import { apiKey } from "@better-auth/api-key";
import { sso } from "@better-auth/sso";
import * as bcrypt from "bcrypt";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { APIError } from "better-auth/api";
import { admin, apiKey, organization, twoFactor } from "better-auth/plugins";
import { admin, organization, twoFactor } from "better-auth/plugins";
import { and, desc, eq } from "drizzle-orm";
import { BETTER_AUTH_SECRET, IS_CLOUD } from "../constants";
import { db } from "../db";
import * as schema from "../db/schema";
import { getTrustedOrigins, getUserByToken } from "../services/admin";
import {
getTrustedOrigins,
getTrustedProviders,
getUserByToken,
} from "../services/admin";
import { createAuditLog } from "../services/proprietary/audit-log";
import {
getWebServerSettings,
updateWebServerSettings,
@@ -17,8 +23,7 @@ import {
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
import { sendEmail } from "../verification/send-verification-email";
import { getPublicIpWithFallback } from "../wss/utils";
const trustedProviders = process.env?.TRUSTED_PROVIDERS?.split(",") || [];
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
const { handler, api } = betterAuth({
database: drizzleAdapter(db, {
@@ -49,7 +54,10 @@ const { handler, api } = betterAuth({
account: {
accountLinking: {
enabled: true,
trustedProviders: ["github", "google", ...(trustedProviders || [])],
async trustedProviders() {
const fromDb = await getTrustedProviders();
return ["github", "google", ...fromDb];
},
allowDifferentEmails: true,
},
},
@@ -68,25 +76,32 @@ const { handler, api } = betterAuth({
disabled: process.env.NODE_ENV === "production",
},
async trustedOrigins() {
const trustedOrigins = await getTrustedOrigins();
if (IS_CLOUD) {
return trustedOrigins;
}
const settings = await getWebServerSettings();
if (!settings) {
try {
if (IS_CLOUD) {
return await getTrustedOrigins();
}
const [trustedOrigins, settings] = await Promise.all([
getTrustedOrigins(),
getWebServerSettings(),
]);
if (!settings) return [];
const devOrigins =
process.env.NODE_ENV === "development"
? [
"http://localhost:3000",
"https://absolutely-handy-falcon.ngrok-free.app",
]
: [];
return [
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
...(settings?.host ? [`https://${settings?.host}`] : []),
...devOrigins,
...trustedOrigins,
];
} catch (error) {
console.error("Failed to resolve trusted origins:", error);
return [];
}
return [
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
...(settings?.host ? [`https://${settings?.host}`] : []),
...(process.env.NODE_ENV === "development"
? [
"http://localhost:3000",
"https://absolutely-handy-falcon.ngrok-free.app",
]
: []),
...trustedOrigins,
];
},
emailVerification: {
sendOnSignUp: true,
@@ -106,7 +121,7 @@ const { handler, api } = betterAuth({
emailAndPassword: {
enabled: true,
autoSignIn: !IS_CLOUD,
requireEmailVerification: IS_CLOUD,
requireEmailVerification: IS_CLOUD && process.env.NODE_ENV === "production",
password: {
async hash(password) {
return bcrypt.hashSync(password, 10);
@@ -262,6 +277,52 @@ const { handler, api } = betterAuth({
},
};
},
after: async (session) => {
const orgId = (
session as typeof session & { activeOrganizationId?: string }
).activeOrganizationId;
if (!orgId) return;
const memberRecord = await db.query.member.findFirst({
where: and(
eq(schema.member.userId, session.userId),
eq(schema.member.organizationId, orgId),
),
with: { user: true },
});
if (!memberRecord) return;
await createAuditLog({
organizationId: orgId,
userId: session.userId,
userEmail: memberRecord.user.email,
userRole: memberRecord.role,
action: "login",
resourceType: "session",
});
},
},
delete: {
after: async (session) => {
const orgId = (
session as typeof session & { activeOrganizationId?: string }
).activeOrganizationId;
if (!orgId) return;
const memberRecord = await db.query.member.findFirst({
where: and(
eq(schema.member.userId, session.userId),
eq(schema.member.organizationId, orgId),
),
with: { user: true },
});
if (!memberRecord) return;
await createAuditLog({
organizationId: orgId,
userId: session.userId,
userEmail: memberRecord.user.email,
userRole: memberRecord.role,
action: "logout",
resourceType: "session",
});
},
},
},
},
@@ -311,10 +372,21 @@ const { handler, api } = betterAuth({
plugins: [
apiKey({
enableMetadata: true,
references: "user",
}),
sso(),
twoFactor(),
organization({
ac,
roles: {
owner: ownerRole,
admin: adminRole,
member: memberRole,
},
dynamicAccessControl: {
enabled: true,
maximumRolesPerOrganization: 10,
},
async sendInvitationEmail(data, _request) {
if (IS_CLOUD) {
const host =
@@ -343,13 +415,16 @@ const { handler, api } = betterAuth({
],
});
export const auth = {
const _auth = {
handler,
createApiKey: api.createApiKey,
registerSSOProvider: api.registerSSOProvider,
updateSSOProvider: api.updateSSOProvider,
};
export type AuthType = typeof _auth;
export const auth: AuthType = _auth;
export const validateRequest = async (request: IncomingMessage) => {
const apiKey = request.headers["x-api-key"] as string;
if (apiKey) {
@@ -460,11 +535,16 @@ export const validateRequest = async (request: IncomingMessage) => {
const member = await db.query.member.findFirst({
where: and(
eq(schema.member.userId, session.user.id),
eq(
schema.member.organizationId,
session.session.activeOrganizationId || "",
),
...(session.session.activeOrganizationId
? [
eq(
schema.member.organizationId,
session.session.activeOrganizationId || "",
),
]
: []),
),
orderBy: [desc(schema.member.isDefault), desc(schema.member.createdAt)],
with: {
organization: true,
user: true,
@@ -476,6 +556,7 @@ export const validateRequest = async (request: IncomingMessage) => {
member?.user.enableEnterpriseFeatures || false;
session.user.isValidEnterpriseLicense =
member?.user.isValidEnterpriseLicense || false;
session.session.activeOrganizationId = member?.organization.id || "";
if (member) {
session.user.ownerId = member.organization.ownerId;
} else {

View File

@@ -117,21 +117,50 @@ export const getDokployUrl = async () => {
return `http://${settings?.serverIp}:${process.env.PORT}`;
};
export const getTrustedOrigins = async () => {
const members = await db.query.member.findMany({
where: eq(member.role, "owner"),
with: {
user: true,
},
});
const TRUSTED_ORIGINS_CACHE_TTL_MS = 30 * 60_000;
let trustedOriginsCache: { data: string[]; expiresAt: number } | null = null;
if (members.length === 0) {
return [];
export const getTrustedOrigins = async () => {
const runQuery = async () => {
const rows = await db
.select({ trustedOrigins: user.trustedOrigins })
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(eq(member.role, "owner"));
return Array.from(new Set(rows.flatMap((r) => r.trustedOrigins ?? [])));
};
if (IS_CLOUD) {
const now = Date.now();
if (trustedOriginsCache && now < trustedOriginsCache.expiresAt) {
return trustedOriginsCache.data;
}
try {
const trustedOrigins = await runQuery();
trustedOriginsCache = {
data: trustedOrigins,
expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS,
};
return trustedOrigins;
} catch (error) {
console.error("Failed to fetch trusted origins:", error);
return trustedOriginsCache?.data ?? [];
}
}
const trustedOrigins = members.flatMap(
(member) => member.user.trustedOrigins || [],
);
return Array.from(new Set(trustedOrigins));
try {
return await runQuery();
} catch (error) {
console.error("Failed to fetch trusted origins:", error);
return [];
}
};
export const getTrustedProviders = async () => {
try {
const providers = await db.query.ssoProvider.findMany();
return providers.map((provider) => provider.providerId);
} catch (error) {
return [];
}
};

View File

@@ -29,6 +29,7 @@ import { cloneGitlabRepository } from "@dokploy/server/utils/providers/gitlab";
import { createTraefikConfig } from "@dokploy/server/utils/traefik/application";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
import { encodeBase64 } from "../utils/docker/utils";
import { getDokployUrl } from "./admin";
import {
@@ -53,7 +54,7 @@ import { validUniqueServerAppName } from "./project";
export type Application = typeof applications.$inferSelect;
export const createApplication = async (
input: typeof apiCreateApplication._type,
input: z.infer<typeof apiCreateApplication>,
) => {
const appName = buildAppName("app", input.appName);

View File

@@ -2,17 +2,16 @@ import { db } from "@dokploy/server/db";
import { type apiCreateBackup, backups } from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
export type Backup = typeof backups.$inferSelect;
export type BackupSchedule = Awaited<ReturnType<typeof findBackupById>>;
export type BackupScheduleList = Awaited<ReturnType<typeof findBackupsByDbId>>;
export const createBackup = async (input: typeof apiCreateBackup._type) => {
export const createBackup = async (input: z.infer<typeof apiCreateBackup>) => {
const newBackup = await db
.insert(backups)
.values({
...input,
})
.values({ ...input } as typeof backups.$inferInsert)
.returning()
.then((value) => value[0]);
@@ -34,6 +33,7 @@ export const findBackupById = async (backupId: string) => {
mysql: true,
mariadb: true,
mongo: true,
libsql: true,
destination: true,
compose: true,
},
@@ -73,7 +73,7 @@ export const removeBackupById = async (backupId: string) => {
export const findBackupsByDbId = async (
id: string,
type: "postgres" | "mysql" | "mariadb" | "mongo",
type: "postgres" | "mysql" | "mariadb" | "mongo" | "libsql",
) => {
const result = await db.query.backups.findMany({
where: eq(backups[`${type}Id`], id),
@@ -82,6 +82,7 @@ export const findBackupsByDbId = async (
mysql: true,
mariadb: true,
mongo: true,
libsql: true,
destination: true,
},
});

View File

@@ -7,11 +7,12 @@ import {
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
export type Bitbucket = typeof bitbucket.$inferSelect;
export const createBitbucket = async (
input: typeof apiCreateBitbucket._type,
input: z.infer<typeof apiCreateBitbucket>,
organizationId: string,
userId: string,
) => {
@@ -65,7 +66,7 @@ export const findBitbucketById = async (bitbucketId: string) => {
export const updateBitbucket = async (
bitbucketId: string,
input: typeof apiUpdateBitbucket._type,
input: z.infer<typeof apiUpdateBitbucket>,
) => {
return await db.transaction(async (tx) => {
// First get the current bitbucket provider to get gitProviderId

View File

@@ -126,3 +126,36 @@ const createCertificateFiles = async (certificate: Certificate) => {
fs.writeFileSync(configFile, yamlConfig);
}
};
export const updateCertificate = async (
certificateId: string,
updates: {
name?: string;
certificateData?: string;
privateKey?: string;
},
) => {
const updated = await db
.update(certificates)
.set({
...updates,
})
.where(eq(certificates.certificateId, certificateId))
.returning();
if (!updated || updated[0] === undefined) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Failed to update the certificate",
});
}
const cert = updated[0];
// If cert data or private key changed, rewrite files
if (updates.certificateData || updates.privateKey) {
await createCertificateFiles(cert);
}
return cert;
};

View File

@@ -33,6 +33,7 @@ import { cloneGitlabRepository } from "@dokploy/server/utils/providers/gitlab";
import { getCreateComposeFileCommand } from "@dokploy/server/utils/providers/raw";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
import { encodeBase64 } from "../utils/docker/utils";
import { getDokployUrl } from "./admin";
import {
@@ -45,7 +46,9 @@ import { validUniqueServerAppName } from "./project";
export type Compose = typeof compose.$inferSelect;
export const createCompose = async (input: typeof apiCreateCompose._type) => {
export const createCompose = async (
input: z.infer<typeof apiCreateCompose>,
) => {
const appName = buildAppName("compose", input.appName);
const valid = await validUniqueServerAppName(appName);

View File

@@ -10,7 +10,11 @@ import {
type apiCreateDeploymentSchedule,
type apiCreateDeploymentServer,
type apiCreateDeploymentVolumeBackup,
applications,
compose,
deployments,
environments,
projects,
} from "@dokploy/server/db/schema";
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
import {
@@ -19,7 +23,8 @@ import {
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { format } from "date-fns";
import { desc, eq } from "drizzle-orm";
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
import type { z } from "zod";
import {
type Application,
findApplicationById,
@@ -37,6 +42,41 @@ import { findScheduleById } from "./schedule";
import { findServerById, type Server } from "./server";
import { findVolumeBackupById } from "./volume-backups";
export type ServicePath = { href: string | null; label: string };
export async function resolveServicePath(
orgId: string,
data: Record<string, unknown>,
): Promise<ServicePath> {
try {
const applicationId = data?.applicationId as string | undefined;
const composeId = data?.composeId as string | undefined;
if (applicationId) {
const app = await findApplicationById(applicationId);
if (app.environment.project.organizationId !== orgId) {
return { href: null, label: "Application" };
}
return {
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
label: "Application",
};
}
if (composeId) {
const comp = await findComposeById(composeId);
if (comp.environment.project.organizationId !== orgId) {
return { href: null, label: "Compose" };
}
return {
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
label: "Compose",
};
}
} catch {
// not found or unauthorized
}
return { href: null, label: "—" };
}
export type Deployment = typeof deployments.$inferSelect;
export const findDeploymentById = async (deploymentId: string) => {
@@ -72,17 +112,17 @@ export const findDeploymentByApplicationId = async (applicationId: string) => {
export const createDeployment = async (
deployment: Omit<
typeof apiCreateDeployment._type,
z.infer<typeof apiCreateDeployment>,
"deploymentId" | "createdAt" | "status" | "logPath"
>,
) => {
const application = await findApplicationById(deployment.applicationId);
await removeLastTenDeployments(
deployment.applicationId,
"application",
application.serverId,
);
try {
await removeLastTenDeployments(
deployment.applicationId,
"application",
application.serverId,
);
const serverId = application.buildServerId || application.serverId;
const { LOGS_PATH } = paths(!!serverId);
@@ -137,7 +177,7 @@ export const createDeployment = async (
status: "error",
logPath: "",
description: deployment.description || "",
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
errorMessage: `An error have occurred: ${error instanceof Error ? error.message : error}`,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
})
@@ -153,20 +193,19 @@ export const createDeployment = async (
export const createDeploymentPreview = async (
deployment: Omit<
typeof apiCreateDeploymentPreview._type,
z.infer<typeof apiCreateDeploymentPreview>,
"deploymentId" | "createdAt" | "status" | "logPath"
>,
) => {
const previewDeployment = await findPreviewDeploymentById(
deployment.previewDeploymentId,
);
await removeLastTenDeployments(
deployment.previewDeploymentId,
"previewDeployment",
previewDeployment?.application?.serverId,
);
try {
await removeLastTenDeployments(
deployment.previewDeploymentId,
"previewDeployment",
previewDeployment?.application?.serverId,
);
const appName = `${previewDeployment.appName}`;
const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
@@ -218,7 +257,7 @@ export const createDeploymentPreview = async (
status: "error",
logPath: "",
description: deployment.description || "",
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
errorMessage: `An error have occurred: ${error instanceof Error ? error.message : error}`,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
})
@@ -236,17 +275,17 @@ export const createDeploymentPreview = async (
export const createDeploymentCompose = async (
deployment: Omit<
typeof apiCreateDeploymentCompose._type,
z.infer<typeof apiCreateDeploymentCompose>,
"deploymentId" | "createdAt" | "status" | "logPath"
>,
) => {
const compose = await findComposeById(deployment.composeId);
await removeLastTenDeployments(
deployment.composeId,
"compose",
compose.serverId,
);
try {
await removeLastTenDeployments(
deployment.composeId,
"compose",
compose.serverId,
);
const { LOGS_PATH } = paths(!!compose.serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${compose.appName}-${formattedDateTime}.log`;
@@ -295,7 +334,7 @@ echo "Initializing deployment\n" >> ${logFilePath};
status: "error",
logPath: "",
description: deployment.description || "",
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
errorMessage: `An error have occurred: ${error instanceof Error ? error.message : error}`,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
})
@@ -313,7 +352,7 @@ echo "Initializing deployment\n" >> ${logFilePath};
export const createDeploymentBackup = async (
deployment: Omit<
typeof apiCreateDeploymentBackup._type,
z.infer<typeof apiCreateDeploymentBackup>,
"deploymentId" | "createdAt" | "status" | "logPath"
>,
) => {
@@ -329,8 +368,8 @@ export const createDeploymentBackup = async (
} else if (backup.backupType === "compose") {
serverId = backup.compose?.serverId;
}
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
try {
await removeLastTenDeployments(deployment.backupId, "backup", serverId);
const { LOGS_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${backup.appName}-${formattedDateTime}.log`;
@@ -379,7 +418,7 @@ echo "Initializing backup\n" >> ${logFilePath};
status: "error",
logPath: "",
description: deployment.description || "",
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
errorMessage: `An error have occurred: ${error instanceof Error ? error.message : error}`,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
})
@@ -393,18 +432,18 @@ echo "Initializing backup\n" >> ${logFilePath};
export const createDeploymentSchedule = async (
deployment: Omit<
typeof apiCreateDeploymentSchedule._type,
z.infer<typeof apiCreateDeploymentSchedule>,
"deploymentId" | "createdAt" | "status" | "logPath"
>,
) => {
const schedule = await findScheduleById(deployment.scheduleId);
const serverId =
schedule.application?.serverId ||
schedule.compose?.serverId ||
schedule.server?.serverId;
await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId);
try {
const serverId =
schedule.application?.serverId ||
schedule.compose?.serverId ||
schedule.server?.serverId;
await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId);
const { SCHEDULES_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${schedule.appName}-${formattedDateTime}.log`;
@@ -454,7 +493,7 @@ export const createDeploymentSchedule = async (
status: "error",
logPath: "",
description: deployment.description || "",
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
errorMessage: `An error have occurred: ${error instanceof Error ? error.message : error}`,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
})
@@ -469,20 +508,20 @@ export const createDeploymentSchedule = async (
export const createDeploymentVolumeBackup = async (
deployment: Omit<
typeof apiCreateDeploymentVolumeBackup._type,
z.infer<typeof apiCreateDeploymentVolumeBackup>,
"deploymentId" | "createdAt" | "status" | "logPath"
>,
) => {
const volumeBackup = await findVolumeBackupById(deployment.volumeBackupId);
const serverId =
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
await removeLastTenDeployments(
deployment.volumeBackupId,
"volumeBackup",
serverId,
);
try {
const serverId =
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
await removeLastTenDeployments(
deployment.volumeBackupId,
"volumeBackup",
serverId,
);
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${volumeBackup.appName}-${formattedDateTime}.log`;
@@ -539,7 +578,7 @@ export const createDeploymentVolumeBackup = async (
status: "error",
logPath: "",
description: deployment.description || "",
errorMessage: `An error have occured: ${error instanceof Error ? error.message : error}`,
errorMessage: `An error have occurred: ${error instanceof Error ? error.message : error}`,
startedAt: new Date().toISOString(),
finishedAt: new Date().toISOString(),
})
@@ -561,24 +600,23 @@ export const removeDeployment = async (deploymentId: string) => {
.then((result) => result[0]);
if (!deployment) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Deployment not found",
});
return null;
}
const command = `
rm -f ${deployment.logPath};
`;
if (deployment.serverId) {
await execAsyncRemote(deployment.serverId, command);
} else {
await execAsync(command);
const logPath = path.join(deployment.logPath);
if (logPath && logPath !== ".") {
const command = `rm -f ${logPath};`;
if (deployment.serverId) {
await execAsyncRemote(deployment.serverId, command);
} else {
await execAsync(command);
}
}
return deployment;
} catch (error) {
const message =
error instanceof Error ? error.message : "Error creating the deployment";
error instanceof Error ? error.message : "Error removing the deployment";
throw new TRPCError({
code: "BAD_REQUEST",
message,
@@ -646,34 +684,49 @@ const removeLastTenDeployments = async (
if (serverId) {
let command = "";
for (const oldDeployment of deploymentsToDelete) {
const logPath = path.join(oldDeployment.logPath);
if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
}
try {
const logPath = path.join(oldDeployment.logPath);
if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
}
if (logPath !== ".") {
command += `
rm -rf ${logPath};
`;
if (logPath && logPath !== ".") {
command += `rm -rf ${logPath};`;
}
await removeDeployment(oldDeployment.deploymentId);
} catch (err) {
console.error(
`Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`,
err,
);
}
await removeDeployment(oldDeployment.deploymentId);
}
await execAsyncRemote(serverId, command);
if (command) {
await execAsyncRemote(serverId, command);
}
} else {
for (const oldDeployment of deploymentsToDelete) {
if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
try {
if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
}
const logPath = path.join(oldDeployment.logPath);
if (
logPath &&
logPath !== "." &&
existsSync(logPath) &&
!oldDeployment.errorMessage
) {
await fsPromises.unlink(logPath);
}
await removeDeployment(oldDeployment.deploymentId);
} catch (err) {
console.error(
`Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`,
err,
);
}
const logPath = path.join(oldDeployment.logPath);
if (
existsSync(logPath) &&
!oldDeployment.errorMessage &&
logPath !== "."
) {
await fsPromises.unlink(logPath);
}
await removeDeployment(oldDeployment.deploymentId);
}
}
}
@@ -737,6 +790,135 @@ export const findAllDeploymentsByComposeId = async (composeId: string) => {
return deploymentsList;
};
const centralizedDeploymentsWith = {
application: {
columns: { applicationId: true, name: true, appName: true },
with: {
environment: {
columns: { environmentId: true, name: true },
with: {
project: {
columns: { projectId: true, name: true },
},
},
},
server: {
columns: { serverId: true, name: true, serverType: true },
},
buildServer: {
columns: { serverId: true, name: true, serverType: true },
},
},
},
compose: {
columns: { composeId: true, name: true, appName: true },
with: {
environment: {
columns: { environmentId: true, name: true },
with: {
project: {
columns: { projectId: true, name: true },
},
},
},
server: {
columns: { serverId: true, name: true, serverType: true },
},
},
},
server: {
columns: { serverId: true, name: true, serverType: true },
},
buildServer: {
columns: { serverId: true, name: true, serverType: true },
},
} as const;
async function getApplicationIdsInOrg(
orgId: string,
accessedServices: string[] | null,
): Promise<string[]> {
const rows = await db
.select({ applicationId: applications.applicationId })
.from(applications)
.innerJoin(
environments,
eq(applications.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(
accessedServices !== null
? and(
eq(projects.organizationId, orgId),
inArray(applications.applicationId, accessedServices),
)
: eq(projects.organizationId, orgId),
);
return rows.map((r) => r.applicationId);
}
async function getComposeIdsInOrg(
orgId: string,
accessedServices: string[] | null,
): Promise<string[]> {
const rows = await db
.select({ composeId: compose.composeId })
.from(compose)
.innerJoin(
environments,
eq(compose.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(
accessedServices !== null
? and(
eq(projects.organizationId, orgId),
inArray(compose.composeId, accessedServices),
)
: eq(projects.organizationId, orgId),
);
return rows.map((r) => r.composeId);
}
/**
* All deployments for applications and compose in the org.
* Pass accessedServices for members (only those services), null for owner/admin.
*/
export const findAllDeploymentsCentralized = async (
orgId: string,
accessedServices: string[] | null,
) => {
if (accessedServices !== null && accessedServices.length === 0) {
return [];
}
const [appIds, compIds] = await Promise.all([
getApplicationIdsInOrg(orgId, accessedServices),
getComposeIdsInOrg(orgId, accessedServices),
]);
if (appIds.length === 0 && compIds.length === 0) {
return [];
}
const conditions = [
...(appIds.length > 0 ? [inArray(deployments.applicationId, appIds)] : []),
...(compIds.length > 0 ? [inArray(deployments.composeId, compIds)] : []),
];
const whereClause =
conditions.length === 0
? sql`1 = 0`
: conditions.length === 1
? conditions[0]
: or(...conditions);
return db.query.deployments.findMany({
where: whereClause,
orderBy: desc(deployments.createdAt),
with: centralizedDeploymentsWith,
});
};
export const updateDeployment = async (
deploymentId: string,
deploymentData: Partial<Deployment>,
@@ -773,7 +955,7 @@ export const updateDeploymentStatus = async (
export const createServerDeployment = async (
deployment: Omit<
typeof apiCreateDeploymentServer._type,
z.infer<typeof apiCreateDeploymentServer>,
"deploymentId" | "createdAt" | "status" | "logPath"
>,
) => {

View File

@@ -5,11 +5,12 @@ import {
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import type { z } from "zod";
export type Destination = typeof destinations.$inferSelect;
export const createDestintation = async (
input: typeof apiCreateDestination._type,
export const createDestination = async (
input: z.infer<typeof apiCreateDestination>,
organizationId: string,
) => {
const newDestination = await db

View File

@@ -371,6 +371,21 @@ export const containerRestart = async (containerId: string) => {
} catch {}
};
export const containerRemove = async (
containerId: string,
serverId?: string,
) => {
const command = `docker rm -f ${containerId}`;
const { stderr } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
if (stderr) {
console.error(`Error: ${stderr}`);
throw new Error(stderr);
}
};
export const getSwarmNodes = async (serverId?: string) => {
try {
let stdout = "";
@@ -397,7 +412,9 @@ export const getSwarmNodes = async (serverId?: string) => {
.split("\n")
.map((line) => JSON.parse(line));
return nodesArray;
} catch {}
} catch (error) {
console.error("getSwarmNodes error:", error);
}
};
export const getNodeInfo = async (nodeId: string, serverId?: string) => {
@@ -448,6 +465,10 @@ export const getNodeApplications = async (serverId?: string) => {
return;
}
if (!stdout.trim()) {
return [];
}
const appArray = stdout
.trim()
.split("\n")
@@ -455,13 +476,19 @@ export const getNodeApplications = async (serverId?: string) => {
.filter((service) => !service.Name.startsWith("dokploy-"));
return appArray;
} catch {}
} catch (error) {
console.error("getNodeApplications error:", error);
return [];
}
};
export const getApplicationInfo = async (
appNames: string[],
serverId?: string,
) => {
if (appNames.length === 0) {
return [];
}
try {
let stdout = "";
let stderr = "";
@@ -482,11 +509,84 @@ export const getApplicationInfo = async (
return;
}
if (!stdout.trim()) {
return [];
}
const appArray = stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
return appArray;
} catch {}
} catch (error) {
console.error("getApplicationInfo error:", error);
return [];
}
};
export const getAllContainerStats = async (serverId?: string) => {
try {
let stdout = "";
const command =
'docker stats --no-stream --format \'{"BlockIO":"{{.BlockIO}}","CPUPerc":"{{.CPUPerc}}","Container":"{{.Container}}","ID":"{{.ID}}","MemPerc":"{{.MemPerc}}","MemUsage":"{{.MemUsage}}","Name":"{{.Name}}","NetIO":"{{.NetIO}}"}\'';
if (serverId) {
const result = await execAsyncRemote(serverId, command);
stdout = result.stdout;
} else {
const result = await execAsync(command);
stdout = result.stdout;
}
if (!stdout.trim()) {
return [];
}
const stats = stdout
.trim()
.split("\n")
.map((line) => JSON.parse(line));
return stats;
} catch (error) {
console.error("getAllContainerStats error:", error);
return [];
}
};
export const uploadFileToContainer = async (
containerId: string,
fileBuffer: Buffer,
fileName: string,
destinationPath: string,
serverId?: string | null,
): Promise<void> => {
const containerIdRegex = /^[a-zA-Z0-9.\-_]+$/;
if (!containerIdRegex.test(containerId)) {
throw new Error("Invalid container ID");
}
// Ensure destination path starts with /
const normalizedPath = destinationPath.startsWith("/")
? destinationPath
: `/${destinationPath}`;
const base64Content = fileBuffer.toString("base64");
const tempFileName = `dokploy-upload-${Date.now()}-${fileName.replace(/[^a-zA-Z0-9.-]/g, "_")}`;
const tempPath = `/tmp/${tempFileName}`;
const command = `echo '${base64Content}' | base64 -d > "${tempPath}" && docker cp "${tempPath}" "${containerId}:${normalizedPath}" ; rm -f "${tempPath}"`;
try {
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
} catch (error) {
throw new Error(
`Failed to upload file to container: ${error instanceof Error ? error.message : String(error)}`,
);
}
};

View File

@@ -6,6 +6,7 @@ import { generateRandomDomain } from "@dokploy/server/templates";
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
import { type apiCreateDomain, domains } from "../db/schema";
import { findApplicationById } from "./application";
import { detectCDNProvider } from "./cdn";
@@ -13,14 +14,14 @@ import { findServerById } from "./server";
export type Domain = typeof domains.$inferSelect;
export const createDomain = async (input: typeof apiCreateDomain._type) => {
export const createDomain = async (input: z.infer<typeof apiCreateDomain>) => {
const result = await db.transaction(async (tx) => {
const domain = await tx
.insert(domains)
.values({
...input,
host: input.host?.trim(),
})
} as typeof domains.$inferInsert)
.returning()
.then((response) => response[0]);
@@ -44,7 +45,7 @@ export const createDomain = async (input: typeof apiCreateDomain._type) => {
export const generateTraefikMeDomain = async (
appName: string,
userId: string,
_userId: string,
serverId?: string,
) => {
if (serverId) {

View File

@@ -6,11 +6,12 @@ import {
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { asc, eq } from "drizzle-orm";
import type { z } from "zod";
export type Environment = typeof environments.$inferSelect;
export const createEnvironment = async (
input: typeof apiCreateEnvironment._type,
input: z.infer<typeof apiCreateEnvironment>,
) => {
const newEnvironment = await db
.insert(environments)
@@ -33,42 +34,158 @@ export const createEnvironment = async (
export const findEnvironmentById = async (environmentId: string) => {
const environment = await db.query.environments.findFirst({
where: eq(environments.environmentId, environmentId),
columns: {
name: true,
description: true,
environmentId: true,
isDefault: true,
projectId: true,
env: true,
},
with: {
applications: {
with: {
deployments: true,
server: true,
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
name: true,
applicationId: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
icon: true,
},
},
mariadb: {
with: {
server: true,
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
mariadbId: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
mongo: {
with: {
server: true,
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
mongoId: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
mysql: {
with: {
server: true,
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
mysqlId: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
postgres: {
with: {
server: true,
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
postgresId: true,
name: true,
description: true,
createdAt: true,
applicationStatus: true,
serverId: true,
},
},
redis: {
with: {
server: true,
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
redisId: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
compose: {
with: {
deployments: true,
server: true,
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
composeId: true,
name: true,
createdAt: true,
composeStatus: true,
description: true,
serverId: true,
},
},
libsql: {
with: {
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
libsqlId: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
project: true,
@@ -95,8 +212,15 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
postgres: true,
redis: true,
compose: true,
libsql: true,
project: true,
},
columns: {
name: true,
description: true,
environmentId: true,
isDefault: true,
},
});
return projectEnvironments;
};
@@ -107,6 +231,7 @@ const environmentHasServices = (
return (
(env.applications?.length ?? 0) > 0 ||
(env.compose?.length ?? 0) > 0 ||
(env.libsql?.length ?? 0) > 0 ||
(env.mariadb?.length ?? 0) > 0 ||
(env.mongo?.length ?? 0) > 0 ||
(env.mysql?.length ?? 0) > 0 ||
@@ -156,7 +281,7 @@ export const updateEnvironmentById = async (
};
export const duplicateEnvironment = async (
input: typeof apiDuplicateEnvironment._type,
input: z.infer<typeof apiDuplicateEnvironment>,
) => {
// Find the original environment
const originalEnvironment = await findEnvironmentById(input.environmentId);
@@ -168,6 +293,7 @@ export const duplicateEnvironment = async (
name: input.name,
description: input.description || originalEnvironment.description,
projectId: originalEnvironment.projectId,
env: originalEnvironment.env,
})
.returning()
.then((value) => value[0]);

View File

@@ -1,7 +1,8 @@
import { db } from "@dokploy/server/db";
import { gitProvider } from "@dokploy/server/db/schema";
import { gitProvider, member } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
export type GitProvider = typeof gitProvider.$inferSelect;
@@ -41,3 +42,48 @@ export const updateGitProvider = async (
.returning()
.then((response) => response[0]);
};
export const getAccessibleGitProviderIds = async (session: {
userId: string;
activeOrganizationId: string;
}): Promise<Set<string>> => {
const { userId, activeOrganizationId } = session;
const allOrgProviders = await db.query.gitProvider.findMany({
where: eq(gitProvider.organizationId, activeOrganizationId),
columns: {
gitProviderId: true,
userId: true,
sharedWithOrganization: true,
},
});
const memberRecord = await db.query.member.findFirst({
where: and(
eq(member.userId, userId),
eq(member.organizationId, activeOrganizationId),
),
columns: { accessedGitProviders: true, role: true },
});
if (memberRecord?.role === "owner" || memberRecord?.role === "admin") {
return new Set(allOrgProviders.map((p) => p.gitProviderId));
}
const licensed = await hasValidLicense(activeOrganizationId);
const assignedSet = licensed
? new Set(memberRecord?.accessedGitProviders ?? [])
: new Set<string>();
const result = new Set<string>();
for (const p of allOrgProviders) {
if (
p.userId === userId ||
p.sharedWithOrganization ||
assignedSet.has(p.gitProviderId)
) {
result.add(p.gitProviderId);
}
}
return result;
};

View File

@@ -6,11 +6,12 @@ import {
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
export type Gitea = typeof gitea.$inferSelect;
export const createGitea = async (
input: typeof apiCreateGitea._type,
input: z.infer<typeof apiCreateGitea>,
organizationId: string,
userId: string,
) => {

View File

@@ -6,12 +6,13 @@ import {
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
import { authGithub } from "../utils/providers/github";
import { updatePreviewDeployment } from "./preview-deployment";
export type Github = typeof github.$inferSelect;
export const createGithub = async (
input: typeof apiCreateGithub._type,
input: z.infer<typeof apiCreateGithub>,
organizationId: string,
userId: string,
) => {

View File

@@ -6,11 +6,12 @@ import {
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
export type Gitlab = typeof gitlab.$inferSelect;
export const createGitlab = async (
input: typeof apiCreateGitlab._type,
input: z.infer<typeof apiCreateGitlab>,
organizationId: string,
userId: string,
) => {

View File

@@ -0,0 +1,162 @@
import { db } from "@dokploy/server/db";
import {
type apiCreateLibsql,
backups,
buildAppName,
libsql,
} from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildLibsql } from "@dokploy/server/utils/databases/libsql";
import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
export type Libsql = typeof libsql.$inferSelect;
export const createLibsql = async (input: z.infer<typeof apiCreateLibsql>) => {
const appName = buildAppName("libsql", input.appName);
const valid = await validUniqueServerAppName(input.appName);
if (!valid) {
throw new TRPCError({
code: "CONFLICT",
message: "Service with this 'AppName' already exists",
});
}
const newLibsql = await db
.insert(libsql)
.values({
...input,
databasePassword: input.databasePassword
? input.databasePassword
: generatePassword(),
appName,
})
.returning()
.then((value) => value[0]);
if (!newLibsql) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting libsql database",
});
}
return newLibsql;
};
// https://github.com/drizzle-team/drizzle-orm/discussions/1483#discussioncomment-7523881
export const findLibsqlById = async (libsqlId: string) => {
const result = await db.query.libsql.findFirst({
where: eq(libsql.libsqlId, libsqlId),
with: {
environment: {
with: {
project: true,
},
},
mounts: true,
server: true,
backups: {
with: {
destination: true,
deployments: true,
},
},
},
});
if (!result) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Libsql not found",
});
}
return result;
};
export const updateLibsqlById = async (
libsqlId: string,
libsqlData: Partial<Libsql>,
) => {
const { appName, ...rest } = libsqlData;
const result = await db
.update(libsql)
.set({
...rest,
})
.where(eq(libsql.libsqlId, libsqlId))
.returning();
return result[0];
};
export const removeLibsqlById = async (libsqlId: string) => {
const result = await db
.delete(libsql)
.where(eq(libsql.libsqlId, libsqlId))
.returning();
return result[0];
};
export const findLibsqlByBackupId = async (backupId: string) => {
const result = await db
.select({
...getTableColumns(libsql),
})
.from(libsql)
.innerJoin(backups, eq(libsql.libsqlId, backups.libsqlId))
.where(eq(backups.backupId, backupId))
.limit(1);
if (!result || !result[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Libsql not found",
});
}
return result[0];
};
export const deployLibsql = async (
libsqlId: string,
onData?: (data: any) => void,
) => {
const libsql = await findLibsqlById(libsqlId);
try {
await updateLibsqlById(libsqlId, {
applicationStatus: "running",
});
onData?.("Starting libsql deployment...");
if (libsql.serverId) {
await execAsyncRemote(
libsql.serverId,
`docker pull ${libsql.dockerImage}`,
onData,
);
} else {
await pullImage(libsql.dockerImage, onData);
}
await buildLibsql(libsql);
await updateLibsqlById(libsqlId, {
applicationStatus: "done",
});
onData?.("Deployment completed successfully!");
} catch (error) {
onData?.(`Error: ${error}`);
await updateLibsqlById(libsqlId, {
applicationStatus: "error",
});
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: `Error on deploy libsql${error}`,
});
}
return libsql;
};

View File

@@ -11,11 +11,14 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
export type Mariadb = typeof mariadb.$inferSelect;
export const createMariadb = async (input: typeof apiCreateMariaDB._type) => {
export const createMariadb = async (
input: z.infer<typeof apiCreateMariaDB>,
) => {
const appName = buildAppName("mariadb", input.appName);
const valid = await validUniqueServerAppName(appName);

View File

@@ -12,11 +12,12 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
export type Mongo = typeof mongo.$inferSelect;
export const createMongo = async (input: typeof apiCreateMongo._type) => {
export const createMongo = async (input: z.infer<typeof apiCreateMongo>) => {
const appName = buildAppName("mongo", input.appName);
const valid = await validUniqueServerAppName(appName);

View File

@@ -18,10 +18,11 @@ import {
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, type SQL, sql } from "drizzle-orm";
import type { z } from "zod";
export type Mount = typeof mounts.$inferSelect;
export const createMount = async (input: typeof apiCreateMount._type) => {
export const createMount = async (input: z.infer<typeof apiCreateMount>) => {
try {
const { serviceId, ...rest } = input;
const value = await db
@@ -31,8 +32,11 @@ export const createMount = async (input: typeof apiCreateMount._type) => {
...(input.serviceType === "application" && {
applicationId: serviceId,
}),
...(input.serviceType === "postgres" && {
postgresId: serviceId,
...(input.serviceType === "compose" && {
composeId: serviceId,
}),
...(input.serviceType === "libsql" && {
libsqlId: serviceId,
}),
...(input.serviceType === "mariadb" && {
mariadbId: serviceId,
@@ -43,12 +47,12 @@ export const createMount = async (input: typeof apiCreateMount._type) => {
...(input.serviceType === "mysql" && {
mysqlId: serviceId,
}),
...(input.serviceType === "postgres" && {
postgresId: serviceId,
}),
...(input.serviceType === "redis" && {
redisId: serviceId,
}),
...(input.serviceType === "compose" && {
composeId: serviceId,
}),
})
.returning()
.then((value) => value[0]);
@@ -114,7 +118,16 @@ export const findMountById = async (mountId: string) => {
},
},
},
postgres: {
compose: {
with: {
environment: {
with: {
project: true,
},
},
},
},
libsql: {
with: {
environment: {
with: {
@@ -150,7 +163,7 @@ export const findMountById = async (mountId: string) => {
},
},
},
redis: {
postgres: {
with: {
environment: {
with: {
@@ -159,7 +172,7 @@ export const findMountById = async (mountId: string) => {
},
},
},
compose: {
redis: {
with: {
environment: {
with: {
@@ -185,8 +198,11 @@ export const findMountOrganizationId = async (mountId: string) => {
if (mount.application) {
return mount.application.environment.project.organizationId;
}
if (mount.postgres) {
return mount.postgres.environment.project.organizationId;
if (mount.compose) {
return mount.compose.environment.project.organizationId;
}
if (mount.libsql) {
return mount.libsql.environment.project.organizationId;
}
if (mount.mariadb) {
return mount.mariadb.environment.project.organizationId;
@@ -197,13 +213,13 @@ export const findMountOrganizationId = async (mountId: string) => {
if (mount.mysql) {
return mount.mysql.environment.project.organizationId;
}
if (mount.postgres) {
return mount.postgres.environment.project.organizationId;
}
if (mount.redis) {
return mount.redis.environment.project.organizationId;
}
if (mount.compose) {
return mount.compose.environment.project.organizationId;
}
return null;
};
@@ -247,8 +263,8 @@ export const findMountsByApplicationId = async (
case "application":
sqlChunks.push(eq(mounts.applicationId, serviceId));
break;
case "postgres":
sqlChunks.push(eq(mounts.postgresId, serviceId));
case "libsql":
sqlChunks.push(eq(mounts.libsqlId, serviceId));
break;
case "mariadb":
sqlChunks.push(eq(mounts.mariadbId, serviceId));
@@ -259,9 +275,15 @@ export const findMountsByApplicationId = async (
case "mysql":
sqlChunks.push(eq(mounts.mysqlId, serviceId));
break;
case "postgres":
sqlChunks.push(eq(mounts.postgresId, serviceId));
break;
case "redis":
sqlChunks.push(eq(mounts.redisId, serviceId));
break;
case "compose":
sqlChunks.push(eq(mounts.composeId, serviceId));
break;
default:
throw new Error(`Unknown service type: ${serviceType}`);
}
@@ -358,6 +380,10 @@ export const getBaseFilesPath = async (mountId: string) => {
const { COMPOSE_PATH } = paths(!!mount.compose.serverId);
appName = mount.compose.appName;
absoluteBasePath = path.resolve(COMPOSE_PATH);
} else if (mount.serviceType === "libsql" && mount.libsql) {
const { APPLICATIONS_PATH } = paths(!!mount.libsql.serverId);
absoluteBasePath = path.resolve(APPLICATIONS_PATH);
appName = mount.libsql.appName;
}
directoryPath = path.join(absoluteBasePath, appName, "files");
@@ -387,6 +413,9 @@ export const getServerId = async (mount: MountNested) => {
if (mount.serviceType === "compose" && mount?.compose?.serverId) {
return mount.compose.serverId;
}
if (mount.serviceType === "libsql" && mount?.libsql?.serverId) {
return mount.libsql.serverId;
}
return null;
};

View File

@@ -11,11 +11,12 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
export type MySql = typeof mysql.$inferSelect;
export const createMysql = async (input: typeof apiCreateMySql._type) => {
export const createMysql = async (input: z.infer<typeof apiCreateMySql>) => {
const appName = buildAppName("mysql", input.appName);
const valid = await validUniqueServerAppName(appName);

View File

@@ -5,6 +5,7 @@ import {
type apiCreateEmail,
type apiCreateGotify,
type apiCreateLark,
type apiCreateMattermost,
type apiCreateNtfy,
type apiCreatePushover,
type apiCreateResend,
@@ -16,6 +17,7 @@ import {
type apiUpdateEmail,
type apiUpdateGotify,
type apiUpdateLark,
type apiUpdateMattermost,
type apiUpdateNtfy,
type apiUpdatePushover,
type apiUpdateResend,
@@ -27,6 +29,7 @@ import {
email,
gotify,
lark,
mattermost,
notifications,
ntfy,
pushover,
@@ -37,11 +40,12 @@ import {
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
export type Notification = typeof notifications.$inferSelect;
export const createSlackNotification = async (
input: typeof apiCreateSlack._type,
input: z.infer<typeof apiCreateSlack>,
organizationId: string,
) => {
await db.transaction(async (tx) => {
@@ -69,6 +73,7 @@ export const createSlackNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -91,7 +96,7 @@ export const createSlackNotification = async (
};
export const updateSlackNotification = async (
input: typeof apiUpdateSlack._type,
input: z.infer<typeof apiUpdateSlack>,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
@@ -101,6 +106,7 @@ export const updateSlackNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -133,7 +139,7 @@ export const updateSlackNotification = async (
};
export const createTelegramNotification = async (
input: typeof apiCreateTelegram._type,
input: z.infer<typeof apiCreateTelegram>,
organizationId: string,
) => {
await db.transaction(async (tx) => {
@@ -162,6 +168,7 @@ export const createTelegramNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -184,7 +191,7 @@ export const createTelegramNotification = async (
};
export const updateTelegramNotification = async (
input: typeof apiUpdateTelegram._type,
input: z.infer<typeof apiUpdateTelegram>,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
@@ -194,6 +201,7 @@ export const updateTelegramNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -227,7 +235,7 @@ export const updateTelegramNotification = async (
};
export const createDiscordNotification = async (
input: typeof apiCreateDiscord._type,
input: z.infer<typeof apiCreateDiscord>,
organizationId: string,
) => {
await db.transaction(async (tx) => {
@@ -255,6 +263,7 @@ export const createDiscordNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -277,7 +286,7 @@ export const createDiscordNotification = async (
};
export const updateDiscordNotification = async (
input: typeof apiUpdateDiscord._type,
input: z.infer<typeof apiUpdateDiscord>,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
@@ -287,6 +296,7 @@ export const updateDiscordNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -319,7 +329,7 @@ export const updateDiscordNotification = async (
};
export const createEmailNotification = async (
input: typeof apiCreateEmail._type,
input: z.infer<typeof apiCreateEmail>,
organizationId: string,
) => {
await db.transaction(async (tx) => {
@@ -351,6 +361,7 @@ export const createEmailNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -373,7 +384,7 @@ export const createEmailNotification = async (
};
export const updateEmailNotification = async (
input: typeof apiUpdateEmail._type,
input: z.infer<typeof apiUpdateEmail>,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
@@ -383,6 +394,7 @@ export const updateEmailNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -419,7 +431,7 @@ export const updateEmailNotification = async (
};
export const createResendNotification = async (
input: typeof apiCreateResend._type,
input: z.infer<typeof apiCreateResend>,
organizationId: string,
) => {
await db.transaction(async (tx) => {
@@ -448,6 +460,7 @@ export const createResendNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -470,7 +483,7 @@ export const createResendNotification = async (
};
export const updateResendNotification = async (
input: typeof apiUpdateResend._type,
input: z.infer<typeof apiUpdateResend>,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
@@ -480,6 +493,7 @@ export const updateResendNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -513,7 +527,7 @@ export const updateResendNotification = async (
};
export const createGotifyNotification = async (
input: typeof apiCreateGotify._type,
input: z.infer<typeof apiCreateGotify>,
organizationId: string,
) => {
await db.transaction(async (tx) => {
@@ -543,6 +557,7 @@ export const createGotifyNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -564,7 +579,7 @@ export const createGotifyNotification = async (
};
export const updateGotifyNotification = async (
input: typeof apiUpdateGotify._type,
input: z.infer<typeof apiUpdateGotify>,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
@@ -574,6 +589,7 @@ export const updateGotifyNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -605,7 +621,7 @@ export const updateGotifyNotification = async (
};
export const createNtfyNotification = async (
input: typeof apiCreateNtfy._type,
input: z.infer<typeof apiCreateNtfy>,
organizationId: string,
) => {
await db.transaction(async (tx) => {
@@ -635,6 +651,7 @@ export const createNtfyNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -656,7 +673,7 @@ export const createNtfyNotification = async (
};
export const updateNtfyNotification = async (
input: typeof apiUpdateNtfy._type,
input: z.infer<typeof apiUpdateNtfy>,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
@@ -666,6 +683,7 @@ export const updateNtfyNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -697,7 +715,7 @@ export const updateNtfyNotification = async (
};
export const createCustomNotification = async (
input: typeof apiCreateCustom._type,
input: z.infer<typeof apiCreateCustom>,
organizationId: string,
) => {
await db.transaction(async (tx) => {
@@ -725,6 +743,8 @@ export const createCustomNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "custom",
@@ -746,7 +766,7 @@ export const createCustomNotification = async (
};
export const updateCustomNotification = async (
input: typeof apiUpdateCustom._type,
input: z.infer<typeof apiUpdateCustom>,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
@@ -756,6 +776,7 @@ export const updateCustomNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -796,6 +817,7 @@ export const findNotificationById = async (notificationId: string) => {
resend: true,
gotify: true,
ntfy: true,
mattermost: true,
custom: true,
lark: true,
pushover: true,
@@ -821,7 +843,7 @@ export const removeNotificationById = async (notificationId: string) => {
};
export const createLarkNotification = async (
input: typeof apiCreateLark._type,
input: z.infer<typeof apiCreateLark>,
organizationId: string,
) => {
await db.transaction(async (tx) => {
@@ -848,6 +870,8 @@ export const createLarkNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "lark",
@@ -869,7 +893,7 @@ export const createLarkNotification = async (
};
export const updateLarkNotification = async (
input: typeof apiUpdateLark._type,
input: z.infer<typeof apiUpdateLark>,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
@@ -879,6 +903,7 @@ export const updateLarkNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -910,7 +935,7 @@ export const updateLarkNotification = async (
};
export const createTeamsNotification = async (
input: typeof apiCreateTeams._type,
input: z.infer<typeof apiCreateTeams>,
organizationId: string,
) => {
await db.transaction(async (tx) => {
@@ -937,6 +962,7 @@ export const createTeamsNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -959,7 +985,7 @@ export const createTeamsNotification = async (
};
export const updateTeamsNotification = async (
input: typeof apiUpdateTeams._type,
input: z.infer<typeof apiUpdateTeams>,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
@@ -969,6 +995,7 @@ export const updateTeamsNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -1014,8 +1041,104 @@ export const updateNotificationById = async (
return result[0];
};
export const createMattermostNotification = async (
input: z.infer<typeof apiCreateMattermost>,
organizationId: string,
) => {
await db.transaction(async (tx) => {
const newMattermost = await tx
.insert(mattermost)
.values({
webhookUrl: input.webhookUrl,
channel: input.channel,
username: input.username,
})
.returning()
.then((value) => value[0]);
if (!newMattermost) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting mattermost",
});
}
const newDestination = await tx
.insert(notifications)
.values({
mattermostId: newMattermost.mattermostId,
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "mattermost",
organizationId: organizationId,
serverThreshold: input.serverThreshold,
})
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting notification",
});
}
return newDestination;
});
};
export const updateMattermostNotification = async (
input: z.infer<typeof apiUpdateMattermost>,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
.update(notifications)
.set({
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
organizationId: input.organizationId,
serverThreshold: input.serverThreshold,
})
.where(eq(notifications.notificationId, input.notificationId))
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error Updating notification",
});
}
await tx
.update(mattermost)
.set({
webhookUrl: input.webhookUrl,
channel: input.channel,
username: input.username,
})
.where(eq(mattermost.mattermostId, input.mattermostId))
.returning()
.then((value) => value[0]);
return newDestination;
});
};
export const createPushoverNotification = async (
input: typeof apiCreatePushover._type,
input: z.infer<typeof apiCreatePushover>,
organizationId: string,
) => {
await db.transaction(async (tx) => {
@@ -1046,6 +1169,7 @@ export const createPushoverNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
@@ -1068,7 +1192,7 @@ export const createPushoverNotification = async (
};
export const updatePushoverNotification = async (
input: typeof apiUpdatePushover._type,
input: z.infer<typeof apiUpdatePushover>,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
@@ -1078,6 +1202,7 @@ export const updatePushoverNotification = async (
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployBackup: input.dokployBackup,
volumeBackup: input.volumeBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,

View File

@@ -4,13 +4,14 @@ import { db } from "@dokploy/server/db";
import { type apiCreatePatch, patch } from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import type { z } from "zod";
import { encodeBase64 } from "../utils/docker/utils";
import { findApplicationById } from "./application";
import { findComposeById } from "./compose";
export type Patch = typeof patch.$inferSelect;
export const createPatch = async (input: typeof apiCreatePatch._type) => {
export const createPatch = async (input: z.infer<typeof apiCreatePatch>) => {
if (!input.applicationId && !input.composeId) {
throw new TRPCError({
code: "BAD_REQUEST",
@@ -148,6 +149,10 @@ export const generateApplyPatchesCommand = async ({
const resultPatches = await findPatchesByEntityId(id, type);
const patches = resultPatches.filter((p) => p.enabled);
if (patches.length === 0) {
return "";
}
let command = `echo "Applying ${patches.length} patch(es)...";`;
for (const p of patches) {

View File

@@ -0,0 +1,431 @@
import { db } from "@dokploy/server/db";
import { member, organizationRole } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { TRPCError } from "@trpc/server";
import { and, eq } from "drizzle-orm";
import {
ac,
adminRole,
enterpriseOnlyResources,
memberRole,
ownerRole,
statements,
} from "../lib/access-control";
type Statements = typeof statements;
type Resource = keyof Statements;
type Action<R extends Resource> = Statements[R][number];
type Permissions = {
[R in Resource]?: Action<R>[];
};
export type PermissionCtx = {
user: { id: string };
session: { activeOrganizationId: string };
};
export type ResolvedPermissions = {
[R in Resource]: {
[A in Statements[R][number]]: boolean;
};
};
const staticRoles: Record<string, ReturnType<typeof ac.newRole>> = {
owner: ownerRole,
admin: adminRole,
member: memberRole,
};
const resolveRole = async (
roleName: string,
organizationId: string,
): Promise<ReturnType<typeof ac.newRole> | null> => {
if (staticRoles[roleName]) {
return staticRoles[roleName];
}
const licensed = await hasValidLicense(organizationId);
if (!licensed) {
return null;
}
const customRoles = await db.query.organizationRole.findMany({
where: and(
eq(organizationRole.organizationId, organizationId),
eq(organizationRole.role, roleName),
),
});
if (customRoles.length === 0) {
return null;
}
const merged: Record<string, string[]> = {};
for (const entry of customRoles) {
const parsed = JSON.parse(entry.permission) as Record<string, string[]>;
for (const [resource, actions] of Object.entries(parsed)) {
merged[resource] = [
...new Set([...(merged[resource] ?? []), ...actions]),
];
}
}
return ac.newRole(merged as any);
};
export const checkPermission = async (
ctx: PermissionCtx,
permissions: Permissions,
) => {
const { id: userId } = ctx.user;
const { activeOrganizationId: organizationId } = ctx.session;
const memberRecord = await findMemberByUserId(userId, organizationId);
const isStaticRole = memberRecord.role in staticRoles;
if (isStaticRole) {
const allEnterprise = Object.keys(permissions).every((r) =>
enterpriseOnlyResources.has(r),
);
if (allEnterprise) return;
}
const role = await resolveRole(memberRecord.role, organizationId);
if (!role) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid role",
});
}
const result = role.authorize(permissions);
if (result.success) {
return;
}
if (memberRecord.role === "member") {
const overrides = getLegacyOverrides(memberRecord);
const allGranted = Object.entries(permissions).every(
([resource, actions]) =>
(actions as string[]).every(
(action) =>
!!(overrides[resource] as Record<string, boolean> | undefined)?.[
action
],
),
);
if (allGranted) {
return;
}
}
throw new TRPCError({
code: "UNAUTHORIZED",
message: result.error || "Permission denied",
});
};
export const hasPermission = async (
ctx: PermissionCtx,
permissions: Permissions,
): Promise<boolean> => {
try {
await checkPermission(ctx, permissions);
return true;
} catch {
return false;
}
};
const getLegacyOverrides = (
memberRecord: Awaited<ReturnType<typeof findMemberByUserId>>,
): Partial<Record<string, Record<string, boolean>>> => {
return {
project: {
create: !!memberRecord.canCreateProjects,
delete: !!memberRecord.canDeleteProjects,
},
service: {
create: !!memberRecord.canCreateServices,
delete: !!memberRecord.canDeleteServices,
},
environment: {
create: !!memberRecord.canCreateEnvironments,
delete: !!memberRecord.canDeleteEnvironments,
},
traefikFiles: {
read: !!memberRecord.canAccessToTraefikFiles,
},
docker: {
read: !!memberRecord.canAccessToDocker,
},
api: {
read: !!memberRecord.canAccessToAPI,
},
sshKeys: {
read: !!memberRecord.canAccessToSSHKeys,
},
gitProviders: {
read: !!memberRecord.canAccessToGitProviders,
},
};
};
export const resolvePermissions = async (
ctx: PermissionCtx,
): Promise<ResolvedPermissions> => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
const role = await resolveRole(memberRecord.role, organizationId);
const legacyOverrides =
memberRecord.role === "member" ? getLegacyOverrides(memberRecord) : {};
const isPrivilegedRole =
memberRecord.role === "owner" || memberRecord.role === "admin";
const result = {} as ResolvedPermissions;
for (const [resource, actions] of Object.entries(statements)) {
const resourcePerms = {} as Record<string, boolean>;
for (const action of actions) {
if (isPrivilegedRole && enterpriseOnlyResources.has(resource)) {
resourcePerms[action] = true;
continue;
}
if (!role) {
resourcePerms[action] = false;
continue;
}
const check = role.authorize({ [resource]: [action] });
resourcePerms[action] =
check.success ||
!!(legacyOverrides[resource] as Record<string, boolean> | undefined)?.[
action
];
}
(result as any)[resource] = resourcePerms;
}
return result;
};
export const checkProjectAccess = async (
ctx: PermissionCtx,
action: "create" | "delete",
projectId?: string,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { project: [action] });
if (
action !== "create" &&
projectId &&
memberRecord.role !== "owner" &&
memberRecord.role !== "admin"
) {
if (!memberRecord.accessedProjects.includes(projectId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
}
};
export const checkServicePermissionAndAccess = async (
ctx: PermissionCtx,
serviceId: string,
permissions: Permissions,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, permissions);
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
if (!memberRecord.accessedServices.includes(serviceId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this service",
});
}
}
};
export const checkServiceAccess = async (
ctx: PermissionCtx,
serviceId: string,
action: "create" | "read" | "delete" = "read",
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { service: [action] });
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
if (action === "create") {
if (!memberRecord.accessedProjects.includes(serviceId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
} else {
if (!memberRecord.accessedServices.includes(serviceId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this service",
});
}
}
}
};
export const checkEnvironmentAccess = async (
ctx: PermissionCtx,
environmentId: string,
action: "read" | "create" | "delete" = "read",
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { environment: [action] });
if (
action !== "create" &&
memberRecord.role !== "owner" &&
memberRecord.role !== "admin"
) {
if (!memberRecord.accessedEnvironments.includes(environmentId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this environment",
});
}
}
};
export const checkEnvironmentCreationPermission = async (
ctx: PermissionCtx,
projectId: string,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { environment: ["create"] });
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
if (!memberRecord.accessedProjects.includes(projectId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
}
};
export const checkEnvironmentDeletionPermission = async (
ctx: PermissionCtx,
projectId: string,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await checkPermission(ctx, { environment: ["delete"] });
if (memberRecord.role !== "owner" && memberRecord.role !== "admin") {
if (!memberRecord.accessedProjects.includes(projectId)) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this project",
});
}
}
};
export const addNewProject = async (ctx: PermissionCtx, projectId: string) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await db
.update(member)
.set({
accessedProjects: [...memberRecord.accessedProjects, projectId],
})
.where(
and(
eq(member.id, memberRecord.id),
eq(member.organizationId, organizationId),
),
);
};
export const addNewEnvironment = async (
ctx: PermissionCtx,
environmentId: string,
) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await db
.update(member)
.set({
accessedEnvironments: [
...memberRecord.accessedEnvironments,
environmentId,
],
})
.where(
and(
eq(member.id, memberRecord.id),
eq(member.organizationId, organizationId),
),
);
};
export const addNewService = async (ctx: PermissionCtx, serviceId: string) => {
const userId = ctx.user.id;
const organizationId = ctx.session.activeOrganizationId;
const memberRecord = await findMemberByUserId(userId, organizationId);
await db
.update(member)
.set({
accessedServices: [...memberRecord.accessedServices, serviceId],
})
.where(
and(
eq(member.id, memberRecord.id),
eq(member.organizationId, organizationId),
),
);
};
export const findMemberByUserId = async (
userId: string,
organizationId: string,
) => {
const result = await db.query.member.findFirst({
where: and(
eq(member.userId, userId),
eq(member.organizationId, organizationId),
),
with: {
user: true,
},
});
if (!result) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Permission denied",
});
}
return result;
};

View File

@@ -2,10 +2,11 @@ import { db } from "@dokploy/server/db";
import { type apiCreatePort, ports } from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
export type Port = typeof ports.$inferSelect;
export const createPort = async (input: typeof apiCreatePort._type) => {
export const createPort = async (input: z.infer<typeof apiCreatePort>) => {
const newPort = await db
.insert(ports)
.values({

View File

@@ -11,6 +11,7 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq, getTableColumns } from "drizzle-orm";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
export function getMountPath(dockerImage: string): string {
@@ -28,7 +29,9 @@ export function getMountPath(dockerImage: string): string {
export type Postgres = typeof postgres.$inferSelect;
export const createPostgres = async (input: typeof apiCreatePostgres._type) => {
export const createPostgres = async (
input: z.infer<typeof apiCreatePostgres>,
) => {
const appName = buildAppName("postgres", input.appName);
const valid = await validUniqueServerAppName(appName);

View File

@@ -7,6 +7,7 @@ import {
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { and, desc, eq } from "drizzle-orm";
import type { z } from "zod";
import { generatePassword } from "../templates";
import { removeService } from "../utils/docker/utils";
import { removeDirectoryCode } from "../utils/filesystem/directory";
@@ -130,7 +131,7 @@ export const findPreviewDeploymentsByApplicationId = async (
};
export const createPreviewDeployment = async (
schema: typeof apiCreatePreviewDeployment._type,
schema: z.infer<typeof apiCreatePreviewDeployment>,
) => {
const application = await findApplicationById(schema.applicationId);
const appName = `preview-${application.appName}-${generatePassword(6)}`;
@@ -235,7 +236,7 @@ const generateWildcardDomain = async (
baseDomain: string,
appName: string,
serverIp: string,
userId: string,
_userId: string,
): Promise<string> => {
if (!baseDomain.startsWith("*.")) {
throw new Error('The base domain must start with "*."');

View File

@@ -2,6 +2,7 @@ import { db } from "@dokploy/server/db";
import {
type apiCreateProject,
applications,
libsql,
mariadb,
mongo,
mysql,
@@ -11,12 +12,13 @@ import {
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
import { createProductionEnvironment } from "./environment";
export type Project = typeof projects.$inferSelect;
export const createProject = async (
input: typeof apiCreateProject._type,
input: z.infer<typeof apiCreateProject>,
organizationId: string,
) => {
const newProject = await db
@@ -52,12 +54,18 @@ export const findProjectById = async (projectId: string) => {
environments: {
with: {
applications: true,
compose: true,
libsql: true,
mariadb: true,
mongo: true,
mysql: true,
postgres: true,
redis: true,
compose: true,
},
},
projectTags: {
with: {
tag: true,
},
},
},
@@ -103,6 +111,9 @@ export const validUniqueServerAppName = async (appName: string) => {
applications: {
where: eq(applications.appName, appName),
},
libsql: {
where: eq(libsql.appName, appName),
},
mariadb: {
where: eq(mariadb.appName, appName),
},
@@ -125,6 +136,7 @@ export const validUniqueServerAppName = async (appName: string) => {
const nonEmptyProjects = query.filter(
(project) =>
project.applications.length > 0 ||
project.libsql.length > 0 ||
project.mariadb.length > 0 ||
project.mongo.length > 0 ||
project.mysql.length > 0 ||

View File

@@ -0,0 +1,95 @@
import { db } from "@dokploy/server/db";
import type { AuditAction, AuditResourceType } from "@dokploy/server/db/schema";
import { auditLog } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { and, desc, eq, gte, ilike, lte } from "drizzle-orm";
export type { AuditAction, AuditResourceType };
export interface CreateAuditLogInput {
organizationId: string;
userId: string;
userEmail: string;
userRole: string;
action: AuditAction;
resourceType: AuditResourceType;
resourceId?: string;
resourceName?: string;
metadata?: Record<string, unknown>;
}
/**
* Creates an audit log entry. Fire-and-forget safe — errors are swallowed
* so a logging failure never breaks the main operation.
*/
export const createAuditLog = async (input: CreateAuditLogInput) => {
try {
const licensed = await hasValidLicense(input.organizationId);
if (!licensed) return;
await db.insert(auditLog).values({
organizationId: input.organizationId,
userId: input.userId,
userEmail: input.userEmail,
userRole: input.userRole,
action: input.action,
resourceType: input.resourceType,
resourceId: input.resourceId,
resourceName: input.resourceName,
metadata: input.metadata ? JSON.stringify(input.metadata) : undefined,
});
} catch (err) {
console.error("[audit-log] Failed to create audit log entry:", err);
}
};
export interface GetAuditLogsInput {
organizationId: string;
userId?: string;
userEmail?: string;
resourceName?: string;
action?: AuditAction;
resourceType?: AuditResourceType;
from?: Date;
to?: Date;
limit?: number;
offset?: number;
}
export const getAuditLogs = async (input: GetAuditLogsInput) => {
const {
organizationId,
userId,
userEmail,
resourceName,
action,
resourceType,
from,
to,
limit = 50,
offset = 0,
} = input;
const conditions = [eq(auditLog.organizationId, organizationId)];
if (userId) conditions.push(eq(auditLog.userId, userId));
if (userEmail) conditions.push(ilike(auditLog.userEmail, `%${userEmail}%`));
if (resourceName)
conditions.push(ilike(auditLog.resourceName, `%${resourceName}%`));
if (action) conditions.push(eq(auditLog.action, action));
if (resourceType) conditions.push(eq(auditLog.resourceType, resourceType));
if (from) conditions.push(gte(auditLog.createdAt, from));
if (to) conditions.push(lte(auditLog.createdAt, to));
const [logs, total] = await Promise.all([
db.query.auditLog.findMany({
where: and(...conditions),
orderBy: [desc(auditLog.createdAt)],
limit,
offset,
}),
db.$count(auditLog, and(...conditions)),
]);
return { logs, total };
};

View File

@@ -10,12 +10,13 @@ import { pullImage } from "@dokploy/server/utils/docker/utils";
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
import { validUniqueServerAppName } from "./project";
export type Redis = typeof redis.$inferSelect;
// https://github.com/drizzle-team/drizzle-orm/discussions/1483#discussioncomment-7523881
export const createRedis = async (input: typeof apiCreateRedis._type) => {
export const createRedis = async (input: z.infer<typeof apiCreateRedis>) => {
const appName = buildAppName("redis", input.appName);
const valid = await validUniqueServerAppName(appName);

View File

@@ -6,6 +6,7 @@ import {
} from "@dokploy/server/utils/process/execAsync";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
import { IS_CLOUD } from "../constants";
export type Registry = typeof registry.$inferSelect;
@@ -15,7 +16,7 @@ function shEscape(s: string | undefined): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
function safeDockerLoginCommand(
export function safeDockerLoginCommand(
registry: string | undefined,
user: string | undefined,
pass: string | undefined,
@@ -27,7 +28,7 @@ function safeDockerLoginCommand(
}
export const createRegistry = async (
input: typeof apiCreateRegistry._type,
input: z.infer<typeof apiCreateRegistry>,
organizationId: string,
) => {
return await db.transaction(async (tx) => {

View File

@@ -23,7 +23,7 @@ import { findDeploymentById } from "./deployment";
import type { Mount } from "./mount";
import type { Port } from "./port";
import type { Project } from "./project";
import type { Registry } from "./registry";
import { type Registry, safeDockerLoginCommand } from "./registry";
export const createRollback = async (
input: z.infer<typeof createRollbackSchema>,
@@ -111,7 +111,7 @@ const deleteRollbackImage = async (image: string, serverId?: string | null) => {
const command = `docker image rm ${image} --force`;
if (serverId) {
await execAsyncRemote(command, serverId);
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
@@ -171,6 +171,23 @@ export const rollback = async (rollbackId: string) => {
);
};
const dockerLoginForRegistry = async (
registry: Registry,
serverId?: string | null,
) => {
const loginCommand = safeDockerLoginCommand(
registry.registryUrl,
registry.username,
registry.password,
);
if (serverId) {
await execAsyncRemote(serverId, loginCommand);
} else {
await execAsync(loginCommand);
}
};
const rollbackApplication = async (
appName: string,
image: string,
@@ -188,6 +205,14 @@ const rollbackApplication = async (
throw new Error("Full context is required for rollback");
}
// Ensure Docker daemon is authenticated with the rollback registry
// before updating the swarm service. The authconfig in CreateServiceOptions
// alone is not sufficient — Docker Swarm also relies on the daemon's
// cached credentials (~/.docker/config.json) to distribute auth to nodes.
if (fullContext.rollbackRegistry) {
await dockerLoginForRegistry(fullContext.rollbackRegistry, serverId);
}
const docker = await getRemoteDocker(serverId);
// Use the same configuration as mechanizeDockerContainer

View File

@@ -18,7 +18,10 @@ export const createSchedule = async (
input: z.infer<typeof createScheduleSchema>,
) => {
const { scheduleId, ...rest } = input;
const [newSchedule] = await db.insert(schedules).values(rest).returning();
const [newSchedule] = await db
.insert(schedules)
.values(rest as typeof schedules.$inferInsert)
.returning();
if (
newSchedule &&
@@ -120,7 +123,7 @@ export const updateSchedule = async (
const { scheduleId, ...rest } = input;
const [updatedSchedule] = await db
.update(schedules)
.set(rest)
.set(rest as Partial<typeof schedules.$inferInsert>)
.where(eq(schedules.scheduleId, scheduleId))
.returning();

View File

@@ -50,7 +50,8 @@ export const createSecurity = async (
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating this security",
message:
error instanceof Error ? error.message : "Error creating this security",
cause: error,
});
}
@@ -90,15 +91,35 @@ export const updateSecurityById = async (
data: Partial<Security>,
) => {
try {
const response = await db
.update(security)
.set({
...data,
})
.where(eq(security.securityId, securityId))
.returning();
await db.transaction(async (tx) => {
const securityResponse = await findSecurityById(securityId);
return response[0];
const application = await findApplicationById(
securityResponse.applicationId,
);
await removeSecurityMiddleware(application, securityResponse);
const response = await tx
.update(security)
.set({
...data,
})
.where(eq(security.securityId, securityId))
.returning()
.then((res) => res[0]);
if (!response) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Security not found",
});
}
await createSecurityMiddleware(application, response);
return response;
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Error updating this security";

View File

@@ -6,11 +6,12 @@ import {
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
export type Server = typeof server.$inferSelect;
export const createServer = async (
input: typeof apiCreateServer._type,
input: z.infer<typeof apiCreateServer>,
organizationId: string,
) => {
const newServer = await db
@@ -19,7 +20,7 @@ export const createServer = async (
...input,
organizationId: organizationId,
createdAt: new Date().toISOString(),
})
} as typeof server.$inferInsert)
.returning()
.then((value) => value[0]);
@@ -79,11 +80,12 @@ export const haveActiveServices = async (serverId: string) => {
with: {
applications: true,
compose: true,
redis: true,
libsql: true,
mariadb: true,
mongo: true,
mysql: true,
postgres: true,
redis: true,
},
});
@@ -94,11 +96,12 @@ export const haveActiveServices = async (serverId: string) => {
const total =
currentServer?.applications?.length +
currentServer?.compose?.length +
currentServer?.redis?.length +
currentServer?.libsql?.length +
currentServer?.mariadb?.length +
currentServer?.mongo?.length +
currentServer?.mysql?.length +
currentServer?.postgres?.length;
currentServer?.postgres?.length +
currentServer?.redis?.length;
if (total === 0) {
return false;

View File

@@ -383,12 +383,12 @@ export const readPorts = async (
const seenPorts = new Set<string>();
for (const key in parsedResult) {
if (Object.hasOwn(parsedResult, key)) {
const containerPortMapppings = parsedResult[key];
const containerPortMappings = parsedResult[key];
const protocol = key.split("/")[1];
const targetPort = Number.parseInt(key.split("/")[0] ?? "0", 10);
// Take only the first mapping to avoid duplicates (IPv4 and IPv6)
const firstMapping = containerPortMapppings[0];
const firstMapping = containerPortMappings[0];
if (firstMapping) {
const publishedPort = Number.parseInt(firstMapping.HostPort, 10);
const portKey = `${targetPort}-${publishedPort}-${protocol}`;
@@ -413,17 +413,38 @@ export const checkPortInUse = async (
serverId?: string,
): Promise<{ isInUse: boolean; conflictingContainer?: string }> => {
try {
const command = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
const { stdout } = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
// Check if port is in use by a Docker container
const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`;
const { stdout: dockerOut } = serverId
? await execAsyncRemote(serverId, dockerCommand)
: await execAsync(dockerCommand);
const container = stdout.trim();
const container = dockerOut.trim();
return {
isInUse: !!container,
conflictingContainer: container || undefined,
};
if (container) {
return {
isInUse: true,
conflictingContainer: `container "${container}"`,
};
}
// Check if port is in use by a host-level service (non-Docker)
// Dokploy runs inside a container, so we spawn an ephemeral container
// with --net=host to share the host's network stack and use nc -z to
// check if something is listening on the port
const hostCommand = `docker run --rm --net=host busybox sh -c 'nc -z 0.0.0.0 ${port} 2>/dev/null && echo in_use || echo free'`;
const { stdout: hostOut } = serverId
? await execAsyncRemote(serverId, hostCommand)
: await execAsync(hostCommand);
if (hostOut.includes("in_use")) {
return {
isInUse: true,
conflictingContainer: "a host-level service",
};
}
return { isInUse: false };
} catch (error) {
console.error("Error checking port availability:", error);
return { isInUse: false };

View File

@@ -8,8 +8,9 @@ import {
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
export const createSshKey = async (input: typeof apiCreateSshKey._type) => {
export const createSshKey = async (input: z.infer<typeof apiCreateSshKey>) => {
await db.transaction(async (tx) => {
const sshKey = await tx
.insert(sshKeys)
@@ -29,7 +30,7 @@ export const createSshKey = async (input: typeof apiCreateSshKey._type) => {
};
export const removeSSHKeyById = async (
sshKeyId: (typeof apiRemoveSshKey._type)["sshKeyId"],
sshKeyId: z.infer<typeof apiRemoveSshKey>["sshKeyId"],
) => {
const result = await db
.delete(sshKeys)
@@ -42,7 +43,7 @@ export const removeSSHKeyById = async (
export const updateSSHKeyById = async ({
sshKeyId,
...input
}: typeof apiUpdateSshKey._type) => {
}: z.infer<typeof apiUpdateSshKey>) => {
const result = await db
.update(sshKeys)
.set(input)
@@ -53,7 +54,7 @@ export const updateSSHKeyById = async ({
};
export const findSSHKeyById = async (
sshKeyId: (typeof apiFindOneSshKey._type)["sshKeyId"],
sshKeyId: z.infer<typeof apiFindOneSshKey>["sshKeyId"],
) => {
const sshKey = await db.query.sshKeys.findFirst({
where: eq(sshKeys.sshKeyId, sshKeyId),

View File

@@ -1,6 +1,13 @@
import { db } from "@dokploy/server/db";
import { apikey, member, user } from "@dokploy/server/db/schema";
import {
account,
apikey,
invitation,
member,
user,
} from "@dokploy/server/db/schema";
import { TRPCError } from "@trpc/server";
import * as bcrypt from "bcrypt";
import { and, eq } from "drizzle-orm";
import { auth } from "../lib/auth";
@@ -89,7 +96,7 @@ export const canPerformAccessService = async (
return false;
};
export const canPeformDeleteService = async (
export const canPerformDeleteService = async (
userId: string,
serviceId: string,
organizationId: string,
@@ -215,7 +222,7 @@ export const checkServiceAccess = async (
);
break;
case "delete":
hasPermission = await canPeformDeleteService(
hasPermission = await canPerformDeleteService(
userId,
serviceId,
organizationId,
@@ -389,6 +396,93 @@ export const findMemberById = async (
return result;
};
export const createOrganizationUserWithCredentials = async ({
organizationId,
email,
password,
role,
}: {
organizationId: string;
email: string;
password: string;
role: string;
}) => {
const normalizedEmail = email.trim().toLowerCase();
const now = new Date();
return await db.transaction(async (tx) => {
const existingUser = await tx.query.user.findFirst({
where: eq(user.email, normalizedEmail),
columns: {
id: true,
},
});
if (existingUser) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"This email already has an account. Use the invitation link flow for existing users.",
});
}
const createdUser = await tx
.insert(user)
.values({
email: normalizedEmail,
emailVerified: false,
updatedAt: now,
})
.returning({
id: user.id,
email: user.email,
})
.then((res) => res[0]);
if (!createdUser) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create user",
});
}
await tx.insert(account).values({
userId: createdUser.id,
providerId: "credential",
password: bcrypt.hashSync(password, 10),
createdAt: now,
updatedAt: now,
});
await tx.insert(member).values({
organizationId,
userId: createdUser.id,
role,
createdAt: now,
isDefault: true,
});
await tx
.update(invitation)
.set({
status: "canceled",
})
.where(
and(
eq(invitation.organizationId, organizationId),
eq(invitation.email, normalizedEmail),
eq(invitation.status, "pending"),
),
);
return {
userId: createdUser.id,
email: createdUser.email,
role,
};
});
};
export const updateUser = async (userId: string, userData: Partial<User>) => {
// Validate email if it's being updated
if (userData.email !== undefined) {
@@ -432,7 +526,7 @@ export const createApiKey = async (
refillInterval?: number;
},
) => {
const apiKey = await auth.createApiKey({
const result = await auth.createApiKey({
body: {
name: input.name,
expiresIn: input.expiresIn,
@@ -450,10 +544,9 @@ export const createApiKey = async (
if (input.metadata) {
await db
.update(apikey)
.set({
metadata: JSON.stringify(input.metadata),
})
.where(eq(apikey.id, apiKey.id));
.set({ metadata: JSON.stringify(input.metadata) })
.where(eq(apikey.id, result.id));
}
return apiKey;
return result;
};

View File

@@ -75,6 +75,15 @@ export const findVolumeBackupById = async (volumeBackupId: string) => {
},
},
},
libsql: {
with: {
environment: {
with: {
project: true,
},
},
},
},
destination: true,
},
});
@@ -94,7 +103,7 @@ export const createVolumeBackup = async (
) => {
const newVolumeBackup = await db
.insert(volumeBackups)
.values(volumeBackup)
.values(volumeBackup as typeof volumeBackups.$inferInsert)
.returning()
.then((e) => e[0]);
@@ -113,7 +122,7 @@ export const updateVolumeBackup = async (
) => {
return await db
.update(volumeBackups)
.set(volumeBackup)
.set(volumeBackup as Partial<typeof volumeBackups.$inferInsert>)
.where(eq(volumeBackups.volumeBackupId, volumeBackupId))
.returning()
.then((e) => e[0]);

View File

@@ -115,9 +115,20 @@ SYS_ARCH=$(uname -m)
CURRENT_USER=$USER
echo "Installing requirements for: OS: $OS_TYPE"
if [ $EUID != 0 ]; then
echo "Please run this script as root or with sudo ❌"
exit
# Auto-detect sudo requirement
if [ "$EUID" -eq 0 ]; then
SUDO_CMD=""
echo "Running as root"
else
if sudo -n true 2>/dev/null; then
SUDO_CMD="sudo"
echo "Running as $CURRENT_USER with sudo privileges"
else
echo "Error: Non-root user requires passwordless sudo access. ❌"
echo "Configure with: echo '$CURRENT_USER ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/$CURRENT_USER"
exit 1
fi
fi
# Check if the OS is manjaro, if so, change it to arch
@@ -152,7 +163,7 @@ else
fi
if [ "$OS_TYPE" = 'amzn' ]; then
dnf install -y findutils >/dev/null
$SUDO_CMD dnf install -y findutils >/dev/null
fi
case "$OS_TYPE" in
@@ -218,6 +229,9 @@ ${installBuildpacks()}
echo -e "13. Installing Railpack"
${installRailpack()}
echo -e "14. Configuring permissions"
${setupPermissions()}
`
: `
echo -e "2. Installing Docker. "
@@ -235,6 +249,9 @@ ${installBuildpacks()}
echo -e "6. Installing Railpack"
${installRailpack()}
echo -e "7. Configuring permissions"
${setupPermissions()}
`
}
`;
@@ -281,17 +298,43 @@ const installRequirements = async (
.on("error", (err) => {
client.end();
if (err.level === "client-authentication") {
onData?.(
`Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`,
);
const technicalDetail = `Error: ${err.message} ${err.level}`;
const friendlyMessage = [
"",
"❌ Couldn't connect to your server — the SSH key was not accepted.",
"",
"This usually means the key doesn't match what's on the server, or the key format is invalid.",
"",
`Technical details: ${technicalDetail}`,
"",
"💡 Hints:",
" • Check that the SSH key you added in Dokploy is the same one installed on the server (e.g. in ~/.ssh/authorized_keys).",
" • Try generating a new SSH key in Dokploy and add only the public key to the server, then try again.",
" • Make sure to follow the instructions on the Setup Server Button on the SSH Keys tab",
].join("\n");
onData?.(friendlyMessage);
reject(
new Error(
`Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`,
`Authentication failed: Invalid SSH private key. ${technicalDetail}`,
),
);
} else {
onData?.(`SSH connection error: ${err.message} ${err.level}`);
reject(new Error(`SSH connection error: ${err.message}`));
const technicalDetail = `${err.message} ${err.level ?? ""}`.trim();
const friendlyMessage = [
"",
"❌ Couldn't connect to your server.",
"",
"The connection failed before setup could run. Common causes: wrong IP or port, firewall blocking access, or the server is offline.",
"",
`Technical details: ${technicalDetail}`,
"",
"💡 Hints:",
" • Check that the server IP address and SSH port are correct and the server is powered on.",
" • If the server is in a private network, ensure Dokploy can reach it (VPN, firewall rules, or correct security groups).",
" • Make sure the SSH port (usually 22) is open and the SSH service is running on the server.",
].join("\n");
onData?.(friendlyMessage);
reject(new Error(`SSH connection error: ${technicalDetail}`));
}
})
.connect({
@@ -326,16 +369,18 @@ const setupMainDirectory = () => `
echo "/etc/dokploy already exists ✅"
else
# Create the /etc/dokploy directory
mkdir -p /etc/dokploy
chmod 777 /etc/dokploy
$SUDO_CMD mkdir -p /etc/dokploy
echo "Directory /etc/dokploy created ✅"
fi
# Ensure the current user owns the directory
if [ -n "$SUDO_CMD" ]; then
$SUDO_CMD chown -R $CURRENT_USER:$CURRENT_USER /etc/dokploy
fi
`;
export const setupSwarm = () => `
# Check if the node is already part of a Docker Swarm
if docker info | grep -q 'Swarm: active'; then
if $SUDO_CMD docker info | grep -q 'Swarm: active'; then
echo "Already part of a Docker Swarm ✅"
else
# Get IP address
@@ -385,18 +430,18 @@ export const setupSwarm = () => `
echo "Advertise address: \$advertise_addr"
# Initialize Docker Swarm
docker swarm init --advertise-addr \$advertise_addr
$SUDO_CMD docker swarm init --advertise-addr \$advertise_addr
echo "Swarm initialized ✅"
fi
`;
const setupNetwork = () => `
# Check if the dokploy-network already exists
if docker network ls | grep -q 'dokploy-network'; then
if $SUDO_CMD docker network ls | grep -q 'dokploy-network'; then
echo "Network dokploy-network already exists ✅"
else
# Create the dokploy-network if it doesn't exist
if docker network create --driver overlay --attachable dokploy-network; then
if $SUDO_CMD docker network create --driver overlay --attachable dokploy-network; then
echo "Network created ✅"
else
echo "Failed to create dokploy-network ❌" >&2
@@ -421,33 +466,34 @@ const installUtilities = () => `
case "$OS_TYPE" in
arch)
pacman -Sy --noconfirm --needed curl wget git git-lfs jq openssl >/dev/null || true
$SUDO_CMD pacman -Sy --noconfirm --needed curl wget git git-lfs jq openssl >/dev/null || true
;;
alpine)
sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories
apk update >/dev/null
apk add curl wget git git-lfs jq openssl sudo unzip tar >/dev/null
$SUDO_CMD sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories
$SUDO_CMD apk update >/dev/null
$SUDO_CMD apk add curl wget git git-lfs jq openssl sudo unzip tar >/dev/null
;;
ubuntu | debian | raspbian)
DEBIAN_FRONTEND=noninteractive apt-get update -y >/dev/null
DEBIAN_FRONTEND=noninteractive apt-get install -y unzip curl wget git git-lfs jq openssl >/dev/null
export DEBIAN_FRONTEND=noninteractive
$SUDO_CMD apt-get update -y >/dev/null
$SUDO_CMD apt-get install -y unzip curl wget git git-lfs jq openssl >/dev/null
;;
centos | fedora | rhel | ol | rocky | almalinux | opencloudos | amzn)
if [ "$OS_TYPE" = "amzn" ]; then
dnf install -y wget git git-lfs jq openssl >/dev/null
$SUDO_CMD dnf install -y wget git git-lfs jq openssl >/dev/null
else
if ! command -v dnf >/dev/null; then
yum install -y dnf >/dev/null
$SUDO_CMD yum install -y dnf >/dev/null
fi
if ! command -v curl >/dev/null; then
dnf install -y curl >/dev/null
$SUDO_CMD dnf install -y curl >/dev/null
fi
dnf install -y wget git git-lfs jq openssl unzip >/dev/null
$SUDO_CMD dnf install -y wget git git-lfs jq openssl unzip >/dev/null
fi
;;
sles | opensuse-leap | opensuse-tumbleweed)
zypper refresh >/dev/null
zypper install -y curl wget git git-lfs jq openssl >/dev/null
$SUDO_CMD zypper refresh >/dev/null
$SUDO_CMD zypper install -y curl wget git git-lfs jq openssl >/dev/null
;;
*)
echo "This script only supports Debian, Redhat, Arch Linux, or SLES based operating systems for now."
@@ -474,41 +520,41 @@ if ! [ -x "$(command -v docker)" ]; then
echo " - Docker is not installed. Installing Docker. It may take a while."
case "$OS_TYPE" in
"almalinux")
dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
$SUDO_CMD dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo >/dev/null 2>&1
$SUDO_CMD dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
$SUDO_CMD systemctl start docker >/dev/null 2>&1
$SUDO_CMD systemctl enable docker >/dev/null 2>&1
;;
"opencloudos")
# Special handling for OpenCloud OS
echo " - Installing Docker for OpenCloud OS..."
dnf install -y docker >/dev/null 2>&1
$SUDO_CMD dnf install -y docker >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
# Remove --live-restore parameter from Docker configuration if it exists
if [ -f "/etc/sysconfig/docker" ]; then
echo " - Removing --live-restore parameter from Docker configuration..."
sed -i 's/--live-restore[^[:space:]]*//' /etc/sysconfig/docker >/dev/null 2>&1
sed -i 's/--live-restore//' /etc/sysconfig/docker >/dev/null 2>&1
$SUDO_CMD sed -i 's/--live-restore[^[:space:]]*//' /etc/sysconfig/docker >/dev/null 2>&1
$SUDO_CMD sed -i 's/--live-restore//' /etc/sysconfig/docker >/dev/null 2>&1
# Clean up any double spaces that might be left
sed -i 's/ */ /g' /etc/sysconfig/docker >/dev/null 2>&1
$SUDO_CMD sed -i 's/ */ /g' /etc/sysconfig/docker >/dev/null 2>&1
fi
systemctl enable docker >/dev/null 2>&1
systemctl start docker >/dev/null 2>&1
$SUDO_CMD systemctl enable docker >/dev/null 2>&1
$SUDO_CMD systemctl start docker >/dev/null 2>&1
echo " - Docker configured for OpenCloud OS"
;;
"alpine")
apk add docker docker-cli-compose >/dev/null 2>&1
rc-update add docker default >/dev/null 2>&1
service docker start >/dev/null 2>&1
$SUDO_CMD apk add docker docker-cli-compose >/dev/null 2>&1
$SUDO_CMD rc-update add docker default >/dev/null 2>&1
$SUDO_CMD service docker start >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with apk. Try to install it manually."
echo " Please visit https://wiki.alpinelinux.org/wiki/Docker for more information."
@@ -516,8 +562,8 @@ if ! [ -x "$(command -v docker)" ]; then
fi
;;
"arch")
pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1
systemctl enable docker.service >/dev/null 2>&1
$SUDO_CMD pacman -Sy docker docker-compose --noconfirm >/dev/null 2>&1
$SUDO_CMD systemctl enable docker.service >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with pacman. Try to install it manually."
echo " Please visit https://wiki.archlinux.org/title/docker for more information."
@@ -525,13 +571,13 @@ if ! [ -x "$(command -v docker)" ]; then
fi
;;
"amzn")
dnf install docker -y >/dev/null 2>&1
$SUDO_CMD dnf install docker -y >/dev/null 2>&1
DOCKER_CONFIG=/usr/local/lib/docker
mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1
curl -sL https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m) -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
$SUDO_CMD mkdir -p $DOCKER_CONFIG/cli-plugins >/dev/null 2>&1
$SUDO_CMD curl -sL https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m) -o $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
$SUDO_CMD chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose >/dev/null 2>&1
$SUDO_CMD systemctl start docker >/dev/null 2>&1
$SUDO_CMD systemctl enable docker >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Failed to install Docker with dnf. Try to install it manually."
echo " Please visit https://www.cyberciti.biz/faq/how-to-install-docker-on-amazon-linux-2/ for more information."
@@ -541,18 +587,18 @@ if ! [ -x "$(command -v docker)" ]; then
"fedora")
if [ -x "$(command -v dnf5)" ]; then
# dnf5 is available
dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/fedora/docker-ce.repo --overwrite >/dev/null 2>&1
$SUDO_CMD dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/fedora/docker-ce.repo --overwrite >/dev/null 2>&1
else
# dnf5 is not available, use dnf
dnf config-manager --add-repo=https://download.docker.com/linux/fedora/docker-ce.repo >/dev/null 2>&1
$SUDO_CMD dnf config-manager --add-repo=https://download.docker.com/linux/fedora/docker-ce.repo >/dev/null 2>&1
fi
dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
$SUDO_CMD dnf install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin >/dev/null 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker could not be installed automatically. Please visit https://docs.docker.com/engine/install/ and install Docker manually to continue."
exit 1
fi
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
$SUDO_CMD systemctl start docker >/dev/null 2>&1
$SUDO_CMD systemctl enable docker >/dev/null 2>&1
;;
*)
if [ "$OS_TYPE" = "ubuntu" ] && [ "$OS_VERSION" = "24.10" ]; then
@@ -560,9 +606,9 @@ if ! [ -x "$(command -v docker)" ]; then
echo "Please install Docker manually."
exit 1
fi
if ! [ -x "$(command -v docker)" ]; then
curl -s https://get.docker.com | sh -s -- --version $DOCKER_VERSION 2>&1
curl -s https://get.docker.com | $SUDO_CMD sh -s -- --version $DOCKER_VERSION 2>&1
if ! [ -x "$(command -v docker)" ]; then
echo " - Docker installation failed."
echo " Maybe your OS is not supported?"
@@ -571,13 +617,13 @@ if ! [ -x "$(command -v docker)" ]; then
fi
fi
if [ "$OS_TYPE" = "rocky" ]; then
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
$SUDO_CMD systemctl start docker >/dev/null 2>&1
$SUDO_CMD systemctl enable docker >/dev/null 2>&1
fi
if [ "$OS_TYPE" = "centos" ]; then
systemctl start docker >/dev/null 2>&1
systemctl enable docker >/dev/null 2>&1
$SUDO_CMD systemctl start docker >/dev/null 2>&1
$SUDO_CMD systemctl enable docker >/dev/null 2>&1
fi
@@ -621,7 +667,7 @@ export const installRClone = () => `
if command_exists rclone; then
echo "RClone already installed ✅"
else
curl https://rclone.org/install.sh | sudo bash
curl https://rclone.org/install.sh | $SUDO_CMD bash
RCLONE_VERSION=$(rclone --version | head -n 1 | awk '{print $2}' | sed 's/^v//')
echo "RClone version $RCLONE_VERSION installed ✅"
fi
@@ -630,19 +676,19 @@ export const installRClone = () => `
export const createTraefikInstance = () => {
const command = `
# Check if dokpyloy-traefik exists
if docker service inspect dokploy-traefik > /dev/null 2>&1; then
if $SUDO_CMD docker service inspect dokploy-traefik > /dev/null 2>&1; then
echo "Migrating Traefik to Standalone..."
docker service rm dokploy-traefik
$SUDO_CMD docker service rm dokploy-traefik
sleep 8
echo "Traefik migrated to Standalone ✅"
fi
if docker inspect dokploy-traefik > /dev/null 2>&1; then
if $SUDO_CMD docker inspect dokploy-traefik > /dev/null 2>&1; then
echo "Traefik already exists ✅"
else
# Create the dokploy-traefik container
TRAEFIK_VERSION=${TRAEFIK_VERSION}
docker run -d \
$SUDO_CMD docker run -d \
--name dokploy-traefik \
--restart always \
-v /etc/dokploy/traefik/traefik.yml:/etc/traefik/traefik.yml \
@@ -653,7 +699,7 @@ export const createTraefikInstance = () => {
-p ${TRAEFIK_HTTP3_PORT}:${TRAEFIK_HTTP3_PORT}/udp \
traefik:v$TRAEFIK_VERSION
docker network connect dokploy-network dokploy-traefik;
$SUDO_CMD docker network connect dokploy-network dokploy-traefik;
echo "Traefik version $TRAEFIK_VERSION installed ✅"
fi
`;
@@ -666,7 +712,7 @@ const installNixpacks = () => `
echo "Nixpacks already installed ✅"
else
export NIXPACKS_VERSION=1.41.0
bash -c "$(curl -fsSL https://nixpacks.com/install.sh)"
$SUDO_CMD bash -c "$(curl -fsSL https://nixpacks.com/install.sh)"
echo "Nixpacks version $NIXPACKS_VERSION installed ✅"
fi
`;
@@ -676,11 +722,28 @@ const installRailpack = () => `
echo "Railpack already installed ✅"
else
export RAILPACK_VERSION=0.15.4
bash -c "$(curl -fsSL https://railpack.com/install.sh)"
$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"
echo "Railpack version $RAILPACK_VERSION installed ✅"
fi
`;
const setupPermissions = () => `
# Add user to docker group if not root
if [ -n "$SUDO_CMD" ]; then
if ! groups $CURRENT_USER | grep -qw docker; then
$SUDO_CMD usermod -aG docker $CURRENT_USER
echo "User $CURRENT_USER added to docker group ✅"
else
echo "User $CURRENT_USER already in docker group ✅"
fi
# Ensure the user owns the dokploy directory
$SUDO_CMD chown -R $CURRENT_USER:$CURRENT_USER /etc/dokploy
echo "Permissions configured for $CURRENT_USER ✅"
else
echo "Running as root, no extra permissions needed ✅"
fi
`;
const installBuildpacks = () => `
SUFFIX=""
if [ "$SYS_ARCH" = "aarch64" ] || [ "$SYS_ARCH" = "arm64" ]; then
@@ -690,7 +753,7 @@ const installBuildpacks = () => `
echo "Buildpacks already installed ✅"
else
BUILDPACKS_VERSION=0.39.1
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v$BUILDPACKS_VERSION-linux$SUFFIX.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.39.1/pack-v$BUILDPACKS_VERSION-linux$SUFFIX.tgz" | $SUDO_CMD tar -C /usr/local/bin/ --no-same-owner -xzv pack
echo "Buildpacks version $BUILDPACKS_VERSION installed ✅"
fi
`;

View File

@@ -79,6 +79,24 @@ export const validateDokployNetwork = () => `
fi
`;
export const validateSudoAccess = () => `
if [ "$(id -u)" -eq 0 ]; then
echo "root true"
elif sudo -n true 2>/dev/null; then
echo "sudo true"
else
echo "none false"
fi
`;
export const validateDockerGroup = () => `
if groups | grep -qw docker; then
echo true
else
echo false
fi
`;
export const serverValidate = async (serverId: string) => {
const client = new Client();
const server = await findServerById(serverId);
@@ -118,7 +136,11 @@ export const serverValidate = async (serverId: string) => {
isSwarmInstalled=$(${validateSwarm()})
isMainDirectoryInstalled=$(${validateMainDirectory()})
echo "{\\"docker\\": {\\"version\\": \\"$dockerVersion\\", \\"enabled\\": $dockerEnabled}, \\"rclone\\": {\\"version\\": \\"$rcloneVersion\\", \\"enabled\\": $rcloneEnabled}, \\"nixpacks\\": {\\"version\\": \\"$nixpacksVersion\\", \\"enabled\\": $nixpacksEnabled}, \\"buildpacks\\": {\\"version\\": \\"$buildpacksVersion\\", \\"enabled\\": $buildpacksEnabled}, \\"railpack\\": {\\"version\\": \\"$railpackVersion\\", \\"enabled\\": $railpackEnabled}, \\"isDokployNetworkInstalled\\": $isDokployNetworkInstalled, \\"isSwarmInstalled\\": $isSwarmInstalled, \\"isMainDirectoryInstalled\\": $isMainDirectoryInstalled}"
sudoAccessResult=$(${validateSudoAccess()})
privilegeMode=$(echo $sudoAccessResult | awk '{print $1}')
isDockerGroupMember=$(${validateDockerGroup()})
echo "{\\"docker\\": {\\"version\\": \\"$dockerVersion\\", \\"enabled\\": $dockerEnabled}, \\"rclone\\": {\\"version\\": \\"$rcloneVersion\\", \\"enabled\\": $rcloneEnabled}, \\"nixpacks\\": {\\"version\\": \\"$nixpacksVersion\\", \\"enabled\\": $nixpacksEnabled}, \\"buildpacks\\": {\\"version\\": \\"$buildpacksVersion\\", \\"enabled\\": $buildpacksEnabled}, \\"railpack\\": {\\"version\\": \\"$railpackVersion\\", \\"enabled\\": $railpackEnabled}, \\"isDokployNetworkInstalled\\": $isDokployNetworkInstalled, \\"isSwarmInstalled\\": $isSwarmInstalled, \\"isMainDirectoryInstalled\\": $isMainDirectoryInstalled, \\"privilegeMode\\": \\"$privilegeMode\\", \\"dockerGroupMember\\": $isDockerGroupMember}"
`;
client.exec(bashCommand, (err, stream) => {
if (err) {

View File

@@ -3,13 +3,13 @@ import { docker } from "../constants";
export const initializeSwarm = async () => {
const swarmInitialized = await dockerSwarmInitialized();
if (swarmInitialized) {
console.log("Swarm is already initilized");
console.log("Swarm is already initialized");
} else {
await docker.swarmInit({
AdvertiseAddr: "127.0.0.1",
ListenAddr: "0.0.0.0",
});
console.log("Swarm was initilized");
console.log("Swarm was initialized");
}
};
@@ -26,14 +26,14 @@ export const dockerSwarmInitialized = async () => {
export const initializeNetwork = async () => {
const networkInitialized = await dockerNetworkInitialized();
if (networkInitialized) {
console.log("Network is already initilized");
console.log("Network is already initialized");
} else {
docker.createNetwork({
Attachable: true,
Name: "dokploy-network",
Driver: "overlay",
});
console.log("Network was initilized");
console.log("Network was initialized");
}
};

View File

@@ -1,3 +1,5 @@
import fs from "node:fs";
import path from "node:path";
import { paths } from "@dokploy/server/constants";
import {
getWebServerSettings,
@@ -12,8 +14,6 @@ export const startLogCleanup = async (
cronExpression = "0 0 * * *",
): Promise<boolean> => {
try {
const { DYNAMIC_TRAEFIK_PATH } = paths();
const existingJob = scheduledJobs[LOG_CLEANUP_JOB_NAME];
if (existingJob) {
existingJob.cancel();
@@ -21,10 +21,17 @@ export const startLogCleanup = async (
scheduleJob(LOG_CLEANUP_JOB_NAME, cronExpression, async () => {
try {
await execAsync(
`tail -n 1000 ${DYNAMIC_TRAEFIK_PATH}/access.log > ${DYNAMIC_TRAEFIK_PATH}/access.log.tmp && mv ${DYNAMIC_TRAEFIK_PATH}/access.log.tmp ${DYNAMIC_TRAEFIK_PATH}/access.log`,
);
const { DYNAMIC_TRAEFIK_PATH } = paths();
const accessLogPath = path.join(DYNAMIC_TRAEFIK_PATH, "access.log");
if (!fs.existsSync(accessLogPath)) {
console.error("Access log file does not exist");
return;
}
await execAsync(
`tail -n 1000 ${accessLogPath} > ${accessLogPath}.tmp && mv ${accessLogPath}.tmp ${accessLogPath}`,
);
await execAsync("docker exec dokploy-traefik kill -USR1 1");
} catch (error) {
console.error("Error during log cleanup:", error);

View File

@@ -30,6 +30,18 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
baseURL: config.apiUrl,
});
case "azure":
// Azure OpenAI-compatible endpoints already include /v1 in the path.
// Using createAzure with such URLs causes a doubled /v1//v1/ suffix.
if (config.apiUrl.includes("/v1")) {
return createOpenAICompatible({
name: "azure",
baseURL: config.apiUrl,
headers: {
"api-key": config.apiKey,
Authorization: `Bearer ${config.apiKey}`,
},
});
}
return createAzure({
apiKey: config.apiKey,
baseURL: config.apiUrl,

View File

@@ -8,19 +8,25 @@ import { findEnvironmentById } from "@dokploy/server/services/environment";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
getBackupCommand,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runComposeBackup = async (
compose: Compose,
backup: BackupSchedule,
) => {
const { environmentId, name } = compose;
const { environmentId, name, appName } = compose;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix, databaseType } = backup;
const { prefix, databaseType, serviceName } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const backupFileName = `${getBackupTimestamp()}.${databaseType === "mongo" ? "bson" : "sql"}.gz`;
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "Compose Backup",

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import { CLEANUP_CRON_JOB } from "@dokploy/server/constants";
import { member } from "@dokploy/server/db/schema";
import type { BackupSchedule } from "@dokploy/server/services/backup";
@@ -11,7 +10,7 @@ import { startLogCleanup } from "../access-log/handler";
import { cleanupAll } from "../docker/utils";
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials, scheduleBackup } from "./utils";
import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils";
export const initCronJobs = async () => {
console.log("Setting up cron jobs....");
@@ -30,15 +29,19 @@ export const initCronJobs = async () => {
const webServerSettings = await getWebServerSettings();
if (webServerSettings?.enableDockerCleanup) {
scheduleJob("docker-cleanup", CLEANUP_CRON_JOB, async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running docker cleanup`,
);
try {
scheduleJob("docker-cleanup", CLEANUP_CRON_JOB, async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running docker cleanup`,
);
await cleanupAll();
await cleanupAll();
await sendDockerCleanupNotifications(admin.user.id);
});
await sendDockerCleanupNotifications(admin.user.id);
});
} catch (error) {
console.error("[Backup] Docker Cleanup Error", error);
}
}
const servers = await getAllServers();
@@ -46,18 +49,22 @@ export const initCronJobs = async () => {
for (const server of servers) {
const { serverId, enableDockerCleanup, name } = server;
if (enableDockerCleanup) {
scheduleJob(serverId, CLEANUP_CRON_JOB, async () => {
console.log(
`SERVER-BACKUP[${new Date().toLocaleString()}] Running Cleanup ${name}`,
);
try {
scheduleJob(serverId, CLEANUP_CRON_JOB, async () => {
console.log(
`SERVER-BACKUP[${new Date().toLocaleString()}] Running Cleanup ${name}`,
);
await cleanupAll(serverId);
await cleanupAll(serverId);
await sendDockerCleanupNotifications(
admin.user.id,
`Docker cleanup for Server ${name} (${serverId})`,
);
});
await sendDockerCleanupNotifications(
admin.user.id,
`Docker cleanup for Server ${name} (${serverId})`,
);
});
} catch (error) {
console.error(`[Backup] ${error}`);
}
}
}
@@ -68,6 +75,7 @@ export const initCronJobs = async () => {
mariadb: true,
mysql: true,
mongo: true,
libsql: true,
user: true,
compose: true,
},
@@ -87,14 +95,33 @@ export const initCronJobs = async () => {
}
if (webServerSettings?.logCleanupCron) {
console.log(
"Starting log requests cleanup",
webServerSettings.logCleanupCron,
);
await startLogCleanup(webServerSettings.logCleanupCron);
try {
console.log(
"Starting log requests cleanup",
webServerSettings.logCleanupCron,
);
await startLogCleanup(webServerSettings.logCleanupCron);
} catch (error) {
console.error("[Backup] Log Cleanup Error", error);
}
}
};
const getServiceAppName = (backup: BackupSchedule): string => {
if (backup.compose?.appName) {
return backup.serviceName
? `${backup.compose.appName}_${backup.serviceName}`
: backup.compose.appName;
}
const serviceAppName =
backup.postgres?.appName ||
backup.mysql?.appName ||
backup.mariadb?.appName ||
backup.mongo?.appName ||
backup.libsql?.appName;
return serviceAppName || backup.appName;
};
export const keepLatestNBackups = async (
backup: BackupSchedule,
serverId?: string | null,
@@ -105,18 +132,16 @@ export const keepLatestNBackups = async (
try {
const rcloneFlags = getS3Credentials(backup.destination);
const backupFilesPath = path.join(
`:s3:${backup.destination.bucket}`,
backup.prefix,
);
const appName = getServiceAppName(backup);
const backupFilesPath = `:s3:${backup.destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
// --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`;
// --include "*.bson.gz" or "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".{sql.gz,bson.gz}"}" ${backupFilesPath}`;
// when we pipe the above command with this one, we only get the list of files we want to delete
const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`;
// this command deletes the files
// to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}/{}
const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}/{}`;
// to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}{}
const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
const rcloneCommand = `${rcloneList} | ${sortAndPickUnwantedBackups} ${rcloneDelete}`;

View File

@@ -0,0 +1,80 @@
import type { BackupSchedule } from "@dokploy/server/services/backup";
import {
createDeploymentBackup,
updateDeploymentStatus,
} from "@dokploy/server/services/deployment";
import { findEnvironmentById } from "@dokploy/server/services/environment";
import type { Libsql } from "@dokploy/server/services/libsql";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import {
getBackupCommand,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runLibsqlBackup = async (
libsql: Libsql,
backup: BackupSchedule,
) => {
const { name, environmentId, appName } = libsql;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "Initializing Backup",
description: "Initializing Backup",
});
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const backupCommand = getBackupCommand(
backup,
rcloneCommand,
deployment.logPath,
);
if (libsql.serverId) {
await execAsyncRemote(libsql.serverId, backupCommand);
} else {
await execAsync(backupCommand, {
shell: "/bin/bash",
});
}
await sendDatabaseBackupNotifications({
applicationName: name,
projectName: project.name,
databaseType: "libsql",
type: "success",
organizationId: project.organizationId,
databaseName: backup.database,
});
await updateDeploymentStatus(deployment.deploymentId, "done");
} catch (error) {
await sendDatabaseBackupNotifications({
applicationName: name,
projectName: project.name,
databaseType: "libsql",
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
organizationId: project.organizationId,
databaseName: backup.database,
});
await updateDeploymentStatus(deployment.deploymentId, "error");
throw error;
}
};

View File

@@ -8,19 +8,24 @@ import type { Mariadb } from "@dokploy/server/services/mariadb";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
getBackupCommand,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runMariadbBackup = async (
mariadb: Mariadb,
backup: BackupSchedule,
) => {
const { environmentId, name } = mariadb;
const { environmentId, name, appName } = mariadb;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MariaDB Backup",

View File

@@ -8,16 +8,21 @@ import type { Mongo } from "@dokploy/server/services/mongo";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
getBackupCommand,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
const { environmentId, name } = mongo;
const { environmentId, name, appName } = mongo;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const backupFileName = `${getBackupTimestamp()}.bson.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MongoDB Backup",

View File

@@ -8,16 +8,21 @@ import type { MySql } from "@dokploy/server/services/mysql";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
getBackupCommand,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
const { environmentId, name } = mysql;
const { environmentId, name, appName } = mysql;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MySQL Backup",

View File

@@ -8,13 +8,18 @@ import type { Postgres } from "@dokploy/server/services/postgres";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils";
import {
getBackupCommand,
getBackupTimestamp,
getS3Credentials,
normalizeS3Path,
} from "./utils";
export const runPostgresBackup = async (
postgres: Postgres,
backup: BackupSchedule,
) => {
const { name, environmentId } = postgres;
const { name, environmentId, appName } = postgres;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
@@ -25,8 +30,8 @@ export const runPostgresBackup = async (
});
const { prefix } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const backupFileName = `${getBackupTimestamp()}.sql.gz`;
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;

Some files were not shown because too many files have changed in this diff Show More