Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,12 @@ export const ShowVolumes = ({ id, type }: Props) => {
</div>

{data && data?.mounts.length > 0 && (
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
<AddVolumes
serviceId={id}
refetch={refetch}
serviceType={type}
serverId={data.serverId || undefined}
>
Add Volume
</AddVolumes>
)}
Expand All @@ -63,7 +68,12 @@ export const ShowVolumes = ({ id, type }: Props) => {
<span className="text-base text-muted-foreground">
No volumes/mounts configured
</span>
<AddVolumes serviceId={id} refetch={refetch} serviceType={type}>
<AddVolumes
serviceId={id}
refetch={refetch}
serviceType={type}
serverId={data?.serverId || undefined}
>
Add Volume
</AddVolumes>
</div>
Expand Down Expand Up @@ -113,6 +123,55 @@ export const ShowVolumes = ({ id, type }: Props) => {
</span>
</div>
)}
{mount.type === "nfs" && (
<>
<div className="flex flex-col gap-1">
<span className="font-medium">NFS Server</span>
<span className="text-sm text-muted-foreground">
{mount.nfsServer}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">NFS Path</span>
<span className="text-sm text-muted-foreground">
{mount.nfsPath}
</span>
</div>
</>
)}
{mount.type === "smb" && (
<>
<div className="flex flex-col gap-1">
<span className="font-medium">SMB Server</span>
<span className="text-sm text-muted-foreground">
{mount.smbServer}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">SMB Share</span>
<span className="text-sm text-muted-foreground">
{mount.smbShare}
</span>
</div>
{mount.smbPath && (
<div className="flex flex-col gap-1">
<span className="font-medium">SMB Path</span>
<span className="text-sm text-muted-foreground">
{mount.smbPath}
</span>
</div>
)}
</>
)}
{(mount.type === "nfs" || mount.type === "smb") &&
mount.replicateToSwarm && (
<div className="flex flex-col gap-1">
<span className="font-medium">Swarm Replication</span>
<span className="text-sm text-muted-foreground">
{mount.targetNodes?.length || 0} node(s)
</span>
</div>
)}
{mount.type === "file" && (
<div className="flex flex-col gap-1">
<span className="font-medium">File Path</span>
Expand Down
206 changes: 206 additions & 0 deletions apps/dokploy/server/api/routers/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ import {
findMountOrganizationId,
getServiceContainer,
updateMount,
getSwarmNodes,
findServerById,
distributeCredentialsToNodes,
syncMountToAllNodes,
verifyMountsOnNodes,
cleanupMountFromNodes,
testNodeConnectivity,
getSwarmNodesForMount,
getServerId,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
Expand All @@ -16,6 +25,9 @@ import {
apiUpdateMount,
} from "@/server/db/schema";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { db } from "@/server/db";
import { mountNodeStatus } from "@dokploy/server/db/schema/mount-node-status";
import { eq, and } from "drizzle-orm";

export const mountRouter = createTRPCRouter({
create: protectedProcedure
Expand Down Expand Up @@ -71,4 +83,198 @@ export const mountRouter = createTRPCRouter({
);
return mounts;
}),
getAvailableNodes: protectedProcedure
.input(
z.object({
serverId: z.string().optional(),
}),
)
.query(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
const nodes = await getSwarmNodes(input.serverId || undefined);
return (
nodes?.map((node) => ({
nodeId: node.ID,
hostname: node.Description?.Hostname || node.ID,
ip: node.Status?.Addr || "",
role: node.Spec.Role,
status: node.Status?.State || "unknown",
availability: node.Spec.Availability,
labels: node.Spec.Labels || {},
})) || []
);
}),
testNodeConnectivity: protectedProcedure
.input(
z.object({
nodeId: z.string().min(1),
serverId: z.string().optional(),
nfsServer: z.string().optional(),
smbServer: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
if (input.serverId) {
const server = await findServerById(input.serverId);
if (server.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
}
return await testNodeConnectivity(
input.nodeId,
input.nfsServer,
input.smbServer,
input.serverId || undefined,
);
}),
syncMountToSwarm: protectedProcedure
.input(
z.object({
mountId: z.string().min(1),
nodeIds: z.array(z.string()).min(1),
}),
)
.mutation(async ({ input, ctx }) => {
const organizationId = await findMountOrganizationId(input.mountId);
if (organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to sync this mount",
});
}

const mount = await findMountById(input.mountId);
const serverId = await getServerId(mount);

// Distribute credentials if needed
if (mount.credentialsId) {
await distributeCredentialsToNodes(
mount,
input.nodeIds,
serverId,
);
}

// Sync mounts to nodes
const results = await syncMountToAllNodes(
mount,
input.nodeIds,
serverId,
);

return Array.from(results.entries()).map(([nodeId, result]) => ({
nodeId,
...result,
}));
}),
getMountNodeStatus: protectedProcedure
.input(apiFindOneMount)
.query(async ({ input, ctx }) => {
const organizationId = await findMountOrganizationId(input.mountId);
if (organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to access this mount",
});
}

const statuses = await db.query.mountNodeStatus.findMany({
where: eq(mountNodeStatus.mountId, input.mountId),
});

return statuses;
}),
verifyMountsOnNodes: protectedProcedure
.input(
z.object({
mountId: z.string().min(1),
nodeIds: z.array(z.string()).min(1).optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const organizationId = await findMountOrganizationId(input.mountId);
if (organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to verify this mount",
});
}

const mount = await findMountById(input.mountId);
const serverId = await getServerId(mount);

const nodeIds =
input.nodeIds || mount.targetNodes || [];

if (nodeIds.length === 0) {
return [];
}

const results = await verifyMountsOnNodes(
input.mountId,
nodeIds,
serverId,
);

return Array.from(results.entries()).map(([nodeId, result]) => ({
nodeId,
...result,
}));
}),
updateMountNodes: protectedProcedure
.input(
z.object({
mountId: z.string().min(1),
nodeIds: z.array(z.string()).min(1),
}),
)
.mutation(async ({ input, ctx }) => {
const organizationId = await findMountOrganizationId(input.mountId);
if (organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this mount",
});
}

const mount = await findMountById(input.mountId);
const serverId = await getServerId(mount);

// Get current target nodes
const currentNodes = mount.targetNodes || [];
const newNodes = input.nodeIds;

// Find nodes to add and remove
const nodesToAdd = newNodes.filter((n) => !currentNodes.includes(n));
const nodesToRemove = currentNodes.filter((n) => !newNodes.includes(n));

// Remove mounts from nodes that are no longer targeted
if (nodesToRemove.length > 0) {
await cleanupMountFromNodes(mount, nodesToRemove, serverId);
}

// Add mounts to new nodes
if (nodesToAdd.length > 0) {
if (mount.credentialsId) {
await distributeCredentialsToNodes(
mount,
nodesToAdd,
serverId,
);
}
await syncMountToAllNodes(mount, nodesToAdd, serverId);
}

// Update mount with new target nodes
await updateMount(input.mountId, {
targetNodes: newNodes,
});

return true;
}),
});
2 changes: 2 additions & 0 deletions packages/server/src/db/schema/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export * from "./gitlab";
export * from "./mariadb";
export * from "./mongo";
export * from "./mount";
export * from "./mount-credentials";
export * from "./mount-node-status";
export * from "./mysql";
export * from "./notification";
export * from "./port";
Expand Down
55 changes: 55 additions & 0 deletions packages/server/src/db/schema/mount-credentials.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { relations } from "drizzle-orm";
import { pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { mounts } from "./mount";

export const mountCredentials = pgTable("mount_credentials", {
credentialsId: text("credentialsId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
mountId: text("mountId")
.notNull()
.references(() => mounts.mountId, { onDelete: "cascade" }),
username: text("username").notNull(), // Encrypted
password: text("password").notNull(), // Encrypted
domain: text("domain"), // For SMB
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
updatedAt: text("updatedAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
});

export const mountCredentialsRelations = relations(
mountCredentials,
({ one }) => ({
mount: one(mounts, {
fields: [mountCredentials.mountId],
references: [mounts.mountId],
}),
}),
);

export const apiCreateMountCredentials = createInsertSchema(
mountCredentials,
{
credentialsId: z.string().optional(),
mountId: z.string().min(1),
username: z.string().min(1),
password: z.string().min(1),
domain: z.string().optional(),
createdAt: z.string().optional(),
updatedAt: z.string().optional(),
},
);

export const apiUpdateMountCredentials = apiCreateMountCredentials
.partial()
.extend({
credentialsId: z.string().min(1),
});

Loading