Merge pull request #5198 from Dokploy/fix/network-sync-docker-id-5179

fix: detect network delete/recreate by Docker ID during sync
This commit is contained in:
Mauricio Siu 2026-08-27 01:17:52 -06:00 committed by GitHub
commit 2a585933e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 9318 additions and 8 deletions

6
CLAUDE.md Normal file
View File

@ -0,0 +1,6 @@
## Code style
- Don't write comments that restate what the code already says.
- Comment only the "why" when something isn't obvious: workarounds,
counterintuitive decisions, constraints from an external API.
- No section-divider comments like `// --- Helpers ---`.
- Don't leave comments describing the change you just made.

View File

@ -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,45 @@ 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 />

View File

@ -0,0 +1 @@
ALTER TABLE "network" ADD COLUMN "dockerId" text;

File diff suppressed because it is too large Load Diff

View File

@ -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
}
]
}

View File

@ -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 }) => {

View File

@ -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),

View File

@ -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",