mirror of
https://github.com/andwati/dokploy.git
synced 2026-10-26 23:12:17 +00:00
Merge remote-tracking branch 'origin/canary' into fix/cmdi-domain-servicename
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
timestamp,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { network } from "./network";
|
||||
import { projects } from "./project";
|
||||
import { server } from "./server";
|
||||
import { ssoProvider } from "./sso";
|
||||
@@ -108,6 +109,7 @@ export const organizationRelations = relations(
|
||||
references: [user.id],
|
||||
}),
|
||||
servers: many(server),
|
||||
networks: many(network),
|
||||
projects: many(projects),
|
||||
members: many(member),
|
||||
ssoProviders: many(ssoProvider),
|
||||
@@ -221,6 +223,36 @@ export const twoFactor = pgTable("two_factor", {
|
||||
lockedUntil: timestamp("locked_until"),
|
||||
});
|
||||
|
||||
export const passkey = pgTable(
|
||||
"passkey",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name"),
|
||||
publicKey: text("public_key").notNull(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
credentialID: text("credential_id").notNull(),
|
||||
counter: integer("counter").notNull(),
|
||||
deviceType: text("device_type").notNull(),
|
||||
backedUp: boolean("backed_up").notNull(),
|
||||
transports: text("transports"),
|
||||
createdAt: timestamp("created_at"),
|
||||
aaguid: text("aaguid"),
|
||||
},
|
||||
(table) => [
|
||||
index("passkey_userId_idx").on(table.userId),
|
||||
index("passkey_credentialID_idx").on(table.credentialID),
|
||||
],
|
||||
);
|
||||
|
||||
export const passkeyRelations = relations(passkey, ({ one }) => ({
|
||||
user: one(user, {
|
||||
fields: [passkey.userId],
|
||||
references: [user.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const apikey = pgTable("apikey", {
|
||||
id: text("id").primaryKey(),
|
||||
name: text("name"),
|
||||
|
||||
@@ -54,6 +54,15 @@ export const apiUpdateAi = createSchema
|
||||
})
|
||||
.omit({ organizationId: true });
|
||||
|
||||
export const aiCustomProviderSchema = z.object({
|
||||
name: z.string().min(1, { message: "Name is required" }),
|
||||
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
|
||||
});
|
||||
|
||||
export const apiSaveAiCustomProviders = z.object({
|
||||
providers: z.array(aiCustomProviderSchema),
|
||||
});
|
||||
|
||||
export const deploySuggestionSchema = z.object({
|
||||
environmentId: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
|
||||
@@ -233,6 +233,10 @@ export const applications = pgTable("application", {
|
||||
onDelete: "set null",
|
||||
},
|
||||
),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const applicationsRelations = relations(
|
||||
@@ -374,6 +378,8 @@ const createSchema = createInsertSchema(applications, {
|
||||
previewRequireCollaboratorPermissions: z.boolean().optional(),
|
||||
watchPaths: z.array(z.string()).optional().optional(),
|
||||
previewLabels: z.array(z.string()).optional(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
cleanCache: z.boolean().optional(),
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
|
||||
@@ -91,4 +91,6 @@ export type AuditResourceType =
|
||||
| "cluster"
|
||||
| "mount"
|
||||
| "application"
|
||||
| "compose";
|
||||
| "compose"
|
||||
| "network"
|
||||
| "vaultProvider";
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { boolean, integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
@@ -80,6 +87,7 @@ export const compose = pgTable("compose", {
|
||||
),
|
||||
command: text("command").notNull().default(""),
|
||||
//
|
||||
createEnvFile: boolean("createEnvFile").notNull().default(true),
|
||||
enableSubmodules: boolean("enableSubmodules").notNull().default(false),
|
||||
composePath: text("composePath").notNull().default("./docker-compose.yml"),
|
||||
suffix: text("suffix").notNull().default(""),
|
||||
@@ -91,6 +99,7 @@ export const compose = pgTable("compose", {
|
||||
.default(false),
|
||||
triggerType: triggerType("triggerType").default("push"),
|
||||
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
||||
icon: text("icon"),
|
||||
environmentId: text("environmentId")
|
||||
.notNull()
|
||||
.references(() => environments.environmentId, { onDelete: "cascade" }),
|
||||
@@ -113,6 +122,15 @@ export const compose = pgTable("compose", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
serviceNetworks: jsonb("serviceNetworks")
|
||||
.$type<
|
||||
Array<{
|
||||
serviceName: string;
|
||||
networkIds: string[];
|
||||
detachDokployNetwork: boolean;
|
||||
}>
|
||||
>()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export const composeRelations = relations(compose, ({ one, many }) => ({
|
||||
@@ -166,6 +184,7 @@ const createSchema = createInsertSchema(compose, {
|
||||
environmentId: z.string(),
|
||||
customGitSSHKeyId: z.string().optional(),
|
||||
command: z.string().optional(),
|
||||
createEnvFile: z.boolean().optional(),
|
||||
composePath: z.string().min(1),
|
||||
composeType: z.enum(["docker-compose", "stack"]).optional(),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
@@ -174,6 +193,20 @@ const createSchema = createInsertSchema(compose, {
|
||||
.optional(),
|
||||
triggerType: z.enum(["push", "tag"]).optional(),
|
||||
composeStatus: z.enum(["idle", "running", "done", "error"]).optional(),
|
||||
icon: z
|
||||
.string()
|
||||
.max(2 * 1024 * 1024, "Icon must be less than 2MB")
|
||||
.nullable()
|
||||
.optional(),
|
||||
serviceNetworks: z
|
||||
.array(
|
||||
z.object({
|
||||
serviceName: z.string(),
|
||||
networkIds: z.array(z.string()),
|
||||
detachDokployNetwork: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const apiCreateCompose = createSchema.pick({
|
||||
@@ -235,7 +268,10 @@ export const apiSaveEnvironmentVariablesCompose = createSchema
|
||||
composeId: true,
|
||||
env: true,
|
||||
})
|
||||
.required();
|
||||
.required()
|
||||
.extend({
|
||||
createEnvFile: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiRandomizeCompose = createSchema
|
||||
.pick({
|
||||
|
||||
@@ -16,6 +16,7 @@ export const github = pgTable("github", {
|
||||
githubInstallationId: text("githubInstallationId"),
|
||||
githubPrivateKey: text("githubPrivateKey"),
|
||||
githubWebhookSecret: text("githubWebhookSecret"),
|
||||
githubUrl: text("githubUrl").default("https://github.com").notNull(),
|
||||
gitProviderId: text("gitProviderId")
|
||||
.notNull()
|
||||
.references(() => gitProvider.gitProviderId, { onDelete: "cascade" }),
|
||||
@@ -36,6 +37,10 @@ export const apiCreateGithub = z.object({
|
||||
githubInstallationId: z.string().optional(),
|
||||
githubPrivateKey: z.string().optional(),
|
||||
githubWebhookSecret: z.string().nullable(),
|
||||
githubUrl: z
|
||||
.url()
|
||||
.startsWith("https://", "Only https is supported for GitHub instances")
|
||||
.optional(),
|
||||
gitProviderId: z.string().optional(),
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ export * from "./mariadb";
|
||||
export * from "./mongo";
|
||||
export * from "./mount";
|
||||
export * from "./mysql";
|
||||
export * from "./network";
|
||||
export * from "./notification";
|
||||
export * from "./patch";
|
||||
export * from "./port";
|
||||
@@ -41,5 +42,6 @@ export * from "./sso";
|
||||
export * from "./tag";
|
||||
export * from "./user";
|
||||
export * from "./utils";
|
||||
export * from "./vault-provider";
|
||||
export * from "./volume-backups";
|
||||
export * from "./web-server-settings";
|
||||
|
||||
@@ -83,6 +83,10 @@ export const libsql = pgTable("libsql", {
|
||||
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "number" }),
|
||||
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
|
||||
replicas: integer("replicas").default(1).notNull(),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
@@ -146,6 +150,8 @@ const createSchema = createInsertSchema(libsql, {
|
||||
networkSwarm: NetworkSwarmSchema.nullable(),
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateLibsql = createSchema
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
|
||||
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";
|
||||
@@ -88,6 +95,10 @@ export const mariadb = pgTable("mariadb", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const mariadbRelations = relations(mariadb, ({ one, many }) => ({
|
||||
@@ -148,6 +159,8 @@ const createSchema = createInsertSchema(mariadb, {
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateMariaDB = createSchema.pick({
|
||||
|
||||
@@ -92,6 +92,10 @@ export const mongo = pgTable("mongo", {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
replicaSets: boolean("replicaSets").default(false),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const mongoRelations = relations(mongo, ({ one, many }) => ({
|
||||
@@ -146,6 +150,8 @@ const createSchema = createInsertSchema(mongo, {
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateMongo = createSchema.pick({
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
|
||||
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";
|
||||
@@ -86,6 +93,10 @@ export const mysql = pgTable("mysql", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const mysqlRelations = relations(mysql, ({ one, many }) => ({
|
||||
@@ -145,6 +156,8 @@ const createSchema = createInsertSchema(mysql, {
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateMySql = createSchema.pick({
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { server } from "./server";
|
||||
|
||||
export const networkDriver = pgEnum("networkDriver", ["bridge", "overlay"]);
|
||||
|
||||
export const network = pgTable("network", {
|
||||
networkId: text("networkId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
driver: networkDriver("driver").notNull().default("bridge"),
|
||||
internal: boolean("internal").notNull().default(false),
|
||||
attachable: boolean("attachable").notNull().default(false),
|
||||
enableIPv4: boolean("enableIPv4").notNull().default(true),
|
||||
enableIPv6: boolean("enableIPv6").notNull().default(false),
|
||||
mtu: integer("mtu"),
|
||||
ipam: jsonb("ipam")
|
||||
.$type<{
|
||||
driver?: string;
|
||||
config?: Array<{ subnet?: string; gateway?: string; ipRange?: string }>;
|
||||
}>()
|
||||
.default({}),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
});
|
||||
|
||||
export const networkRelations = relations(network, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [network.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
server: one(server, {
|
||||
fields: [network.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
}));
|
||||
|
||||
const createSchema = createInsertSchema(network, {
|
||||
networkId: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
driver: z.enum(["bridge", "overlay"]).optional(),
|
||||
internal: z.boolean().optional(),
|
||||
attachable: z.boolean().optional(),
|
||||
enableIPv4: z.boolean().optional(),
|
||||
enableIPv6: z.boolean().optional(),
|
||||
mtu: z.number().int().min(68).max(65535).optional().nullable(),
|
||||
ipam: z
|
||||
.object({
|
||||
driver: z.string().optional(),
|
||||
config: z
|
||||
.array(
|
||||
z.object({
|
||||
subnet: z.string().optional(),
|
||||
gateway: z.string().optional(),
|
||||
ipRange: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
organizationId: z.string().min(1),
|
||||
serverId: z.string().optional().nullable(),
|
||||
});
|
||||
|
||||
const validateNetworkInput = (
|
||||
input: {
|
||||
enableIPv4?: boolean;
|
||||
enableIPv6?: boolean;
|
||||
ipam?: {
|
||||
config?: Array<{ subnet?: string; gateway?: string; ipRange?: string }>;
|
||||
} | null;
|
||||
},
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
if (input.enableIPv4 === false && input.enableIPv6 !== true) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["enableIPv4"],
|
||||
message: "IPv4 or IPv6 must be enabled",
|
||||
});
|
||||
}
|
||||
for (const [index, entry] of (input.ipam?.config ?? []).entries()) {
|
||||
if (!entry.subnet && (entry.gateway || entry.ipRange)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["ipam", "config", index, "subnet"],
|
||||
message: "Gateway and IP range require a subnet",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const apiCreateNetwork = createSchema
|
||||
.pick({
|
||||
name: true,
|
||||
driver: true,
|
||||
internal: true,
|
||||
attachable: true,
|
||||
enableIPv4: true,
|
||||
enableIPv6: true,
|
||||
mtu: true,
|
||||
ipam: true,
|
||||
serverId: true,
|
||||
})
|
||||
.partial()
|
||||
.required({ name: true })
|
||||
.superRefine(validateNetworkInput);
|
||||
|
||||
export const apiFindOneNetwork = z.object({
|
||||
networkId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiRemoveNetwork = z.object({
|
||||
networkId: z.string().min(1),
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
|
||||
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";
|
||||
@@ -86,6 +93,10 @@ export const postgres = pgTable("postgres", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const postgresRelations = relations(postgres, ({ one, many }) => ({
|
||||
@@ -140,6 +151,8 @@ const createSchema = createInsertSchema(postgres, {
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreatePostgres = createSchema.pick({
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
|
||||
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";
|
||||
@@ -80,6 +87,10 @@ export const redis = pgTable("redis", {
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
networkIds: text("networkIds").array().default([]),
|
||||
detachDokployNetwork: boolean("detachDokployNetwork")
|
||||
.notNull()
|
||||
.default(false),
|
||||
});
|
||||
|
||||
export const redisRelations = relations(redis, ({ one, many }) => ({
|
||||
@@ -129,6 +140,8 @@ const createSchema = createInsertSchema(redis, {
|
||||
stopGracePeriodSwarm: z.number().nullable(),
|
||||
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
|
||||
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
|
||||
networkIds: z.array(z.string()).optional(),
|
||||
detachDokployNetwork: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiCreateRedis = createSchema.pick({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Application } from "@dokploy/server/services/application";
|
||||
import type { Environment } from "@dokploy/server/services/environment";
|
||||
import type { Mount } from "@dokploy/server/services/mount";
|
||||
import type { Port } from "@dokploy/server/services/port";
|
||||
import type { Project } from "@dokploy/server/services/project";
|
||||
@@ -27,7 +28,7 @@ export const rollbacks = pgTable("rollback", {
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
fullContext: jsonb("fullContext").$type<
|
||||
Application & {
|
||||
environment: {
|
||||
environment: Environment & {
|
||||
project: Project;
|
||||
};
|
||||
mounts: Mount[];
|
||||
|
||||
@@ -19,6 +19,7 @@ import { libsql } from "./libsql";
|
||||
import { mariadb } from "./mariadb";
|
||||
import { mongo } from "./mongo";
|
||||
import { mysql } from "./mysql";
|
||||
import { network } from "./network";
|
||||
import { postgres } from "./postgres";
|
||||
import { redis } from "./redis";
|
||||
import { schedules } from "./schedule";
|
||||
@@ -125,6 +126,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
|
||||
mysql: many(mysql),
|
||||
postgres: many(postgres),
|
||||
certificates: many(certificates),
|
||||
networks: many(network),
|
||||
organization: one(organization, {
|
||||
fields: [server.organizationId],
|
||||
references: [organization.id],
|
||||
|
||||
@@ -41,6 +41,12 @@ export const APP_NAME_REGEX = /^[a-zA-Z0-9._-]+$/;
|
||||
export const APP_NAME_MESSAGE =
|
||||
"App name can only contain letters, numbers, dots, underscores and hyphens";
|
||||
|
||||
/** Docker volume name: must start alphanumeric, then letters/numbers/._- only. Safe for shell. */
|
||||
export const VOLUME_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||
|
||||
export const VOLUME_NAME_MESSAGE =
|
||||
"Volume name must start with a letter or number and contain only letters, numbers, dots, underscores and hyphens";
|
||||
|
||||
/** Database password: blocks shell-dangerous characters like $ ! ' " \ / and spaces. */
|
||||
export const DATABASE_PASSWORD_REGEX =
|
||||
/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { jsonb, pgEnum, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { createInsertSchema } from "drizzle-zod";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
|
||||
export const vaultProviderType = pgEnum("VaultProviderType", [
|
||||
"hashicorp",
|
||||
"infisical",
|
||||
"aws",
|
||||
"doppler",
|
||||
"azure",
|
||||
]);
|
||||
|
||||
export const hashicorpVaultConfigSchema = z.object({
|
||||
providerType: z.literal("hashicorp"),
|
||||
url: z.string().url(),
|
||||
token: z.string().min(1),
|
||||
namespace: z.string().optional(),
|
||||
mount: z.string().min(1).default("secret"),
|
||||
});
|
||||
|
||||
export const infisicalVaultConfigSchema = z.object({
|
||||
providerType: z.literal("infisical"),
|
||||
siteUrl: z.string().url().default("https://app.infisical.com"),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
projectId: z.string().min(1),
|
||||
environmentSlug: z.string().min(1),
|
||||
secretPath: z.string().default("/"),
|
||||
});
|
||||
|
||||
export const awsVaultConfigSchema = z.object({
|
||||
providerType: z.literal("aws"),
|
||||
region: z.string().min(1),
|
||||
accessKeyId: z.string().min(1),
|
||||
secretAccessKey: z.string().min(1),
|
||||
endpoint: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export const dopplerVaultConfigSchema = z.object({
|
||||
providerType: z.literal("doppler"),
|
||||
serviceToken: z.string().min(1),
|
||||
project: z.string().optional(),
|
||||
config: z.string().optional(),
|
||||
});
|
||||
|
||||
export const azureVaultConfigSchema = z.object({
|
||||
providerType: z.literal("azure"),
|
||||
vaultUri: z.string().url(),
|
||||
tenantId: z.string().min(1),
|
||||
clientId: z.string().min(1),
|
||||
clientSecret: z.string().min(1),
|
||||
});
|
||||
|
||||
export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
|
||||
hashicorpVaultConfigSchema,
|
||||
infisicalVaultConfigSchema,
|
||||
awsVaultConfigSchema,
|
||||
dopplerVaultConfigSchema,
|
||||
azureVaultConfigSchema,
|
||||
]);
|
||||
|
||||
export type VaultProviderConfig = z.infer<typeof vaultProviderConfigSchema>;
|
||||
|
||||
export const vaultProviderAssignmentSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
environmentIds: z.array(z.string().min(1)).default([]),
|
||||
});
|
||||
|
||||
export type VaultProviderAssignment = z.infer<
|
||||
typeof vaultProviderAssignmentSchema
|
||||
>;
|
||||
|
||||
export const vaultProvider = pgTable(
|
||||
"vault_provider",
|
||||
{
|
||||
vaultProviderId: text("vaultProviderId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
providerType: vaultProviderType("providerType").notNull(),
|
||||
config: jsonb("config").$type<VaultProviderConfig>().notNull(),
|
||||
assignments: jsonb("assignments")
|
||||
.$type<VaultProviderAssignment[]>()
|
||||
.notNull()
|
||||
.default([]),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("vault_provider_org_name_idx").on(
|
||||
table.organizationId,
|
||||
table.name,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
const vaultProviderNameSchema = z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.regex(
|
||||
/^[a-zA-Z0-9_-]+$/,
|
||||
"Name can only contain letters, numbers, dashes and underscores",
|
||||
);
|
||||
|
||||
const createSchema = createInsertSchema(vaultProvider);
|
||||
|
||||
export const apiCreateVaultProvider = createSchema.pick({}).extend({
|
||||
name: vaultProviderNameSchema,
|
||||
config: vaultProviderConfigSchema,
|
||||
assignments: z.array(vaultProviderAssignmentSchema),
|
||||
});
|
||||
|
||||
export const apiUpdateVaultProvider = createSchema.pick({}).extend({
|
||||
vaultProviderId: z.string().min(1),
|
||||
name: vaultProviderNameSchema,
|
||||
config: vaultProviderConfigSchema,
|
||||
assignments: z.array(vaultProviderAssignmentSchema),
|
||||
});
|
||||
|
||||
export const apiFindOneVaultProvider = z.object({
|
||||
vaultProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiRemoveVaultProvider = z.object({
|
||||
vaultProviderId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiTestVaultProvider = z.object({
|
||||
vaultProviderId: z.string().min(1).optional(),
|
||||
config: vaultProviderConfigSchema.optional(),
|
||||
});
|
||||
|
||||
export const apiListVaultSecretNames = z.object({
|
||||
vaultProviderId: z.string().min(1),
|
||||
projectId: z.string().min(1),
|
||||
environmentId: z.string().optional(),
|
||||
});
|
||||
@@ -14,7 +14,11 @@ import { serviceType } from "./mount";
|
||||
import { mysql } from "./mysql";
|
||||
import { postgres } from "./postgres";
|
||||
import { redis } from "./redis";
|
||||
import { generateAppName } from "./utils";
|
||||
import {
|
||||
generateAppName,
|
||||
VOLUME_NAME_MESSAGE,
|
||||
VOLUME_NAME_REGEX,
|
||||
} from "./utils";
|
||||
|
||||
export const volumeBackups = pgTable("volume_backup", {
|
||||
volumeBackupId: text("volumeBackupId")
|
||||
@@ -113,7 +117,9 @@ export const volumeBackupsRelations = relations(
|
||||
}),
|
||||
);
|
||||
|
||||
export const createVolumeBackupSchema = createInsertSchema(volumeBackups).omit({
|
||||
export const createVolumeBackupSchema = createInsertSchema(volumeBackups, {
|
||||
volumeName: z.string().regex(VOLUME_NAME_REGEX, VOLUME_NAME_MESSAGE),
|
||||
}).omit({
|
||||
volumeBackupId: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Img,
|
||||
Preview,
|
||||
Section,
|
||||
Tailwind,
|
||||
Text,
|
||||
} from "@react-email/components";
|
||||
import { emailTailwindConfig } from "../tailwind-config";
|
||||
|
||||
export type TemplateProps = {
|
||||
serverName: string;
|
||||
type: "CPU" | "Memory";
|
||||
value: string;
|
||||
threshold: string;
|
||||
message: string;
|
||||
date: string;
|
||||
};
|
||||
|
||||
export const ServerThresholdEmail = ({
|
||||
serverName = "my-server",
|
||||
type = "CPU",
|
||||
value = "95.00",
|
||||
threshold = "90.00",
|
||||
message = "Resource usage exceeded the configured threshold",
|
||||
date = "2023-05-01T00:00:00.000Z",
|
||||
}: TemplateProps) => {
|
||||
const previewText = `Server ${type} alert for ${serverName} ⚠️`;
|
||||
return (
|
||||
<Html>
|
||||
<Preview>{previewText}</Preview>
|
||||
<Tailwind config={emailTailwindConfig}>
|
||||
<Head />
|
||||
|
||||
<Body className="bg-white my-auto mx-auto font-sans px-2">
|
||||
<Container className="border border-solid border-[#eaeaea] rounded-lg my-[40px] mx-auto p-[20px] max-w-[465px]">
|
||||
<Section className="mt-[32px]">
|
||||
<Img
|
||||
src={
|
||||
"https://raw.githubusercontent.com/Dokploy/dokploy/refs/heads/canary/apps/dokploy/logo.png"
|
||||
}
|
||||
width="100"
|
||||
height="50"
|
||||
alt="Dokploy"
|
||||
className="my-0 mx-auto"
|
||||
/>
|
||||
</Section>
|
||||
<Heading className="text-black text-[24px] font-normal text-center p-0 my-[30px] mx-0">
|
||||
⚠️ Server {type} alert for <strong>{serverName}</strong>
|
||||
</Heading>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
Hello,
|
||||
</Text>
|
||||
<Text className="text-black text-[14px] leading-[24px]">
|
||||
The {type} usage on <strong>{serverName}</strong> exceeded the
|
||||
configured threshold.
|
||||
</Text>
|
||||
<Section className="flex text-black text-[14px] leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||
<Text className="!leading-3 font-bold">Details: </Text>
|
||||
<Text className="!leading-3">
|
||||
Server Name: <strong>{serverName}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Type: <strong>{type}</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Current Value: <strong>{value}%</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Threshold: <strong>{threshold}%</strong>
|
||||
</Text>
|
||||
<Text className="!leading-3">
|
||||
Date: <strong>{date}</strong>
|
||||
</Text>
|
||||
</Section>
|
||||
<Section className="flex text-black text-[14px] mt-4 leading-[24px] bg-[#F4F4F5] rounded-lg p-2">
|
||||
<Text className="!leading-3 font-bold">Message: </Text>
|
||||
<Text className="text-[12px] leading-[24px]">{message}</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerThresholdEmail;
|
||||
@@ -17,6 +17,7 @@ export * from "./services/compose";
|
||||
export * from "./services/deployment";
|
||||
export * from "./services/destination";
|
||||
export * from "./services/docker";
|
||||
export * from "./services/docker-volume";
|
||||
export * from "./services/domain";
|
||||
export * from "./services/environment";
|
||||
export * from "./services/git-provider";
|
||||
@@ -28,6 +29,7 @@ export * from "./services/mariadb";
|
||||
export * from "./services/mongo";
|
||||
export * from "./services/mount";
|
||||
export * from "./services/mysql";
|
||||
export * from "./services/network";
|
||||
export * from "./services/notification";
|
||||
export * from "./services/patch";
|
||||
export * from "./services/patch-repo";
|
||||
@@ -49,13 +51,13 @@ export * from "./services/server";
|
||||
export * from "./services/settings";
|
||||
export * from "./services/ssh-key";
|
||||
export * from "./services/user";
|
||||
export * from "./services/vault-provider";
|
||||
export * from "./services/volume-backups";
|
||||
export * from "./services/web-server-settings";
|
||||
export * from "./setup/config-paths";
|
||||
export * from "./setup/forward-auth-setup";
|
||||
export * from "./setup/monitoring-setup";
|
||||
export * from "./setup/postgres-setup";
|
||||
export * from "./setup/redis-setup";
|
||||
export * from "./setup/server-audit";
|
||||
export * from "./setup/server-setup";
|
||||
export * from "./setup/server-validate";
|
||||
@@ -101,6 +103,7 @@ export * from "./utils/docker/compose/volume";
|
||||
export * from "./utils/docker/domain";
|
||||
export * from "./utils/docker/types";
|
||||
export * from "./utils/docker/utils";
|
||||
export * from "./utils/vault";
|
||||
export * from "./utils/filesystem/directory";
|
||||
export * from "./utils/filesystem/ssh";
|
||||
export * from "./utils/git-branch-validation";
|
||||
|
||||
@@ -48,6 +48,7 @@ export const statements = {
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -74,6 +75,7 @@ export const enterpriseOnlyResources = new Set<string>([
|
||||
"logs",
|
||||
"monitoring",
|
||||
"auditLog",
|
||||
"vaultProvider",
|
||||
]);
|
||||
|
||||
export const ac = createAccessControl(statements);
|
||||
@@ -113,6 +115,7 @@ export const ownerRole = ac.newRole({
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -150,6 +153,7 @@ export const adminRole = ac.newRole({
|
||||
logs: ["read"],
|
||||
monitoring: ["read"],
|
||||
auditLog: ["read"],
|
||||
vaultProvider: ["read", "create", "update", "delete"],
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -192,4 +196,6 @@ export const memberRole = ac.newRole({
|
||||
notification: [],
|
||||
tag: ["read"],
|
||||
auditLog: [],
|
||||
// Members need provider/secret names for env editor autocomplete; values are never exposed
|
||||
vaultProvider: ["read"],
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
import { passkey } from "@better-auth/passkey";
|
||||
import { scim } from "@better-auth/scim";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import { betterAuth } from "better-auth";
|
||||
@@ -33,6 +34,7 @@ export const auth = betterAuth({
|
||||
apiKey({ enableMetadata: true, references: "user" }),
|
||||
sso(),
|
||||
twoFactor(),
|
||||
passkey(),
|
||||
organization({
|
||||
ac,
|
||||
roles: { owner: ownerRole, admin: adminRole, member: memberRole },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import { apiKey } from "@better-auth/api-key";
|
||||
import { passkey } from "@better-auth/passkey";
|
||||
import { scim } from "@better-auth/scim";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import * as bcrypt from "bcrypt";
|
||||
@@ -419,7 +420,7 @@ const createBetterAuth = () =>
|
||||
enableMetadata: true,
|
||||
references: "user",
|
||||
}),
|
||||
sso(),
|
||||
sso({ trustEmailVerified: true }),
|
||||
scim({
|
||||
beforeSCIMTokenGenerated: async ({ user }) => {
|
||||
const dbUser = await db.query.user.findFirst({
|
||||
@@ -435,6 +436,7 @@ const createBetterAuth = () =>
|
||||
},
|
||||
}),
|
||||
twoFactor(),
|
||||
passkey(),
|
||||
organization({
|
||||
ac,
|
||||
roles: {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
const IO_UNIT_FACTORS: Record<string, number> = {
|
||||
B: 1 / 1e6,
|
||||
kB: 1e-3,
|
||||
KB: 1e-3,
|
||||
MB: 1,
|
||||
GB: 1e3,
|
||||
TB: 1e6,
|
||||
KiB: 1024 / 1e6,
|
||||
MiB: 1024 ** 2 / 1e6,
|
||||
GiB: 1024 ** 3 / 1e6,
|
||||
TiB: 1024 ** 4 / 1e6,
|
||||
};
|
||||
|
||||
export const toMb = (value: number | string | undefined): number => {
|
||||
if (typeof value === "number") {
|
||||
return value;
|
||||
}
|
||||
if (!value) {
|
||||
return 0;
|
||||
}
|
||||
const parsed = Number.parseFloat(value);
|
||||
if (Number.isNaN(parsed)) {
|
||||
return 0;
|
||||
}
|
||||
const unit = value.replace(/[0-9.\s]/g, "");
|
||||
return parsed * (IO_UNIT_FACTORS[unit] ?? 1);
|
||||
};
|
||||
|
||||
export const parseIoToMb = (raw: string | undefined): number =>
|
||||
Number(toMb(raw).toFixed(6));
|
||||
|
||||
export const formatMb = (value: number | string | undefined): string => {
|
||||
const mb = toMb(value);
|
||||
if (mb >= 1000) {
|
||||
return `${(mb / 1000).toFixed(2)} GB`;
|
||||
}
|
||||
if (mb < 0.01 && mb > 0) {
|
||||
return `${(mb * 1000).toFixed(2)} kB`;
|
||||
}
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { promises } from "node:fs";
|
||||
import { OSUtils } from "node-os-utils";
|
||||
import { paths } from "../constants";
|
||||
import { parseIoToMb } from "./units";
|
||||
|
||||
export interface Container {
|
||||
BlockIO: string;
|
||||
@@ -28,13 +29,13 @@ export const recordAdvancedStats = async (
|
||||
});
|
||||
|
||||
await updateStatsFile(appName, "block", {
|
||||
readMb: stats.BlockIO.split(" ")[0],
|
||||
writeMb: stats.BlockIO.split(" ")[2],
|
||||
readMb: parseIoToMb(stats.BlockIO.split(" ")[0]),
|
||||
writeMb: parseIoToMb(stats.BlockIO.split(" ")[2]),
|
||||
});
|
||||
|
||||
await updateStatsFile(appName, "network", {
|
||||
inputMb: stats.NetIO.split(" ")[0],
|
||||
outputMb: stats.NetIO.split(" ")[2],
|
||||
inputMb: parseIoToMb(stats.NetIO.split(" ")[0]),
|
||||
outputMb: parseIoToMb(stats.NetIO.split(" ")[2]),
|
||||
});
|
||||
|
||||
if (appName === "dokploy") {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { ai } from "@dokploy/server/db/schema";
|
||||
import { ai, organization } from "@dokploy/server/db/schema";
|
||||
import { aiCustomProviderSchema } from "@dokploy/server/db/schema/ai";
|
||||
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { generateText, Output } from "ai";
|
||||
@@ -48,9 +49,74 @@ export const getAiSettingById = async (aiId: string) => {
|
||||
return aiSetting;
|
||||
};
|
||||
|
||||
type AiCustomProvider = z.infer<typeof aiCustomProviderSchema>;
|
||||
|
||||
const parseOrgMetadata = (metadata: string | null) => {
|
||||
try {
|
||||
const parsed = JSON.parse(metadata || "{}");
|
||||
return typeof parsed === "object" && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export const getCustomAiProviders = async (organizationId: string) => {
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(organization.id, organizationId),
|
||||
});
|
||||
const metadata = parseOrgMetadata(org?.metadata ?? null);
|
||||
const result = z
|
||||
.array(aiCustomProviderSchema)
|
||||
.safeParse(metadata.aiProviders);
|
||||
return result.success ? result.data : [];
|
||||
};
|
||||
|
||||
export const saveCustomAiProviders = async (
|
||||
organizationId: string,
|
||||
providers: AiCustomProvider[],
|
||||
) => {
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(organization.id, organizationId),
|
||||
});
|
||||
if (!org) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Organization not found",
|
||||
});
|
||||
}
|
||||
const metadata = parseOrgMetadata(org.metadata);
|
||||
metadata.aiProviders = providers;
|
||||
await db
|
||||
.update(organization)
|
||||
.set({ metadata: JSON.stringify(metadata) })
|
||||
.where(eq(organization.id, organizationId));
|
||||
return providers;
|
||||
};
|
||||
|
||||
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
|
||||
|
||||
export const saveAiSettings = async (organizationId: string, settings: any) => {
|
||||
const aiId = settings.aiId;
|
||||
|
||||
if (settings.apiUrl) {
|
||||
const customProviders = await getCustomAiProviders(organizationId);
|
||||
if (customProviders.length > 0) {
|
||||
const isAllowed = customProviders.some(
|
||||
(provider) =>
|
||||
normalizeApiUrl(provider.apiUrl) === normalizeApiUrl(settings.apiUrl),
|
||||
);
|
||||
if (!isAllowed) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message:
|
||||
"This API URL is not in your organization's allowed AI providers",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return db
|
||||
.insert(ai)
|
||||
.values({
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import { stringify } from "yaml";
|
||||
import type { z } from "zod";
|
||||
import { encodeBase64 } from "../utils/docker/utils";
|
||||
@@ -63,7 +64,7 @@ export const removeCertificateById = async (certificateId: string) => {
|
||||
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
|
||||
|
||||
if (certificate.serverId) {
|
||||
await execAsyncRemote(certificate.serverId, `rm -rf ${certDir}`);
|
||||
await execAsyncRemote(certificate.serverId, `rm -rf ${quote([certDir])}`);
|
||||
} else {
|
||||
await removeDirectoryIfExistsContent(certDir);
|
||||
}
|
||||
@@ -108,10 +109,10 @@ const createCertificateFiles = async (certificate: Certificate) => {
|
||||
const certificateData = encodeBase64(certificate.certificateData);
|
||||
const privateKey = encodeBase64(certificate.privateKey);
|
||||
const command = `
|
||||
mkdir -p ${certDir};
|
||||
echo "${certificateData}" | base64 -d > "${crtPath}";
|
||||
echo "${privateKey}" | base64 -d > "${keyPath}";
|
||||
echo "${yamlConfig}" > "${configFile}";
|
||||
mkdir -p ${quote([certDir])};
|
||||
echo "${certificateData}" | base64 -d > ${quote([crtPath])};
|
||||
echo "${privateKey}" | base64 -d > ${quote([keyPath])};
|
||||
echo "${yamlConfig}" > ${quote([configFile])};
|
||||
`;
|
||||
|
||||
await execAsyncRemote(certificate.serverId, command);
|
||||
|
||||
@@ -83,8 +83,17 @@ export const findDeploymentById = async (deploymentId: string) => {
|
||||
const deployment = await db.query.deployments.findFirst({
|
||||
where: eq(deployments.deploymentId, deploymentId),
|
||||
with: {
|
||||
application: true,
|
||||
compose: true,
|
||||
application: {
|
||||
columns: {
|
||||
applicationId: true,
|
||||
appName: true,
|
||||
name: true,
|
||||
serverId: true,
|
||||
},
|
||||
},
|
||||
compose: {
|
||||
columns: { composeId: true, appName: true, name: true, serverId: true },
|
||||
},
|
||||
schedule: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { quote } from "shell-quote";
|
||||
import { CONTAINER_FILE_SIZE_LIMIT } from "./docker";
|
||||
|
||||
export interface DockerVolume {
|
||||
Name: string;
|
||||
Driver: string;
|
||||
Scope: string;
|
||||
Mountpoint: string;
|
||||
Labels: string;
|
||||
Availability?: string;
|
||||
Group?: string;
|
||||
Links?: string;
|
||||
Size?: string;
|
||||
Status?: string;
|
||||
}
|
||||
|
||||
const VOLUME_MOUNT = "/__volume";
|
||||
|
||||
export const getVolumes = async (serverId?: string) => {
|
||||
try {
|
||||
const command = "docker volume ls --format '{{json .}}'";
|
||||
const { stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
return stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line) as DockerVolume);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getVolumesSize = async (serverId?: string) => {
|
||||
try {
|
||||
const command = "docker system df -v --format '{{json .}}'";
|
||||
const { stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
const diskUsage = JSON.parse(stdout.trim());
|
||||
return ((diskUsage?.Volumes ?? []) as Partial<DockerVolume>[])
|
||||
.filter((volume) => volume?.Name)
|
||||
.map((volume) => ({
|
||||
name: volume.Name as string,
|
||||
size: volume.Size ?? null,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getVolumeConfig = async (
|
||||
volumeName: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker volume inspect ${quote([String(volumeName ?? "")])}`;
|
||||
const { stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
return JSON.parse(stdout.trim())[0];
|
||||
};
|
||||
|
||||
export const removeVolume = async (volumeName: string, serverId?: string) => {
|
||||
const command = `docker volume rm ${quote([String(volumeName ?? "")])}`;
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
};
|
||||
|
||||
export const listVolumeFiles = async (
|
||||
volumeName: string,
|
||||
path: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}:ro`])} busybox ls -1Ap ${quote([`${VOLUME_MOUNT}${path}`])}`;
|
||||
const { stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
return stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((entry) => ({
|
||||
name: entry.endsWith("/") ? entry.slice(0, -1) : entry,
|
||||
isDirectory: entry.endsWith("/"),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
|
||||
export const readVolumeFile = async (
|
||||
volumeName: string,
|
||||
filePath: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}:ro`])} busybox cat ${quote([`${VOLUME_MOUNT}${filePath}`])} | head -c ${CONTAINER_FILE_SIZE_LIMIT + 1} | base64 | tr -d '\\n'`;
|
||||
const { stdout, stderr } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
if (stderr && !stdout) {
|
||||
throw new Error(stderr);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(stdout.trim(), "base64");
|
||||
return {
|
||||
content: buffer.subarray(0, CONTAINER_FILE_SIZE_LIMIT).toString("base64"),
|
||||
truncated: buffer.byteLength > CONTAINER_FILE_SIZE_LIMIT,
|
||||
};
|
||||
};
|
||||
|
||||
export const writeVolumeFile = async (
|
||||
volumeName: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const base64Content = Buffer.from(content, "utf8").toString("base64");
|
||||
if (base64Content.length > CONTAINER_FILE_SIZE_LIMIT * 2) {
|
||||
throw new Error("File is too large to save from the editor (max 512KB)");
|
||||
}
|
||||
|
||||
const innerCommand = `printf '%s' ${base64Content} | base64 -d > ${quote([`${VOLUME_MOUNT}${filePath}`])}`;
|
||||
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}`])} busybox sh -c ${quote([innerCommand])}`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteVolumeFile = async (
|
||||
volumeName: string,
|
||||
path: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker run --rm -v ${quote([`${volumeName}:${VOLUME_MOUNT}`])} busybox rm -rf ${quote([`${VOLUME_MOUNT}${path}`])}`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
ExecError,
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
@@ -108,6 +109,9 @@ export const getContainersByAppNameMatch = async (
|
||||
serverId?: string,
|
||||
) => {
|
||||
try {
|
||||
if (appType === "stack") {
|
||||
return await getStackTaskContainers(appName, serverId);
|
||||
}
|
||||
let result: string[] = [];
|
||||
const cmd =
|
||||
"docker ps -a --format 'CONTAINER ID : {{.ID}} | Name: {{.Names}} | State: {{.State}} | Status: {{.Status}}'";
|
||||
@@ -166,6 +170,66 @@ export const getContainersByAppNameMatch = async (
|
||||
return [];
|
||||
};
|
||||
|
||||
const getStackTaskContainers = async (appName: string, serverId?: string) => {
|
||||
try {
|
||||
const divider = "__DOKPLOY_DIVIDER__";
|
||||
const tasksCommand = `docker stack ps ${appName} --no-trunc --filter "desired-state=running" --format 'TASK : {{.ID}} | Name: {{.Name}} | Node: {{.Node}} | CurrentState: {{.CurrentState}} | Error: {{.Error}}'`;
|
||||
const inspectCommand = `docker stack ps ${appName} -q --no-trunc --filter "desired-state=running" | xargs -r docker inspect --format '{{if .Status.ContainerStatus}}TASK : {{.ID}} | ContainerId: {{.Status.ContainerStatus.ContainerID}}{{end}}' 2>/dev/null`;
|
||||
const command = `${tasksCommand} && echo "${divider}" && (${inspectCommand} || true)`;
|
||||
|
||||
let stdout = "";
|
||||
|
||||
if (serverId) {
|
||||
const result = await execAsyncRemote(serverId, command);
|
||||
stdout = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(command);
|
||||
stdout = result.stdout;
|
||||
}
|
||||
|
||||
if (!stdout) return [];
|
||||
|
||||
const [tasksSection = "", inspectSection = ""] = stdout.split(divider);
|
||||
|
||||
const containerIdByTask = new Map<string, string>();
|
||||
for (const line of inspectSection.trim().split("\n")) {
|
||||
const parts = line.split(" | ");
|
||||
const taskId = parts[0]?.replace("TASK : ", "").trim();
|
||||
const containerId = parts[1]?.replace("ContainerId: ", "").trim();
|
||||
if (taskId && containerId) {
|
||||
containerIdByTask.set(taskId, containerId);
|
||||
}
|
||||
}
|
||||
|
||||
const containers = [];
|
||||
|
||||
for (const line of tasksSection.trim().split("\n")) {
|
||||
if (!line) continue;
|
||||
const parts = line.split(" | ");
|
||||
const taskId = parts[0]?.replace("TASK : ", "").trim() ?? "";
|
||||
const name = parts[1]?.replace("Name: ", "").trim() ?? "";
|
||||
const node = parts[2]?.replace("Node: ", "").trim() ?? "";
|
||||
const currentState = parts[3]
|
||||
? parts[3].replace("CurrentState: ", "").trim()
|
||||
: "";
|
||||
const error = parts[4] ? parts[4].replace("Error: ", "").trim() : "";
|
||||
const containerId = containerIdByTask.get(taskId) ?? "";
|
||||
|
||||
containers.push({
|
||||
containerId: containerId.slice(0, 12),
|
||||
name: `${name}.${taskId}`,
|
||||
node,
|
||||
state: currentState.split(" ")[0]?.toLowerCase() ?? "No state",
|
||||
status: error ? `${currentState} (${error})` : currentState,
|
||||
});
|
||||
}
|
||||
|
||||
return containers;
|
||||
} catch {}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
export const getStackContainersByAppName = async (
|
||||
appName: string,
|
||||
serverId?: string,
|
||||
@@ -175,7 +239,6 @@ export const getStackContainersByAppName = async (
|
||||
|
||||
const command = `docker stack ps ${appName} --no-trunc --format 'CONTAINER ID : {{.ID}} | Name: {{.Name}} | State: {{.DesiredState}} | Node: {{.Node}} | CurrentState: {{.CurrentState}} | Error: {{.Error}}'`;
|
||||
|
||||
console.log("command ", command);
|
||||
if (serverId) {
|
||||
const { stdout, stderr } = await execAsyncRemote(serverId, command);
|
||||
|
||||
@@ -698,3 +761,113 @@ export const uploadFileToContainer = async (
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const CONTAINER_FILE_SIZE_LIMIT = 512 * 1024;
|
||||
|
||||
const NO_SHELL_UTILITIES_ERROR =
|
||||
"This container image has no shell utilities and its filesystem is not accessible from the Dokploy host.";
|
||||
|
||||
const isMissingBinaryError = (error: unknown) =>
|
||||
error instanceof ExecError &&
|
||||
(error.exitCode === 126 ||
|
||||
error.exitCode === 127 ||
|
||||
!!error.stderr?.includes("executable file not found"));
|
||||
|
||||
export const listContainerFiles = async (
|
||||
containerId: string,
|
||||
path: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker exec ${quote([containerId])} ls -1Ap ${quote([path])}`;
|
||||
let stdout: string;
|
||||
try {
|
||||
({ stdout } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command));
|
||||
} catch (error) {
|
||||
if (isMissingBinaryError(error)) {
|
||||
throw new Error(NO_SHELL_UTILITIES_ERROR);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((entry) => ({
|
||||
name: entry.endsWith("/") ? entry.slice(0, -1) : entry,
|
||||
isDirectory: entry.endsWith("/"),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
};
|
||||
|
||||
export const readContainerFile = async (
|
||||
containerId: string,
|
||||
filePath: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker exec ${quote([containerId])} cat ${quote([filePath])} | head -c ${CONTAINER_FILE_SIZE_LIMIT + 1} | base64 | tr -d '\\n'`;
|
||||
const { stdout, stderr } = serverId
|
||||
? await execAsyncRemote(serverId, command)
|
||||
: await execAsync(command);
|
||||
|
||||
if (stderr && !stdout) {
|
||||
if (stderr.includes("executable file not found")) {
|
||||
throw new Error(NO_SHELL_UTILITIES_ERROR);
|
||||
}
|
||||
throw new Error(stderr);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(stdout.trim(), "base64");
|
||||
return {
|
||||
content: buffer.subarray(0, CONTAINER_FILE_SIZE_LIMIT).toString("base64"),
|
||||
truncated: buffer.byteLength > CONTAINER_FILE_SIZE_LIMIT,
|
||||
};
|
||||
};
|
||||
|
||||
export const writeContainerFile = async (
|
||||
containerId: string,
|
||||
filePath: string,
|
||||
content: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const base64Content = Buffer.from(content, "utf8").toString("base64");
|
||||
if (base64Content.length > CONTAINER_FILE_SIZE_LIMIT * 2) {
|
||||
throw new Error("File is too large to save from the editor (max 512KB)");
|
||||
}
|
||||
|
||||
const tempPath = `/tmp/dokploy-edit-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const command = `printf '%s' ${quote([base64Content])} | base64 -d > ${quote([tempPath])} && docker cp ${quote([tempPath])} ${quote([`${containerId}:${filePath}`])}; status=$?; rm -f ${quote([tempPath])}; exit $status`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteContainerFile = async (
|
||||
containerId: string,
|
||||
path: string,
|
||||
serverId?: string,
|
||||
) => {
|
||||
const command = `docker exec ${quote([containerId])} rm -rf ${quote([path])}`;
|
||||
|
||||
try {
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingBinaryError(error)) {
|
||||
throw new Error(NO_SHELL_UTILITIES_ERROR);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,7 +80,9 @@ export const findDomainById = async (domainId: string) => {
|
||||
const domain = await db.query.domains.findFirst({
|
||||
where: eq(domains.domainId, domainId),
|
||||
with: {
|
||||
application: true,
|
||||
application: {
|
||||
columns: { applicationId: true, appName: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!domain) {
|
||||
@@ -96,7 +98,9 @@ export const findDomainsByApplicationId = async (applicationId: string) => {
|
||||
const domainsArray = await db.query.domains.findMany({
|
||||
where: eq(domains.applicationId, applicationId),
|
||||
with: {
|
||||
application: true,
|
||||
application: {
|
||||
columns: { applicationId: true, appName: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -107,7 +111,9 @@ export const findDomainsByComposeId = async (composeId: string) => {
|
||||
const domainsArray = await db.query.domains.findMany({
|
||||
where: eq(domains.composeId, composeId),
|
||||
with: {
|
||||
compose: true,
|
||||
compose: {
|
||||
columns: { composeId: true, appName: true, name: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ export const findEnvironmentById = async (environmentId: string) => {
|
||||
composeStatus: true,
|
||||
description: true,
|
||||
serverId: true,
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
libsql: {
|
||||
@@ -201,18 +202,56 @@ export const findEnvironmentById = async (environmentId: string) => {
|
||||
};
|
||||
|
||||
export const findEnvironmentsByProjectId = async (projectId: string) => {
|
||||
const serviceColumns = {
|
||||
name: true,
|
||||
description: true,
|
||||
appName: true,
|
||||
createdAt: true,
|
||||
serverId: true,
|
||||
applicationStatus: true,
|
||||
} as const;
|
||||
|
||||
const projectEnvironments = await db.query.environments.findMany({
|
||||
where: eq(environments.projectId, projectId),
|
||||
orderBy: asc(environments.createdAt),
|
||||
with: {
|
||||
applications: true,
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
mysql: true,
|
||||
postgres: true,
|
||||
redis: true,
|
||||
compose: true,
|
||||
libsql: true,
|
||||
applications: {
|
||||
columns: { ...serviceColumns, applicationId: true, icon: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
mariadb: {
|
||||
columns: { ...serviceColumns, mariadbId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
mongo: {
|
||||
columns: { ...serviceColumns, mongoId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
mysql: {
|
||||
columns: { ...serviceColumns, mysqlId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
postgres: {
|
||||
columns: { ...serviceColumns, postgresId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
redis: {
|
||||
columns: { ...serviceColumns, redisId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
compose: {
|
||||
columns: {
|
||||
...serviceColumns,
|
||||
composeId: true,
|
||||
composeStatus: true,
|
||||
icon: true,
|
||||
},
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
libsql: {
|
||||
columns: { ...serviceColumns, libsqlId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
project: true,
|
||||
},
|
||||
columns: {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq, type SQL, sql } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
|
||||
export type Mount = typeof mounts.$inferSelect;
|
||||
@@ -106,81 +107,29 @@ export const createFileMount = async (mountId: string) => {
|
||||
};
|
||||
|
||||
export const findMountById = async (mountId: string) => {
|
||||
const serviceWith = {
|
||||
columns: { serverId: true, appName: true },
|
||||
with: {
|
||||
environment: {
|
||||
columns: {},
|
||||
with: {
|
||||
project: { columns: { organizationId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const mount = await db.query.mounts.findFirst({
|
||||
where: eq(mounts.mountId, mountId),
|
||||
with: {
|
||||
application: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
compose: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
libsql: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mariadb: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mongo: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
mysql: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
postgres: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
redis: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
application: serviceWith,
|
||||
compose: serviceWith,
|
||||
libsql: serviceWith,
|
||||
mariadb: serviceWith,
|
||||
mongo: serviceWith,
|
||||
mysql: serviceWith,
|
||||
postgres: serviceWith,
|
||||
redis: serviceWith,
|
||||
},
|
||||
});
|
||||
if (!mount) {
|
||||
@@ -317,7 +266,7 @@ export const updateFileMount = async (mountId: string) => {
|
||||
try {
|
||||
const serverId = await getServerId(mount);
|
||||
const encodedContent = encodeBase64(mount.content || "");
|
||||
const command = `echo "${encodedContent}" | base64 -d > ${fullPath}`;
|
||||
const command = `echo "${encodedContent}" | base64 -d > ${quote([fullPath])}`;
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
@@ -337,7 +286,7 @@ export const deleteFileMount = async (mountId: string) => {
|
||||
try {
|
||||
const serverId = await getServerId(mount);
|
||||
if (serverId) {
|
||||
const command = `rm -rf ${fullPath}`;
|
||||
const command = `rm -rf ${quote([fullPath])}`;
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await removeFileOrDirectory(fullPath);
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { type apiCreateNetwork, network } from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
import type { ApplicationNested } from "../utils/builders";
|
||||
import { getRemoteDocker } from "../utils/servers/remote-docker";
|
||||
|
||||
const RESERVED_NETWORKS = [
|
||||
"bridge",
|
||||
"host",
|
||||
"none",
|
||||
"ingress",
|
||||
"docker_gwbridge",
|
||||
"dokploy-network",
|
||||
];
|
||||
|
||||
type DockerNetworkInfo = {
|
||||
Name: string;
|
||||
Driver: string;
|
||||
Internal?: boolean;
|
||||
Attachable?: boolean;
|
||||
Ingress?: boolean;
|
||||
EnableIPv4?: boolean;
|
||||
EnableIPv6?: boolean;
|
||||
Options?: Record<string, string> | null;
|
||||
IPAM?: {
|
||||
Driver?: string;
|
||||
Config?: Array<{
|
||||
Subnet?: string;
|
||||
Gateway?: string;
|
||||
IPRange?: string;
|
||||
}> | null;
|
||||
};
|
||||
};
|
||||
|
||||
const parseMtu = (value: string | undefined) => {
|
||||
const mtu = Number.parseInt(value ?? "", 10);
|
||||
return Number.isNaN(mtu) ? null : mtu;
|
||||
};
|
||||
|
||||
const isImportableDockerNetwork = (dockerNetwork: DockerNetworkInfo) =>
|
||||
!RESERVED_NETWORKS.includes(dockerNetwork.Name) &&
|
||||
!dockerNetwork.Ingress &&
|
||||
(dockerNetwork.Driver === "bridge" || dockerNetwork.Driver === "overlay");
|
||||
|
||||
const mapDockerNetworkToRow = (
|
||||
dockerNetwork: DockerNetworkInfo,
|
||||
organizationId: string,
|
||||
serverId: string | null,
|
||||
) => ({
|
||||
name: dockerNetwork.Name,
|
||||
driver: dockerNetwork.Driver as "bridge" | "overlay",
|
||||
internal: dockerNetwork.Internal ?? false,
|
||||
attachable: dockerNetwork.Attachable ?? false,
|
||||
// Older daemons don't report EnableIPv4; IPv4 is always on there
|
||||
enableIPv4: dockerNetwork.EnableIPv4 ?? true,
|
||||
enableIPv6: dockerNetwork.EnableIPv6 ?? false,
|
||||
mtu: parseMtu(dockerNetwork.Options?.["com.docker.network.driver.mtu"]),
|
||||
ipam: {
|
||||
driver: dockerNetwork.IPAM?.Driver,
|
||||
config: (dockerNetwork.IPAM?.Config ?? []).map((c) => ({
|
||||
subnet: c.Subnet,
|
||||
gateway: c.Gateway,
|
||||
ipRange: c.IPRange,
|
||||
})),
|
||||
},
|
||||
organizationId,
|
||||
serverId,
|
||||
});
|
||||
|
||||
const findNetworksByServer = async (
|
||||
organizationId: string,
|
||||
serverId: string | null,
|
||||
) => {
|
||||
return await db.query.network.findMany({
|
||||
where: and(
|
||||
eq(network.organizationId, organizationId),
|
||||
serverId ? eq(network.serverId, serverId) : isNull(network.serverId),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const findNetworksToSync = async (
|
||||
organizationId: string,
|
||||
serverId: string | null,
|
||||
) => {
|
||||
if (IS_CLOUD && !serverId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Server is required",
|
||||
});
|
||||
}
|
||||
|
||||
const docker = await getRemoteDocker(serverId);
|
||||
let dockerNetworks: DockerNetworkInfo[] = [];
|
||||
try {
|
||||
dockerNetworks = (await docker.listNetworks()) as DockerNetworkInfo[];
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to list Docker networks",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
const existing = await findNetworksByServer(organizationId, serverId);
|
||||
const existingNames = new Set(existing.map((row) => row.name));
|
||||
const dockerNames = new Set(dockerNetworks.map((d) => d.Name));
|
||||
|
||||
const importable = dockerNetworks
|
||||
.filter(
|
||||
(dockerNetwork) =>
|
||||
isImportableDockerNetwork(dockerNetwork) &&
|
||||
!existingNames.has(dockerNetwork.Name),
|
||||
)
|
||||
.map((dockerNetwork) => ({
|
||||
name: dockerNetwork.Name,
|
||||
driver: dockerNetwork.Driver,
|
||||
internal: dockerNetwork.Internal ?? false,
|
||||
attachable: dockerNetwork.Attachable ?? false,
|
||||
subnets: (dockerNetwork.IPAM?.Config ?? [])
|
||||
.map((c) => c.Subnet)
|
||||
.filter((s): s is string => !!s),
|
||||
}));
|
||||
|
||||
// Rows in Dokploy whose network no longer exists in Docker
|
||||
const missing = existing
|
||||
.filter((row) => !dockerNames.has(row.name))
|
||||
.map((row) => ({ networkId: row.networkId, name: row.name }));
|
||||
|
||||
return { importable, missing };
|
||||
};
|
||||
|
||||
export const importDockerNetworks = async (
|
||||
organizationId: string,
|
||||
serverId: string | null,
|
||||
names: string[],
|
||||
) => {
|
||||
if (IS_CLOUD && !serverId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Server is required",
|
||||
});
|
||||
}
|
||||
|
||||
const docker = await getRemoteDocker(serverId);
|
||||
const existing = await findNetworksByServer(organizationId, serverId);
|
||||
const existingNames = new Set(existing.map((row) => row.name));
|
||||
|
||||
const imported: string[] = [];
|
||||
const errors: { name: string; error: string }[] = [];
|
||||
|
||||
for (const name of names) {
|
||||
if (existingNames.has(name)) {
|
||||
errors.push({ name, error: "Already imported" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const info = (await docker
|
||||
.getNetwork(name)
|
||||
.inspect()) as DockerNetworkInfo;
|
||||
if (!isImportableDockerNetwork(info)) {
|
||||
errors.push({ name, error: "Network is reserved or not supported" });
|
||||
continue;
|
||||
}
|
||||
await db
|
||||
.insert(network)
|
||||
.values(mapDockerNetworkToRow(info, organizationId, serverId));
|
||||
imported.push(name);
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
name,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to inspect network",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { imported, errors };
|
||||
};
|
||||
|
||||
export const findNetworkById = async (networkId: string) => {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(network)
|
||||
.where(eq(network.networkId, networkId))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
return row;
|
||||
};
|
||||
|
||||
export const createNetwork = async (
|
||||
input: z.infer<typeof apiCreateNetwork>,
|
||||
organizationId: string,
|
||||
) => {
|
||||
if (IS_CLOUD) {
|
||||
if (!input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Server is required",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(network)
|
||||
.values({
|
||||
...input,
|
||||
organizationId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!row) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create network",
|
||||
});
|
||||
}
|
||||
|
||||
await createDockerNetworkFromRow(row);
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
return created;
|
||||
};
|
||||
|
||||
const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
|
||||
const ipam = row.ipam ?? {};
|
||||
const ipamConfig = (ipam.config ?? [])
|
||||
.map((c) => {
|
||||
const entry: Record<string, string> = {};
|
||||
if (c.subnet) entry.Subnet = c.subnet;
|
||||
if (c.gateway) entry.Gateway = c.gateway;
|
||||
if (c.ipRange) entry.IPRange = c.ipRange;
|
||||
return entry;
|
||||
})
|
||||
.filter((e) => Object.keys(e).length > 0);
|
||||
|
||||
const docker = await getRemoteDocker(row.serverId ?? null);
|
||||
try {
|
||||
await docker.createNetwork({
|
||||
Name: row.name,
|
||||
Driver: row.driver,
|
||||
CheckDuplicate: true,
|
||||
Internal: row.internal,
|
||||
Attachable: row.attachable,
|
||||
// EnableIPv4 is missing from dockerode's types but supported by
|
||||
// the daemon (API >= 1.47); the body is sent as-is
|
||||
EnableIPv4: row.enableIPv4,
|
||||
EnableIPv6: row.enableIPv6,
|
||||
Options: row.mtu
|
||||
? { "com.docker.network.driver.mtu": String(row.mtu) }
|
||||
: undefined,
|
||||
IPAM: {
|
||||
Driver: ipam.driver || "default",
|
||||
Config: ipamConfig.length > 0 ? ipamConfig : undefined,
|
||||
},
|
||||
} as Parameters<typeof docker.createNetwork>[0]);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to create Docker network",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const recreateNetwork = async (networkId: string) => {
|
||||
const row = await findNetworkById(networkId);
|
||||
await createDockerNetworkFromRow(row);
|
||||
return row;
|
||||
};
|
||||
|
||||
export const resolveServiceNetworks = async (
|
||||
application: Partial<ApplicationNested>,
|
||||
) => {
|
||||
if (application.networkSwarm) {
|
||||
return application.networkSwarm;
|
||||
}
|
||||
|
||||
const { networkIds, detachDokployNetwork } = application;
|
||||
const rows =
|
||||
networkIds && networkIds.length > 0
|
||||
? await db.query.network.findMany({
|
||||
where: and(
|
||||
inArray(network.networkId, networkIds),
|
||||
eq(network.driver, "overlay"),
|
||||
),
|
||||
columns: { name: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const networks = detachDokployNetwork ? [] : ["dokploy-network"];
|
||||
for (const row of rows) {
|
||||
networks.push(row.name);
|
||||
}
|
||||
|
||||
return networks.map((name) => ({ Target: name }));
|
||||
};
|
||||
|
||||
export const inspectNetwork = async (networkId: string) => {
|
||||
const row = await findNetworkById(networkId);
|
||||
|
||||
const docker = await getRemoteDocker(row.serverId ?? null);
|
||||
try {
|
||||
return await docker.getNetwork(row.name).inspect();
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to inspect Docker network",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const removeNetwork = async (networkId: string) => {
|
||||
const row = await findNetworkById(networkId);
|
||||
|
||||
const docker = await getRemoteDocker(row.serverId ?? null);
|
||||
try {
|
||||
await docker.getNetwork(row.name).remove();
|
||||
} catch (error) {
|
||||
const statusCode = (error as { statusCode?: number })?.statusCode;
|
||||
if (statusCode !== 404) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to remove Docker network",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const [deleted] = await db
|
||||
.delete(network)
|
||||
.where(eq(network.networkId, networkId))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
return deleted;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { quote } from "shell-quote";
|
||||
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
||||
import { cloneBitbucketRepository } from "../utils/providers/bitbucket";
|
||||
import { cloneGitRepository } from "../utils/providers/git";
|
||||
@@ -85,7 +86,7 @@ export const readPatchRepoDirectory = async (
|
||||
serverId?: string | null,
|
||||
): Promise<DirectoryEntry[]> => {
|
||||
// Use git ls-tree to get tracked files only
|
||||
const command = `cd "${repoPath}" && git ls-tree -r --name-only HEAD`;
|
||||
const command = `cd ${quote([repoPath])} && git ls-tree -r --name-only HEAD`;
|
||||
|
||||
let stdout: string;
|
||||
try {
|
||||
@@ -168,7 +169,7 @@ export const readPatchRepoFile = async (
|
||||
const repoPath = join(PATCH_REPOS_PATH, type, application.appName);
|
||||
const fullPath = join(repoPath, filePath);
|
||||
|
||||
const command = `cat "${fullPath}"`;
|
||||
const command = `cat ${quote([fullPath])}`;
|
||||
|
||||
if (serverId) {
|
||||
const result = await execAsyncRemote(serverId, command);
|
||||
|
||||
@@ -30,12 +30,8 @@ export const finPortById = async (portId: string) => {
|
||||
where: eq(ports.portId, portId),
|
||||
with: {
|
||||
application: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
columns: {
|
||||
applicationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ import { manageDomain } from "../utils/traefik/domain";
|
||||
import { findApplicationById } from "./application";
|
||||
import { removeDeploymentsByPreviewDeploymentId } from "./deployment";
|
||||
import { createDomain } from "./domain";
|
||||
import { type Github, getIssueComment } from "./github";
|
||||
import { findGithubById, getIssueComment } from "./github";
|
||||
import { getWebServerSettings } from "./web-server-settings";
|
||||
|
||||
export type PreviewDeployment = typeof previewDeployments.$inferSelect;
|
||||
@@ -142,7 +142,17 @@ export const createPreviewDeployment = async (
|
||||
org?.ownerId || "",
|
||||
);
|
||||
|
||||
const octokit = authGithub(application?.github as Github);
|
||||
if (!application.githubId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Github Account not configured correctly",
|
||||
});
|
||||
}
|
||||
|
||||
// `findApplicationById` redacts `githubPrivateKey` from the `github`
|
||||
// relation, so the provider must be refetched to authenticate.
|
||||
const githubProvider = await findGithubById(application.githubId);
|
||||
const octokit = authGithub(githubProvider);
|
||||
|
||||
const runningComment = getIssueComment(
|
||||
application.name,
|
||||
|
||||
@@ -48,19 +48,60 @@ export const createProject = async (
|
||||
};
|
||||
|
||||
export const findProjectById = async (projectId: string) => {
|
||||
const serviceColumns = {
|
||||
name: true,
|
||||
description: true,
|
||||
appName: true,
|
||||
createdAt: true,
|
||||
serverId: true,
|
||||
applicationStatus: true,
|
||||
} as const;
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(projects.projectId, projectId),
|
||||
with: {
|
||||
environments: {
|
||||
with: {
|
||||
applications: true,
|
||||
compose: true,
|
||||
libsql: true,
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
mysql: true,
|
||||
postgres: true,
|
||||
redis: true,
|
||||
applications: {
|
||||
columns: {
|
||||
...serviceColumns,
|
||||
applicationId: true,
|
||||
icon: true,
|
||||
},
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
compose: {
|
||||
columns: {
|
||||
...serviceColumns,
|
||||
composeId: true,
|
||||
composeStatus: true,
|
||||
},
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
libsql: {
|
||||
columns: { ...serviceColumns, libsqlId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
mariadb: {
|
||||
columns: { ...serviceColumns, mariadbId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
mongo: {
|
||||
columns: { ...serviceColumns, mongoId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
mysql: {
|
||||
columns: { ...serviceColumns, mysqlId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
postgres: {
|
||||
columns: { ...serviceColumns, postgresId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
redis: {
|
||||
columns: { ...serviceColumns, redisId: true },
|
||||
with: { server: { columns: { name: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
projectTags: {
|
||||
@@ -107,27 +148,49 @@ export const updateProjectById = async (
|
||||
|
||||
export const validUniqueServerAppName = async (appName: string) => {
|
||||
const query = await db.query.environments.findMany({
|
||||
columns: { environmentId: true },
|
||||
with: {
|
||||
applications: {
|
||||
where: eq(applications.appName, appName),
|
||||
columns: {
|
||||
appName: true,
|
||||
},
|
||||
},
|
||||
libsql: {
|
||||
where: eq(libsql.appName, appName),
|
||||
columns: {
|
||||
appName: true,
|
||||
},
|
||||
},
|
||||
mariadb: {
|
||||
where: eq(mariadb.appName, appName),
|
||||
columns: {
|
||||
appName: true,
|
||||
},
|
||||
},
|
||||
mongo: {
|
||||
where: eq(mongo.appName, appName),
|
||||
columns: {
|
||||
appName: true,
|
||||
},
|
||||
},
|
||||
mysql: {
|
||||
where: eq(mysql.appName, appName),
|
||||
columns: {
|
||||
appName: true,
|
||||
},
|
||||
},
|
||||
postgres: {
|
||||
where: eq(postgres.appName, appName),
|
||||
columns: {
|
||||
appName: true,
|
||||
},
|
||||
},
|
||||
redis: {
|
||||
where: eq(redis.appName, appName),
|
||||
columns: {
|
||||
appName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,9 +17,12 @@ import {
|
||||
} from "../utils/docker/utils";
|
||||
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
|
||||
import { getRemoteDocker } from "../utils/servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../utils/vault";
|
||||
import { type Application, findApplicationById } from "./application";
|
||||
import { findDeploymentById } from "./deployment";
|
||||
import type { Environment } from "./environment";
|
||||
import type { Mount } from "./mount";
|
||||
import { resolveServiceNetworks } from "./network";
|
||||
import type { Port } from "./port";
|
||||
import type { Project } from "./project";
|
||||
import {
|
||||
@@ -105,19 +108,7 @@ export const findRollbackById = async (rollbackId: string) => {
|
||||
const result = await db.query.rollbacks.findFirst({
|
||||
where: eq(rollbacks.rollbackId, rollbackId),
|
||||
with: {
|
||||
deployment: {
|
||||
with: {
|
||||
application: {
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
project: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
deployment: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -213,7 +204,7 @@ const rollbackApplication = async (
|
||||
image: string,
|
||||
serverId?: string | null,
|
||||
fullContext?: Application & {
|
||||
environment: {
|
||||
environment: Environment & {
|
||||
project: Project;
|
||||
};
|
||||
mounts: Mount[];
|
||||
@@ -225,7 +216,9 @@ const rollbackApplication = async (
|
||||
throw new Error("Full context is required for rollback");
|
||||
}
|
||||
|
||||
const rollbackRegistry = fullContext.rollbackRegistry ?? undefined;
|
||||
const resolvedContext = await withResolvedVaultRefs(fullContext);
|
||||
|
||||
const rollbackRegistry = resolvedContext.rollbackRegistry ?? undefined;
|
||||
|
||||
// Ensure Docker daemon is authenticated with the rollback registry
|
||||
// before updating the swarm service. The authconfig in CreateServiceOptions
|
||||
@@ -246,7 +239,7 @@ const rollbackApplication = async (
|
||||
cpuReservation,
|
||||
command,
|
||||
ports,
|
||||
} = fullContext;
|
||||
} = resolvedContext;
|
||||
|
||||
const resources = calculateResources({
|
||||
memoryLimit,
|
||||
@@ -257,6 +250,10 @@ const rollbackApplication = async (
|
||||
|
||||
const volumesMount = generateVolumeMounts(mounts);
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(
|
||||
resolvedContext as Parameters<typeof resolveServiceNetworks>[0],
|
||||
);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -265,16 +262,16 @@ const rollbackApplication = async (
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
Ulimits,
|
||||
} = generateConfigContainer(
|
||||
fullContext as Parameters<typeof generateConfigContainer>[0],
|
||||
resolvedContext as Parameters<typeof generateConfigContainer>[0],
|
||||
);
|
||||
|
||||
const bindsMount = generateBindMounts(mounts);
|
||||
const envVariables = prepareEnvironmentVariables(
|
||||
env,
|
||||
fullContext.environment.project.env,
|
||||
resolvedContext.environment.project.env,
|
||||
resolvedContext.environment.env,
|
||||
);
|
||||
|
||||
let rollbackImage = image;
|
||||
@@ -304,7 +301,7 @@ const rollbackApplication = async (
|
||||
...(Ulimits && { Ulimits }),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -81,6 +81,11 @@ export const findScheduleById = async (scheduleId: string) => {
|
||||
where: eq(schedules.scheduleId, scheduleId),
|
||||
with: {
|
||||
application: {
|
||||
columns: {
|
||||
applicationId: true,
|
||||
appName: true,
|
||||
serverId: true,
|
||||
},
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
|
||||
@@ -100,15 +100,16 @@ export const deleteServer = async (serverId: string) => {
|
||||
export const haveActiveServices = async (serverId: string) => {
|
||||
const currentServer = await db.query.server.findFirst({
|
||||
where: eq(server.serverId, serverId),
|
||||
columns: { serverId: true },
|
||||
with: {
|
||||
applications: true,
|
||||
compose: true,
|
||||
libsql: true,
|
||||
mariadb: true,
|
||||
mongo: true,
|
||||
mysql: true,
|
||||
postgres: true,
|
||||
redis: true,
|
||||
applications: { columns: { applicationId: true } },
|
||||
compose: { columns: { composeId: true } },
|
||||
libsql: { columns: { libsqlId: true } },
|
||||
mariadb: { columns: { mariadbId: true } },
|
||||
mongo: { columns: { mongoId: true } },
|
||||
mysql: { columns: { mysqlId: true } },
|
||||
postgres: { columns: { postgresId: true } },
|
||||
redis: { columns: { redisId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ import {
|
||||
apikey,
|
||||
invitation,
|
||||
member,
|
||||
passkey,
|
||||
user,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import * as bcrypt from "bcrypt";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { auth } from "../lib/auth";
|
||||
|
||||
export type User = typeof user.$inferSelect;
|
||||
@@ -396,6 +397,21 @@ export const findMemberById = async (
|
||||
return result;
|
||||
};
|
||||
|
||||
export const findPasskeysByUserId = async (userId: string) => {
|
||||
return db.query.passkey.findMany({
|
||||
where: eq(passkey.userId, userId),
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
deviceType: true,
|
||||
backedUp: true,
|
||||
createdAt: true,
|
||||
aaguid: true,
|
||||
},
|
||||
orderBy: [desc(passkey.createdAt)],
|
||||
});
|
||||
};
|
||||
|
||||
export const createOrganizationUserWithCredentials = async ({
|
||||
organizationId,
|
||||
email,
|
||||
@@ -430,7 +446,7 @@ export const createOrganizationUserWithCredentials = async ({
|
||||
.insert(user)
|
||||
.values({
|
||||
email: normalizedEmail,
|
||||
emailVerified: false,
|
||||
emailVerified: true,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning({
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreateVaultProvider,
|
||||
projects,
|
||||
type VaultProviderAssignment,
|
||||
type VaultProviderConfig,
|
||||
vaultProvider,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { getVaultClient } from "@dokploy/server/utils/vault";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
|
||||
export type VaultProvider = typeof vaultProvider.$inferSelect;
|
||||
|
||||
export const VAULT_SECRET_MASK = "********";
|
||||
|
||||
const SENSITIVE_FIELDS: Record<VaultProviderConfig["providerType"], string[]> =
|
||||
{
|
||||
hashicorp: ["token"],
|
||||
infisical: ["clientSecret"],
|
||||
aws: ["secretAccessKey"],
|
||||
doppler: ["serviceToken"],
|
||||
azure: ["clientSecret"],
|
||||
};
|
||||
|
||||
export const maskVaultProviderConfig = (
|
||||
config: VaultProviderConfig,
|
||||
): VaultProviderConfig => {
|
||||
const masked: Record<string, unknown> = { ...config };
|
||||
for (const field of SENSITIVE_FIELDS[config.providerType]) {
|
||||
if (masked[field]) {
|
||||
masked[field] = VAULT_SECRET_MASK;
|
||||
}
|
||||
}
|
||||
return masked as VaultProviderConfig;
|
||||
};
|
||||
|
||||
export const mergeVaultProviderConfig = (
|
||||
incoming: VaultProviderConfig,
|
||||
existing: VaultProviderConfig,
|
||||
): VaultProviderConfig => {
|
||||
const merged: Record<string, unknown> = { ...incoming };
|
||||
for (const field of SENSITIVE_FIELDS[incoming.providerType]) {
|
||||
if (merged[field] === VAULT_SECRET_MASK) {
|
||||
if (incoming.providerType !== existing.providerType) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Credentials must be re-entered when changing the provider type",
|
||||
});
|
||||
}
|
||||
merged[field] = (existing as Record<string, unknown>)[field];
|
||||
}
|
||||
}
|
||||
return merged as VaultProviderConfig;
|
||||
};
|
||||
|
||||
const isUniqueNameViolation = (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.message.includes("vault_provider_org_name_idx");
|
||||
|
||||
const validateAssignments = async (
|
||||
assignments: VaultProviderAssignment[],
|
||||
organizationId: string,
|
||||
) => {
|
||||
const orgProjects = await db.query.projects.findMany({
|
||||
where: eq(projects.organizationId, organizationId),
|
||||
with: { environments: true },
|
||||
});
|
||||
for (const assignment of assignments) {
|
||||
const project = orgProjects.find(
|
||||
(p) => p.projectId === assignment.projectId,
|
||||
);
|
||||
if (!project) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Assignment references a project outside this organization",
|
||||
});
|
||||
}
|
||||
const environmentIds = new Set(
|
||||
project.environments.map((e) => e.environmentId),
|
||||
);
|
||||
for (const environmentId of assignment.environmentIds) {
|
||||
if (!environmentIds.has(environmentId)) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
"Assignment references an environment outside the selected project",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const createVaultProvider = async (
|
||||
input: z.infer<typeof apiCreateVaultProvider>,
|
||||
organizationId: string,
|
||||
) => {
|
||||
await validateAssignments(input.assignments, organizationId);
|
||||
try {
|
||||
const newProvider = await db
|
||||
.insert(vaultProvider)
|
||||
.values({
|
||||
name: input.name,
|
||||
providerType: input.config.providerType,
|
||||
config: input.config,
|
||||
assignments: input.assignments,
|
||||
organizationId,
|
||||
})
|
||||
.returning()
|
||||
.then((value) => value[0]);
|
||||
|
||||
if (!newProvider) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error creating the vault provider",
|
||||
});
|
||||
}
|
||||
return newProvider;
|
||||
} catch (error) {
|
||||
if (isUniqueNameViolation(error)) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `A vault provider named "${input.name}" already exists in this organization`,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const findVaultProviderById = async (vaultProviderId: string) => {
|
||||
const provider = await db.query.vaultProvider.findFirst({
|
||||
where: eq(vaultProvider.vaultProviderId, vaultProviderId),
|
||||
});
|
||||
if (!provider) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Vault provider not found",
|
||||
});
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const findVaultProviderInOrganization = async (
|
||||
vaultProviderId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const provider = await findVaultProviderById(vaultProviderId);
|
||||
if (provider.organizationId !== organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not allowed to access this vault provider",
|
||||
});
|
||||
}
|
||||
return provider;
|
||||
};
|
||||
|
||||
export const findVaultProvidersByOrganizationId = async (
|
||||
organizationId: string,
|
||||
) => {
|
||||
return await db.query.vaultProvider.findMany({
|
||||
where: eq(vaultProvider.organizationId, organizationId),
|
||||
orderBy: (providers, { asc }) => [asc(providers.name)],
|
||||
});
|
||||
};
|
||||
|
||||
export const updateVaultProvider = async (
|
||||
vaultProviderId: string,
|
||||
name: string,
|
||||
config: VaultProviderConfig,
|
||||
assignments: VaultProviderAssignment[],
|
||||
) => {
|
||||
const existing = await findVaultProviderById(vaultProviderId);
|
||||
const mergedConfig = mergeVaultProviderConfig(config, existing.config);
|
||||
await validateAssignments(assignments, existing.organizationId);
|
||||
|
||||
try {
|
||||
const updated = await db
|
||||
.update(vaultProvider)
|
||||
.set({
|
||||
name,
|
||||
providerType: mergedConfig.providerType,
|
||||
config: mergedConfig,
|
||||
assignments,
|
||||
})
|
||||
.where(eq(vaultProvider.vaultProviderId, vaultProviderId))
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error updating the vault provider",
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
} catch (error) {
|
||||
if (isUniqueNameViolation(error)) {
|
||||
throw new TRPCError({
|
||||
code: "CONFLICT",
|
||||
message: `A vault provider named "${name}" already exists in this organization`,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const removeVaultProvider = async (vaultProviderId: string) => {
|
||||
const removed = await db
|
||||
.delete(vaultProvider)
|
||||
.where(eq(vaultProvider.vaultProviderId, vaultProviderId))
|
||||
.returning()
|
||||
.then((res) => res[0]);
|
||||
|
||||
if (!removed) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Vault provider not found",
|
||||
});
|
||||
}
|
||||
return removed;
|
||||
};
|
||||
|
||||
export const testVaultProviderConnection = async (
|
||||
config: VaultProviderConfig,
|
||||
) => {
|
||||
const client = getVaultClient(config.providerType);
|
||||
try {
|
||||
await client.testConnection(config);
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Error connecting to the vault provider",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const listVaultProviderSecretNames = async (
|
||||
config: VaultProviderConfig,
|
||||
) => {
|
||||
const client = getVaultClient(config.providerType);
|
||||
if (!client.listSecretNames) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
return await client.listSecretNames(config);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
@@ -13,6 +13,11 @@ export const findVolumeBackupById = async (volumeBackupId: string) => {
|
||||
where: eq(volumeBackups.volumeBackupId, volumeBackupId),
|
||||
with: {
|
||||
application: {
|
||||
columns: {
|
||||
applicationId: true,
|
||||
appName: true,
|
||||
serverId: true,
|
||||
},
|
||||
with: {
|
||||
environment: {
|
||||
with: {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { docker } from "../constants";
|
||||
import { pullImage } from "../utils/docker/utils";
|
||||
|
||||
export const initializeRedis = async () => {
|
||||
const imageName = "redis:8";
|
||||
const containerName = "dokploy-redis";
|
||||
|
||||
const settings: CreateServiceOptions = {
|
||||
Name: containerName,
|
||||
TaskTemplate: {
|
||||
ContainerSpec: {
|
||||
Image: imageName,
|
||||
Mounts: [
|
||||
{
|
||||
Type: "volume",
|
||||
Source: "dokploy-redis",
|
||||
Target: "/data",
|
||||
},
|
||||
],
|
||||
},
|
||||
Networks: [{ Target: "dokploy-network" }],
|
||||
Placement: {
|
||||
Constraints: ["node.role==manager"],
|
||||
},
|
||||
},
|
||||
Mode: {
|
||||
Replicated: {
|
||||
Replicas: 1,
|
||||
},
|
||||
},
|
||||
...(process.env.NODE_ENV === "development" && {
|
||||
EndpointSpec: {
|
||||
Ports: [
|
||||
{
|
||||
TargetPort: 6379,
|
||||
PublishedPort: 6379,
|
||||
Protocol: "tcp",
|
||||
PublishMode: "host",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
try {
|
||||
await pullImage(imageName);
|
||||
|
||||
const service = docker.getService(containerName);
|
||||
const inspect = await service.inspect();
|
||||
await service.update({
|
||||
version: Number.parseInt(inspect.Version.Index),
|
||||
...settings,
|
||||
});
|
||||
console.log("Redis Started ✅");
|
||||
} catch (_) {
|
||||
try {
|
||||
await docker.createService(settings);
|
||||
} catch (error: any) {
|
||||
if (error?.statusCode !== 409) {
|
||||
throw error;
|
||||
}
|
||||
console.log("Redis service already exists, continuing...");
|
||||
}
|
||||
console.log("Redis Not Found: Starting ✅");
|
||||
}
|
||||
};
|
||||
@@ -482,7 +482,7 @@ const installUtilities = () => `
|
||||
|
||||
case "$OS_TYPE" in
|
||||
arch)
|
||||
$SUDO_CMD pacman -Sy --noconfirm --needed curl wget git git-lfs jq openssl >/dev/null || true
|
||||
$SUDO_CMD pacman -Sy --noconfirm --needed unzip curl wget git git-lfs jq openssl >/dev/null || true
|
||||
;;
|
||||
alpine)
|
||||
$SUDO_CMD sed -i '/^#.*\/community/s/^#//' /etc/apk/repositories
|
||||
|
||||
@@ -74,6 +74,49 @@ export async function fetchTemplatesList(
|
||||
}));
|
||||
}
|
||||
|
||||
const LOGO_MIME_TYPES: Record<string, string> = {
|
||||
png: "image/png",
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
svg: "image/svg+xml",
|
||||
webp: "image/webp",
|
||||
gif: "image/gif",
|
||||
};
|
||||
|
||||
export async function fetchTemplateLogo(
|
||||
templateId: string,
|
||||
baseUrl = "https://templates.dokploy.com",
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const templates = await fetchTemplatesList(baseUrl);
|
||||
const template = templates.find((t) => t.id === templateId);
|
||||
if (!template?.logo) return null;
|
||||
|
||||
const response = await fetch(
|
||||
`${baseUrl}/blueprints/${templateId}/${template.logo}`,
|
||||
{ signal: AbortSignal.timeout(10000) },
|
||||
);
|
||||
if (!response.ok) return null;
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
if (buffer.length === 0) return null;
|
||||
|
||||
const contentType = response.headers.get("content-type")?.split(";")[0];
|
||||
const extension = template.logo.split(".").pop()?.toLowerCase() ?? "";
|
||||
const mimeType = contentType?.startsWith("image/")
|
||||
? contentType
|
||||
: LOGO_MIME_TYPES[extension];
|
||||
if (!mimeType) return null;
|
||||
|
||||
const dataUrl = `data:${mimeType};base64,${buffer.toString("base64")}`;
|
||||
if (dataUrl.length > 2 * 1024 * 1024) return null;
|
||||
|
||||
return dataUrl;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a specific template's files
|
||||
*/
|
||||
|
||||
@@ -35,7 +35,8 @@ export const generateRandomDomain = ({
|
||||
projectName,
|
||||
}: Schema): string => {
|
||||
const hash = randomBytes(3).toString("hex");
|
||||
const slugIp = serverIp.replaceAll(".", "-").replaceAll(":", "-");
|
||||
const effectiveIp = serverIp || "127.0.0.1";
|
||||
const slugIp = effectiveIp.replaceAll(".", "-").replaceAll(":", "-");
|
||||
|
||||
// Domain labels have a max length of 63 characters
|
||||
// Reserve space for: hash (6) + separators (1-2) + ip section + dot + sslip.io (8)
|
||||
@@ -46,7 +47,7 @@ export const generateRandomDomain = ({
|
||||
? projectName.substring(0, maxProjectNameLength)
|
||||
: projectName;
|
||||
|
||||
return `${truncatedProjectName}-${hash}${slugIp === "" ? "" : `-${slugIp}`}.sslip.io`;
|
||||
return `${truncatedProjectName}-${hash}-${slugIp}.sslip.io`;
|
||||
};
|
||||
|
||||
export const generateHash = (length = 8): string => {
|
||||
|
||||
@@ -120,7 +120,7 @@ export function parseRawConfig(
|
||||
|
||||
if (search) {
|
||||
parsedLogs = parsedLogs.filter((log) =>
|
||||
log.RequestHost.toLowerCase().includes(search.toLowerCase()),
|
||||
(log.RequestHost ?? "").toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,11 +37,10 @@ export const runComposeBackup = async (
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
if (compose.serverId) {
|
||||
|
||||
@@ -36,12 +36,10 @@ export const runLibsqlBackup = async (
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
if (libsql.serverId) {
|
||||
|
||||
@@ -35,11 +35,10 @@ export const runMariadbBackup = async (
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
if (mariadb.serverId) {
|
||||
|
||||
@@ -32,11 +32,10 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
|
||||
|
||||
@@ -33,12 +33,10 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
|
||||
|
||||
@@ -36,12 +36,10 @@ export const runPostgresBackup = async (
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
const backupCommand = getBackupCommand(
|
||||
backup,
|
||||
rcloneCommand,
|
||||
rcloneFlags,
|
||||
rcloneDestination,
|
||||
deployment.logPath,
|
||||
);
|
||||
if (postgres.serverId) {
|
||||
|
||||
@@ -72,16 +72,16 @@ export const getS3Credentials = (destination: Destination) => {
|
||||
const { accessKey, secretAccessKey, region, endpoint, provider } =
|
||||
destination;
|
||||
const rcloneFlags = [
|
||||
`--s3-access-key-id="${accessKey}"`,
|
||||
`--s3-secret-access-key="${secretAccessKey}"`,
|
||||
`--s3-region="${region}"`,
|
||||
`--s3-endpoint="${endpoint}"`,
|
||||
`--s3-access-key-id=${quote([accessKey])}`,
|
||||
`--s3-secret-access-key=${quote([secretAccessKey])}`,
|
||||
`--s3-region=${quote([region])}`,
|
||||
`--s3-endpoint=${quote([endpoint])}`,
|
||||
"--s3-no-check-bucket",
|
||||
"--s3-force-path-style",
|
||||
];
|
||||
|
||||
if (provider) {
|
||||
rcloneFlags.unshift(`--s3-provider="${provider}"`);
|
||||
rcloneFlags.unshift(`--s3-provider=${quote([provider])}`);
|
||||
}
|
||||
|
||||
if (destination.additionalFlags?.length) {
|
||||
@@ -259,11 +259,14 @@ export const generateBackupCommand = (backup: BackupSchedule) => {
|
||||
|
||||
export const getBackupCommand = (
|
||||
backup: BackupSchedule,
|
||||
rcloneCommand: string,
|
||||
rcloneFlags: string[],
|
||||
rcloneDestination: string,
|
||||
logPath: string,
|
||||
) => {
|
||||
const containerSearch = getContainerSearchCommand(backup);
|
||||
const backupCommand = generateBackupCommand(backup);
|
||||
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
const rcloneDeleteCommand = `rclone deletefile ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
|
||||
|
||||
logger.info(
|
||||
{
|
||||
@@ -279,33 +282,24 @@ export const getBackupCommand = (
|
||||
set -eo pipefail;
|
||||
echo "[$(date)] Starting backup process..." >> ${logPath};
|
||||
echo "[$(date)] Executing backup command..." >> ${logPath};
|
||||
CONTAINER_ID=$(${containerSearch})
|
||||
CONTAINER_ID=$(${containerSearch});
|
||||
|
||||
if [ -z "$CONTAINER_ID" ]; then
|
||||
echo "[$(date)] ❌ Error: Container not found" >> ${logPath};
|
||||
exit 1;
|
||||
fi
|
||||
fi;
|
||||
|
||||
echo "[$(date)] Container Up: $CONTAINER_ID" >> ${logPath};
|
||||
echo "[$(date)] Starting backup and upload to S3..." >> ${logPath};
|
||||
|
||||
# Run the backup command and capture the exit status
|
||||
BACKUP_OUTPUT=$(${backupCommand} 2>&1 >/dev/null) || {
|
||||
UPLOAD_OUTPUT=$({ ${backupCommand} | ${rcloneCommand}; } 2>&1 >/dev/null) || {
|
||||
echo "[$(date)] ❌ Error: Backup failed" >> ${logPath};
|
||||
echo "Error: $BACKUP_OUTPUT" >> ${logPath};
|
||||
exit 1;
|
||||
}
|
||||
|
||||
echo "[$(date)] ✅ backup completed successfully" >> ${logPath};
|
||||
echo "[$(date)] Starting upload to S3..." >> ${logPath};
|
||||
|
||||
# Run the upload command and capture the exit status
|
||||
UPLOAD_OUTPUT=$(${backupCommand} | ${rcloneCommand} 2>&1 >/dev/null) || {
|
||||
echo "[$(date)] ❌ Error: Upload to S3 failed" >> ${logPath};
|
||||
echo "Error: $UPLOAD_OUTPUT" >> ${logPath};
|
||||
${rcloneDeleteCommand} >/dev/null 2>&1 || true;
|
||||
exit 1;
|
||||
}
|
||||
};
|
||||
|
||||
echo "[$(date)] ✅ Upload to S3 completed successfully" >> ${logPath};
|
||||
echo "[$(date)] ✅ Backup uploaded to S3 successfully" >> ${logPath};
|
||||
echo "Backup done ✅" >> ${logPath};
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -7,19 +7,23 @@ import { writeDomainsToCompose } from "../docker/domain";
|
||||
import {
|
||||
encodeBase64,
|
||||
getEnvironmentVariablesObject,
|
||||
prepareEnvironmentVariables,
|
||||
prepareEnvironmentVariablesForFile,
|
||||
} from "../docker/utils";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type ComposeNested = InferResultType<
|
||||
"compose",
|
||||
{ environment: { with: { project: true } }; mounts: true; domains: true }
|
||||
>;
|
||||
|
||||
export const getBuildComposeCommand = async (compose: ComposeNested) => {
|
||||
export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
|
||||
const compose = await withResolvedVaultRefs(rawCompose);
|
||||
const { COMPOSE_PATH } = paths(!!compose.serverId);
|
||||
const { sourceType, appName, mounts, composeType, domains } = compose;
|
||||
const command = createCommand(compose);
|
||||
const envCommand = getCreateEnvFileCommand(compose);
|
||||
const envCommand = compose.createEnvFile
|
||||
? getCreateEnvFileCommand(compose)
|
||||
: "";
|
||||
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
|
||||
const exportEnvCommand = getExportEnvCommand(compose);
|
||||
|
||||
@@ -70,7 +74,8 @@ Compose Type: ${composeType} ✅`;
|
||||
// Shell control characters that must never appear in a user-provided compose
|
||||
// command: they would let it break out of the `docker ${command}` invocation
|
||||
// into arbitrary host commands. A normal docker compose CLI line never needs them.
|
||||
const UNSAFE_COMPOSE_COMMAND = /[;&|`$(){}<>\n\\]/;
|
||||
// Removed '&' from the blocklist to allow '&&' chaining
|
||||
const UNSAFE_COMPOSE_COMMAND = /[;|`$(){}<>\n\\]/;
|
||||
|
||||
const sanitizeCommand = (command: string) => {
|
||||
const sanitizedCommand = command.trim();
|
||||
@@ -81,8 +86,33 @@ const sanitizeCommand = (command: string) => {
|
||||
);
|
||||
}
|
||||
|
||||
const parts = sanitizedCommand.split(/\s+/);
|
||||
if (sanitizedCommand.includes("&")) {
|
||||
// Block single '&' (e.g., backgrounding tasks) or malformed chains like '&&&'
|
||||
if (
|
||||
/(?<!&)&(?!&)/.test(sanitizedCommand) ||
|
||||
sanitizedCommand.includes("&&&")
|
||||
) {
|
||||
throw new Error("Single '&' is not allowed. Use '&&' for chaining.");
|
||||
}
|
||||
|
||||
// Split by '&&' and check that every chained command (skipping the first one) is safe
|
||||
const chains = sanitizedCommand.split("&&").map((cmd) => cmd.trim());
|
||||
const isSafeChain = chains
|
||||
.slice(1)
|
||||
.every(
|
||||
(cmd) =>
|
||||
cmd.startsWith("docker compose ") ||
|
||||
cmd.startsWith("docker-compose "),
|
||||
);
|
||||
|
||||
if (!isSafeChain) {
|
||||
throw new Error(
|
||||
"Chained commands must strictly start with 'docker compose '",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const parts = sanitizedCommand.split(/\s+/);
|
||||
const restCommand = parts.map((arg) => arg.replace(/^"(.*)"$/, "$1"));
|
||||
|
||||
return restCommand.join(" ");
|
||||
@@ -127,7 +157,7 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
|
||||
envContent += `\nCOMPOSE_PREFIX=${compose.suffix}`;
|
||||
}
|
||||
|
||||
const envFileContent = prepareEnvironmentVariables(
|
||||
const envFileContent = prepareEnvironmentVariablesForFile(
|
||||
envContent,
|
||||
compose.environment.project.env,
|
||||
compose.environment.env,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveServiceNetworks } from "@dokploy/server/services/network";
|
||||
import { findRegistryByIdWithCredentials } from "@dokploy/server/services/registry";
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
import { getDockerCommand } from "./docker-file";
|
||||
import { getHerokuCommand } from "./heroku";
|
||||
import { getNixpacksCommand } from "./nixpacks";
|
||||
@@ -37,7 +39,8 @@ export type ApplicationNested = InferResultType<
|
||||
}
|
||||
>;
|
||||
|
||||
export const getBuildCommand = async (application: ApplicationNested) => {
|
||||
export const getBuildCommand = async (rawApplication: ApplicationNested) => {
|
||||
const application = await withResolvedVaultRefs(rawApplication);
|
||||
let command = "";
|
||||
|
||||
if (application.sourceType !== "docker") {
|
||||
@@ -76,8 +79,9 @@ export const getBuildCommand = async (application: ApplicationNested) => {
|
||||
};
|
||||
|
||||
export const mechanizeDockerContainer = async (
|
||||
application: ApplicationNested,
|
||||
rawApplication: ApplicationNested,
|
||||
) => {
|
||||
const application = await withResolvedVaultRefs(rawApplication);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
@@ -100,6 +104,8 @@ export const mechanizeDockerContainer = async (
|
||||
|
||||
const volumesMount = generateVolumeMounts(mounts);
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(application);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -108,7 +114,6 @@ export const mechanizeDockerContainer = async (
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
StopGracePeriod,
|
||||
EndpointSpec,
|
||||
Ulimits,
|
||||
@@ -147,7 +152,7 @@ export const mechanizeDockerContainer = async (
|
||||
...(Ulimits && { Ulimits }),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -53,14 +53,9 @@ export const getRailpackCommand = (application: ApplicationNested) => {
|
||||
"build",
|
||||
"--builder",
|
||||
builderName,
|
||||
...(cacheKey
|
||||
? [
|
||||
"--build-arg",
|
||||
`secrets-hash=${secretsHash}`,
|
||||
"--build-arg",
|
||||
`cache-key=${cacheKey}`,
|
||||
]
|
||||
: []),
|
||||
"--build-arg",
|
||||
`secrets-hash=${secretsHash}`,
|
||||
...(cacheKey ? ["--build-arg", `cache-key=${cacheKey}`] : []),
|
||||
"--build-arg",
|
||||
`BUILDKIT_SYNTAX=ghcr.io/railwayapp/railpack-frontend:v${application.railpackVersion}`,
|
||||
"-f",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions, PortConfig } from "dockerode";
|
||||
import { resolveServiceNetworks } from "../../services/network";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type LibsqlNested = InferResultType<
|
||||
"libsql",
|
||||
@@ -17,7 +19,8 @@ export type LibsqlNested = InferResultType<
|
||||
environment: { with: { project: true } };
|
||||
}
|
||||
>;
|
||||
export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
export const buildLibsql = async (rawLibsql: LibsqlNested) => {
|
||||
const libsql = await withResolvedVaultRefs(rawLibsql);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
@@ -46,6 +49,8 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
env ? `\n${env}` : ""
|
||||
}${sqldNode === "replica" ? `\nSQLD_PRIMARY_URL="${sqldPrimaryUrl}"` : ""}`;
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(libsql);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -54,7 +59,6 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
} = generateConfigContainer(libsql);
|
||||
const resources = calculateResources({
|
||||
memoryLimit,
|
||||
@@ -96,7 +100,7 @@ export const buildLibsql = async (libsql: LibsqlNested) => {
|
||||
: {}),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { resolveServiceNetworks } from "../../services/network";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -9,12 +10,14 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type MariadbNested = InferResultType<
|
||||
"mariadb",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
export const buildMariadb = async (mariadb: MariadbNested) => {
|
||||
export const buildMariadb = async (rawMariadb: MariadbNested) => {
|
||||
const mariadb = await withResolvedVaultRefs(rawMariadb);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
@@ -37,6 +40,8 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
|
||||
env ? `\n${env}` : ""
|
||||
}`;
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(mariadb);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -45,7 +50,6 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
StopGracePeriod,
|
||||
EndpointSpec,
|
||||
Ulimits,
|
||||
@@ -87,7 +91,7 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
|
||||
...(Ulimits && { Ulimits }),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { resolveServiceNetworks } from "../../services/network";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -9,13 +10,15 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type MongoNested = InferResultType<
|
||||
"mongo",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
|
||||
export const buildMongo = async (mongo: MongoNested) => {
|
||||
export const buildMongo = async (rawMongo: MongoNested) => {
|
||||
const mongo = await withResolvedVaultRefs(rawMongo);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
@@ -83,6 +86,8 @@ ${command ?? "wait $MONGOD_PID"}`;
|
||||
env ? `\n${env}` : ""
|
||||
}`;
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(mongo);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -91,7 +96,6 @@ ${command ?? "wait $MONGOD_PID"}`;
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
StopGracePeriod,
|
||||
EndpointSpec,
|
||||
Ulimits,
|
||||
@@ -143,7 +147,7 @@ ${command ?? "wait $MONGOD_PID"}`;
|
||||
...(Ulimits && { Ulimits }),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { resolveServiceNetworks } from "../../services/network";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -9,13 +10,15 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type MysqlNested = InferResultType<
|
||||
"mysql",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
|
||||
export const buildMysql = async (mysql: MysqlNested) => {
|
||||
export const buildMysql = async (rawMysql: MysqlNested) => {
|
||||
const mysql = await withResolvedVaultRefs(rawMysql);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
@@ -43,6 +46,8 @@ export const buildMysql = async (mysql: MysqlNested) => {
|
||||
env ? `\n${env}` : ""
|
||||
}`;
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(mysql);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -51,7 +56,6 @@ export const buildMysql = async (mysql: MysqlNested) => {
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
StopGracePeriod,
|
||||
EndpointSpec,
|
||||
Ulimits,
|
||||
@@ -93,7 +97,7 @@ export const buildMysql = async (mysql: MysqlNested) => {
|
||||
...(Ulimits && { Ulimits }),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { resolveServiceNetworks } from "../../services/network";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -9,12 +10,14 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type PostgresNested = InferResultType<
|
||||
"postgres",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
export const buildPostgres = async (postgres: PostgresNested) => {
|
||||
export const buildPostgres = async (rawPostgres: PostgresNested) => {
|
||||
const postgres = await withResolvedVaultRefs(rawPostgres);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
@@ -36,6 +39,8 @@ export const buildPostgres = async (postgres: PostgresNested) => {
|
||||
env ? `\n${env}` : ""
|
||||
}`;
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(postgres);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -44,7 +49,6 @@ export const buildPostgres = async (postgres: PostgresNested) => {
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
StopGracePeriod,
|
||||
EndpointSpec,
|
||||
Ulimits,
|
||||
@@ -85,7 +89,7 @@ export const buildPostgres = async (postgres: PostgresNested) => {
|
||||
...(Ulimits && { Ulimits }),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { deployMySql } from "@dokploy/server/services/mysql";
|
||||
import { deployPostgres } from "@dokploy/server/services/postgres";
|
||||
import { deployRedis } from "@dokploy/server/services/redis";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { quote } from "shell-quote";
|
||||
import { removeService } from "../docker/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
|
||||
@@ -40,7 +41,7 @@ export const rebuildDatabase = async (
|
||||
|
||||
for (const mount of database.mounts) {
|
||||
if (mount.type === "volume") {
|
||||
const command = `docker volume rm ${mount?.volumeName} --force`;
|
||||
const command = `docker volume rm ${quote([mount?.volumeName ?? ""])} --force`;
|
||||
if (database.serverId) {
|
||||
await execAsyncRemote(database.serverId, command);
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import type { CreateServiceOptions } from "dockerode";
|
||||
import { resolveServiceNetworks } from "../../services/network";
|
||||
import {
|
||||
calculateResources,
|
||||
generateBindMounts,
|
||||
@@ -9,12 +10,14 @@ import {
|
||||
prepareEnvironmentVariables,
|
||||
} from "../docker/utils";
|
||||
import { getRemoteDocker } from "../servers/remote-docker";
|
||||
import { withResolvedVaultRefs } from "../vault";
|
||||
|
||||
export type RedisNested = InferResultType<
|
||||
"redis",
|
||||
{ mounts: true; environment: { with: { project: true } } }
|
||||
>;
|
||||
export const buildRedis = async (redis: RedisNested) => {
|
||||
export const buildRedis = async (rawRedis: RedisNested) => {
|
||||
const redis = await withResolvedVaultRefs(rawRedis);
|
||||
const {
|
||||
appName,
|
||||
env,
|
||||
@@ -34,6 +37,8 @@ export const buildRedis = async (redis: RedisNested) => {
|
||||
env ? `\n${env}` : ""
|
||||
}`;
|
||||
|
||||
const resolvedNetworks = await resolveServiceNetworks(redis);
|
||||
|
||||
const {
|
||||
HealthCheck,
|
||||
RestartPolicy,
|
||||
@@ -42,7 +47,6 @@ export const buildRedis = async (redis: RedisNested) => {
|
||||
Mode,
|
||||
RollbackConfig,
|
||||
UpdateConfig,
|
||||
Networks,
|
||||
StopGracePeriod,
|
||||
EndpointSpec,
|
||||
Ulimits,
|
||||
@@ -91,7 +95,7 @@ export const buildRedis = async (redis: RedisNested) => {
|
||||
...(Ulimits && { Ulimits }),
|
||||
Labels,
|
||||
},
|
||||
Networks,
|
||||
Networks: resolvedNetworks,
|
||||
RestartPolicy,
|
||||
Placement,
|
||||
Resources: {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import fs, { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { network, patch } from "@dokploy/server/db/schema";
|
||||
import type { Compose } from "@dokploy/server/services/compose";
|
||||
import type { Domain } from "@dokploy/server/services/domain";
|
||||
import { quote } from "shell-quote";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { execAsyncRemote } from "../process/execAsync";
|
||||
import { cloneBitbucketRepository } from "../providers/bitbucket";
|
||||
@@ -135,6 +138,38 @@ exit 1;
|
||||
`;
|
||||
}
|
||||
};
|
||||
export const applyComposeFilePatch = async (
|
||||
compose: Compose,
|
||||
): Promise<ComposeSpecification | null> => {
|
||||
if (compose.sourceType === "raw") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const composePatches = await db.query.patch.findMany({
|
||||
where: eq(patch.composeId, compose.composeId),
|
||||
});
|
||||
|
||||
const composeFilePatch = composePatches.find(
|
||||
(p) =>
|
||||
p.enabled &&
|
||||
p.type !== "delete" &&
|
||||
join(p.filePath) === join(compose.composePath),
|
||||
);
|
||||
|
||||
if (!composeFilePatch?.content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parse(composeFilePatch.content, {
|
||||
maxAliasCount: 10000,
|
||||
}) as ComposeSpecification;
|
||||
return parsed ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const addDomainToCompose = async (
|
||||
compose: Compose,
|
||||
domains: Domain[],
|
||||
@@ -153,6 +188,8 @@ export const addDomainToCompose = async (
|
||||
return null;
|
||||
}
|
||||
|
||||
result = (await applyComposeFilePatch(compose)) ?? result;
|
||||
|
||||
if (compose.isolatedDeployment) {
|
||||
const randomized = randomizeDeployableSpecificationFile(
|
||||
result,
|
||||
@@ -221,6 +258,17 @@ export const addDomainToCompose = async (
|
||||
labels.unshift("traefik.swarm.network=dokploy-network");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const isolatedNetwork = compose.suffix || compose.appName;
|
||||
if (compose.composeType === "docker-compose") {
|
||||
if (!labels.includes(`traefik.docker.network=${isolatedNetwork}`)) {
|
||||
labels.unshift(`traefik.docker.network=${isolatedNetwork}`);
|
||||
}
|
||||
} else {
|
||||
if (!labels.includes(`traefik.swarm.network=${isolatedNetwork}`)) {
|
||||
labels.unshift(`traefik.swarm.network=${isolatedNetwork}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,14 +280,79 @@ export const addDomainToCompose = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Add dokploy-network to the root of the compose file
|
||||
const injectedNetworkNames = await applyServiceNetworks(result, compose);
|
||||
|
||||
if (!compose.isolatedDeployment) {
|
||||
result.networks = addDokployNetworkToRoot(result.networks);
|
||||
declareUsedNetworksInRoot(result, injectedNetworkNames);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const applyServiceNetworks = async (
|
||||
result: ComposeSpecification,
|
||||
compose: Compose,
|
||||
) => {
|
||||
const injectedNetworkNames = new Set<string>();
|
||||
const serviceNetworks = compose.serviceNetworks ?? [];
|
||||
if (serviceNetworks.length === 0) return injectedNetworkNames;
|
||||
|
||||
const allNetworkIds = [
|
||||
...new Set(serviceNetworks.flatMap((s) => s.networkIds)),
|
||||
];
|
||||
const networks =
|
||||
allNetworkIds.length > 0
|
||||
? await db.query.network.findMany({
|
||||
where: inArray(network.networkId, allNetworkIds),
|
||||
})
|
||||
: [];
|
||||
|
||||
for (const config of serviceNetworks) {
|
||||
const service = result.services?.[config.serviceName];
|
||||
if (!service) continue;
|
||||
|
||||
for (const networkId of config.networkIds) {
|
||||
const match = networks.find((n) => n.networkId === networkId);
|
||||
if (!match) continue;
|
||||
service.networks = addDokployNetworkToService(
|
||||
service.networks,
|
||||
match.name,
|
||||
);
|
||||
injectedNetworkNames.add(match.name);
|
||||
}
|
||||
|
||||
if (config.detachDokployNetwork) {
|
||||
removeNetworkFromService(service, "dokploy-network");
|
||||
removeNetworkFromService(service, "default");
|
||||
removeDokployNetworkLabel(service);
|
||||
}
|
||||
}
|
||||
|
||||
return injectedNetworkNames;
|
||||
};
|
||||
|
||||
export const declareUsedNetworksInRoot = (
|
||||
result: ComposeSpecification,
|
||||
injectedNetworkNames: Set<string>,
|
||||
) => {
|
||||
const isUsed = (name: string) =>
|
||||
Object.values(result.services ?? {}).some((service) => {
|
||||
const nets = service?.networks;
|
||||
if (Array.isArray(nets)) return nets.includes(name);
|
||||
if (nets && typeof nets === "object") return name in nets;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (isUsed("dokploy-network")) {
|
||||
result.networks = addDokployNetworkToRoot(result.networks);
|
||||
}
|
||||
for (const name of injectedNetworkNames) {
|
||||
if (isUsed(name)) {
|
||||
result.networks = addDokployNetworkToRoot(result.networks, name);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const writeComposeFile = async (
|
||||
compose: Compose,
|
||||
composeSpec: ComposeSpecification,
|
||||
@@ -353,9 +466,10 @@ export const createDomainLabels = (
|
||||
|
||||
export const addDokployNetworkToService = (
|
||||
networkService: DefinitionsService["networks"],
|
||||
networkName = "dokploy-network",
|
||||
) => {
|
||||
let networks = networkService;
|
||||
const network = "dokploy-network";
|
||||
const network = networkName;
|
||||
const defaultNetwork = "default";
|
||||
if (!networks) {
|
||||
networks = [];
|
||||
@@ -380,11 +494,40 @@ export const addDokployNetworkToService = (
|
||||
return networks;
|
||||
};
|
||||
|
||||
export const removeNetworkFromService = (
|
||||
service: DefinitionsService,
|
||||
networkName: string,
|
||||
) => {
|
||||
const networks = service.networks;
|
||||
if (Array.isArray(networks)) {
|
||||
service.networks = networks.filter((n) => n !== networkName);
|
||||
} else if (networks && typeof networks === "object") {
|
||||
delete networks[networkName];
|
||||
}
|
||||
};
|
||||
|
||||
const removeDokployNetworkLabel = (service: DefinitionsService) => {
|
||||
const stripped = (labels: DefinitionsService["labels"]) => {
|
||||
if (Array.isArray(labels)) {
|
||||
return labels.filter(
|
||||
(l) =>
|
||||
l !== "traefik.docker.network=dokploy-network" &&
|
||||
l !== "traefik.swarm.network=dokploy-network",
|
||||
);
|
||||
}
|
||||
return labels;
|
||||
};
|
||||
if (service.labels) service.labels = stripped(service.labels);
|
||||
if (service.deploy?.labels)
|
||||
service.deploy.labels = stripped(service.deploy.labels);
|
||||
};
|
||||
|
||||
export const addDokployNetworkToRoot = (
|
||||
networkRoot: PropertiesNetworks | undefined,
|
||||
networkName = "dokploy-network",
|
||||
) => {
|
||||
let networks = networkRoot;
|
||||
const network = "dokploy-network";
|
||||
const network = networkName;
|
||||
|
||||
if (!networks) {
|
||||
networks = {};
|
||||
|
||||
@@ -401,6 +401,13 @@ export const prepareEnvironmentVariables = (
|
||||
projectEnv?: string | null,
|
||||
environmentEnv?: string | null,
|
||||
) => {
|
||||
for (const source of [serviceEnv, projectEnv, environmentEnv]) {
|
||||
if (source?.includes("${{vault.")) {
|
||||
throw new Error(
|
||||
"Unresolved vault reference: call withResolvedVaultRefs() on the entity before preparing environment variables",
|
||||
);
|
||||
}
|
||||
}
|
||||
const projectVars = parse(projectEnv ?? "");
|
||||
const environmentVars = parse(environmentEnv ?? "");
|
||||
const serviceVars = parse(serviceEnv ?? "");
|
||||
@@ -465,6 +472,27 @@ export const prepareEnvironmentVariablesForShell = (
|
||||
return envVars.map((env) => quote([env]));
|
||||
};
|
||||
|
||||
export const prepareEnvironmentVariablesForFile = (
|
||||
serviceEnv: string | null,
|
||||
projectEnv?: string | null,
|
||||
environmentEnv?: string | null,
|
||||
): string[] => {
|
||||
const envVars = prepareEnvironmentVariables(
|
||||
serviceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
return envVars.map((pair) => {
|
||||
const [key, value] = parseEnvironmentKeyValuePair(pair);
|
||||
const escapedValue = value
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/\$/g, "\\$");
|
||||
return `${key}="${escapedValue}"`;
|
||||
});
|
||||
};
|
||||
|
||||
export const parseEnvironmentKeyValuePair = (
|
||||
pair: string,
|
||||
): [string, string] => {
|
||||
@@ -548,7 +576,6 @@ export const generateConfigContainer = (
|
||||
labelsSwarm,
|
||||
replicas,
|
||||
mounts,
|
||||
networkSwarm,
|
||||
stopGracePeriodSwarm,
|
||||
endpointSpecSwarm,
|
||||
ulimitsSwarm,
|
||||
@@ -611,13 +638,6 @@ export const generateConfigContainer = (
|
||||
stopGracePeriodSwarm !== undefined && {
|
||||
StopGracePeriod: stopGracePeriodSwarm,
|
||||
}),
|
||||
...(networkSwarm
|
||||
? {
|
||||
Networks: networkSwarm,
|
||||
}
|
||||
: {
|
||||
Networks: [{ Target: "dokploy-network" }],
|
||||
}),
|
||||
...(endpointSpecSwarm && {
|
||||
EndpointSpec: {
|
||||
...(endpointSpecSwarm.Mode && { Mode: endpointSpecSwarm.Mode }),
|
||||
@@ -712,14 +732,14 @@ export const getCreateFileCommand = (
|
||||
) => {
|
||||
const fullPath = path.join(outputPath, filePath);
|
||||
if (fullPath.endsWith(path.sep) || filePath.endsWith("/")) {
|
||||
return `mkdir -p ${fullPath};`;
|
||||
return `mkdir -p ${quote([fullPath])};`;
|
||||
}
|
||||
|
||||
const directory = path.dirname(fullPath);
|
||||
const encodedContent = encodeBase64(content);
|
||||
return `
|
||||
mkdir -p ${directory};
|
||||
echo "${encodedContent}" | base64 -d > "${fullPath}";
|
||||
mkdir -p ${quote([directory])};
|
||||
echo "${encodedContent}" | base64 -d > ${quote([fullPath])};
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -900,50 +920,6 @@ export const checkPostgresHealth = async (): Promise<ServiceHealthStatus> => {
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Valid hostname per RFC 1123: labels of letters, digits and hyphens
|
||||
// (no leading/trailing hyphen), separated by dots. Underscores are rejected
|
||||
// (no leading/trailing hyphen), optionally separated by dots. Underscores are rejected
|
||||
// because Let's Encrypt refuses to issue certificates for them.
|
||||
export const VALID_HOSTNAME_REGEX =
|
||||
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||
/^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
||||
|
||||
export const INVALID_HOSTNAME_MESSAGE =
|
||||
"Invalid domain name. Use only letters, numbers, hyphens and dots (e.g. example.com). Underscores are not allowed.";
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { render } from "@react-email/components";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../../db";
|
||||
import { notifications } from "../../db/schema";
|
||||
import ServerThresholdEmail from "../../emails/emails/server-threshold";
|
||||
import {
|
||||
sendCustomNotification,
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
sendGotifyNotification,
|
||||
sendLarkNotification,
|
||||
sendMattermostNotification,
|
||||
sendNtfyNotification,
|
||||
sendPushoverNotification,
|
||||
sendResendNotification,
|
||||
sendSlackNotification,
|
||||
sendTeamsNotification,
|
||||
sendTelegramNotification,
|
||||
@@ -39,6 +45,9 @@ export const sendServerThresholdNotifications = async (
|
||||
discord: true,
|
||||
telegram: true,
|
||||
slack: true,
|
||||
resend: true,
|
||||
gotify: true,
|
||||
ntfy: true,
|
||||
mattermost: true,
|
||||
custom: true,
|
||||
lark: true,
|
||||
@@ -52,9 +61,13 @@ export const sendServerThresholdNotifications = async (
|
||||
|
||||
for (const notification of notificationList) {
|
||||
const {
|
||||
email,
|
||||
discord,
|
||||
telegram,
|
||||
slack,
|
||||
resend,
|
||||
gotify,
|
||||
ntfy,
|
||||
mattermost,
|
||||
custom,
|
||||
lark,
|
||||
@@ -63,6 +76,64 @@ export const sendServerThresholdNotifications = async (
|
||||
} = notification;
|
||||
|
||||
try {
|
||||
if (email || resend) {
|
||||
const template = await render(
|
||||
ServerThresholdEmail({
|
||||
serverName: payload.ServerName,
|
||||
type: payload.Type,
|
||||
value: payload.Value.toFixed(2),
|
||||
threshold: payload.Threshold.toFixed(2),
|
||||
message: payload.Message,
|
||||
date: date.toLocaleString(),
|
||||
}),
|
||||
);
|
||||
|
||||
if (email) {
|
||||
await sendEmailNotification(
|
||||
email,
|
||||
`Server ${payload.Type} alert for ${payload.ServerName}`,
|
||||
template,
|
||||
);
|
||||
}
|
||||
|
||||
if (resend) {
|
||||
await sendResendNotification(
|
||||
resend,
|
||||
`Server ${payload.Type} alert for ${payload.ServerName}`,
|
||||
template,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (gotify) {
|
||||
const decorate = (decoration: string, text: string) =>
|
||||
`${gotify.decoration ? decoration : ""} ${text}\n`;
|
||||
|
||||
await sendGotifyNotification(
|
||||
gotify,
|
||||
decorate("⚠️", `Server ${payload.Type} Alert`),
|
||||
`${decorate("🏷️", `Server: ${payload.ServerName}`)}` +
|
||||
`${decorate("📊", `Current Value: ${payload.Value.toFixed(2)}%`)}` +
|
||||
`${decorate("⚠️", `Threshold: ${payload.Threshold.toFixed(2)}%`)}` +
|
||||
`${decorate("📜", `Message: ${payload.Message}`)}` +
|
||||
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (ntfy) {
|
||||
await sendNtfyNotification(
|
||||
ntfy,
|
||||
`Server ${payload.Type} Alert`,
|
||||
"warning",
|
||||
"",
|
||||
`🏷️Server: ${payload.ServerName}\n` +
|
||||
`📊Current Value: ${payload.Value.toFixed(2)}%\n` +
|
||||
`⚠️Threshold: ${payload.Threshold.toFixed(2)}%\n` +
|
||||
`📜Message: ${payload.Message}\n` +
|
||||
`🕒Date: ${date.toLocaleString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (discord) {
|
||||
const decorate = (decoration: string, text: string) =>
|
||||
`${discord.decoration ? decoration : ""} ${text}`.trim();
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
} from "@dokploy/server/services/bitbucket";
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { shellWord } from "./utils";
|
||||
|
||||
export type ApplicationWithBitbucket = InferResultType<
|
||||
"applications",
|
||||
@@ -125,8 +125,8 @@ export const cloneBitbucketRepository = async ({
|
||||
const repoToUse = entity.bitbucketRepositorySlug || bitbucketRepository;
|
||||
const repoclone = `bitbucket.org/${bitbucketOwner}/${repoToUse}.git`;
|
||||
const cloneUrl = getBitbucketCloneUrl(bitbucket, repoclone);
|
||||
command += `echo ${shellWord(`Cloning Repo ${repoclone} to ${outputPath}: ✅`)};`;
|
||||
command += `git clone --branch ${shellWord(bitbucketBranch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${shellWord(cloneUrl)} ${shellWord(outputPath)} --progress;`;
|
||||
command += `echo ${quote([`Cloning Repo ${repoclone} to ${outputPath}: ✅`])};`;
|
||||
command += `git clone --branch ${quote([String(bitbucketBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||
return command;
|
||||
};
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
findSSHKeyById,
|
||||
updateSSHKeyById,
|
||||
} from "@dokploy/server/services/ssh-key";
|
||||
import { quote } from "shell-quote";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { shellWord } from "./utils";
|
||||
|
||||
interface CloneGitRepository {
|
||||
appName: string;
|
||||
@@ -62,7 +62,7 @@ export const cloneGitRepository = async ({
|
||||
}
|
||||
command += `rm -rf ${outputPath};`;
|
||||
command += `mkdir -p ${outputPath};`;
|
||||
command += `echo ${shellWord(`Cloning Repo Custom ${customGitUrl} to ${outputPath}: ✅`)};`;
|
||||
command += `echo ${quote([`Cloning Repo Custom ${customGitUrl} to ${outputPath}: ✅`])};`;
|
||||
|
||||
if (customGitSSHKeyId) {
|
||||
await updateSSHKeyById({
|
||||
@@ -79,8 +79,8 @@ export const cloneGitRepository = async ({
|
||||
command += "chmod 600 /tmp/id_rsa;";
|
||||
command += `export GIT_SSH_COMMAND="${gitSshCommand}";`;
|
||||
}
|
||||
command += `if ! git clone --branch ${shellWord(customGitBranch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${shellWord(customGitUrl)} ${shellWord(outputPath)}; then
|
||||
echo ${shellWord(`❌ [ERROR] Fail to clone the repository ${customGitUrl}`)};
|
||||
command += `if ! git clone --branch ${quote([String(customGitBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${quote([String(customGitUrl ?? "")])} ${quote([String(outputPath ?? "")])}; then
|
||||
echo ${quote([`❌ [ERROR] Fail to clone the repository ${customGitUrl}`])};
|
||||
exit 1;
|
||||
fi
|
||||
`;
|
||||
@@ -115,7 +115,7 @@ const addHostToKnownHostsCommand = (repositoryURL: string) => {
|
||||
// ssh-keyscan is best-effort: some Git hosts (e.g. Hugging Face) never answer
|
||||
// it, and its exit code must not abort the clone under `set -e`. The clone's
|
||||
// own host-key check (StrictHostKeyChecking=accept-new) is the real boundary.
|
||||
return `ssh-keyscan -p ${Number(port)} ${shellWord(domain)} >> ${knownHostsPath} || true;`;
|
||||
return `ssh-keyscan -p ${Number(port)} ${quote([String(domain ?? "")])} >> ${knownHostsPath} || true;`;
|
||||
};
|
||||
const sanitizeRepoPathSSH = (input: string) => {
|
||||
const SSH_PATH_RE = new RegExp(
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from "@dokploy/server/services/gitea";
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { shellWord } from "./utils";
|
||||
import { quote } from "shell-quote";
|
||||
|
||||
export const getErrorCloneRequirements = (entity: {
|
||||
giteaRepository?: string | null;
|
||||
@@ -177,8 +177,8 @@ export const cloneGiteaRepository = async ({
|
||||
giteaRepository!,
|
||||
);
|
||||
|
||||
command += `echo ${shellWord(`Cloning Repo ${repoClone} to ${outputPath}: ✅`)};`;
|
||||
command += `git clone --branch ${shellWord(giteaBranch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${shellWord(cloneUrl)} ${shellWord(outputPath)} --progress;`;
|
||||
command += `echo ${quote([`Cloning Repo ${repoClone} to ${outputPath}: ✅`])};`;
|
||||
command += `git clone --branch ${quote([String(giteaBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||
return command;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,8 +6,70 @@ import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import { createAppAuth } from "@octokit/auth-app";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { Octokit } from "octokit";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { shellWord } from "./utils";
|
||||
|
||||
export const DEFAULT_GITHUB_URL = "https://github.com";
|
||||
export const DEFAULT_GITHUB_API_URL = "https://api.github.com";
|
||||
|
||||
export const parseGithubBaseUrl = (
|
||||
githubUrl?: string | null,
|
||||
): { url: string } | { error: string } => {
|
||||
const raw = githubUrl?.trim();
|
||||
if (!raw) {
|
||||
return { url: DEFAULT_GITHUB_URL };
|
||||
}
|
||||
|
||||
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw)
|
||||
? raw
|
||||
: `https://${raw}`;
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(withScheme);
|
||||
} catch {
|
||||
return { error: `"${raw}" is not a valid URL` };
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "https:") {
|
||||
return { error: "Only https is supported for GitHub instances" };
|
||||
}
|
||||
|
||||
// "acme.ghe.com." resolves the same as "acme.ghe.com", but the trailing dot
|
||||
// would slip past the checks below.
|
||||
if (parsed.hostname.endsWith(".")) {
|
||||
parsed.hostname = parsed.hostname.replace(/\.+$/, "");
|
||||
}
|
||||
|
||||
const { hostname } = parsed;
|
||||
|
||||
if (!hostname.includes(".")) {
|
||||
return { error: `"${hostname}" is not a fully qualified hostname` };
|
||||
}
|
||||
|
||||
return { url: `${parsed.protocol}//${parsed.host}` };
|
||||
};
|
||||
|
||||
export const normalizeGithubUrl = (githubUrl?: string | null): string => {
|
||||
const result = parseGithubBaseUrl(githubUrl);
|
||||
return "url" in result ? result.url : DEFAULT_GITHUB_URL;
|
||||
};
|
||||
|
||||
export const deriveGithubApiUrl = (githubUrl?: string | null): string => {
|
||||
const normalized = normalizeGithubUrl(githubUrl);
|
||||
const { protocol, host } = new URL(normalized);
|
||||
|
||||
if (host === "github.com" || host === "www.github.com") {
|
||||
return DEFAULT_GITHUB_API_URL;
|
||||
}
|
||||
|
||||
// Data residency tenants keep the api. prefix; GHES does not have one.
|
||||
if (host === "ghe.com" || host.endsWith(".ghe.com")) {
|
||||
return `${protocol}//api.${host}`;
|
||||
}
|
||||
|
||||
return `${protocol}//${host}/api/v3`;
|
||||
};
|
||||
|
||||
export const authGithub = (githubProvider: Github): Octokit => {
|
||||
if (!haveGithubRequirements(githubProvider)) {
|
||||
@@ -24,6 +86,7 @@ export const authGithub = (githubProvider: Github): Octokit => {
|
||||
privateKey: githubProvider?.githubPrivateKey || "",
|
||||
installationId: githubProvider?.githubInstallationId,
|
||||
},
|
||||
baseUrl: deriveGithubApiUrl(githubProvider?.githubUrl),
|
||||
});
|
||||
|
||||
return octokit;
|
||||
@@ -162,13 +225,14 @@ export const cloneGithubRepository = async ({
|
||||
const outputPath = outputPathOverride ?? join(basePath, appName, "code");
|
||||
const octokit = authGithub(githubProvider);
|
||||
const token = await getGithubToken(octokit);
|
||||
const repoclone = `github.com/${owner}/${repository}.git`;
|
||||
const cloneBase = new URL(normalizeGithubUrl(githubProvider.githubUrl));
|
||||
const repoclone = `${cloneBase.host}/${owner}/${repository}.git`;
|
||||
command += `rm -rf ${outputPath};`;
|
||||
command += `mkdir -p ${outputPath};`;
|
||||
const cloneUrl = `https://oauth2:${token}@${repoclone}`;
|
||||
const cloneUrl = `${cloneBase.protocol}//oauth2:${token}@${repoclone}`;
|
||||
|
||||
command += `echo ${shellWord(`Cloning Repo ${repoclone} to ${outputPath}: ✅`)};`;
|
||||
command += `git clone --branch ${shellWord(branch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${shellWord(cloneUrl)} ${shellWord(outputPath)} --progress;`;
|
||||
command += `echo ${quote([`Cloning Repo ${repoclone} to ${outputPath}: ✅`])};`;
|
||||
command += `git clone --branch ${quote([String(branch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||
|
||||
return command;
|
||||
};
|
||||
@@ -187,6 +251,7 @@ export const getGithubRepositories = async (githubId?: string) => {
|
||||
privateKey: githubProvider.githubPrivateKey,
|
||||
installationId: githubProvider.githubInstallationId,
|
||||
},
|
||||
baseUrl: deriveGithubApiUrl(githubProvider.githubUrl),
|
||||
});
|
||||
|
||||
const repositories = (await octokit.paginate(
|
||||
@@ -213,6 +278,7 @@ export const getGithubBranches = async (
|
||||
privateKey: githubProvider.githubPrivateKey,
|
||||
installationId: githubProvider.githubInstallationId,
|
||||
},
|
||||
baseUrl: deriveGithubApiUrl(githubProvider.githubUrl),
|
||||
});
|
||||
|
||||
const branches = (await octokit.paginate(octokit.rest.repos.listBranches, {
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
} from "@dokploy/server/services/gitlab";
|
||||
import type { InferResultType } from "@dokploy/server/types/with";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { shellWord } from "./utils";
|
||||
|
||||
export const refreshGitlabToken = async (gitlabProviderId: string) => {
|
||||
const gitlabProvider = await findGitlabById(gitlabProviderId);
|
||||
@@ -152,8 +152,8 @@ export const cloneGitlabRepository = async ({
|
||||
command += `mkdir -p ${outputPath};`;
|
||||
const repoClone = getGitlabRepoClone(gitlab, gitlabPathNamespace);
|
||||
const cloneUrl = getGitlabCloneUrl(gitlab, repoClone);
|
||||
command += `echo ${shellWord(`Cloning Repo ${repoClone} to ${outputPath}: ✅`)};`;
|
||||
command += `git clone --branch ${shellWord(gitlabBranch)} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${shellWord(cloneUrl)} ${shellWord(outputPath)} --progress;`;
|
||||
command += `echo ${quote([`Cloning Repo ${repoClone} to ${outputPath}: ✅`])};`;
|
||||
command += `git clone --branch ${quote([String(gitlabBranch ?? "")])} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${quote([String(cloneUrl ?? "")])} ${quote([String(outputPath ?? "")])} --progress;`;
|
||||
return command;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { quote } from "shell-quote";
|
||||
|
||||
/**
|
||||
* Escapes a single value so it can be safely interpolated as one argument into
|
||||
* a `/bin/sh -c` command string (both local `execAsync` and remote SSH
|
||||
* `execAsyncRemote`). User-controlled fields such as git URLs, branch names,
|
||||
* repository owners and SSH hostnames must never reach a shell unescaped.
|
||||
*/
|
||||
export const shellWord = (value: string | number | null | undefined): string =>
|
||||
quote([String(value ?? "")]);
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||
import type { Compose } from "@dokploy/server/services/compose";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { getS3Credentials } from "../backups/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
@@ -26,10 +27,10 @@ export const restoreComposeBackup = async (
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const bucketPath = `:s3:${destination.bucket}`;
|
||||
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||
let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
|
||||
let rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
|
||||
|
||||
if (backupInput.metadata?.mongo) {
|
||||
rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
|
||||
rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} ${quote([backupPath])}`;
|
||||
}
|
||||
|
||||
let credentials: DatabaseCredentials = {};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { getS3Credentials, getServiceContainerCommand } from "../backups/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
@@ -19,7 +20,7 @@ export const restoreLibsqlBackup = async (
|
||||
|
||||
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}"`;
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])}`;
|
||||
|
||||
const containerSearch = getServiceContainerCommand(appName);
|
||||
const restoreCommand = `docker exec -i $CONTAINER_ID sh -c "tar xzf - -C /var/lib/sqld"`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import type { Mariadb } from "@dokploy/server/services/mariadb";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { getS3Credentials } from "../backups/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
@@ -19,7 +20,7 @@ export const restoreMariadbBackup = async (
|
||||
const bucketPath = `:s3:${destination.bucket}`;
|
||||
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
|
||||
|
||||
const command = getRestoreCommand({
|
||||
appName,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import type { Mongo } from "@dokploy/server/services/mongo";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { getS3Credentials } from "../backups/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
@@ -18,7 +19,7 @@ export const restoreMongoBackup = async (
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const bucketPath = `:s3:${destination.bucket}`;
|
||||
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||
const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} "${backupPath}"`;
|
||||
const rcloneCommand = `rclone copy ${rcloneFlags.join(" ")} ${quote([backupPath])}`;
|
||||
|
||||
const command = getRestoreCommand({
|
||||
appName,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import type { MySql } from "@dokploy/server/services/mysql";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { getS3Credentials } from "../backups/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
@@ -19,7 +20,7 @@ export const restoreMySqlBackup = async (
|
||||
const bucketPath = `:s3:${destination.bucket}`;
|
||||
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
|
||||
|
||||
const command = getRestoreCommand({
|
||||
appName,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { apiRestoreBackup } from "@dokploy/server/db/schema";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import type { Postgres } from "@dokploy/server/services/postgres";
|
||||
import { quote } from "shell-quote";
|
||||
import type { z } from "zod";
|
||||
import { getS3Credentials } from "../backups/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
@@ -20,7 +21,7 @@ export const restorePostgresBackup = async (
|
||||
|
||||
const backupPath = `${bucketPath}/${backupInput.backupFile}`;
|
||||
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip`;
|
||||
const rcloneCommand = `rclone cat ${rcloneFlags.join(" ")} ${quote([backupPath])} | gunzip`;
|
||||
|
||||
const command = getRestoreCommand({
|
||||
appName,
|
||||
|
||||
@@ -79,6 +79,10 @@ const generateRestoreCommand = (
|
||||
}
|
||||
};
|
||||
|
||||
// Dumps taken with `--databases` carry `USE`/`CREATE DATABASE` statements that
|
||||
// would redirect the restore away from the database selected in the dialog.
|
||||
export const stripDatabaseSwitchCommand = `grep -viE '^[[:space:]]*(use|create[[:space:]]+database)[[:space:]]'`;
|
||||
|
||||
const getMongoSpecificCommand = (
|
||||
rcloneCommand: string,
|
||||
restoreCommand: string,
|
||||
@@ -92,8 +96,8 @@ rm -rf ${tempDir} && \
|
||||
mkdir -p ${tempDir} && \
|
||||
${rcloneCommand} ${tempDir} && \
|
||||
cd ${tempDir} && \
|
||||
gunzip -f "${fileName}" && \
|
||||
${restoreCommand} < "${decompressedName}" && \
|
||||
gunzip -f ${quote([fileName])} && \
|
||||
${restoreCommand} < ${quote([decompressedName])} && \
|
||||
rm -rf ${tempDir}
|
||||
`;
|
||||
};
|
||||
@@ -125,7 +129,9 @@ export const getRestoreCommand = ({
|
||||
const restoreCommand = generateRestoreCommand(type, credentials);
|
||||
let cmd = `CONTAINER_ID=$(${containerSearch})`;
|
||||
|
||||
if (type !== "mongo") {
|
||||
if (type === "mysql" || type === "mariadb") {
|
||||
cmd += ` && ${rcloneCommand} | ${stripDatabaseSwitchCommand} | ${restoreCommand}`;
|
||||
} else if (type !== "mongo") {
|
||||
cmd += ` && ${rcloneCommand} | ${restoreCommand}`;
|
||||
} else {
|
||||
cmd += ` && ${getMongoSpecificCommand(rcloneCommand, restoreCommand, backupFile || "")}`;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { IS_CLOUD, paths } from "@dokploy/server/constants";
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import { quote } from "shell-quote";
|
||||
import { getS3Credentials } from "../backups/utils";
|
||||
import { execAsync } from "../process/execAsync";
|
||||
|
||||
@@ -35,7 +36,7 @@ export const restoreWebServerBackup = async (
|
||||
// Download backup from S3
|
||||
emit("Downloading backup from S3...");
|
||||
await execAsync(
|
||||
`rclone copyto ${rcloneFlags.join(" ")} "${backupPath}" "${tempDir}/${backupFile}"`,
|
||||
`rclone copyto ${rcloneFlags.join(" ")} ${quote([backupPath])} ${quote([`${tempDir}/${backupFile}`])}`,
|
||||
);
|
||||
|
||||
// List files before extraction
|
||||
@@ -45,7 +46,9 @@ export const restoreWebServerBackup = async (
|
||||
|
||||
// Extract backup
|
||||
emit("Extracting backup...");
|
||||
await execAsync(`cd ${tempDir} && unzip ${backupFile} > /dev/null 2>&1`);
|
||||
await execAsync(
|
||||
`cd ${quote([tempDir])} && unzip ${quote([backupFile])} > /dev/null 2>&1`,
|
||||
);
|
||||
|
||||
// Restore filesystem first
|
||||
emit("Restoring filesystem...");
|
||||
|
||||
@@ -9,8 +9,12 @@ export const initSchedules = async () => {
|
||||
where: eq(schedules.enabled, true),
|
||||
with: {
|
||||
server: true,
|
||||
application: true,
|
||||
compose: true,
|
||||
application: {
|
||||
columns: { applicationId: true, appName: true, serverId: true },
|
||||
},
|
||||
compose: {
|
||||
columns: { composeId: true, appName: true, serverId: true },
|
||||
},
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@dokploy/server/services/deployment";
|
||||
import { findScheduleById } from "@dokploy/server/services/schedule";
|
||||
import { scheduledJobs, scheduleJob as scheduleJobNode } from "node-schedule";
|
||||
import { quote } from "shell-quote";
|
||||
import { getComposeContainer, getServiceContainer } from "../docker/utils";
|
||||
import { execAsyncRemote } from "../process/execAsync";
|
||||
import { spawnAsync } from "../process/spawnAsync";
|
||||
@@ -54,78 +55,97 @@ export const runCommand = async (scheduleId: string) => {
|
||||
description: "Schedule",
|
||||
});
|
||||
|
||||
if (scheduleType === "application" || scheduleType === "compose") {
|
||||
let containerId = "";
|
||||
let serverId = "";
|
||||
if (scheduleType === "application" && application) {
|
||||
const container = await getServiceContainer(
|
||||
application.appName,
|
||||
application.serverId,
|
||||
);
|
||||
containerId = container?.Id || "";
|
||||
serverId = application.serverId || "";
|
||||
}
|
||||
if (scheduleType === "compose" && compose) {
|
||||
const container = await getComposeContainer(compose, serviceName || "");
|
||||
containerId = container?.Id || "";
|
||||
serverId = compose.serverId || "";
|
||||
}
|
||||
try {
|
||||
if (scheduleType === "application" || scheduleType === "compose") {
|
||||
let containerId = "";
|
||||
let serverId = "";
|
||||
if (scheduleType === "application" && application) {
|
||||
const container = await getServiceContainer(
|
||||
application.appName,
|
||||
application.serverId,
|
||||
);
|
||||
containerId = container?.Id || "";
|
||||
serverId = application.serverId || "";
|
||||
}
|
||||
if (scheduleType === "compose" && compose) {
|
||||
const container = await getComposeContainer(compose, serviceName || "");
|
||||
containerId = container?.Id || "";
|
||||
serverId = compose.serverId || "";
|
||||
}
|
||||
|
||||
if (serverId) {
|
||||
try {
|
||||
if (!containerId) {
|
||||
const target =
|
||||
scheduleType === "compose"
|
||||
? `service '${serviceName}' of compose '${compose?.name}'`
|
||||
: `application '${application?.appName}'`;
|
||||
const message = `Container not found for ${target}, make sure the service is running`;
|
||||
if (serverId) {
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`echo ${quote([`❌ ${message}`])} >> ${quote([deployment.logPath])}`,
|
||||
);
|
||||
} else {
|
||||
const writeStream = createWriteStream(deployment.logPath, {
|
||||
flags: "a",
|
||||
});
|
||||
writeStream.write(`❌ ${message}\n`);
|
||||
writeStream.end();
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`
|
||||
set -e
|
||||
echo "Running command: docker exec ${containerId} ${shellType} -c '${command}'" >> ${deployment.logPath};
|
||||
docker exec ${containerId} ${shellType} -c '${command}' >> ${deployment.logPath} 2>> ${deployment.logPath} || {
|
||||
echo "❌ Command failed" >> ${deployment.logPath};
|
||||
echo "Running scheduled command" >> ${quote([deployment.logPath])};
|
||||
docker exec ${quote([containerId])} ${quote([shellType])} -c ${quote([command])} >> ${quote([deployment.logPath])} 2>> ${quote([deployment.logPath])} || {
|
||||
echo "❌ Command failed" >> ${quote([deployment.logPath])};
|
||||
exit 1;
|
||||
}
|
||||
echo "✅ Command executed successfully" >> ${deployment.logPath};
|
||||
echo "✅ Command executed successfully" >> ${quote([deployment.logPath])};
|
||||
`,
|
||||
);
|
||||
} catch (error) {
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
|
||||
} else {
|
||||
const writeStream = createWriteStream(deployment.logPath, {
|
||||
flags: "a",
|
||||
});
|
||||
|
||||
try {
|
||||
if (IS_CLOUD) {
|
||||
try {
|
||||
if (IS_CLOUD) {
|
||||
writeStream.write(
|
||||
"This feature is not available in the cloud version.",
|
||||
);
|
||||
writeStream.end();
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
return { ...deployment, status: "error" as const };
|
||||
}
|
||||
writeStream.write(
|
||||
"This feature is not available in the cloud version.",
|
||||
`docker exec ${containerId} ${shellType} -c ${command}\n`,
|
||||
);
|
||||
await spawnAsync(
|
||||
"docker",
|
||||
["exec", containerId, shellType, "-c", command],
|
||||
(data) => {
|
||||
if (writeStream.writable) {
|
||||
writeStream.write(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
writeStream.write("✅ Command executed successfully\n");
|
||||
writeStream.end();
|
||||
} catch (error) {
|
||||
writeStream.write("❌ Command failed\n");
|
||||
writeStream.write(
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
);
|
||||
writeStream.end();
|
||||
return;
|
||||
throw error;
|
||||
}
|
||||
writeStream.write(
|
||||
`docker exec ${containerId} ${shellType} -c ${command}\n`,
|
||||
);
|
||||
await spawnAsync(
|
||||
"docker",
|
||||
["exec", containerId, shellType, "-c", command],
|
||||
(data) => {
|
||||
if (writeStream.writable) {
|
||||
writeStream.write(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
writeStream.write("✅ Command executed successfully\n");
|
||||
} catch (error) {
|
||||
writeStream.write("❌ Command failed\n");
|
||||
writeStream.write(
|
||||
error instanceof Error ? error.message : "Unknown error",
|
||||
);
|
||||
writeStream.end();
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (scheduleType === "dokploy-server") {
|
||||
try {
|
||||
} else if (scheduleType === "dokploy-server") {
|
||||
const writeStream = createWriteStream(deployment.logPath, { flags: "a" });
|
||||
const { SCHEDULES_PATH } = paths();
|
||||
const fullPath = path.join(SCHEDULES_PATH, appName || "");
|
||||
@@ -150,18 +170,13 @@ export const runCommand = async (scheduleId: string) => {
|
||||
cwd: fullPath,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
throw error;
|
||||
}
|
||||
} else if (scheduleType === "server") {
|
||||
try {
|
||||
} else if (scheduleType === "server") {
|
||||
const { SCHEDULES_PATH } = paths(true);
|
||||
const fullPath = path.join(SCHEDULES_PATH, appName || "");
|
||||
const command = `
|
||||
set -e
|
||||
set -euo pipefail
|
||||
echo "Running script" >> ${deployment.logPath};
|
||||
bash -c ${fullPath}/script.sh 2>&1 | tee -a ${deployment.logPath} || {
|
||||
bash -c ${fullPath}/script.sh 2>&1 | tee -a ${deployment.logPath} || {
|
||||
echo "❌ Command failed" >> ${deployment.logPath};
|
||||
exit 1;
|
||||
}
|
||||
@@ -176,10 +191,11 @@ export const runCommand = async (scheduleId: string) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
throw error;
|
||||
}
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
return { ...deployment, status: "done" as const };
|
||||
} catch {
|
||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||
return { ...deployment, status: "error" as const };
|
||||
}
|
||||
await updateDeploymentStatus(deployment.deploymentId, "done");
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { paths } from "@dokploy/server/constants";
|
||||
import type { Domain } from "@dokploy/server/services/domain";
|
||||
import { quote } from "shell-quote";
|
||||
import { parse, stringify } from "yaml";
|
||||
import { encodeBase64 } from "../docker/utils";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
@@ -57,7 +58,7 @@ export const removeTraefikConfig = async (
|
||||
try {
|
||||
const { DYNAMIC_TRAEFIK_PATH } = paths(!!serverId);
|
||||
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
|
||||
const command = `rm -f ${configPath}`;
|
||||
const command = `rm -f ${quote([configPath])}`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
@@ -76,7 +77,7 @@ export const removeTraefikConfigRemote = async (
|
||||
try {
|
||||
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
|
||||
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
|
||||
await execAsyncRemote(serverId, `rm -f ${configPath}`);
|
||||
await execAsyncRemote(serverId, `rm -f ${quote([configPath])}`);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Error removing remote traefik config for ${appName}:`,
|
||||
@@ -106,7 +107,10 @@ export const loadOrCreateConfigRemote = async (
|
||||
const fileConfig: FileConfig = { http: { routers: {}, services: {} } };
|
||||
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
|
||||
try {
|
||||
const { stdout } = await execAsyncRemote(serverId, `cat ${configPath}`);
|
||||
const { stdout } = await execAsyncRemote(
|
||||
serverId,
|
||||
`cat ${quote([configPath])}`,
|
||||
);
|
||||
|
||||
if (!stdout) return fileConfig;
|
||||
|
||||
@@ -133,7 +137,10 @@ export const readRemoteConfig = async (serverId: string, appName: string) => {
|
||||
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
|
||||
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
|
||||
try {
|
||||
const { stdout } = await execAsyncRemote(serverId, `cat ${configPath}`);
|
||||
const { stdout } = await execAsyncRemote(
|
||||
serverId,
|
||||
`cat ${quote([configPath])}`,
|
||||
);
|
||||
if (!stdout) return null;
|
||||
return stdout;
|
||||
} catch {
|
||||
@@ -189,7 +196,10 @@ export const readConfigInPath = async (pathFile: string, serverId?: string) => {
|
||||
const configPath = path.join(pathFile);
|
||||
|
||||
if (serverId) {
|
||||
const { stdout } = await execAsyncRemote(serverId, `cat ${configPath}`);
|
||||
const { stdout } = await execAsyncRemote(
|
||||
serverId,
|
||||
`cat ${quote([configPath])}`,
|
||||
);
|
||||
if (!stdout) return null;
|
||||
return stdout;
|
||||
}
|
||||
@@ -221,7 +231,7 @@ export const writeConfigRemote = async (
|
||||
const encoded = encodeBase64(traefikConfig);
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`echo "${encoded}" | base64 -d > "${configPath}"`,
|
||||
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error saving the YAML config file:", e);
|
||||
@@ -239,7 +249,7 @@ export const writeTraefikConfigInPath = async (
|
||||
const encoded = encodeBase64(traefikConfig);
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`echo "${encoded}" | base64 -d > "${configPath}"`,
|
||||
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
|
||||
);
|
||||
} else {
|
||||
fs.writeFileSync(configPath, traefikConfig, "utf8");
|
||||
@@ -272,7 +282,11 @@ export const writeTraefikConfigRemote = async (
|
||||
const { DYNAMIC_TRAEFIK_PATH } = paths(true);
|
||||
const configPath = path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`);
|
||||
const yamlStr = stringify(traefikConfig);
|
||||
await execAsyncRemote(serverId, `echo '${yamlStr}' > ${configPath}`);
|
||||
const encoded = encodeBase64(yamlStr);
|
||||
await execAsyncRemote(
|
||||
serverId,
|
||||
`echo "${encoded}" | base64 -d > ${quote([configPath])}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Error saving the YAML config file:", e);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
GetSecretValueCommand,
|
||||
ListSecretsCommand,
|
||||
SecretsManagerClient,
|
||||
} from "@aws-sdk/client-secrets-manager";
|
||||
import type { awsVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import type { VaultClient } from "./types";
|
||||
|
||||
type AwsConfig = z.infer<typeof awsVaultConfigSchema>;
|
||||
|
||||
const parseRef = (ref: string) => {
|
||||
if (ref.startsWith("arn:")) {
|
||||
throw new Error(
|
||||
`Invalid AWS Secrets Manager reference "${ref}": use the secret name, not the ARN`,
|
||||
);
|
||||
}
|
||||
const separatorIndex = ref.lastIndexOf(":");
|
||||
if (separatorIndex === -1) {
|
||||
return { secretId: ref, field: null };
|
||||
}
|
||||
return {
|
||||
secretId: ref.slice(0, separatorIndex),
|
||||
field: ref.slice(separatorIndex + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const createClient = (config: AwsConfig) =>
|
||||
new SecretsManagerClient({
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
...(config.endpoint && { endpoint: config.endpoint }),
|
||||
});
|
||||
|
||||
export const awsClient: VaultClient<AwsConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const client = createClient(config);
|
||||
const secretIds = [...new Set(refs.map((ref) => parseRef(ref).secretId))];
|
||||
|
||||
const secretStrings = new Map<string, string>();
|
||||
await Promise.all(
|
||||
secretIds.map(async (secretId) => {
|
||||
const response = await client.send(
|
||||
new GetSecretValueCommand({ SecretId: secretId }),
|
||||
);
|
||||
if (response.SecretString === undefined) {
|
||||
throw new Error(
|
||||
`AWS Secrets Manager: secret "${secretId}" has no string value (binary secrets are not supported)`,
|
||||
);
|
||||
}
|
||||
secretStrings.set(secretId, response.SecretString);
|
||||
}),
|
||||
);
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
for (const ref of refs) {
|
||||
const { secretId, field } = parseRef(ref);
|
||||
const secretString = secretStrings.get(secretId) as string;
|
||||
if (field === null) {
|
||||
result[ref] = secretString;
|
||||
continue;
|
||||
}
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(secretString);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`AWS Secrets Manager: secret "${secretId}" is not JSON, cannot extract field "${field}"`,
|
||||
);
|
||||
}
|
||||
const value = parsed[field];
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(
|
||||
`AWS Secrets Manager: field "${field}" not found in secret "${secretId}"`,
|
||||
);
|
||||
}
|
||||
result[ref] = typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
const client = createClient(config);
|
||||
await client.send(new ListSecretsCommand({ MaxResults: 1 }));
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const client = createClient(config);
|
||||
const names: string[] = [];
|
||||
let nextToken: string | undefined;
|
||||
do {
|
||||
const response = await client.send(
|
||||
new ListSecretsCommand({ MaxResults: 100, NextToken: nextToken }),
|
||||
);
|
||||
for (const secret of response.SecretList ?? []) {
|
||||
if (secret.Name) {
|
||||
names.push(secret.Name);
|
||||
}
|
||||
}
|
||||
nextToken = response.NextToken;
|
||||
} while (nextToken && names.length < 500);
|
||||
return names;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { azureVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type AzureConfig = z.infer<typeof azureVaultConfigSchema>;
|
||||
|
||||
const API_VERSION = "7.4";
|
||||
|
||||
const baseUrl = (config: AzureConfig) => config.vaultUri.replace(/\/+$/, "");
|
||||
|
||||
const getAccessToken = async (config: AzureConfig) => {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "client_credentials",
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
scope: "https://vault.azure.net/.default",
|
||||
});
|
||||
const response = await vaultFetch(
|
||||
`https://login.microsoftonline.com/${encodeURIComponent(config.tenantId)}/oauth2/v2.0/token`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: body.toString(),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await response.json()) as { error_description?: string };
|
||||
detail = (body.error_description ?? "").split("\n")[0] ?? "";
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Azure Key Vault: authentication failed (status ${response.status}${detail ? `: ${detail}` : ""})`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { access_token?: string };
|
||||
if (!data.access_token) {
|
||||
throw new Error("Azure Key Vault: no access token returned");
|
||||
}
|
||||
return data.access_token;
|
||||
};
|
||||
|
||||
const readSecret = async (config: AzureConfig, token: string, name: string) => {
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/secrets/${encodeURIComponent(name)}?api-version=${API_VERSION}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new Error(`Azure Key Vault: secret "${name}" not found`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Azure Key Vault: failed to read secret "${name}" (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { value?: string };
|
||||
if (data.value === undefined) {
|
||||
throw new Error(`Azure Key Vault: secret "${name}" has no value`);
|
||||
}
|
||||
return data.value;
|
||||
};
|
||||
|
||||
export const azureClient: VaultClient<AzureConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const token = await getAccessToken(config);
|
||||
const result: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
refs.map(async (ref) => {
|
||||
result[ref] = await readSecret(config, token, ref);
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
const token = await getAccessToken(config);
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/secrets?api-version=${API_VERSION}&maxresults=1`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Azure Key Vault: cannot list secrets (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const token = await getAccessToken(config);
|
||||
const names: string[] = [];
|
||||
let url: string | null =
|
||||
`${baseUrl(config)}/secrets?api-version=${API_VERSION}&maxresults=25`;
|
||||
|
||||
while (url && names.length < 200) {
|
||||
const response = await vaultFetch(url, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!response.ok) {
|
||||
break;
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
value?: { id: string }[];
|
||||
nextLink?: string | null;
|
||||
};
|
||||
for (const item of data.value ?? []) {
|
||||
const name = item.id.split("/").pop();
|
||||
if (name) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
url = data.nextLink ?? null;
|
||||
}
|
||||
return names.slice(0, 200);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { dopplerVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type DopplerConfig = z.infer<typeof dopplerVaultConfigSchema>;
|
||||
|
||||
const downloadUrl = (config: DopplerConfig) => {
|
||||
const params = new URLSearchParams({ format: "json" });
|
||||
if (config.project) {
|
||||
params.set("project", config.project);
|
||||
}
|
||||
if (config.config) {
|
||||
params.set("config", config.config);
|
||||
}
|
||||
return `https://api.doppler.com/v3/configs/config/secrets/download?${params.toString()}`;
|
||||
};
|
||||
|
||||
const downloadSecrets = async (config: DopplerConfig) => {
|
||||
const response = await vaultFetch(downloadUrl(config), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.serviceToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const body = (await response.json()) as { messages?: string[] };
|
||||
detail = (body.messages ?? []).join(", ");
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Doppler: failed to fetch secrets (status ${response.status}${detail ? `: ${detail}` : ""})`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as Record<string, string>;
|
||||
};
|
||||
|
||||
export const dopplerClient: VaultClient<DopplerConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const secrets = await downloadSecrets(config);
|
||||
const result: Record<string, string> = {};
|
||||
for (const ref of refs) {
|
||||
if (secrets[ref] === undefined) {
|
||||
throw new Error(`Doppler: secret "${ref}" not found in this config`);
|
||||
}
|
||||
result[ref] = secrets[ref];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
await downloadSecrets(config);
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const secrets = await downloadSecrets(config);
|
||||
return Object.keys(secrets);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { hashicorpVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type HashicorpConfig = z.infer<typeof hashicorpVaultConfigSchema>;
|
||||
|
||||
const parseRef = (ref: string) => {
|
||||
const separatorIndex = ref.lastIndexOf(":");
|
||||
if (separatorIndex <= 0 || separatorIndex === ref.length - 1) {
|
||||
throw new Error(
|
||||
`Invalid HashiCorp Vault reference "${ref}": expected format <path>:<field> (e.g. myapp/prod:DB_PASSWORD)`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
path: ref.slice(0, separatorIndex),
|
||||
field: ref.slice(separatorIndex + 1),
|
||||
};
|
||||
};
|
||||
|
||||
const buildHeaders = (config: HashicorpConfig) => {
|
||||
const headers: Record<string, string> = {
|
||||
"X-Vault-Token": config.token,
|
||||
};
|
||||
if (config.namespace) {
|
||||
headers["X-Vault-Namespace"] = config.namespace;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
|
||||
const baseUrl = (config: HashicorpConfig) => config.url.replace(/\/+$/, "");
|
||||
|
||||
const encodePath = (path: string) =>
|
||||
path.split("/").map(encodeURIComponent).join("/");
|
||||
|
||||
const readSecret = async (config: HashicorpConfig, path: string) => {
|
||||
const url = `${baseUrl(config)}/v1/${encodeURIComponent(config.mount)}/data/${encodePath(path)}`;
|
||||
const response = await vaultFetch(url, { headers: buildHeaders(config) });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`HashiCorp Vault: failed to read secret at "${path}" (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
data?: { data?: Record<string, unknown> };
|
||||
};
|
||||
return body.data?.data ?? {};
|
||||
};
|
||||
|
||||
export const hashicorpClient: VaultClient<HashicorpConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const byPath = new Map<string, string[]>();
|
||||
for (const ref of refs) {
|
||||
const { path } = parseRef(ref);
|
||||
byPath.set(path, [...(byPath.get(path) ?? []), ref]);
|
||||
}
|
||||
|
||||
const result: Record<string, string> = {};
|
||||
await Promise.all(
|
||||
[...byPath.entries()].map(async ([path, pathRefs]) => {
|
||||
const data = await readSecret(config, path);
|
||||
for (const ref of pathRefs) {
|
||||
const { field } = parseRef(ref);
|
||||
const value = data[field];
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(
|
||||
`HashiCorp Vault: field "${field}" not found in secret "${path}"`,
|
||||
);
|
||||
}
|
||||
result[ref] =
|
||||
typeof value === "string" ? value : JSON.stringify(value);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/v1/auth/token/lookup-self`,
|
||||
{ headers: buildHeaders(config) },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`HashiCorp Vault: token validation failed (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const MAX_DEPTH = 4;
|
||||
const MAX_ENTRIES = 200;
|
||||
const names: string[] = [];
|
||||
|
||||
const listKeys = async (path: string) => {
|
||||
const cleanPath = path.replace(/\/+$/, "");
|
||||
const suffix = cleanPath ? `/${encodePath(cleanPath)}` : "";
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/v1/${encodeURIComponent(config.mount)}/metadata${suffix}?list=true`,
|
||||
{ headers: buildHeaders(config) },
|
||||
);
|
||||
if (!response.ok) {
|
||||
return [];
|
||||
}
|
||||
const body = (await response.json()) as { data?: { keys?: string[] } };
|
||||
return body.data?.keys ?? [];
|
||||
};
|
||||
|
||||
const walk = async (path: string, depth: number) => {
|
||||
if (depth > MAX_DEPTH || names.length >= MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
for (const key of await listKeys(path)) {
|
||||
if (names.length >= MAX_ENTRIES) {
|
||||
return;
|
||||
}
|
||||
const fullPath = `${path}${key}`;
|
||||
if (key.endsWith("/")) {
|
||||
await walk(fullPath, depth + 1);
|
||||
continue;
|
||||
}
|
||||
const fields = await readSecret(config, fullPath).catch(
|
||||
() => ({}) as Record<string, unknown>,
|
||||
);
|
||||
const fieldNames = Object.keys(fields);
|
||||
if (fieldNames.length === 0) {
|
||||
names.push(fullPath);
|
||||
continue;
|
||||
}
|
||||
for (const field of fieldNames) {
|
||||
names.push(`${fullPath}:${field}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk("", 0);
|
||||
return names.slice(0, MAX_ENTRIES);
|
||||
},
|
||||
};
|
||||
Binary file not shown.
@@ -0,0 +1,87 @@
|
||||
import type { infisicalVaultConfigSchema } from "@dokploy/server/db/schema";
|
||||
import type { z } from "zod";
|
||||
import { type VaultClient, vaultFetch } from "./types";
|
||||
|
||||
type InfisicalConfig = z.infer<typeof infisicalVaultConfigSchema>;
|
||||
|
||||
const baseUrl = (config: InfisicalConfig) => config.siteUrl.replace(/\/+$/, "");
|
||||
|
||||
const login = async (config: InfisicalConfig) => {
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/api/v1/auth/universal-auth/login`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
clientId: config.clientId,
|
||||
clientSecret: config.clientSecret,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Infisical: authentication failed (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { accessToken?: string };
|
||||
if (!body.accessToken) {
|
||||
throw new Error("Infisical: no access token returned");
|
||||
}
|
||||
return body.accessToken;
|
||||
};
|
||||
|
||||
const fetchSecrets = async (config: InfisicalConfig) => {
|
||||
const accessToken = await login(config);
|
||||
const params = new URLSearchParams({
|
||||
workspaceId: config.projectId,
|
||||
environment: config.environmentSlug,
|
||||
secretPath: config.secretPath,
|
||||
});
|
||||
const response = await vaultFetch(
|
||||
`${baseUrl(config)}/api/v3/secrets/raw?${params.toString()}`,
|
||||
{ headers: { Authorization: `Bearer ${accessToken}` } },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Infisical: failed to fetch secrets (status ${response.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as {
|
||||
secrets?: { secretKey: string; secretValue: string }[];
|
||||
};
|
||||
|
||||
const secrets: Record<string, string> = {};
|
||||
for (const secret of body.secrets ?? []) {
|
||||
secrets[secret.secretKey] = secret.secretValue;
|
||||
}
|
||||
return secrets;
|
||||
};
|
||||
|
||||
export const infisicalClient: VaultClient<InfisicalConfig> = {
|
||||
async getSecrets(config, refs) {
|
||||
const secrets = await fetchSecrets(config);
|
||||
const result: Record<string, string> = {};
|
||||
for (const ref of refs) {
|
||||
if (secrets[ref] === undefined) {
|
||||
throw new Error(
|
||||
`Infisical: secret "${ref}" not found in environment "${config.environmentSlug}"`,
|
||||
);
|
||||
}
|
||||
result[ref] = secrets[ref];
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
async testConnection(config) {
|
||||
await fetchSecrets(config);
|
||||
},
|
||||
|
||||
async listSecretNames(config) {
|
||||
const secrets = await fetchSecrets(config);
|
||||
return Object.keys(secrets);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { VaultProviderConfig } from "@dokploy/server/db/schema";
|
||||
|
||||
export interface VaultClient<
|
||||
C extends VaultProviderConfig = VaultProviderConfig,
|
||||
> {
|
||||
getSecrets(config: C, refs: string[]): Promise<Record<string, string>>;
|
||||
testConnection(config: C): Promise<void>;
|
||||
listSecretNames?(config: C): Promise<string[]>;
|
||||
}
|
||||
|
||||
export const VAULT_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
export const vaultFetch = async (url: string, init: RequestInit = {}) => {
|
||||
return await fetch(url, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(VAULT_REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
};
|
||||
@@ -13,8 +13,8 @@ export const initVolumeBackupsCronJobs = async () => {
|
||||
const volumeBackupsResult = await db.query.volumeBackups.findMany({
|
||||
where: eq(volumeBackups.enabled, true),
|
||||
with: {
|
||||
application: true,
|
||||
compose: true,
|
||||
application: { columns: { applicationId: true } },
|
||||
compose: { columns: { composeId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { quote } from "shell-quote";
|
||||
import {
|
||||
findApplicationById,
|
||||
findComposeById,
|
||||
@@ -23,7 +24,7 @@ export const restoreVolume = async (
|
||||
const backupPath = `${bucketPath}/${backupFileName}`;
|
||||
|
||||
// Command to download backup file from S3
|
||||
const downloadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${backupPath}" "${volumeBackupPath}/${backupFileName}"`;
|
||||
const downloadCommand = `rclone copyto ${rcloneFlags.join(" ")} ${quote([backupPath])} ${quote([`${volumeBackupPath}/${backupFileName}`])}`;
|
||||
|
||||
// Base restore command that creates the volume and restores data
|
||||
const baseRestoreCommand = `
|
||||
@@ -40,7 +41,7 @@ export const restoreVolume = async (
|
||||
-v ${volumeName}:/volume_data \
|
||||
-v ${volumeBackupPath}:/backup \
|
||||
ubuntu \
|
||||
bash -c "cd /volume_data && tar xvf /backup/${backupFileName} ."
|
||||
bash -c "cd /volume_data && tar xvf /backup/${quote([backupFileName])} ."
|
||||
echo "Volume restore completed ✅"
|
||||
`;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user