mirror of
https://github.com/andwati/dokploy.git
synced 2026-10-18 03:02:56 +00:00
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:
@@ -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.
|
||||||
@@ -34,6 +34,7 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
|||||||
const importMutation = api.network.import.useMutation();
|
const importMutation = api.network.import.useMutation();
|
||||||
const removeMutation = api.network.remove.useMutation();
|
const removeMutation = api.network.remove.useMutation();
|
||||||
const recreateMutation = api.network.recreate.useMutation();
|
const recreateMutation = api.network.recreate.useMutation();
|
||||||
|
const resyncMutation = api.network.resync.useMutation();
|
||||||
|
|
||||||
const toggleSelected = (name: string) => {
|
const toggleSelected = (name: string) => {
|
||||||
setSelected((prev) => {
|
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 (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
@@ -177,6 +192,45 @@ export const SyncNetworks = ({ serverId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{!!data?.missing.length && (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<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,
|
"when": 1786526512677,
|
||||||
"tag": "0185_needy_kingpin",
|
"tag": "0185_needy_kingpin",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 186,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787813846067,
|
||||||
|
"tag": "0186_tearful_dragon_man",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
inspectNetwork,
|
inspectNetwork,
|
||||||
recreateNetwork,
|
recreateNetwork,
|
||||||
removeNetwork,
|
removeNetwork,
|
||||||
|
resyncNetwork,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||||
@@ -129,6 +130,29 @@ export const networkRouter = createTRPCRouter({
|
|||||||
return recreated;
|
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
|
remove: protectedProcedure
|
||||||
.input(apiRemoveNetwork)
|
.input(apiRemoveNetwork)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export const network = pgTable("network", {
|
|||||||
.primaryKey()
|
.primaryKey()
|
||||||
.$defaultFn(() => nanoid()),
|
.$defaultFn(() => nanoid()),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
|
dockerId: text("dockerId"),
|
||||||
driver: networkDriver("driver").notNull().default("bridge"),
|
driver: networkDriver("driver").notNull().default("bridge"),
|
||||||
internal: boolean("internal").notNull().default(false),
|
internal: boolean("internal").notNull().default(false),
|
||||||
attachable: boolean("attachable").notNull().default(false),
|
attachable: boolean("attachable").notNull().default(false),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
import { type apiCreateNetwork, network } from "@dokploy/server/db/schema";
|
import { type apiCreateNetwork, network } from "@dokploy/server/db/schema";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import type Dockerode from "dockerode";
|
||||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { IS_CLOUD } from "../constants";
|
import { IS_CLOUD } from "../constants";
|
||||||
@@ -17,6 +18,7 @@ const RESERVED_NETWORKS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type DockerNetworkInfo = {
|
type DockerNetworkInfo = {
|
||||||
|
Id?: string;
|
||||||
Name: string;
|
Name: string;
|
||||||
Driver: string;
|
Driver: string;
|
||||||
Internal?: boolean;
|
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 parseMtu = (value: string | undefined) => {
|
||||||
const mtu = Number.parseInt(value ?? "", 10);
|
const mtu = Number.parseInt(value ?? "", 10);
|
||||||
return Number.isNaN(mtu) ? null : mtu;
|
return Number.isNaN(mtu) ? null : mtu;
|
||||||
@@ -51,6 +59,7 @@ const mapDockerNetworkToRow = (
|
|||||||
serverId: string | null,
|
serverId: string | null,
|
||||||
) => ({
|
) => ({
|
||||||
name: dockerNetwork.Name,
|
name: dockerNetwork.Name,
|
||||||
|
dockerId: dockerNetwork.Id ?? null,
|
||||||
driver: dockerNetwork.Driver as "bridge" | "overlay",
|
driver: dockerNetwork.Driver as "bridge" | "overlay",
|
||||||
internal: dockerNetwork.Internal ?? false,
|
internal: dockerNetwork.Internal ?? false,
|
||||||
attachable: dockerNetwork.Attachable ?? false,
|
attachable: dockerNetwork.Attachable ?? false,
|
||||||
@@ -110,7 +119,7 @@ export const findNetworksToSync = async (
|
|||||||
|
|
||||||
const existing = await findNetworksByServer(organizationId, serverId);
|
const existing = await findNetworksByServer(organizationId, serverId);
|
||||||
const existingNames = new Set(existing.map((row) => row.name));
|
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
|
const importable = dockerNetworks
|
||||||
.filter(
|
.filter(
|
||||||
@@ -128,12 +137,28 @@ export const findNetworksToSync = async (
|
|||||||
.filter((s): s is string => !!s),
|
.filter((s): s is string => !!s),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Rows in Dokploy whose network no longer exists in Docker
|
|
||||||
const missing = existing
|
const missing = existing
|
||||||
.filter((row) => !dockerNames.has(row.name))
|
.filter((row) => !dockerByName.has(row.name))
|
||||||
.map((row) => ({ networkId: row.networkId, name: 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 (
|
export const importDockerNetworks = async (
|
||||||
@@ -184,6 +209,49 @@ export const importDockerNetworks = async (
|
|||||||
return { imported, errors };
|
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) => {
|
export const findNetworkById = async (networkId: string) => {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -252,14 +320,12 @@ const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
|
|||||||
|
|
||||||
const docker = await getRemoteDocker(row.serverId ?? null);
|
const docker = await getRemoteDocker(row.serverId ?? null);
|
||||||
try {
|
try {
|
||||||
await docker.createNetwork({
|
const createOptions: NetworkCreateOptions = {
|
||||||
Name: row.name,
|
Name: row.name,
|
||||||
Driver: row.driver,
|
Driver: row.driver,
|
||||||
CheckDuplicate: true,
|
CheckDuplicate: true,
|
||||||
Internal: row.internal,
|
Internal: row.internal,
|
||||||
Attachable: row.attachable,
|
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,
|
EnableIPv4: row.enableIPv4,
|
||||||
EnableIPv6: row.enableIPv6,
|
EnableIPv6: row.enableIPv6,
|
||||||
Options: row.mtu
|
Options: row.mtu
|
||||||
@@ -269,7 +335,13 @@ const createDockerNetworkFromRow = async (row: typeof network.$inferSelect) => {
|
|||||||
Driver: ipam.driver || "default",
|
Driver: ipam.driver || "default",
|
||||||
Config: ipamConfig.length > 0 ? ipamConfig : undefined,
|
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) {
|
} catch (error) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
|
|||||||
Reference in New Issue
Block a user