mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-10 00:15:28 +02:00
Merge branch 'canary' into feat/add-mattermost-notification-provider
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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),
|
||||
@@ -148,7 +182,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 +214,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 +237,7 @@ export const apikey = pgTable("apikey", {
|
||||
|
||||
export const apikeyRelations = relations(apikey, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [apikey.userId],
|
||||
fields: [apikey.referenceId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -365,12 +365,13 @@ 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.bigint().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
enableSubmodules: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateApplication = createSchema.pick({
|
||||
@@ -433,13 +434,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({
|
||||
@@ -451,10 +452,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({
|
||||
@@ -465,10 +465,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({
|
||||
@@ -478,10 +477,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({
|
||||
@@ -506,6 +504,7 @@ export const apiSaveGitProvider = createSchema
|
||||
.merge(
|
||||
createSchema.pick({
|
||||
customGitSSHKeyId: true,
|
||||
enableSubmodules: true,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
94
packages/server/src/db/schema/audit-log.ts
Normal file
94
packages/server/src/db/schema/audit-log.ts
Normal 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";
|
||||
@@ -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,
|
||||
@@ -192,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),
|
||||
|
||||
@@ -4,6 +4,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";
|
||||
@@ -36,12 +37,13 @@ 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),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./account";
|
||||
export * from "./ai";
|
||||
export * from "./audit-log";
|
||||
export * from "./application";
|
||||
export * from "./backups";
|
||||
export * from "./bitbucket";
|
||||
@@ -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";
|
||||
|
||||
249
packages/server/src/db/schema/libsql.ts
Normal file
249
packages/server/src/db/schema/libsql.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
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 { 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(/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/, {
|
||||
message:
|
||||
"Password contains invalid characters. Please avoid: $ ! ' \" \\ / and space characters for database compatibility",
|
||||
}),
|
||||
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 = createSchema
|
||||
.pick({
|
||||
libsqlId: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
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();
|
||||
@@ -202,6 +202,7 @@ export const apiUpdateMariaDB = createSchema
|
||||
.partial()
|
||||
.extend({
|
||||
mariadbId: z.string().min(1),
|
||||
dockerImage: z.string().optional(),
|
||||
})
|
||||
.omit({ serverId: true });
|
||||
|
||||
|
||||
@@ -191,6 +191,7 @@ export const apiUpdateMongo = createSchema
|
||||
.partial()
|
||||
.extend({
|
||||
mongoId: z.string().min(1),
|
||||
dockerImage: z.string().optional(),
|
||||
})
|
||||
.omit({ serverId: true });
|
||||
|
||||
|
||||
@@ -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,8 +129,8 @@ export const apiCreateMount = createSchema
|
||||
volumeName: true,
|
||||
content: true,
|
||||
mountPath: true,
|
||||
serviceType: true,
|
||||
filePath: true,
|
||||
serviceType: true,
|
||||
})
|
||||
.extend({
|
||||
serviceId: z.string().min(1),
|
||||
@@ -144,21 +150,12 @@ export const apiRemoveMount = createSchema
|
||||
.required();
|
||||
|
||||
export const apiFindMountByApplicationId = createSchema
|
||||
.pick({
|
||||
serviceType: true,
|
||||
})
|
||||
.required()
|
||||
.extend({
|
||||
serviceId: z.string().min(1),
|
||||
serviceType: z.enum([
|
||||
"application",
|
||||
"postgres",
|
||||
"mysql",
|
||||
"mariadb",
|
||||
"mongo",
|
||||
"redis",
|
||||
"compose",
|
||||
]),
|
||||
})
|
||||
.pick({
|
||||
serviceId: true,
|
||||
serviceType: true,
|
||||
});
|
||||
|
||||
export const apiUpdateMount = createSchema.partial().extend({
|
||||
|
||||
@@ -199,6 +199,7 @@ export const apiUpdateMySql = createSchema
|
||||
.partial()
|
||||
.extend({
|
||||
mysqlId: z.string().min(1),
|
||||
dockerImage: z.string().optional(),
|
||||
})
|
||||
.omit({ serverId: true });
|
||||
|
||||
|
||||
@@ -192,6 +192,7 @@ export const apiUpdatePostgres = createSchema
|
||||
.partial()
|
||||
.extend({
|
||||
postgresId: z.string().min(1),
|
||||
dockerImage: z.string().optional(),
|
||||
})
|
||||
.omit({ serverId: true });
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -178,6 +178,7 @@ export const apiUpdateRedis = createSchema
|
||||
.partial()
|
||||
.extend({
|
||||
redisId: z.string().min(1),
|
||||
dockerImage: z.string().optional(),
|
||||
})
|
||||
.omit({ serverId: true });
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
99
packages/server/src/db/schema/tag.ts
Normal file
99
packages/server/src/db/schema/tag.ts
Normal 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),
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { boolean, integer, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { serviceType } from "./mount";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -7,9 +8,9 @@ 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";
|
||||
import { mysql } from "./mysql";
|
||||
import { postgres } from "./postgres";
|
||||
import { redis } from "./redis";
|
||||
@@ -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],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,7 +22,8 @@ export type TemplateProps = {
|
||||
| "mongodb"
|
||||
| "mariadb"
|
||||
| "redis"
|
||||
| "compose";
|
||||
| "compose"
|
||||
| "libsql";
|
||||
type: "error" | "success";
|
||||
errorMessage?: string;
|
||||
backupSize?: string;
|
||||
|
||||
@@ -22,6 +22,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 +68,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";
|
||||
|
||||
195
packages/server/src/lib/access-control.ts
Normal file
195
packages/server/src/lib/access-control.ts
Normal 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", "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", "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", "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: [],
|
||||
});
|
||||
@@ -1,10 +1,11 @@
|
||||
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";
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
getTrustedProviders,
|
||||
getUserByToken,
|
||||
} from "../services/admin";
|
||||
import { createAuditLog } from "../services/proprietary/audit-log";
|
||||
import {
|
||||
getWebServerSettings,
|
||||
updateWebServerSettings,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
|
||||
import { sendEmail } from "../verification/send-verification-email";
|
||||
import { getPublicIpWithFallback } from "../wss/utils";
|
||||
import { ac, adminRole, memberRole, ownerRole } from "./access-control";
|
||||
|
||||
const { handler, api } = betterAuth({
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -73,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,
|
||||
@@ -111,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);
|
||||
@@ -267,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",
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -316,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 =
|
||||
|
||||
@@ -117,23 +117,43 @@ 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 () => {
|
||||
|
||||
@@ -33,6 +33,7 @@ export const findBackupById = async (backupId: string) => {
|
||||
mysql: true,
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
libsql: true,
|
||||
destination: true,
|
||||
compose: true,
|
||||
},
|
||||
@@ -72,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),
|
||||
@@ -81,6 +82,7 @@ export const findBackupsByDbId = async (
|
||||
mysql: true,
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
libsql: true,
|
||||
destination: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,7 @@ import {
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { format } from "date-fns";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { desc, eq, and, inArray, or, sql } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
type Application,
|
||||
@@ -38,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) => {
|
||||
@@ -78,12 +117,12 @@ export const createDeployment = async (
|
||||
>,
|
||||
) => {
|
||||
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);
|
||||
@@ -161,13 +200,12 @@ export const createDeploymentPreview = async (
|
||||
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");
|
||||
@@ -242,12 +280,12 @@ export const createDeploymentCompose = async (
|
||||
>,
|
||||
) => {
|
||||
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`;
|
||||
@@ -330,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`;
|
||||
@@ -400,12 +438,12 @@ export const createDeploymentSchedule = async (
|
||||
) => {
|
||||
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`;
|
||||
@@ -476,14 +514,14 @@ export const createDeploymentVolumeBackup = async (
|
||||
) => {
|
||||
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`;
|
||||
@@ -562,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,
|
||||
@@ -647,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -738,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>,
|
||||
|
||||
@@ -45,7 +45,7 @@ export const createDomain = async (input: z.infer<typeof apiCreateDomain>) => {
|
||||
|
||||
export const generateTraefikMeDomain = async (
|
||||
appName: string,
|
||||
userId: string,
|
||||
_userId: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
if (serverId) {
|
||||
|
||||
@@ -34,42 +34,157 @@ 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,
|
||||
},
|
||||
},
|
||||
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,
|
||||
@@ -96,8 +211,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;
|
||||
};
|
||||
@@ -108,6 +230,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 ||
|
||||
@@ -169,6 +292,7 @@ export const duplicateEnvironment = async (
|
||||
name: input.name,
|
||||
description: input.description || originalEnvironment.description,
|
||||
projectId: originalEnvironment.projectId,
|
||||
env: originalEnvironment.env,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
162
packages/server/src/services/libsql.ts
Normal file
162
packages/server/src/services/libsql.ts
Normal 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;
|
||||
};
|
||||
@@ -32,8 +32,11 @@ export const createMount = async (input: z.infer<typeof apiCreateMount>) => {
|
||||
...(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,
|
||||
@@ -44,12 +47,12 @@ export const createMount = async (input: z.infer<typeof apiCreateMount>) => {
|
||||
...(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]);
|
||||
@@ -115,7 +118,16 @@ export const findMountById = async (mountId: string) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
postgres: {
|
||||
compose: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
libsql: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
@@ -151,7 +163,7 @@ export const findMountById = async (mountId: string) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
redis: {
|
||||
postgres: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
@@ -160,7 +172,7 @@ export const findMountById = async (mountId: string) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
compose: {
|
||||
redis: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
@@ -186,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;
|
||||
@@ -198,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;
|
||||
};
|
||||
|
||||
@@ -248,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));
|
||||
@@ -260,6 +275,9 @@ 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;
|
||||
@@ -362,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");
|
||||
|
||||
@@ -391,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;
|
||||
};
|
||||
|
||||
431
packages/server/src/services/permission.ts
Normal file
431
packages/server/src/services/permission.ts
Normal 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;
|
||||
};
|
||||
@@ -236,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 "*."');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreateProject,
|
||||
applications,
|
||||
libsql,
|
||||
mariadb,
|
||||
mongo,
|
||||
mysql,
|
||||
@@ -53,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,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -104,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),
|
||||
},
|
||||
@@ -126,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 ||
|
||||
|
||||
95
packages/server/src/services/proprietary/audit-log.ts
Normal file
95
packages/server/src/services/proprietary/audit-log.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { auditLog } from "@dokploy/server/db/schema";
|
||||
import type { AuditAction, AuditResourceType } 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 };
|
||||
};
|
||||
@@ -16,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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -432,7 +432,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 +450,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;
|
||||
};
|
||||
|
||||
@@ -75,6 +75,15 @@ export const findVolumeBackupById = async (volumeBackupId: string) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
libsql: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
destination: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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()}
|
||||
`
|
||||
}
|
||||
`;
|
||||
@@ -352,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
|
||||
@@ -411,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
|
||||
@@ -447,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."
|
||||
@@ -500,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."
|
||||
@@ -542,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."
|
||||
@@ -551,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."
|
||||
@@ -567,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
|
||||
@@ -586,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?"
|
||||
@@ -597,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
|
||||
|
||||
|
||||
@@ -647,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
|
||||
@@ -656,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 \
|
||||
@@ -679,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
|
||||
`;
|
||||
@@ -692,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
|
||||
`;
|
||||
@@ -702,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
|
||||
@@ -716,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
|
||||
`;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,13 +14,14 @@ 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 s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
|
||||
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
title: "Compose Backup",
|
||||
|
||||
@@ -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....");
|
||||
@@ -76,6 +75,7 @@ export const initCronJobs = async () => {
|
||||
mariadb: true,
|
||||
mysql: true,
|
||||
mongo: true,
|
||||
libsql: true,
|
||||
user: true,
|
||||
compose: true,
|
||||
},
|
||||
@@ -107,6 +107,21 @@ export const initCronJobs = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -117,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}`;
|
||||
// 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}`;
|
||||
|
||||
|
||||
75
packages/server/src/utils/backups/libsql.ts
Normal file
75
packages/server/src/utils/backups/libsql.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
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, 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 = `${new Date().toISOString()}.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;
|
||||
}
|
||||
};
|
||||
@@ -14,13 +14,13 @@ 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 bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
title: "MariaDB Backup",
|
||||
|
||||
@@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { getBackupCommand, 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 bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
title: "MongoDB Backup",
|
||||
|
||||
@@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { getBackupCommand, 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 bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const deployment = await createDeploymentBackup({
|
||||
backupId: backup.backupId,
|
||||
title: "MySQL Backup",
|
||||
|
||||
@@ -14,7 +14,7 @@ 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);
|
||||
|
||||
@@ -26,7 +26,7 @@ export const runPostgresBackup = async (
|
||||
const { prefix } = backup;
|
||||
const destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Destination } from "@dokploy/server/services/destination";
|
||||
import { scheduledJobs, scheduleJob } from "node-schedule";
|
||||
import { keepLatestNBackups } from ".";
|
||||
import { runComposeBackup } from "./compose";
|
||||
import { runLibsqlBackup } from "./libsql";
|
||||
import { runMariadbBackup } from "./mariadb";
|
||||
import { runMongoBackup } from "./mongo";
|
||||
import { runMySqlBackup } from "./mysql";
|
||||
@@ -19,6 +20,7 @@ export const scheduleBackup = (backup: BackupSchedule) => {
|
||||
mysql,
|
||||
mongo,
|
||||
mariadb,
|
||||
libsql,
|
||||
compose,
|
||||
} = backup;
|
||||
scheduleJob(backupId, schedule, async () => {
|
||||
@@ -35,6 +37,9 @@ export const scheduleBackup = (backup: BackupSchedule) => {
|
||||
} else if (databaseType === "mariadb" && mariadb) {
|
||||
await runMariadbBackup(mariadb, backup);
|
||||
await keepLatestNBackups(backup, mariadb.serverId);
|
||||
} else if (databaseType === "libsql" && libsql) {
|
||||
await runLibsqlBackup(libsql, backup);
|
||||
await keepLatestNBackups(backup, libsql.serverId);
|
||||
} else if (databaseType === "web-server") {
|
||||
await runWebServerBackup(backup);
|
||||
await keepLatestNBackups(backup);
|
||||
@@ -107,6 +112,10 @@ export const getMongoBackupCommand = (
|
||||
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mongodump -d '${database}' -u '${databaseUser}' -p '${databasePassword}' --archive --authenticationDatabase admin --gzip"`;
|
||||
};
|
||||
|
||||
export const getLibsqlBackupCommand = (database: string) => {
|
||||
return `docker exec -i $CONTAINER_ID sh -c "tar cf - -C /var/lib/sqld ${database} | gzip"`;
|
||||
};
|
||||
|
||||
export const getServiceContainerCommand = (appName: string) => {
|
||||
return `docker ps -q --filter "status=running" --filter "label=com.docker.swarm.service.name=${appName}" | head -n 1`;
|
||||
};
|
||||
@@ -123,12 +132,24 @@ export const getComposeContainerCommand = (
|
||||
};
|
||||
|
||||
const getContainerSearchCommand = (backup: BackupSchedule) => {
|
||||
const { backupType, postgres, mysql, mariadb, mongo, compose, serviceName } =
|
||||
backup;
|
||||
const {
|
||||
backupType,
|
||||
postgres,
|
||||
mysql,
|
||||
mariadb,
|
||||
mongo,
|
||||
libsql,
|
||||
compose,
|
||||
serviceName,
|
||||
} = backup;
|
||||
|
||||
if (backupType === "database") {
|
||||
const appName =
|
||||
postgres?.appName || mysql?.appName || mariadb?.appName || mongo?.appName;
|
||||
postgres?.appName ||
|
||||
mysql?.appName ||
|
||||
mariadb?.appName ||
|
||||
mongo?.appName ||
|
||||
libsql?.appName;
|
||||
return getServiceContainerCommand(appName || "");
|
||||
}
|
||||
if (backupType === "compose") {
|
||||
@@ -209,6 +230,12 @@ export const generateBackupCommand = (backup: BackupSchedule) => {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "libsql": {
|
||||
if (backupType === "database") {
|
||||
return getLibsqlBackupCommand(backup.database);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Database type not supported: ${databaseType}`);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||
const { BASE_PATH } = paths();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-"));
|
||||
const backupFileName = `webserver-backup-${timestamp}.zip`;
|
||||
const s3Path = `:s3:${destination.bucket}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
|
||||
const s3Path = `:s3:${destination.bucket}/${backup.appName}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
|
||||
|
||||
try {
|
||||
await execAsync(`mkdir -p ${tempDir}/filesystem`);
|
||||
@@ -67,7 +67,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||
await execAsync(cleanupCommand);
|
||||
|
||||
await execAsync(
|
||||
`rsync -a --ignore-errors ${BASE_PATH}/ ${tempDir}/filesystem/`,
|
||||
`rsync -a --ignore-errors --no-specials --no-devices --exclude='volume-backups/' ${BASE_PATH}/ ${tempDir}/filesystem/`,
|
||||
);
|
||||
|
||||
writeStream.write("Copied filesystem to temp directory\n");
|
||||
|
||||
@@ -53,7 +53,7 @@ Compose Type: ${composeType} ✅`;
|
||||
|
||||
cd "${projectPath}";
|
||||
|
||||
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create --attachable ${compose.appName}` : ""}
|
||||
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create ${compose.composeType === "stack" ? "--driver overlay" : ""} --attachable ${compose.appName}` : ""}
|
||||
env -i PATH="$PATH" ${exportEnvCommand} docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; }
|
||||
${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""}
|
||||
|
||||
|
||||
@@ -182,7 +182,11 @@ export const mechanizeDockerContainer = async (
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
await docker.createService(settings);
|
||||
if (authConfig) {
|
||||
await docker.createService(authConfig, settings);
|
||||
} else {
|
||||
await docker.createService(settings);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { db } from "../../db/index";
|
||||
import { user as userSchema } from "../../db/schema/user";
|
||||
|
||||
export const LICENSE_KEY_URL =
|
||||
process.env.NODE_ENV === "development"
|
||||
? "http://localhost:4002"
|
||||
: "https://licenses-api.dokploy.com";
|
||||
// process.env.NODE_ENV === "development"
|
||||
// ? "http://localhost:4002"
|
||||
"https://licenses-api.dokploy.com";
|
||||
|
||||
export const initEnterpriseBackupCronJobs = async () => {
|
||||
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {
|
||||
|
||||
155
packages/server/src/utils/databases/libsql.ts
Normal file
155
packages/server/src/utils/databases/libsql.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions, PortConfig } from "dockerode";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
generateConfigContainer,
|
||||
generateFileMounts,
|
||||
generateVolumeMounts,
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
|
||||
export type LibsqlNested = InferResultType<
|
||||
"libsql",
|
||||
{
|
||||
mounts: true;
|
||||
environment: { with: { project: true } };
|
||||
}
|
||||
>;
|
||||
export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
externalPort,
|
||||
externalGRPCPort,
|
||||
externalAdminPort,
|
||||
memoryLimit,
|
||||
memoryReservation,
|
||||
databaseUser,
|
||||
databasePassword,
|
||||
sqldNode,
|
||||
sqldPrimaryUrl,
|
||||
cpuLimit,
|
||||
cpuReservation,
|
||||
command,
|
||||
mounts,
|
||||
enableNamespaces,
|
||||
} = libsql;
|
||||
|
||||
const basicAuth = Buffer.from(
|
||||
`${databaseUser}:${databasePassword}`,
|
||||
"utf-8",
|
||||
).toString("base64");
|
||||
|
||||
const defaultLibsqlEnv = `SQLD_NODE="${sqldNode}"\nSQLD_HTTP_AUTH="basic:${basicAuth}"${
|
||||
env ? `\n${env}` : ""
|
||||
}${sqldNode === "replica" ? `\nSQLD_PRIMARY_URL="${sqldPrimaryUrl}"` : ""}`;
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Labels,
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
} = generateConfigContainer(libsql);
|
||||
const resources = calculateResources({
|
||||
memoryLimit,
|
||||
memoryReservation,
|
||||
cpuLimit,
|
||||
cpuReservation,
|
||||
});
|
||||
const envVariables = prepareEnvironmentVariables(
|
||||
defaultLibsqlEnv,
|
||||
libsql.environment.project.env,
|
||||
libsql.environment.env,
|
||||
);
|
||||
const volumesMount = generateVolumeMounts(mounts);
|
||||
const bindsMount = generateBindMounts(mounts);
|
||||
const filesMount = generateFileMounts(appName, libsql);
|
||||
|
||||
const docker = await getRemoteDocker(libsql.serverId);
|
||||
|
||||
let finalCommand =
|
||||
command ??
|
||||
"sqld --db-path iku.db --http-listen-addr 0.0.0.0:8080 --grpc-listen-addr 0.0.0.0:5001 --admin-listen-addr 0.0.0.0:5000";
|
||||
if (enableNamespaces) {
|
||||
finalCommand += " --enable-namespaces";
|
||||
}
|
||||
|
||||
const settings: CreateServiceOptions = {
|
||||
Name: appName,
|
||||
TaskTemplate: {
|
||||
ContainerSpec: {
|
||||
HealthCheck,
|
||||
Image: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
|
||||
Env: envVariables,
|
||||
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
|
||||
...(finalCommand
|
||||
? {
|
||||
Command: ["/bin/sh"],
|
||||
Args: ["-c", finalCommand],
|
||||
}
|
||||
: {}),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
...resources,
|
||||
},
|
||||
},
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
EndpointSpec: {
|
||||
Mode: "dnsrr",
|
||||
Ports: [
|
||||
...(externalPort
|
||||
? [
|
||||
{
|
||||
Protocol: "tcp",
|
||||
TargetPort: 8080,
|
||||
PublishedPort: externalPort,
|
||||
PublishMode: "host",
|
||||
} as PortConfig,
|
||||
]
|
||||
: []),
|
||||
...(externalGRPCPort
|
||||
? [
|
||||
{
|
||||
Protocol: "tcp",
|
||||
TargetPort: 5001,
|
||||
PublishedPort: externalGRPCPort,
|
||||
PublishMode: "host",
|
||||
} as PortConfig,
|
||||
]
|
||||
: []),
|
||||
...(externalAdminPort
|
||||
? [
|
||||
{
|
||||
Protocol: "tcp",
|
||||
TargetPort: 5000,
|
||||
PublishedPort: externalAdminPort,
|
||||
PublishMode: "host",
|
||||
} as PortConfig,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
UpdateConfig,
|
||||
};
|
||||
try {
|
||||
const service = docker.getService(appName);
|
||||
const inspect = await service.inspect();
|
||||
await service.update({
|
||||
version: Number.parseInt(inspect.Version.Index),
|
||||
...settings,
|
||||
});
|
||||
} catch {
|
||||
await docker.createService(settings);
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
libsql,
|
||||
mariadb,
|
||||
mongo,
|
||||
mysql,
|
||||
postgres,
|
||||
redis,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { deployLibsql } from "@dokploy/server/services/libsql";
|
||||
import { deployMariadb } from "@dokploy/server/services/mariadb";
|
||||
import { deployMongo } from "@dokploy/server/services/mongo";
|
||||
import { deployMySql } from "@dokploy/server/services/mysql";
|
||||
@@ -15,7 +17,13 @@ import { eq } from "drizzle-orm";
|
||||
import { removeService } from "../docker/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
|
||||
type DatabaseType = "postgres" | "mysql" | "mariadb" | "mongo" | "redis";
|
||||
type DatabaseType =
|
||||
| "libsql"
|
||||
| "mariadb"
|
||||
| "mongo"
|
||||
| "mysql"
|
||||
| "postgres"
|
||||
| "redis";
|
||||
|
||||
export const rebuildDatabase = async (
|
||||
databaseId: string,
|
||||
@@ -41,31 +49,25 @@ export const rebuildDatabase = async (
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "postgres") {
|
||||
await deployPostgres(databaseId);
|
||||
} else if (type === "mysql") {
|
||||
await deployMySql(databaseId);
|
||||
if (type === "libsql") {
|
||||
await deployLibsql(databaseId);
|
||||
} else if (type === "mariadb") {
|
||||
await deployMariadb(databaseId);
|
||||
} else if (type === "mongo") {
|
||||
await deployMongo(databaseId);
|
||||
} else if (type === "mysql") {
|
||||
await deployMySql(databaseId);
|
||||
} else if (type === "postgres") {
|
||||
await deployPostgres(databaseId);
|
||||
} else if (type === "redis") {
|
||||
await deployRedis(databaseId);
|
||||
}
|
||||
};
|
||||
|
||||
const findDatabaseById = async (databaseId: string, type: DatabaseType) => {
|
||||
if (type === "postgres") {
|
||||
return await db.query.postgres.findFirst({
|
||||
where: eq(postgres.postgresId, databaseId),
|
||||
with: {
|
||||
mounts: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (type === "mysql") {
|
||||
return await db.query.mysql.findFirst({
|
||||
where: eq(mysql.mysqlId, databaseId),
|
||||
if (type === "libsql") {
|
||||
return await db.query.libsql.findFirst({
|
||||
where: eq(libsql.libsqlId, databaseId),
|
||||
with: {
|
||||
mounts: true,
|
||||
},
|
||||
@@ -87,6 +89,22 @@ const findDatabaseById = async (databaseId: string, type: DatabaseType) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (type === "mysql") {
|
||||
return await db.query.mysql.findFirst({
|
||||
where: eq(mysql.mysqlId, databaseId),
|
||||
with: {
|
||||
mounts: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (type === "postgres") {
|
||||
return await db.query.postgres.findFirst({
|
||||
where: eq(postgres.postgresId, databaseId),
|
||||
with: {
|
||||
mounts: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (type === "redis") {
|
||||
return await db.query.redis.findFirst({
|
||||
where: eq(redis.redisId, databaseId),
|
||||
|
||||
@@ -18,7 +18,9 @@ export const randomizeComposeFile = async (
|
||||
) => {
|
||||
const compose = await findComposeById(composeId);
|
||||
const composeFile = compose.composeFile;
|
||||
const composeData = parse(composeFile) as ComposeSpecification;
|
||||
const composeData = parse(composeFile, {
|
||||
maxAliasCount: 10000,
|
||||
}) as ComposeSpecification;
|
||||
|
||||
const randomSuffix = suffix || generateRandomHash();
|
||||
|
||||
|
||||
@@ -63,7 +63,9 @@ export const loadDockerCompose = async (
|
||||
|
||||
if (existsSync(path)) {
|
||||
const yamlStr = readFileSync(path, "utf8");
|
||||
const parsedConfig = parse(yamlStr) as ComposeSpecification;
|
||||
const parsedConfig = parse(yamlStr, {
|
||||
maxAliasCount: 10000,
|
||||
}) as ComposeSpecification;
|
||||
return parsedConfig;
|
||||
}
|
||||
return null;
|
||||
@@ -86,7 +88,9 @@ export const loadDockerComposeRemote = async (
|
||||
return null;
|
||||
}
|
||||
if (!stdout) return null;
|
||||
const parsedConfig = parse(stdout) as ComposeSpecification;
|
||||
const parsedConfig = parse(stdout, {
|
||||
maxAliasCount: 10000,
|
||||
}) as ComposeSpecification;
|
||||
return parsedConfig;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -106,10 +110,6 @@ export const writeDomainsToCompose = async (
|
||||
compose: Compose,
|
||||
domains: Domain[],
|
||||
) => {
|
||||
if (!domains.length) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
const composeConverted = await addDomainToCompose(compose, domains);
|
||||
const path = getComposePath(compose);
|
||||
@@ -145,7 +145,7 @@ export const addDomainToCompose = async (
|
||||
result = await loadDockerCompose(compose);
|
||||
}
|
||||
|
||||
if (!result || domains.length === 0) {
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ContainerInfo, ResourceRequirements } from "dockerode";
|
||||
import { parse } from "dotenv";
|
||||
import { quote } from "shell-quote";
|
||||
import type { ApplicationNested } from "../builders";
|
||||
import type { LibsqlNested } from "../databases/libsql";
|
||||
import type { MariadbNested } from "../databases/mariadb";
|
||||
import type { MongoNested } from "../databases/mongo";
|
||||
import type { MysqlNested } from "../databases/mysql";
|
||||
@@ -610,6 +611,7 @@ export const generateFileMounts = (
|
||||
appName: string,
|
||||
service:
|
||||
| ApplicationNested
|
||||
| LibsqlNested
|
||||
| MongoNested
|
||||
| MariadbNested
|
||||
| MysqlNested
|
||||
@@ -741,3 +743,177 @@ export const getComposeContainer = async (
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
type ServiceHealthStatus = {
|
||||
status: "healthy" | "unhealthy";
|
||||
message?: string;
|
||||
};
|
||||
|
||||
const checkSwarmServiceRunning = async (
|
||||
serviceName: string,
|
||||
): Promise<ServiceHealthStatus> => {
|
||||
try {
|
||||
const service = docker.getService(serviceName);
|
||||
const info = await service.inspect();
|
||||
const replicas = info.Spec?.Mode?.Replicated?.Replicas ?? 0;
|
||||
if (replicas === 0) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message: "Service has 0 replicas configured",
|
||||
};
|
||||
}
|
||||
|
||||
// Check that at least one task is actually running
|
||||
const tasks = await docker.listTasks({
|
||||
filters: JSON.stringify({
|
||||
service: [serviceName],
|
||||
"desired-state": ["running"],
|
||||
}),
|
||||
});
|
||||
|
||||
const runningTask = tasks.find((t) => t.Status?.State === "running");
|
||||
|
||||
if (!runningTask) {
|
||||
const latestTask = tasks[0];
|
||||
const taskState = latestTask?.Status?.State ?? "unknown";
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message: `No running tasks (current state: ${taskState})`,
|
||||
};
|
||||
}
|
||||
|
||||
return { status: "healthy" };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message: error instanceof Error ? error.message : "Service not found",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const getSwarmServiceContainerId = async (
|
||||
serviceName: string,
|
||||
): Promise<string | null> => {
|
||||
try {
|
||||
const tasks = await docker.listTasks({
|
||||
filters: JSON.stringify({
|
||||
service: [serviceName],
|
||||
"desired-state": ["running"],
|
||||
}),
|
||||
});
|
||||
|
||||
const runningTask = tasks.find((t) => t.Status?.State === "running");
|
||||
|
||||
return runningTask?.Status?.ContainerStatus?.ContainerID ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkPostgresHealth = async (): Promise<ServiceHealthStatus> => {
|
||||
const serviceCheck = await checkSwarmServiceRunning("dokploy-postgres");
|
||||
if (serviceCheck.status === "unhealthy") {
|
||||
return serviceCheck;
|
||||
}
|
||||
|
||||
// Verify PostgreSQL actually accepts connections
|
||||
const containerId = await getSwarmServiceContainerId("dokploy-postgres");
|
||||
if (!containerId) {
|
||||
return { status: "unhealthy", message: "Could not find running container" };
|
||||
}
|
||||
|
||||
try {
|
||||
const exec = await docker.getContainer(containerId).exec({
|
||||
Cmd: ["pg_isready", "-U", "dokploy"],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
const stream = await exec.start({});
|
||||
|
||||
const output = await new Promise<string>((resolve) => {
|
||||
let data = "";
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
stream.on("end", () => resolve(data));
|
||||
});
|
||||
|
||||
const inspectResult = await exec.inspect();
|
||||
if (inspectResult.ExitCode !== 0) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message: `PostgreSQL not ready: ${output.trim()}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { status: "healthy" };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Failed to check PostgreSQL",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const checkRedisHealth = async (): Promise<ServiceHealthStatus> => {
|
||||
const serviceCheck = await checkSwarmServiceRunning("dokploy-redis");
|
||||
if (serviceCheck.status === "unhealthy") {
|
||||
return serviceCheck;
|
||||
}
|
||||
|
||||
// Verify Redis actually responds to PING
|
||||
const containerId = await getSwarmServiceContainerId("dokploy-redis");
|
||||
if (!containerId) {
|
||||
return { status: "unhealthy", message: "Could not find running container" };
|
||||
}
|
||||
|
||||
try {
|
||||
const exec = await docker.getContainer(containerId).exec({
|
||||
Cmd: ["redis-cli", "ping"],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
const stream = await exec.start({});
|
||||
|
||||
const output = await new Promise<string>((resolve) => {
|
||||
let data = "";
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
stream.on("end", () => resolve(data));
|
||||
});
|
||||
|
||||
if (!output.includes("PONG")) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message: `Redis did not respond with PONG: ${output.trim()}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { status: "healthy" };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message: error instanceof Error ? error.message : "Failed to check Redis",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const checkTraefikHealth = async (): Promise<ServiceHealthStatus> => {
|
||||
// Traefik can run as a standalone container or a swarm service
|
||||
try {
|
||||
const container = docker.getContainer("dokploy-traefik");
|
||||
const info = await container.inspect();
|
||||
if (!info.State.Running) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
message: "Container is not running",
|
||||
};
|
||||
}
|
||||
return { status: "healthy" };
|
||||
} catch {
|
||||
// Not a standalone container, check as swarm service
|
||||
return checkSwarmServiceRunning("dokploy-traefik");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ export const sendDatabaseBackupNotifications = async ({
|
||||
}: {
|
||||
projectName: string;
|
||||
applicationName: string;
|
||||
databaseType: "postgres" | "mysql" | "mongodb" | "mariadb";
|
||||
databaseType: "postgres" | "mysql" | "mongodb" | "mariadb" | "libsql";
|
||||
type: "error" | "success";
|
||||
organizationId: string;
|
||||
errorMessage?: string;
|
||||
@@ -156,7 +156,7 @@ export const sendDatabaseBackupNotifications = async ({
|
||||
? [
|
||||
{
|
||||
name: decorate("`⚠️`", "Error Message"),
|
||||
value: `\`\`\`${errorMessage}\`\`\``,
|
||||
value: `\`\`\`${errorMessage.length > 1010 ? `${errorMessage.substring(0, 1010)}...` : errorMessage}\`\`\``,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
@@ -39,7 +39,8 @@ export const sendVolumeBackupNotifications = async ({
|
||||
| "mongodb"
|
||||
| "mariadb"
|
||||
| "redis"
|
||||
| "compose";
|
||||
| "compose"
|
||||
| "libsql";
|
||||
type: "error" | "success";
|
||||
organizationId: string;
|
||||
errorMessage?: string;
|
||||
|
||||
@@ -211,7 +211,10 @@ export const testGiteaConnection = async (input: { giteaId: string }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = provider.giteaUrl.replace(/\/+$/, "");
|
||||
const baseUrl = (provider.giteaInternalUrl || provider.giteaUrl).replace(
|
||||
/\/+$/,
|
||||
"",
|
||||
);
|
||||
|
||||
// Use /user/repos to get authenticated user's repositories with pagination
|
||||
let allRepos = 0;
|
||||
@@ -268,7 +271,9 @@ export const getGiteaRepositories = async (giteaId?: string) => {
|
||||
await refreshGiteaToken(giteaId);
|
||||
const giteaProvider = await findGiteaById(giteaId);
|
||||
|
||||
const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, "");
|
||||
const baseUrl = (
|
||||
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
// Use /user/repos to get authenticated user's repositories with pagination
|
||||
let allRepositories: any[] = [];
|
||||
@@ -333,7 +338,9 @@ export const getGiteaBranches = async (input: {
|
||||
|
||||
const giteaProvider = await findGiteaById(input.giteaId);
|
||||
|
||||
const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, "");
|
||||
const baseUrl = (
|
||||
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
// Handle pagination for branches
|
||||
let allBranches: any[] = [];
|
||||
|
||||
@@ -214,10 +214,13 @@ export const getGitlabBranches = async (input: {
|
||||
const allBranches = [];
|
||||
let page = 1;
|
||||
const perPage = 100; // GitLab's max per page is 100
|
||||
const baseUrl = (
|
||||
gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
while (true) {
|
||||
const branchesResponse = await fetch(
|
||||
`${gitlabProvider.gitlabUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`,
|
||||
`${baseUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${gitlabProvider.accessToken}`,
|
||||
@@ -292,10 +295,13 @@ export const validateGitlabProvider = async (gitlabProvider: Gitlab) => {
|
||||
const allProjects = [];
|
||||
let page = 1;
|
||||
const perPage = 100; // GitLab's max per page is 100
|
||||
const baseUrl = (
|
||||
gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl
|
||||
).replace(/\/+$/, "");
|
||||
|
||||
while (true) {
|
||||
const response = await fetch(
|
||||
`${gitlabProvider.gitlabUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`,
|
||||
`${baseUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${gitlabProvider.accessToken}`,
|
||||
|
||||
@@ -32,7 +32,7 @@ export const restoreComposeBackup = async (
|
||||
rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
|
||||
}
|
||||
|
||||
let credentials: DatabaseCredentials;
|
||||
let credentials: DatabaseCredentials = {};
|
||||
|
||||
switch (backupInput.databaseType) {
|
||||
case "postgres":
|
||||
@@ -62,13 +62,18 @@ export const restoreComposeBackup = async (
|
||||
const restoreCommand = getRestoreCommand({
|
||||
appName: appName,
|
||||
serviceName: backupInput.metadata?.serviceName,
|
||||
type: backupInput.databaseType,
|
||||
type: backupInput.databaseType as
|
||||
| "postgres"
|
||||
| "mariadb"
|
||||
| "mysql"
|
||||
| "mongo",
|
||||
credentials: {
|
||||
database: backupInput.databaseName,
|
||||
...credentials,
|
||||
},
|
||||
restoreType: composeType,
|
||||
rcloneCommand,
|
||||
backupFile: backupInput.backupFile,
|
||||
});
|
||||
|
||||
emit("Starting restore...");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { restoreComposeBackup } from "./compose";
|
||||
export { restoreLibsqlBackup } from "./libsql";
|
||||
export { restoreMariadbBackup } from "./mariadb";
|
||||
export { restoreMongoBackup } from "./mongo";
|
||||
export { restoreMySqlBackup } from "./mysql";
|
||||
|
||||
49
packages/server/src/utils/restore/libsql.ts
Normal file
49
packages/server/src/utils/restore/libsql.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import type { Libsql } from "@dokploy/server/services/libsql";
|
||||
import type { z } from "zod";
|
||||
import { getS3Credentials, getServiceContainerCommand } from "../backups/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
|
||||
export const restoreLibsqlBackup = async (
|
||||
libsql: Libsql,
|
||||
destination: Destination,
|
||||
backupInput: z.infer<typeof apiRestoreBackup>,
|
||||
emit: (log: string) => void,
|
||||
) => {
|
||||
try {
|
||||
const { appName, serverId } = libsql;
|
||||
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const bucketPath = `:s3:${destination.bucket}`;
|
||||
|
||||
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"`;
|
||||
|
||||
emit("Starting restore...");
|
||||
emit(`Backup path: ${backupPath}`);
|
||||
|
||||
const containerSearch = getServiceContainerCommand(appName);
|
||||
const restoreCommand = `docker exec -i $CONTAINER_ID sh -c "tar xzf - -C /var/lib/sqld"`;
|
||||
|
||||
const command = `CONTAINER_ID=$(${containerSearch}) && ${rcloneCommand} | ${restoreCommand}`;
|
||||
|
||||
emit(`Executing command: ${command}`);
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
|
||||
emit("Restore completed successfully!");
|
||||
} catch (error) {
|
||||
emit(
|
||||
`Error: ${
|
||||
error instanceof Error ? error.message : "Error restoring libsql backup"
|
||||
}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -30,7 +30,7 @@ export const getMongoRestoreCommand = (
|
||||
databaseUser: string,
|
||||
databasePassword: string,
|
||||
) => {
|
||||
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive"`;
|
||||
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive --drop"`;
|
||||
};
|
||||
|
||||
export const getComposeSearchCommand = (
|
||||
|
||||
@@ -152,16 +152,13 @@ export const createRouterConfig = async (
|
||||
}
|
||||
|
||||
if ((entryPoint === "websecure" && https) || !https) {
|
||||
// redirects
|
||||
for (const redirect of redirects) {
|
||||
let middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`;
|
||||
if (domain.domainType === "preview") {
|
||||
middlewareName = `redirect-${appName.replace(
|
||||
/^preview-(.+)-[^-]+$/,
|
||||
"$1",
|
||||
)}-${redirect.uniqueConfigKey}`;
|
||||
// redirects - skip for preview deployments as wildcard subdomains
|
||||
// should not inherit parent redirect rules (e.g., www-redirect)
|
||||
if (domain.domainType !== "preview") {
|
||||
for (const redirect of redirects) {
|
||||
const middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`;
|
||||
routerConfig.middlewares?.push(middlewareName);
|
||||
}
|
||||
routerConfig.middlewares?.push(middlewareName);
|
||||
}
|
||||
|
||||
// security
|
||||
|
||||
@@ -4,6 +4,25 @@ import { findComposeById } from "@dokploy/server/services/compose";
|
||||
import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups";
|
||||
import { getS3Credentials, normalizeS3Path } from "../backups/utils";
|
||||
|
||||
export const getVolumeServiceAppName = (
|
||||
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
|
||||
): string => {
|
||||
if (volumeBackup.compose?.appName) {
|
||||
return volumeBackup.serviceName
|
||||
? `${volumeBackup.compose.appName}_${volumeBackup.serviceName}`
|
||||
: volumeBackup.compose.appName;
|
||||
}
|
||||
const serviceAppName =
|
||||
volumeBackup.application?.appName ||
|
||||
volumeBackup.postgres?.appName ||
|
||||
volumeBackup.mysql?.appName ||
|
||||
volumeBackup.mariadb?.appName ||
|
||||
volumeBackup.mongo?.appName ||
|
||||
volumeBackup.redis?.appName ||
|
||||
volumeBackup.libsql?.appName;
|
||||
return serviceAppName || volumeBackup.appName;
|
||||
};
|
||||
|
||||
export const backupVolume = async (
|
||||
volumeBackup: Awaited<ReturnType<typeof findVolumeBackupById>>,
|
||||
) => {
|
||||
@@ -12,15 +31,16 @@ export const backupVolume = async (
|
||||
volumeBackup.application?.serverId || volumeBackup.compose?.serverId;
|
||||
const { VOLUME_BACKUPS_PATH, VOLUME_BACKUP_LOCK_PATH } = paths(!!serverId);
|
||||
const destination = volumeBackup.destination;
|
||||
const s3AppName = getVolumeServiceAppName(volumeBackup);
|
||||
const backupFileName = `${volumeName}-${new Date().toISOString()}.tar`;
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix || "")}${backupFileName}`;
|
||||
const rcloneFlags = getS3Credentials(volumeBackup.destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName);
|
||||
|
||||
const rcloneCommand = `rclone copyto ${rcloneFlags.join(" ")} "${volumeBackupPath}/${backupFileName}" "${rcloneDestination}"`;
|
||||
|
||||
const baseCommand = `
|
||||
const backupCommand = `
|
||||
set -e
|
||||
echo "Volume name: ${volumeName}"
|
||||
echo "Backup file name: ${backupFileName}"
|
||||
@@ -33,6 +53,9 @@ export const backupVolume = async (
|
||||
ubuntu \
|
||||
bash -c "cd /volume_data && tar cvf /backup/${backupFileName} ."
|
||||
echo "Volume backup done ✅"
|
||||
`;
|
||||
|
||||
const uploadCommand = `
|
||||
echo "Starting upload to S3..."
|
||||
${rcloneCommand}
|
||||
echo "Upload to S3 done ✅"
|
||||
@@ -42,7 +65,10 @@ export const backupVolume = async (
|
||||
`;
|
||||
|
||||
if (!turnOff) {
|
||||
return baseCommand;
|
||||
return `
|
||||
${backupCommand}
|
||||
${uploadCommand}
|
||||
`;
|
||||
}
|
||||
|
||||
const serviceLockId =
|
||||
@@ -91,9 +117,10 @@ export const backupVolume = async (
|
||||
ACTUAL_REPLICAS=$(docker service inspect ${volumeBackup.application?.appName} --format "{{.Spec.Mode.Replicated.Replicas}}")
|
||||
echo "Actual replicas: $ACTUAL_REPLICAS"
|
||||
docker service update --replicas=0 ${volumeBackup.application?.appName}
|
||||
${baseCommand}
|
||||
${backupCommand}
|
||||
echo "Starting application to $ACTUAL_REPLICAS replicas"
|
||||
docker service update --replicas=$ACTUAL_REPLICAS --with-registry-auth ${volumeBackup.application?.appName}
|
||||
${uploadCommand}
|
||||
`);
|
||||
}
|
||||
if (serviceType === "compose") {
|
||||
@@ -128,8 +155,9 @@ export const backupVolume = async (
|
||||
}
|
||||
return lockWrapper(`
|
||||
${stopCommand}
|
||||
${baseCommand}
|
||||
${backupCommand}
|
||||
${startCommand}
|
||||
${uploadCommand}
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { scheduledJobs, scheduleJob } from "node-schedule";
|
||||
import { getS3Credentials, normalizeS3Path } from "../backups/utils";
|
||||
import { sendVolumeBackupNotifications } from "../notifications/volume-backup";
|
||||
import { backupVolume } from "./backup";
|
||||
import { backupVolume, getVolumeServiceAppName } from "./backup";
|
||||
|
||||
// Helper functions to extract project info from volume backup
|
||||
const getProjectName = (
|
||||
@@ -26,6 +26,7 @@ const getProjectName = (
|
||||
volumeBackup.mariadb,
|
||||
volumeBackup.mongo,
|
||||
volumeBackup.redis,
|
||||
volumeBackup.libsql,
|
||||
];
|
||||
|
||||
for (const service of services) {
|
||||
@@ -48,6 +49,7 @@ const getOrganizationId = (
|
||||
volumeBackup.mariadb,
|
||||
volumeBackup.mongo,
|
||||
volumeBackup.redis,
|
||||
volumeBackup.libsql,
|
||||
];
|
||||
|
||||
for (const service of services) {
|
||||
@@ -81,9 +83,9 @@ const cleanupOldVolumeBackups = async (
|
||||
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const normalizedPrefix = normalizeS3Path(prefix);
|
||||
const backupFilesPath = `:s3:${destination.bucket}/${normalizedPrefix}`;
|
||||
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" :s3:${destination.bucket}/${normalizedPrefix}`;
|
||||
const s3AppName = getVolumeServiceAppName(volumeBackup);
|
||||
const backupFilesPath = `:s3:${destination.bucket}/${s3AppName}/${normalizeS3Path(prefix || "")}`;
|
||||
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" ${backupFilesPath}`;
|
||||
const sortAndPick = `sort -r | tail -n +$((${keepLatestCount}+1)) | xargs -I{}`;
|
||||
const deleteCommand = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
|
||||
const fullCommand = `${listCommand} | ${sortAndPick} ${deleteCommand}`;
|
||||
@@ -131,14 +133,21 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
|
||||
? "mongodb"
|
||||
: volumeBackup.serviceType;
|
||||
|
||||
await sendVolumeBackupNotifications({
|
||||
projectName,
|
||||
applicationName: volumeBackup.name,
|
||||
volumeName: volumeBackup.volumeName,
|
||||
serviceType: mappedServiceType,
|
||||
type: "success",
|
||||
organizationId,
|
||||
});
|
||||
try {
|
||||
await sendVolumeBackupNotifications({
|
||||
projectName,
|
||||
applicationName: volumeBackup.name,
|
||||
volumeName: volumeBackup.volumeName,
|
||||
serviceType: mappedServiceType,
|
||||
type: "success",
|
||||
organizationId,
|
||||
});
|
||||
} catch (notificationError) {
|
||||
console.error(
|
||||
"Failed to send volume backup success notification",
|
||||
notificationError,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
|
||||
const volumeBackupPath = path.join(
|
||||
@@ -160,14 +169,21 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
|
||||
? "mongodb"
|
||||
: volumeBackup.serviceType;
|
||||
|
||||
await sendVolumeBackupNotifications({
|
||||
projectName,
|
||||
applicationName: volumeBackup.name,
|
||||
volumeName: volumeBackup.volumeName,
|
||||
serviceType: mappedServiceType,
|
||||
type: "error",
|
||||
organizationId,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
try {
|
||||
await sendVolumeBackupNotifications({
|
||||
projectName,
|
||||
applicationName: volumeBackup.name,
|
||||
volumeName: volumeBackup.volumeName,
|
||||
serviceType: mappedServiceType,
|
||||
type: "error",
|
||||
organizationId,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} catch (notificationError) {
|
||||
console.error(
|
||||
"Failed to send volume backup error notification",
|
||||
notificationError,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
} from "../utils/notifications/utils";
|
||||
import { sendEmailNotification } from "../utils/notifications/utils";
|
||||
export const sendEmail = async ({
|
||||
email,
|
||||
subject,
|
||||
@@ -26,26 +23,3 @@ export const sendEmail = async ({
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const sendDiscordNotificationWelcome = async (email: string) => {
|
||||
await sendDiscordNotification(
|
||||
{
|
||||
webhookUrl: process.env.DISCORD_WEBHOOK_URL || "",
|
||||
},
|
||||
{
|
||||
title: "New User Registered",
|
||||
color: 0x00ff00,
|
||||
fields: [
|
||||
{
|
||||
name: "Email",
|
||||
value: email,
|
||||
inline: true,
|
||||
},
|
||||
],
|
||||
timestamp: new Date(),
|
||||
footer: {
|
||||
text: "Dokploy User Registration Notification",
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user