mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 13:21:11 +00:00
fix: detect network delete/recreate by Docker ID during sync
Network sync compared only by name, so a network deleted and recreated with the same name but different attributes (driver, attachable, etc.) was reported as already in sync. Store Docker's network Id (dockerId column) and compare it against the live Id during sync. A name match with a dockerId mismatch is now surfaced as a new "changed" state, with an Update action to refresh the DB row from the live network. Fixes #5179
This commit is contained in:
@@ -34,6 +34,7 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
||||
const importMutation = api.network.import.useMutation();
|
||||
const removeMutation = api.network.remove.useMutation();
|
||||
const recreateMutation = api.network.recreate.useMutation();
|
||||
const resyncMutation = api.network.resync.useMutation();
|
||||
|
||||
const toggleSelected = (name: string) => {
|
||||
setSelected((prev) => {
|
||||
@@ -102,6 +103,20 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onResync = async (networkId: string, name: string) => {
|
||||
try {
|
||||
await resyncMutation.mutateAsync({ networkId });
|
||||
toast.success(`Network "${name}" updated from Docker`);
|
||||
await utils.network.all.invalidate();
|
||||
await utils.network.networksToSync.invalidate();
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
toast.error("Error updating network", {
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -177,6 +192,43 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!!data?.changed.length && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
Changed ({data.changed.length})
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
These networks were deleted and recreated in Docker under
|
||||
the same name — attributes in Dokploy are outdated.
|
||||
</span>
|
||||
{data.changed.map((changed) => (
|
||||
<div
|
||||
key={changed.networkId}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm">{changed.name}</span>
|
||||
{changed.driver && (
|
||||
<Badge variant="outline">{changed.driver}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
isLoading={resyncMutation.isPending}
|
||||
onClick={() => onResync(changed.networkId, changed.name)}
|
||||
>
|
||||
<RotateCcw className="size-3.5" />
|
||||
Update
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!!data?.missing.length && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "network" ADD COLUMN "dockerId" text;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1303,6 +1303,13 @@
|
||||
"when": 1786526512677,
|
||||
"tag": "0185_needy_kingpin",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 186,
|
||||
"version": "7",
|
||||
"when": 1787813846067,
|
||||
"tag": "0186_tearful_dragon_man",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
inspectNetwork,
|
||||
recreateNetwork,
|
||||
removeNetwork,
|
||||
resyncNetwork,
|
||||
} from "@dokploy/server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
@@ -129,6 +130,29 @@ export const networkRouter = createTRPCRouter({
|
||||
return recreated;
|
||||
}),
|
||||
|
||||
resync: protectedProcedure
|
||||
.input(apiFindOneNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const network = await findNetworkById(input.networkId);
|
||||
if (network.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
const resynced = await resyncNetwork(
|
||||
input.networkId,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "network",
|
||||
resourceId: resynced.networkId,
|
||||
resourceName: resynced.name,
|
||||
});
|
||||
return resynced;
|
||||
}),
|
||||
|
||||
remove: protectedProcedure
|
||||
.input(apiRemoveNetwork)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
|
||||
@@ -21,6 +21,7 @@ export const network = pgTable("network", {
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
name: text("name").notNull(),
|
||||
dockerId: text("dockerId"),
|
||||
driver: networkDriver("driver").notNull().default("bridge"),
|
||||
internal: boolean("internal").notNull().default(false),
|
||||
attachable: boolean("attachable").notNull().default(false),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { type apiCreateNetwork, network } from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import type Dockerode from "dockerode";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
import { IS_CLOUD } from "../constants";
|
||||
@@ -17,6 +18,7 @@ const RESERVED_NETWORKS = [
|
||||
];
|
||||
|
||||
type DockerNetworkInfo = {
|
||||
Id?: string;
|
||||
Name: string;
|
||||
Driver: string;
|
||||
Internal?: boolean;
|
||||
@@ -35,6 +37,12 @@ type DockerNetworkInfo = {
|
||||
};
|
||||
};
|
||||
|
||||
// EnableIPv4 is missing from dockerode's NetworkCreateOptions but supported
|
||||
// by the daemon (API >= 1.47); the body is sent as-is
|
||||
type NetworkCreateOptions = Dockerode.NetworkCreateOptions & {
|
||||
EnableIPv4?: boolean;
|
||||
};
|
||||
|
||||
const parseMtu = (value: string | undefined) => {
|
||||
const mtu = Number.parseInt(value ?? "", 10);
|
||||
return Number.isNaN(mtu) ? null : mtu;
|
||||
@@ -51,6 +59,7 @@ const mapDockerNetworkToRow = (
|
||||
serverId: string | null,
|
||||
) => ({
|
||||
name: dockerNetwork.Name,
|
||||
dockerId: dockerNetwork.Id ?? null,
|
||||
driver: dockerNetwork.Driver as "bridge" | "overlay",
|
||||
internal: dockerNetwork.Internal ?? false,
|
||||
attachable: dockerNetwork.Attachable ?? false,
|
||||
@@ -110,7 +119,7 @@ export const findNetworksToSync = async (
|
||||
|
||||
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 dockerByName = new Map(dockerNetworks.map((d) => [d.Name, d] as const));
|
||||
|
||||
const importable = dockerNetworks
|
||||
.filter(
|
||||
@@ -128,12 +137,28 @@ export const findNetworksToSync = async (
|
||||
.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))
|
||||
.filter((row) => !dockerByName.has(row.name))
|
||||
.map((row) => ({ networkId: row.networkId, name: row.name }));
|
||||
|
||||
return { importable, missing };
|
||||
const changed = existing
|
||||
.filter((row) => {
|
||||
if (!row.dockerId) return false;
|
||||
const dockerNetwork = dockerByName.get(row.name);
|
||||
return !!dockerNetwork?.Id && dockerNetwork.Id !== row.dockerId;
|
||||
})
|
||||
.map((row) => {
|
||||
const dockerNetwork = dockerByName.get(row.name);
|
||||
return {
|
||||
networkId: row.networkId,
|
||||
name: row.name,
|
||||
driver: dockerNetwork?.Driver,
|
||||
internal: dockerNetwork?.Internal ?? false,
|
||||
attachable: dockerNetwork?.Attachable ?? false,
|
||||
};
|
||||
});
|
||||
|
||||
return { importable, missing, changed };
|
||||
};
|
||||
|
||||
export const importDockerNetworks = async (
|
||||
@@ -184,6 +209,49 @@ export const importDockerNetworks = async (
|
||||
return { imported, errors };
|
||||
};
|
||||
|
||||
export const resyncNetwork = async (
|
||||
networkId: string,
|
||||
organizationId: string,
|
||||
) => {
|
||||
const row = await findNetworkById(networkId);
|
||||
if (row.organizationId !== organizationId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
const docker = await getRemoteDocker(row.serverId ?? null);
|
||||
let info: DockerNetworkInfo;
|
||||
try {
|
||||
info = (await docker.getNetwork(row.name).inspect()) as DockerNetworkInfo;
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to inspect Docker network",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(network)
|
||||
.set(mapDockerNetworkToRow(info, organizationId, row.serverId))
|
||||
.where(eq(network.networkId, networkId))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Network not found",
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
export const findNetworkById = async (networkId: string) => {
|
||||
const [row] = await db
|
||||
.select()
|
||||
@@ -252,14 +320,12 @@ const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
|
||||
|
||||
const docker = await getRemoteDocker(row.serverId ?? null);
|
||||
try {
|
||||
await docker.createNetwork({
|
||||
const createOptions: NetworkCreateOptions = {
|
||||
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
|
||||
@@ -269,7 +335,13 @@ const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
|
||||
Driver: ipam.driver || "default",
|
||||
Config: ipamConfig.length > 0 ? ipamConfig : undefined,
|
||||
},
|
||||
} as Parameters<typeof docker.createNetwork>[0]);
|
||||
};
|
||||
const created = await docker.createNetwork(createOptions);
|
||||
|
||||
await db
|
||||
.update(network)
|
||||
.set({ dockerId: created.id })
|
||||
.where(eq(network.networkId, row.networkId));
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
|
||||
Reference in New Issue
Block a user