diff --git a/apps/dokploy/__test__/compose/domain/labels.test.ts b/apps/dokploy/__test__/compose/domain/labels.test.ts index 9a75e0a845..d287acf21c 100644 --- a/apps/dokploy/__test__/compose/domain/labels.test.ts +++ b/apps/dokploy/__test__/compose/domain/labels.test.ts @@ -21,6 +21,7 @@ describe("createDomainLabels", () => { previewDeploymentId: "", internalPath: "/", stripPath: false, + networkId: null, }; it("should create basic labels for web entrypoint", async () => { diff --git a/apps/dokploy/__test__/drop/drop.test.ts b/apps/dokploy/__test__/drop/drop.test.ts index 6e9940d6d7..30243256c5 100644 --- a/apps/dokploy/__test__/drop/drop.test.ts +++ b/apps/dokploy/__test__/drop/drop.test.ts @@ -71,6 +71,7 @@ const baseApp: ApplicationNested = { previewPort: 3000, previewLimit: 0, previewWildcard: "", + previewNetworkIds: null, environment: { env: "", isDefault: false, @@ -151,6 +152,7 @@ const baseApp: ApplicationNested = { dockerContextPath: null, rollbackActive: false, stopGracePeriodSwarm: null, + customNetworkIds: null, ulimitsSwarm: null, }; diff --git a/apps/dokploy/__test__/network/compose-networks.test.ts b/apps/dokploy/__test__/network/compose-networks.test.ts new file mode 100644 index 0000000000..93708ebb56 --- /dev/null +++ b/apps/dokploy/__test__/network/compose-networks.test.ts @@ -0,0 +1,309 @@ +import type { ComposeSpecification } from "@dokploy/server"; +import { + addAppNameToRootNetwork, + addAppNameToServiceNetworks, + addCustomNetworksToCompose, +} from "@dokploy/server/utils/docker/collision/root-network"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { parse } from "yaml"; + +// Mock the database +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + networks: { + findMany: vi.fn(), + }, + }, + }, +})); + +import { db } from "@dokploy/server/db"; + +describe("addAppNameToRootNetwork", () => { + it("adds external network to compose", () => { + const compose: ComposeSpecification = { + services: { + web: { + image: "nginx", + }, + }, + }; + + const result = addAppNameToRootNetwork(compose, "dokploy-network"); + + expect(result.networks).toBeDefined(); + expect(result.networks?.["dokploy-network"]).toEqual({ + name: "dokploy-network", + external: true, + }); + }); + + it("preserves existing networks", () => { + const compose = parse(` +services: + web: + image: nginx +networks: + frontend: + driver: bridge +`) as ComposeSpecification; + + const result = addAppNameToRootNetwork(compose, "dokploy-network"); + + expect(result.networks?.frontend).toBeDefined(); + expect(result.networks?.["dokploy-network"]).toEqual({ + name: "dokploy-network", + external: true, + }); + }); + + it("initializes networks object if undefined", () => { + const compose: ComposeSpecification = { + services: { + web: { image: "nginx" }, + }, + }; + + const result = addAppNameToRootNetwork(compose, "test-network"); + + expect(result.networks).toBeDefined(); + expect(result.networks?.["test-network"]).toBeDefined(); + }); +}); + +describe("addAppNameToServiceNetworks", () => { + it("adds network to services with array format", () => { + const services = { + web: { + image: "nginx", + networks: ["frontend", "backend"], + }, + api: { + image: "node", + networks: ["backend"], + }, + }; + + const result = addAppNameToServiceNetworks(services, "dokploy-network"); + + expect(result.web.networks).toContain("dokploy-network"); + expect(result.web.networks).toContain("frontend"); + expect(result.web.networks).toContain("backend"); + expect(result.api.networks).toContain("dokploy-network"); + expect(result.api.networks).toContain("backend"); + }); + + it("adds network to services with object format", () => { + const services = { + web: { + image: "nginx", + networks: { + frontend: { + aliases: ["web-alias"], + }, + }, + }, + }; + + const result = addAppNameToServiceNetworks(services, "dokploy-network"); + + expect(result.web.networks).toHaveProperty("dokploy-network"); + expect(result.web.networks).toHaveProperty("frontend"); + }); + + it("handles services without networks", () => { + const services = { + web: { + image: "nginx", + }, + api: { + image: "node", + }, + }; + + const result = addAppNameToServiceNetworks(services, "dokploy-network"); + + expect(result.web.networks).toEqual(["dokploy-network"]); + expect(result.api.networks).toEqual(["dokploy-network"]); + }); + + it("does not duplicate network if already present (array format)", () => { + const services = { + web: { + image: "nginx", + networks: ["dokploy-network", "frontend"], + }, + }; + + const result = addAppNameToServiceNetworks(services, "dokploy-network"); + + const webNetworks = result.web.networks as string[]; + const count = webNetworks.filter((n) => n === "dokploy-network").length; + expect(count).toBe(1); + }); + + it("handles mixed network formats across services", () => { + const services = { + web: { + image: "nginx", + networks: ["frontend"], + }, + api: { + image: "node", + networks: { + backend: {}, + }, + }, + worker: { + image: "worker", + }, + }; + + const result = addAppNameToServiceNetworks(services, "dokploy-network"); + + expect(Array.isArray(result.web.networks)).toBe(true); + expect((result.web.networks as string[]).includes("dokploy-network")).toBe( + true, + ); + + expect(typeof result.api.networks).toBe("object"); + expect(result.api.networks).toHaveProperty("dokploy-network"); + + expect(result.worker.networks).toEqual(["dokploy-network"]); + }); +}); + +describe("addCustomNetworksToCompose (with mocked DB)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("adds custom networks from DB to compose", async () => { + const mockNetworks = [ + { networkId: "net-1", networkName: "custom-net-1" }, + { networkId: "net-2", networkName: "custom-net-2" }, + ]; + + vi.mocked(db.query.networks.findMany).mockResolvedValue(mockNetworks); + + const compose: ComposeSpecification = { + services: { + web: { image: "nginx" }, + }, + }; + + const result = await addCustomNetworksToCompose(compose, [ + "net-1", + "net-2", + ]); + + expect(result.networks).toBeDefined(); + expect(result.networks?.["custom-net-1"]).toEqual({ + name: "custom-net-1", + external: true, + }); + expect(result.networks?.["custom-net-2"]).toEqual({ + name: "custom-net-2", + external: true, + }); + }); + + it("adds custom networks to all services", async () => { + const mockNetworks = [{ networkId: "net-1", networkName: "prod-network" }]; + + vi.mocked(db.query.networks.findMany).mockResolvedValue(mockNetworks); + + const compose: ComposeSpecification = { + services: { + web: { image: "nginx", networks: ["frontend"] }, + api: { image: "node" }, + }, + }; + + const result = await addCustomNetworksToCompose(compose, ["net-1"]); + + expect( + (result.services?.web?.networks as string[]).includes("prod-network"), + ).toBe(true); + expect( + (result.services?.api?.networks as string[]).includes("prod-network"), + ).toBe(true); + }); + + it("handles empty custom networks array", async () => { + vi.mocked(db.query.networks.findMany).mockResolvedValue([]); + + const compose: ComposeSpecification = { + services: { + web: { image: "nginx" }, + }, + }; + + const result = await addCustomNetworksToCompose(compose, []); + + expect(result.networks).toBeDefined(); + expect(Object.keys(result.networks || {}).length).toBe(0); + }); + + it("preserves existing compose networks", async () => { + const mockNetworks = [{ networkId: "net-1", networkName: "custom-net" }]; + + vi.mocked(db.query.networks.findMany).mockResolvedValue(mockNetworks); + + const compose = parse(` +services: + web: + image: nginx +networks: + frontend: + driver: bridge +`) as ComposeSpecification; + + const result = await addCustomNetworksToCompose(compose, ["net-1"]); + + expect(result.networks?.frontend).toBeDefined(); + expect(result.networks?.["custom-net"]).toBeDefined(); + }); + + it("initializes networks if undefined", async () => { + const mockNetworks = [{ networkId: "net-1", networkName: "test-net" }]; + + vi.mocked(db.query.networks.findMany).mockResolvedValue(mockNetworks); + + const compose: ComposeSpecification = { + services: { + web: { image: "nginx" }, + }, + }; + + const result = await addCustomNetworksToCompose(compose, ["net-1"]); + + expect(result.networks).toBeDefined(); + expect(result.networks?.["test-net"]).toBeDefined(); + }); + + it("handles services with object network format", async () => { + const mockNetworks = [{ networkId: "net-1", networkName: "custom-net" }]; + + vi.mocked(db.query.networks.findMany).mockResolvedValue(mockNetworks); + + const compose: ComposeSpecification = { + services: { + web: { + image: "nginx", + networks: { + frontend: { + aliases: ["web"], + }, + }, + }, + }, + }; + + const result = await addCustomNetworksToCompose(compose, ["net-1"]); + + expect(result.services?.web?.networks).toHaveProperty("custom-net"); + expect(result.services?.web?.networks).toHaveProperty("frontend"); + }); +}); diff --git a/apps/dokploy/__test__/network/domain-network-validation.test.ts b/apps/dokploy/__test__/network/domain-network-validation.test.ts new file mode 100644 index 0000000000..9cb203f2f0 --- /dev/null +++ b/apps/dokploy/__test__/network/domain-network-validation.test.ts @@ -0,0 +1,398 @@ +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock database +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + networks: { + findFirst: vi.fn(), + }, + applications: { + findFirst: vi.fn(), + }, + compose: { + findFirst: vi.fn(), + }, + domains: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + }, + }, +})); + +const { db } = await import("@dokploy/server/db"); + +const { createDomain } = await import("@dokploy/server/services/domain"); +const { findNetworkById } = await import("@dokploy/server/services/network"); + +vi.mock("@dokploy/server/services/network", () => ({ + findNetworkById: vi.fn(), + connectTraefikToResourceNetworks: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@dokploy/server/services/application", () => ({ + findApplicationById: vi.fn(), +})); + +vi.mock("@dokploy/server/utils/traefik/domain", () => ({ + manageDomain: vi.fn().mockResolvedValue(undefined), +})); + +describe("Domain Network Validation", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Default mocks for successful scenarios + vi.mocked(db.query.networks.findFirst).mockResolvedValue({ + networkId: "net-1", + name: "Production Network", + networkName: "prod-network", + internal: false, + serverId: "server-1", + server: { name: "Test Server", serverId: "server-1" }, + } as any); + + vi.mocked(db.query.applications.findFirst).mockResolvedValue({ + applicationId: "app-1", + customNetworkIds: ["net-1", "net-2"], + serverId: "server-1", + server: { name: "Test Server", serverId: "server-1" }, + } as any); + + vi.mocked(findNetworkById).mockImplementation(async (id: string) => { + if (id === "net-1") { + return { + networkId: "net-1", + name: "Production Network", + networkName: "prod-network", + internal: false, + serverId: "server-1", + server: { name: "Test Server", serverId: "server-1" }, + } as any; + } + if (id === "net-2") { + return { + networkId: "net-2", + name: "Staging Network", + networkName: "staging-network", + internal: false, + serverId: "server-1", + server: { name: "Test Server", serverId: "server-1" }, + } as any; + } + throw new TRPCError({ code: "NOT_FOUND", message: "Network not found" }); + }); + }); + + describe("createDomain - Multiple networks validation", () => { + it("prevents creating domain with different network than existing domains (application)", async () => { + // Mock existing domain with net-1 + vi.mocked(db.query.domains.findMany).mockResolvedValue([ + { + domainId: "domain-1", + applicationId: "app-1", + networkId: "net-1", + host: "prod.example.com", + }, + ] as any); + + // Mock DB transaction + const mockTx = { + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([ + { + domainId: "domain-2", + applicationId: "app-1", + networkId: "net-2", + host: "staging.example.com", + }, + ]), + }), + }), + }; + vi.mocked(db as any).transaction = vi + .fn() + .mockImplementation((callback) => callback(mockTx)); + + // Try to create domain with net-2 + await expect( + createDomain({ + applicationId: "app-1", + networkId: "net-2", + host: "staging.example.com", + https: true, + port: null, + path: null, + serviceName: null, + domainType: "application", + } as any), + ).rejects.toThrow( + 'This application already has domain "prod.example.com" using network "Production Network". All domains must use the same network due to Docker/Traefik limitations. Please use "Production Network" for all domains.', + ); + }); + + it("prevents creating domain with different network than existing domains (compose)", async () => { + // Mock compose instead of application + vi.mocked(db.query.applications.findFirst).mockResolvedValue( + undefined as any, + ); + vi.mocked(db.query.compose.findFirst).mockResolvedValue({ + composeId: "compose-1", + customNetworkIds: ["net-1", "net-2"], + serverId: "server-1", + server: { name: "Test Server", serverId: "server-1" }, + } as any); + + // Mock existing domain with net-1 + vi.mocked(db.query.domains.findMany).mockResolvedValue([ + { + domainId: "domain-1", + composeId: "compose-1", + networkId: "net-1", + host: "prod.example.com", + }, + ] as any); + + // Mock DB transaction + const mockTx = { + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([ + { + domainId: "domain-2", + composeId: "compose-1", + networkId: "net-2", + host: "staging.example.com", + }, + ]), + }), + }), + }; + vi.mocked(db as any).transaction = vi + .fn() + .mockImplementation((callback) => callback(mockTx)); + + // Try to create domain with net-2 + await expect( + createDomain({ + composeId: "compose-1", + networkId: "net-2", + host: "staging.example.com", + https: true, + port: null, + path: null, + serviceName: null, + domainType: "compose", + } as any), + ).rejects.toThrow( + 'This compose already has domain "prod.example.com" using network "Production Network". All domains must use the same network due to Docker/Traefik limitations. Please use "Production Network" for all domains.', + ); + }); + + it("allows creating domain with same network as existing domains", async () => { + // Mock existing domain with net-1 + vi.mocked(db.query.domains.findMany).mockResolvedValue([ + { + domainId: "domain-1", + applicationId: "app-1", + networkId: "net-1", + host: "prod.example.com", + }, + ] as any); + + // Mock findApplicationById + const { findApplicationById } = await import( + "@dokploy/server/services/application" + ); + vi.mocked(findApplicationById).mockResolvedValue({ + applicationId: "app-1", + serverId: "server-1", + } as any); + + // Mock DB transaction to succeed + const mockTx = { + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([ + { + domainId: "domain-2", + applicationId: "app-1", + networkId: "net-1", + host: "prod2.example.com", + }, + ]), + }), + }), + }; + vi.mocked(db as any).transaction = vi + .fn() + .mockImplementation((callback) => callback(mockTx)); + + // This should succeed - same network + const result = await createDomain({ + applicationId: "app-1", + networkId: "net-1", + host: "prod2.example.com", + https: true, + port: null, + path: null, + serviceName: null, + domainType: "application", + } as any); + + expect(result).toBeDefined(); + expect(result.networkId).toBe("net-1"); + }); + + it("allows creating domain with no network when other domains have no network", async () => { + // Mock existing domain with no network + vi.mocked(db.query.domains.findMany).mockResolvedValue([ + { + domainId: "domain-1", + applicationId: "app-1", + networkId: null, + host: "default.example.com", + }, + ] as any); + + // Mock findApplicationById + const { findApplicationById } = await import( + "@dokploy/server/services/application" + ); + vi.mocked(findApplicationById).mockResolvedValue({ + applicationId: "app-1", + serverId: "server-1", + } as any); + + // Mock DB transaction to succeed + const mockTx = { + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([ + { + domainId: "domain-2", + applicationId: "app-1", + networkId: null, + host: "default2.example.com", + }, + ]), + }), + }), + }; + vi.mocked(db as any).transaction = vi + .fn() + .mockImplementation((callback) => callback(mockTx)); + + // This should succeed - both use default network + const result = await createDomain({ + applicationId: "app-1", + networkId: null, + host: "default2.example.com", + https: true, + port: null, + path: null, + serviceName: null, + domainType: "application", + } as any); + + expect(result).toBeDefined(); + expect(result.networkId).toBeNull(); + }); + }); + + describe("updateDomain - Single domain network change", () => { + it("allows changing network of the only domain without conflict error", async () => { + const { updateDomain } = await import("@dokploy/server/services/domain"); + + // Mock existing single domain with net-1 + vi.mocked(db.query.domains.findFirst).mockResolvedValue({ + domainId: "domain-1", + applicationId: "app-1", + networkId: "net-1", + host: "local-nginx-47i56k-c87c1a.traefik.me", + } as any); + + // Mock findDomainsByApplicationId to return the single domain + vi.mocked(db.query.domains.findMany).mockResolvedValue([ + { + domainId: "domain-1", + applicationId: "app-1", + networkId: "net-1", + host: "local-nginx-47i56k-c87c1a.traefik.me", + }, + ] as any); + + // Mock findApplicationById + const { findApplicationById } = await import( + "@dokploy/server/services/application" + ); + vi.mocked(findApplicationById).mockResolvedValue({ + applicationId: "app-1", + serverId: "server-1", + } as any); + + // Mock network query to include net-2 + vi.mocked(findNetworkById).mockImplementation(async (id: string) => { + if (id === "net-1") { + return { + networkId: "net-1", + name: "Preprod", + networkName: "preprod-network", + internal: false, + serverId: "server-1", + server: { name: "Test Server", serverId: "server-1" }, + } as any; + } + if (id === "net-2") { + return { + networkId: "net-2", + name: "Dev", + networkName: "dev-network", + internal: false, + serverId: "server-1", + server: { name: "Test Server", serverId: "server-1" }, + } as any; + } + throw new TRPCError({ + code: "NOT_FOUND", + message: "Network not found", + }); + }); + + // Update application mock to include net-2 in customNetworkIds + vi.mocked(db.query.applications.findFirst).mockResolvedValue({ + applicationId: "app-1", + customNetworkIds: ["net-1", "net-2"], + serverId: "server-1", + server: { name: "Test Server", serverId: "server-1" }, + } as any); + + // Mock DB update to succeed + vi.mocked(db as any).update = vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([ + { + domainId: "domain-1", + applicationId: "app-1", + networkId: "net-2", + host: "local-nginx-47i56k-c87c1a.traefik.me", + }, + ]), + }), + }), + }); + + // This should succeed - changing the network of the only domain + const result = await updateDomain("domain-1", { + networkId: "net-2", + }); + + expect(result).toBeDefined(); + expect(result.networkId).toBe("net-2"); + }); + }); +}); diff --git a/apps/dokploy/__test__/network/network-service.test.ts b/apps/dokploy/__test__/network/network-service.test.ts new file mode 100644 index 0000000000..faa09c0e53 --- /dev/null +++ b/apps/dokploy/__test__/network/network-service.test.ts @@ -0,0 +1,700 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Mock Docker utilities first +vi.mock("@dokploy/server/utils/docker/network-utils", () => ({ + createDockerNetwork: vi.fn().mockResolvedValue({ id: "docker-net-123" }), + removeDockerNetwork: vi.fn().mockResolvedValue(undefined), + dockerNetworkExists: vi.fn().mockResolvedValue(false), + inspectDockerNetwork: vi + .fn() + .mockResolvedValue({ Id: "docker-net-123", Name: "test-network" }), + listDockerNetworks: vi.fn().mockResolvedValue([]), + ensureTraefikConnectedToNetwork: vi.fn().mockResolvedValue(undefined), + ensureTraefikDisconnectedFromNetwork: vi.fn().mockResolvedValue(undefined), +})); + +// Mock database +vi.mock("@dokploy/server/db", () => ({ + db: { + query: { + networks: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + applications: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + compose: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + postgres: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + mysql: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + mariadb: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + mongo: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + redis: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, + organization: { + findFirst: vi.fn(), + }, + projects: { + findFirst: vi.fn(), + }, + }, + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([ + { + networkId: "net-123", + name: "Test Network", + networkName: "test-network", + driver: "bridge", + organizationId: "org-1", + }, + ]), + })), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([ + { + networkId: "net-123", + name: "Updated Network", + }, + ]), + })), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([ + { + networkId: "net-123", + name: "Deleted Network", + }, + ]), + })), + })), + }, +})); + +import { db } from "@dokploy/server/db"; +import { + assignNetworkToResource, + connectTraefikToResourceNetworks, + createNetwork, + deleteNetwork, + importOrphanedNetworks, + removeNetworkFromResource, + syncNetworks, + updateNetwork, +} from "@dokploy/server/services/network"; +import { + dockerNetworkExists, + ensureTraefikConnectedToNetwork, + ensureTraefikDisconnectedFromNetwork, + inspectDockerNetwork, + listDockerNetworks, + removeDockerNetwork, +} from "@dokploy/server/utils/docker/network-utils"; + +describe("createNetwork", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.query.networks.findFirst).mockResolvedValue(null); + vi.mocked(dockerNetworkExists).mockResolvedValue(false); + }); + + it("creates network with Docker and DB", async () => { + const input = { + name: "My Network", + networkName: "my-network", + driver: "bridge" as const, + organizationId: "org-1", + }; + + const result = await createNetwork(input); + + expect(result.networkId).toBe("net-123"); + expect(result.name).toBe("Test Network"); + expect(dockerNetworkExists).toHaveBeenCalledWith("my-network", undefined); + expect(inspectDockerNetwork).toHaveBeenCalled(); + }); + + it("throws on duplicate network name in organization", async () => { + vi.mocked(db.query.networks.findFirst).mockResolvedValue({ + networkId: "existing-net", + networkName: "existing-network", + organizationId: "org-1", + } as any); + + const input = { + name: "Duplicate", + networkName: "existing-network", + organizationId: "org-1", + }; + + await expect(createNetwork(input)).rejects.toThrow("already exists"); + }); + + it("throws when Docker network already exists", async () => { + vi.mocked(dockerNetworkExists).mockResolvedValue(true); + + const input = { + name: "Test", + networkName: "existing-docker-net", + organizationId: "org-1", + }; + + await expect(createNetwork(input)).rejects.toThrow( + "Docker network 'existing-docker-net' already exists", + ); + }); + + it("creates network with IPAM configuration", async () => { + const input = { + name: "IPAM Network", + networkName: "ipam-net", + organizationId: "org-1", + subnet: "172.20.0.0/16", + gateway: "172.20.0.1", + ipRange: "172.20.10.0/24", + }; + + await createNetwork(input); + + expect(inspectDockerNetwork).toHaveBeenCalled(); + }); +}); + +describe("updateNetwork", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.query.networks.findFirst).mockResolvedValue({ + networkId: "net-123", + name: "Original Network", + organizationId: "org-1", + project: null, + server: null, + } as any); + }); + + it("updates network metadata", async () => { + const updates = { + name: "Updated Name", + description: "New description", + }; + + const result = await updateNetwork("net-123", updates); + + expect(result.name).toBe("Updated Network"); + expect(db.update).toHaveBeenCalled(); + }); +}); + +describe("deleteNetwork", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.query.networks.findFirst).mockResolvedValue({ + networkId: "net-123", + networkName: "test-network", + serverId: null, + project: null, + server: null, + } as any); + + // Mock isNetworkInUse checks - must return undefined for unused + vi.mocked(db.query.applications.findMany).mockResolvedValue([]); + vi.mocked(db.query.compose.findMany).mockResolvedValue([]); + vi.mocked(db.query.postgres.findMany).mockResolvedValue([]); + vi.mocked(db.query.mysql.findMany).mockResolvedValue([]); + vi.mocked(db.query.mariadb.findMany).mockResolvedValue([]); + vi.mocked(db.query.mongo.findMany).mockResolvedValue([]); + vi.mocked(db.query.redis.findMany).mockResolvedValue([]); + + // Mock inspectDockerNetwork for container check + vi.mocked(inspectDockerNetwork).mockResolvedValue({ + Id: "docker-net-123", + Name: "test-network", + Containers: {}, + } as any); + }); + + it("deletes unused network", async () => { + const result = await deleteNetwork("net-123"); + + expect(result.name).toBe("Deleted Network"); + expect(removeDockerNetwork).toHaveBeenCalledWith("test-network", null); + expect(db.delete).toHaveBeenCalled(); + }); + + it("throws when network is in use by application", async () => { + vi.mocked(db.query.applications.findMany).mockResolvedValue([ + { + applicationId: "app-1", + customNetworkIds: ["net-123"], + } as any, + ]); + + await expect(deleteNetwork("net-123")).rejects.toThrow( + "Cannot delete network that is in use", + ); + }); + + it("continues deletion if Docker removal fails", async () => { + vi.mocked(removeDockerNetwork).mockRejectedValue(new Error("Docker error")); + + await expect(deleteNetwork("net-123")).rejects.toThrow( + "Failed to remove Docker network", + ); + }); +}); + +describe("assignNetworkToResource", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.query.networks.findFirst).mockResolvedValue({ + networkId: "net-123", + networkName: "test-network", + driver: "bridge", + project: null, + server: null, + } as any); + }); + + it("assigns bridge network to compose (docker-compose mode)", async () => { + vi.mocked(db.query.compose.findFirst).mockResolvedValue({ + composeId: "compose-1", + customNetworkIds: [], + composeType: "docker-compose", + } as any); + + const result = await assignNetworkToResource( + "net-123", + "compose-1", + "compose", + ); + + expect(result.success).toBe(true); + // Traefik connection is deferred until a domain is added + expect(ensureTraefikConnectedToNetwork).not.toHaveBeenCalled(); + }); + + it("throws when assigning bridge network to swarm service", async () => { + vi.mocked(db.query.applications.findFirst).mockResolvedValue({ + applicationId: "app-1", + customNetworkIds: [], + } as any); + + await expect( + assignNetworkToResource("net-123", "app-1", "application"), + ).rejects.toThrow("requires an overlay network"); + }); + + it("allows overlay network for swarm services", async () => { + vi.mocked(db.query.networks.findFirst).mockResolvedValue({ + networkId: "net-123", + networkName: "overlay-net", + driver: "overlay", + project: null, + server: null, + } as any); + + vi.mocked(db.query.applications.findFirst).mockResolvedValue({ + applicationId: "app-1", + customNetworkIds: [], + } as any); + + const result = await assignNetworkToResource( + "net-123", + "app-1", + "application", + ); + + expect(result.success).toBe(true); + }); + + it("throws when network already assigned", async () => { + vi.mocked(db.query.applications.findFirst).mockResolvedValue({ + applicationId: "app-1", + customNetworkIds: ["net-123"], + } as any); + + await expect( + assignNetworkToResource("net-123", "app-1", "application"), + ).rejects.toThrow("already assigned"); + }); + + it("does not connect Traefik on network assignment (deferred until domain is added)", async () => { + vi.mocked(db.query.applications.findFirst).mockResolvedValue({ + applicationId: "app-1", + customNetworkIds: [], + serverId: "server-1", + } as any); + + vi.mocked(db.query.networks.findFirst).mockResolvedValue({ + networkId: "net-123", + networkName: "overlay-net", + driver: "overlay", + serverId: "server-1", + project: null, + server: { name: "Test Server", serverId: "server-1" }, + } as any); + + await assignNetworkToResource("net-123", "app-1", "application"); + + // Traefik connection is deferred until a domain is added to the resource + expect(ensureTraefikConnectedToNetwork).not.toHaveBeenCalled(); + }); +}); + +describe("removeNetworkFromResource", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.query.networks.findFirst).mockResolvedValue({ + networkId: "net-123", + networkName: "test-network", + serverId: "server-1", + project: null, + server: null, + } as any); + + vi.mocked(db.query.applications.findMany).mockResolvedValue([]); + vi.mocked(db.query.compose.findMany).mockResolvedValue([]); + }); + + it("removes network from resource", async () => { + vi.mocked(db.query.applications.findFirst).mockResolvedValue({ + applicationId: "app-1", + customNetworkIds: ["net-123", "net-456"], + } as any); + + const result = await removeNetworkFromResource( + "net-123", + "app-1", + "application", + ); + + expect(result.success).toBe(true); + expect(db.update).toHaveBeenCalled(); + }); + + it("disconnects Traefik when no resources with domains use network", async () => { + vi.mocked(db.query.applications.findFirst).mockResolvedValue({ + applicationId: "app-1", + customNetworkIds: ["net-123"], + } as any); + + await removeNetworkFromResource("net-123", "app-1", "application"); + + expect(ensureTraefikDisconnectedFromNetwork).toHaveBeenCalledWith( + "test-network", + "server-1", + ); + }); + + it("keeps Traefik connected when other resources with domains use network", async () => { + vi.mocked(db.query.applications.findFirst).mockResolvedValue({ + applicationId: "app-1", + customNetworkIds: ["net-123"], + } as any); + + vi.mocked(db.query.applications.findMany).mockResolvedValue([ + { + applicationId: "app-2", + customNetworkIds: ["net-123"], + domains: [{ domainId: "domain-1" }], + } as any, + ]); + + await removeNetworkFromResource("net-123", "app-1", "application"); + + expect(ensureTraefikDisconnectedFromNetwork).not.toHaveBeenCalled(); + }); +}); + +describe("syncNetworks", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("detects missing networks (in DB, not in Docker)", async () => { + vi.mocked(listDockerNetworks).mockResolvedValue([]); + vi.mocked(db.query.networks.findMany).mockResolvedValue([ + { + networkId: "net-1", + networkName: "missing-network", + } as any, + ]); + + const result = await syncNetworks(); + + expect(result.missing).toEqual(["missing-network"]); + expect(result.orphaned).toEqual([]); + }); + + it("detects orphaned networks (in Docker, not in DB)", async () => { + vi.mocked(listDockerNetworks).mockResolvedValue([ + { + Name: "orphan-network", + Id: "docker-123", + Labels: { "com.dokploy.organization.id": "org-1" }, + } as any, + ]); + vi.mocked(db.query.networks.findMany).mockResolvedValue([]); + + const result = await syncNetworks(); + + expect(result.missing).toEqual([]); + expect(result.orphaned).toEqual(["orphan-network"]); + }); + + it("ignores Docker networks without Dokploy labels", async () => { + vi.mocked(listDockerNetworks).mockResolvedValue([ + { + Name: "bridge", + Id: "bridge-id", + Labels: {}, + } as any, + ]); + vi.mocked(db.query.networks.findMany).mockResolvedValue([]); + + const result = await syncNetworks(); + + expect(result.orphaned).toEqual([]); + }); + + it("detects both missing and orphaned networks", async () => { + vi.mocked(listDockerNetworks).mockResolvedValue([ + { + Name: "orphan-1", + Id: "docker-1", + Labels: { "com.dokploy.organization.id": "org-1" }, + } as any, + ]); + vi.mocked(db.query.networks.findMany).mockResolvedValue([ + { + networkId: "net-1", + networkName: "missing-1", + } as any, + ]); + + const result = await syncNetworks(); + + expect(result.missing).toEqual(["missing-1"]); + expect(result.orphaned).toEqual(["orphan-1"]); + }); +}); + +describe("importOrphanedNetworks", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(db.query.networks.findFirst).mockResolvedValue(null); + }); + + it("imports Docker networks with Dokploy labels", async () => { + vi.mocked(listDockerNetworks).mockResolvedValue([ + { + Name: "custom-network", + Id: "docker-456", + Driver: "bridge", + Labels: { + "com.dokploy.organization.id": "org-1", + "com.dokploy.network.name": "Custom Network", + }, + } as any, + ]); + + vi.mocked(inspectDockerNetwork).mockResolvedValue({ + Id: "docker-456", + Attachable: true, + Internal: false, + IPAM: { + Config: [ + { + Subnet: "172.20.0.0/16", + Gateway: "172.20.0.1", + }, + ], + }, + } as any); + + // Mock organization exists + vi.mocked(db.query.organization.findFirst).mockResolvedValue({ + id: "org-1", + name: "Test Org", + } as any); + + const result = await importOrphanedNetworks(); + + expect(result.imported).toHaveLength(1); + expect(result.errors).toHaveLength(0); + }); + + it("skips networks without Dokploy organization label", async () => { + vi.mocked(listDockerNetworks).mockResolvedValue([ + { + Name: "bridge", + Id: "bridge-id", + Labels: {}, + } as any, + ]); + + const result = await importOrphanedNetworks(); + + expect(result.imported).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); + + it("skips networks already in DB", async () => { + vi.mocked(listDockerNetworks).mockResolvedValue([ + { + Name: "existing-network", + Id: "docker-789", + Labels: { "com.dokploy.organization.id": "org-1" }, + } as any, + ]); + + vi.mocked(db.query.networks.findFirst).mockResolvedValue({ + networkId: "net-1", + networkName: "existing-network", + } as any); + + const result = await importOrphanedNetworks(); + + expect(result.imported).toHaveLength(0); + }); + + it("handles import errors gracefully", async () => { + vi.mocked(listDockerNetworks).mockResolvedValue([ + { + Name: "error-network", + Id: "docker-error", + Labels: { "com.dokploy.organization.id": "org-1" }, + } as any, + ]); + + vi.mocked(inspectDockerNetwork).mockRejectedValue( + new Error("Inspect failed"), + ); + + const result = await importOrphanedNetworks(); + + expect(result.imported).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.networkName).toBe("error-network"); + }); +}); + +describe("connectTraefikToResourceNetworks", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("connects Traefik to specific domain network when domainNetworkId is provided", async () => { + vi.mocked(db.query.networks.findFirst).mockResolvedValue({ + networkId: "net-1", + networkName: "custom-network", + internal: false, + } as any); + + await connectTraefikToResourceNetworks( + "app-1", + "application", + undefined, + "net-1", + ); + + expect(ensureTraefikConnectedToNetwork).toHaveBeenCalledTimes(1); + expect(ensureTraefikConnectedToNetwork).toHaveBeenCalledWith( + "custom-network", + undefined, + ); + }); + + it("connects to dokploy-network when no domainNetworkId is provided", async () => { + await connectTraefikToResourceNetworks( + "app-1", + "application", + undefined, + null, + ); + + expect(ensureTraefikConnectedToNetwork).toHaveBeenCalledTimes(1); + expect(ensureTraefikConnectedToNetwork).toHaveBeenCalledWith( + "dokploy-network", + undefined, + ); + }); + + it("falls back to dokploy-network when domain network is internal", async () => { + vi.mocked(db.query.networks.findFirst).mockResolvedValue({ + networkId: "net-1", + networkName: "internal-network", + internal: true, + } as any); + + await connectTraefikToResourceNetworks( + "app-1", + "application", + undefined, + "net-1", + ); + + expect(ensureTraefikConnectedToNetwork).toHaveBeenCalledTimes(1); + expect(ensureTraefikConnectedToNetwork).toHaveBeenCalledWith( + "dokploy-network", + undefined, + ); + }); + + it("continues on Traefik connection errors and falls back to dokploy-network", async () => { + vi.mocked(db.query.networks.findFirst).mockResolvedValue({ + networkId: "net-1", + networkName: "custom-network", + internal: false, + } as any); + + vi.mocked(ensureTraefikConnectedToNetwork).mockRejectedValueOnce( + new Error("Connection failed"), + ); + + await expect( + connectTraefikToResourceNetworks( + "app-1", + "application", + undefined, + "net-1", + ), + ).resolves.not.toThrow(); + + // Should be called twice: once for custom-network (fails), once for fallback + expect(ensureTraefikConnectedToNetwork).toHaveBeenCalledTimes(2); + expect(ensureTraefikConnectedToNetwork).toHaveBeenNthCalledWith( + 1, + "custom-network", + undefined, + ); + expect(ensureTraefikConnectedToNetwork).toHaveBeenNthCalledWith( + 2, + "dokploy-network", + undefined, + ); + }); +}); diff --git a/apps/dokploy/__test__/network/schema.test.ts b/apps/dokploy/__test__/network/schema.test.ts new file mode 100644 index 0000000000..e716aaef95 --- /dev/null +++ b/apps/dokploy/__test__/network/schema.test.ts @@ -0,0 +1,326 @@ +import { + apiAssignNetworkToResource, + apiCreateNetwork, + apiRemoveNetworkFromResource, + apiUpdateNetwork, +} from "@dokploy/server/db/schema"; +import { describe, expect, it } from "vitest"; + +describe("Network Schema Validation", () => { + describe("apiCreateNetwork", () => { + it("validates correct network creation with all fields", () => { + const validNetwork = { + name: "My Production Network", + description: "Network for production services", + networkName: "prod-network", + driver: "bridge" as const, + subnet: "172.20.0.0/16", + gateway: "172.20.0.1", + ipRange: "172.20.10.0/24", + attachable: true, + internal: false, + organizationId: "org-123", + projectId: "proj-456", + serverId: "server-789", + }; + + expect(() => apiCreateNetwork.parse(validNetwork)).not.toThrow(); + }); + + it("validates minimal required fields", () => { + const minimalNetwork = { + name: "Test Network", + networkName: "test-net", + organizationId: "org-123", + }; + + expect(() => apiCreateNetwork.parse(minimalNetwork)).not.toThrow(); + }); + + it("validates overlay driver", () => { + const overlayNetwork = { + name: "Swarm Network", + networkName: "swarm-net", + driver: "overlay" as const, + organizationId: "org-123", + }; + + expect(() => apiCreateNetwork.parse(overlayNetwork)).not.toThrow(); + }); + + describe("networkName validation", () => { + it("accepts valid network names", () => { + const validNames = [ + "simple", + "with-hyphens", + "with_underscores", + "with.dots", + "AlphaNumerics123", + "a1b2c3", + ]; + + for (const name of validNames) { + expect(() => + apiCreateNetwork.parse({ + name: "Test", + networkName: name, + organizationId: "org-1", + }), + ).not.toThrow(); + } + }); + + it("rejects network names starting with non-alphanumeric", () => { + const invalidNames = ["-starts-with-hyphen", "_starts-with-underscore"]; + + for (const name of invalidNames) { + expect(() => + apiCreateNetwork.parse({ + name: "Test", + networkName: name, + organizationId: "org-1", + }), + ).toThrow(); + } + }); + + it("rejects empty network name", () => { + expect(() => + apiCreateNetwork.parse({ + name: "Test", + networkName: "", + organizationId: "org-1", + }), + ).toThrow(); + }); + + it("rejects network name exceeding 63 characters", () => { + const longName = "a".repeat(64); + expect(() => + apiCreateNetwork.parse({ + name: "Test", + networkName: longName, + organizationId: "org-1", + }), + ).toThrow(); + }); + }); + + describe("subnet validation (CIDR)", () => { + it("accepts valid CIDR formats", () => { + const validSubnets = [ + "172.20.0.0/16", + "10.0.0.0/8", + "192.168.1.0/24", + "172.31.0.0/20", + "10.10.10.0/32", + ]; + + for (const subnet of validSubnets) { + expect(() => + apiCreateNetwork.parse({ + name: "Test", + networkName: "test-net", + subnet, + organizationId: "org-1", + }), + ).not.toThrow(); + } + }); + + it("rejects invalid CIDR formats", () => { + const invalidSubnets = [ + "172.20.0.0", // Missing CIDR notation + "172.20.0.0/33", // Invalid CIDR range (max 32) + "256.0.0.0/16", // Invalid IP (256) + "172.20/16", // Incomplete IP + "not-an-ip/16", + ]; + + for (const subnet of invalidSubnets) { + expect(() => + apiCreateNetwork.parse({ + name: "Test", + networkName: "test-net", + subnet, + organizationId: "org-1", + }), + ).toThrow(); + } + }); + }); + + describe("gateway validation (IPv4)", () => { + it("accepts valid IPv4 addresses", () => { + const validGateways = [ + "172.20.0.1", + "10.0.0.1", + "192.168.1.1", + "255.255.255.255", + "0.0.0.0", + ]; + + for (const gateway of validGateways) { + expect(() => + apiCreateNetwork.parse({ + name: "Test", + networkName: "test-net", + gateway, + organizationId: "org-1", + }), + ).not.toThrow(); + } + }); + + it("rejects invalid IPv4 addresses", () => { + const invalidGateways = [ + "256.0.0.1", // Octet > 255 + "172.20.0", // Incomplete + "172.20.0.1.5", // Extra octet + "not-an-ip", + "172.20.0.1/24", // CIDR notation (not allowed for gateway) + ]; + + for (const gateway of invalidGateways) { + expect(() => + apiCreateNetwork.parse({ + name: "Test", + networkName: "test-net", + gateway, + organizationId: "org-1", + }), + ).toThrow(); + } + }); + }); + + describe("ipRange validation (CIDR)", () => { + it("accepts valid IP range CIDR", () => { + const validRanges = ["172.20.10.0/24", "10.0.1.0/28", "192.168.1.0/26"]; + + for (const ipRange of validRanges) { + expect(() => + apiCreateNetwork.parse({ + name: "Test", + networkName: "test-net", + ipRange, + organizationId: "org-1", + }), + ).not.toThrow(); + } + }); + + it("rejects invalid IP range formats", () => { + const invalidRanges = ["172.20.10.0", "not-a-range", "256.0.0.0/24"]; + + for (const ipRange of invalidRanges) { + expect(() => + apiCreateNetwork.parse({ + name: "Test", + networkName: "test-net", + ipRange, + organizationId: "org-1", + }), + ).toThrow(); + } + }); + }); + }); + + describe("apiUpdateNetwork", () => { + it("validates partial network update", () => { + const partialUpdate = { + networkId: "net-123", + name: "Updated Name", + description: "Updated description", + }; + + expect(() => apiUpdateNetwork.parse(partialUpdate)).not.toThrow(); + }); + + it("requires networkId", () => { + const missingId = { + name: "Updated Name", + }; + + expect(() => apiUpdateNetwork.parse(missingId)).toThrow(); + }); + }); + + describe("apiAssignNetworkToResource", () => { + it("validates correct assignment", () => { + const validAssignment = { + networkId: "net-123", + resourceId: "app-456", + resourceType: "application" as const, + }; + + expect(() => + apiAssignNetworkToResource.parse(validAssignment), + ).not.toThrow(); + }); + + it("validates all resource types", () => { + const resourceTypes = [ + "application", + "compose", + "postgres", + "mysql", + "mariadb", + "mongo", + "redis", + ] as const; + + for (const resourceType of resourceTypes) { + expect(() => + apiAssignNetworkToResource.parse({ + networkId: "net-1", + resourceId: "res-1", + resourceType, + }), + ).not.toThrow(); + } + }); + + it("rejects invalid resource type", () => { + expect(() => + apiAssignNetworkToResource.parse({ + networkId: "net-1", + resourceId: "res-1", + resourceType: "invalid-type", + }), + ).toThrow(); + }); + + it("requires all fields", () => { + expect(() => + apiAssignNetworkToResource.parse({ + networkId: "net-1", + resourceId: "res-1", + }), + ).toThrow(); + }); + }); + + describe("apiRemoveNetworkFromResource", () => { + it("validates correct removal", () => { + const validRemoval = { + networkId: "net-123", + resourceId: "app-456", + resourceType: "application" as const, + }; + + expect(() => + apiRemoveNetworkFromResource.parse(validRemoval), + ).not.toThrow(); + }); + + it("requires all fields", () => { + expect(() => + apiRemoveNetworkFromResource.parse({ + networkId: "net-1", + }), + ).toThrow(); + }); + }); +}); diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 9121dc8a1f..e98b7d6844 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -48,6 +48,7 @@ const baseApp: ApplicationNested = { previewLimit: 0, previewCustomCertResolver: null, previewWildcard: "", + previewNetworkIds: null, environmentId: "", environment: { env: "", @@ -125,6 +126,7 @@ const baseApp: ApplicationNested = { username: null, dockerContextPath: null, stopGracePeriodSwarm: null, + customNetworkIds: null, ulimitsSwarm: null, }; @@ -145,6 +147,7 @@ const baseDomain: Domain = { previewDeploymentId: "", internalPath: "/", stripPath: false, + networkId: null, }; const baseRedirect: Redirect = { diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index 6af0e1e8c9..a0cd0666bb 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -66,6 +66,7 @@ export const domain = z customCertResolver: z.string().optional(), serviceName: z.string().optional(), domainType: z.enum(["application", "compose", "preview"]).optional(), + networkId: z.string().nullable().optional(), }) .superRefine((input, ctx) => { if (input.https && !input.certificateType) { @@ -188,6 +189,17 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { }, ); + const { data: availableNetworks, isLoading: isLoadingNetworks } = + api.network.getResourceNetworksForDomain.useQuery( + { + resourceId: id, + resourceType: type, + }, + { + enabled: !!id, + }, + ); + const form = useForm({ resolver: zodResolver(domain), defaultValues: { @@ -201,6 +213,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { customCertResolver: undefined, serviceName: undefined, domainType: type, + networkId: null, }, mode: "onChange", }); @@ -224,6 +237,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { customCertResolver: data?.customCertResolver || undefined, serviceName: data?.serviceName || undefined, domainType: data?.domainType || type, + networkId: data?.networkId ?? null, // Keep null as is for proper Select handling }); } @@ -238,10 +252,33 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { certificateType: undefined, customCertResolver: undefined, domainType: type, + networkId: null, }); } }, [form, data, isLoading, domainId]); + useEffect(() => { + if ( + !domainId && + !isLoadingNetworks && + availableNetworks && + availableNetworks.length > 0 + ) { + const currentNetworkId = form.getValues("networkId"); + + if (currentNetworkId === null) { + const firstNetwork = availableNetworks[0]; + if (firstNetwork) { + const networkValue = + firstNetwork.networkId === "default" + ? null + : firstNetwork.networkId; + form.setValue("networkId", networkValue); + } + } + } + }, [domainId, isLoadingNetworks, availableNetworks, form]); + // Separate effect for handling custom cert resolver validation useEffect(() => { if (certificateType === "custom") { @@ -484,6 +521,57 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { )} + ( + + Routing Network (Optional) + + + Select which network Traefik should use to route traffic + to this domain. Only non-internal networks available to + this {type} are shown. + + + + )} + /> + { }, ); + const { data: assignedNetworks } = api.network.getResourceNetworks.useQuery({ + resourceId: id, + resourceType: type, + }); + + const hasOnlyInternalNetworks = + assignedNetworks && + assignedNetworks.length > 0 && + assignedNetworks.every((n) => n.internal); + const { mutateAsync: validateDomain } = api.domain.validateDomain.useMutation(); const { mutateAsync: deleteDomain, isLoading: isRemoving } = @@ -158,7 +169,18 @@ export const ShowDomains = ({ id, type }: Props) => { )} - + + {hasOnlyInternalNetworks && data && data.length > 0 && ( + + + + This {type} has custom networks assigned, but they are all + internal. Domains will not be accessible because Traefik cannot + connect to internal networks. Go to the Network tab and assign + at least one non-internal network. + + + )} {isLoadingDomains ? (
@@ -298,6 +320,25 @@ export const ShowDomains = ({ id, type }: Props) => { + + + + + + Network:{" "} + {item.network?.name || + "Default (dokploy-network)"} + + + +

+ Network used by Traefik for routing to this + domain +

+
+
+
+ diff --git a/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx b/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx index bb9321a51b..82a48a29e8 100644 --- a/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx +++ b/apps/dokploy/components/dashboard/application/preview-deployments/add-preview-domain.tsx @@ -82,6 +82,17 @@ export const AddPreviewDomain = ({ const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } = api.domain.generateDomain.useMutation(); + const { data: availableNetworks, isLoading: isLoadingNetworks } = + api.network.getAllNetworksByServer.useQuery( + { + serverId: previewDeployment?.application?.serverId ?? null, + resourceType: "application", + }, + { + enabled: previewDeployment?.application !== undefined, + }, + ); + const form = useForm({ resolver: zodResolver(domain), }); @@ -97,6 +108,7 @@ export const AddPreviewDomain = ({ path: data?.path || undefined, port: data?.port || undefined, customCertResolver: data?.customCertResolver || undefined, + networkId: data?.networkId ?? null, }); } @@ -155,6 +167,55 @@ export const AddPreviewDomain = ({ >
+ ( + + Network + + + Select which network Traefik should use to route traffic + to this domain. Must be one of the networks configured + in the preview deployment settings. + + + + )} + /> + { const { data, refetch } = api.application.one.useQuery({ applicationId }); + const { data: availableNetworks, isLoading: isLoadingNetworks } = + api.network.getAllNetworksByServer.useQuery( + { + serverId: data?.serverId ?? null, + resourceType: "application", + }, + { + enabled: data !== undefined, + }, + ); + const form = useForm({ defaultValues: { env: "", @@ -92,6 +104,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { port: 3000, previewLimit: 3, previewLabels: [], + previewNetworkIds: [], previewHttps: false, previewPath: "/", previewCertificateType: "none", @@ -117,6 +130,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { wildcardDomain: data.previewWildcard || "*.traefik.me", port: data.previewPort || 3000, previewLabels: data.previewLabels || [], + previewNetworkIds: data.previewNetworkIds || [], previewLimit: data.previewLimit || 3, previewHttps: data.previewHttps || false, previewPath: data.previewPath || "/", @@ -136,6 +150,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { previewWildcard: formData.wildcardDomain, previewPort: formData.port, previewLabels: formData.previewLabels, + previewNetworkIds: formData.previewNetworkIds, applicationId, previewLimit: formData.previewLimit, previewHttps: formData.previewHttps, @@ -321,6 +336,60 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => { )} /> + ( + + Preview Networks + + Select networks that preview deployments will use. + Leave empty to inherit all networks from the + application. + +
+ {isLoadingNetworks ? ( +
Loading networks...
+ ) : availableNetworks && + availableNetworks.length > 0 ? ( + availableNetworks.map((network) => ( + { + const current = field.value || []; + if (current.includes(network.networkId)) { + field.onChange( + current.filter( + (id) => id !== network.networkId, + ), + ); + } else { + field.onChange([ + ...current, + network.networkId, + ]); + } + }} + > + {network.name} ({network.networkName}) + + )) + ) : ( +

+ No compatible networks available on this server. +

+ )} +
+ +
+ )} + /> ; - -export const IsolatedDeploymentTab = ({ composeId }: Props) => { - const utils = api.useUtils(); - const [compose, setCompose] = useState(""); - const [isPreviewLoading, setIsPreviewLoading] = useState(false); - const { mutateAsync, error, isError } = - api.compose.isolatedDeployment.useMutation(); - - const [isOpenPreview, setIsOpenPreview] = useState(false); - - const { mutateAsync: updateCompose } = api.compose.update.useMutation(); - - const { data, refetch } = api.compose.one.useQuery( - { composeId }, - { enabled: !!composeId }, - ); - - const form = useForm({ - defaultValues: { - isolatedDeployment: false, - }, - resolver: zodResolver(isolatedSchema), - }); - - useEffect(() => { - if (data) { - form.reset({ - isolatedDeployment: data?.isolatedDeployment || false, - }); - } - }, [form, form.reset, form.formState.isSubmitSuccessful, data]); - - const onSubmit = async (formData: IsolatedSchema) => { - await updateCompose({ - composeId, - isolatedDeployment: formData?.isolatedDeployment || false, - }) - .then(async (_data) => { - await refetch(); - toast.success("Compose updated"); - }) - .catch(() => { - toast.error("Error updating the compose"); - }); - }; - - const generatePreview = async () => { - setIsOpenPreview(true); - setIsPreviewLoading(true); - try { - await mutateAsync({ - composeId, - suffix: data?.appName || "", - }).then(async (data) => { - await utils.project.all.invalidate(); - setCompose(data); - }); - } catch { - toast.error("Error generating preview"); - setIsOpenPreview(false); - } finally { - setIsPreviewLoading(false); - } - }; - - return ( - - - Enable Isolated Deployment - - Configure isolated deployment to the compose file. -
- - This feature creates an isolated environment for your deployment - by adding unique prefixes to all resources. It establishes a - dedicated network based on your compose file's name, ensuring your - services run in isolation. This prevents conflicts when running - multiple instances of the same template or services with identical - names. - -
-
-

- Resources that will be isolated: -

-
    -
  • Docker networks
  • -
-
-
-
-
-
- -
- {isError && {error?.message}} -
- - {isError && ( -
- - - {error?.message} - -
- )} - -
-
- ( - -
- - Enable Isolated Deployment ({data?.appName}) - - - Enable isolated deployment to the compose file. - -
- - - -
- )} - /> -
- -
- -
-
- -
- - - - - Isolated Deployment Preview - - Preview of the compose file with isolated deployment - configuration - - -
- {isPreviewLoading ? ( -
- -

- Generating compose preview... -

-
- ) : ( -
-													
-												
- )} -
-
-
-
-
- -
-
-
- ); -}; diff --git a/apps/dokploy/components/dashboard/network/assign-network-to-resource.tsx b/apps/dokploy/components/dashboard/network/assign-network-to-resource.tsx new file mode 100644 index 0000000000..2721a26416 --- /dev/null +++ b/apps/dokploy/components/dashboard/network/assign-network-to-resource.tsx @@ -0,0 +1,352 @@ +import { Info, Network, Plus, X } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { api } from "@/utils/api"; + +interface Props { + resourceId: string; + resourceType: + | "application" + | "compose" + | "postgres" + | "mysql" + | "mariadb" + | "mongo" + | "redis"; + composeType?: "docker-compose" | "stack"; + showCard?: boolean; +} + +const getResourceTypeName = (type: Props["resourceType"]): string => { + const names: Record = { + application: "application", + compose: "compose service", + postgres: "database", + mysql: "database", + mariadb: "database", + mongo: "database", + redis: "database", + }; + return names[type]; +}; + +const isSwarmResource = ( + type: Props["resourceType"], + composeType?: Props["composeType"], +): boolean => { + if (type === "compose") { + return composeType === "stack"; + } + return true; +}; + +export const AssignNetworkToResource = ({ + resourceId, + resourceType, + composeType, + showCard = false, +}: Props) => { + const [open, setOpen] = useState(false); + const utils = api.useUtils(); + + const { data: availableNetworks } = api.network.allForResource.useQuery({ + resourceType, + resourceId, + composeType, + }); + + const { data: assignedNetworks } = api.network.getResourceNetworks.useQuery({ + resourceId, + resourceType, + }); + + const { data: resourceDomains } = + resourceType === "application" + ? api.domain.byApplicationId.useQuery({ applicationId: resourceId }) + : resourceType === "compose" + ? api.domain.byComposeId.useQuery({ composeId: resourceId }) + : { data: [] }; + + const hasDomains = resourceDomains && resourceDomains.length > 0; + + const { mutateAsync: assignNetwork, isLoading: isAssigning } = + api.network.assignToResource.useMutation(); + + const { mutateAsync: removeNetwork, isLoading: isRemoving } = + api.network.removeFromResource.useMutation(); + + const assignedNetworkIds = assignedNetworks?.map((n) => n.networkId) || []; + + const availableToAssign = availableNetworks?.filter( + (network) => !assignedNetworkIds.includes(network.networkId), + ); + + const wouldBeAllInternal = (networkIdToAssign: string): boolean => { + const currentAssignedNetworks = assignedNetworks || []; + const networkToAssign = availableNetworks?.find( + (n) => n.networkId === networkIdToAssign, + ); + + if (!networkToAssign) return false; + + const allNetworksAfterAssign = [ + ...currentAssignedNetworks, + networkToAssign, + ]; + + return allNetworksAfterAssign.every((n) => n.internal); + }; + + const handleAssign = async (networkId: string) => { + try { + await assignNetwork({ + networkId, + resourceId, + resourceType, + }); + + toast.success("Network assigned successfully"); + await utils.network.getResourceNetworks.invalidate({ + resourceId, + resourceType, + }); + setOpen(false); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to assign network"; + toast.error(message); + } + }; + + const handleRemove = async (networkId: string) => { + try { + await removeNetwork({ + networkId, + resourceId, + resourceType, + }); + + toast.success("Network removed successfully"); + await utils.network.getResourceNetworks.invalidate({ + resourceId, + resourceType, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to remove network"; + toast.error(message); + } + }; + + const resourceTypeName = getResourceTypeName(resourceType); + const isSwarm = isSwarmResource(resourceType, composeType); + const isDatabaseType = [ + "postgres", + "mysql", + "mariadb", + "mongo", + "redis", + ].includes(resourceType); + + const content = ( +
+ {isSwarm && ( + + + + {isDatabaseType ? "Databases" : "Applications"} are deployed as + Docker Swarm services and can only use overlay{" "} + networks. Only overlay networks are shown below. + + + )} + + {resourceType === "compose" && composeType === "docker-compose" && ( + + + + This compose service uses docker-compose mode and + can use both bridge and overlay{" "} + networks. + + + )} + +
+
+

Custom Networks

+

+ Assign this {resourceTypeName} to custom networks for isolation +

+
+ + + + + + + + No networks found. + + + {availableToAssign?.map((network) => { + const showWarning = + hasDomains && wouldBeAllInternal(network.networkId); + + return ( + handleAssign(network.networkId)} + className="cursor-pointer" + > + +
+
{network.name}
+
+ {network.networkName} • {network.driver} + {network.internal && " (internal)"} + {network.server && ` • ${network.server.name}`} +
+ {showWarning && ( +
+ Warning: This will make all networks internal. + Domains will be inaccessible. +
+ )} +
+
+ ); + })} +
+
+
+
+
+
+ + {hasDomains && + assignedNetworks && + assignedNetworks.length > 0 && + assignedNetworks.every((n) => n.internal) && ( + + + + This {resourceTypeName} has {resourceDomains?.length || 0}{" "} + domain(s) but all assigned networks are internal. Domains will not + be accessible because Traefik cannot connect to internal networks. + Assign at least one non-internal network to make domains + accessible. + + + )} + + {assignedNetworks && assignedNetworks.length > 0 ? ( + <> +
+ {assignedNetworks.map((network) => ( + + + {network.name} + + + ))} +
+ {isDatabaseType ? ( +
+

+ Network Isolation Active: This database is + disconnected from{" "} + + dokploy-network + {" "} + and only accessible through the custom networks assigned above. +

+

+ This enhances security by limiting connectivity to explicitly + allowed networks only. +

+
+ ) : ( +

+ This {resourceTypeName} will not be connected to{" "} + + dokploy-network + {" "} + and will only use the networks assigned above. +

+ )} + + ) : ( +
+ + + No custom networks assigned. This {resourceTypeName} will use the + default dokploy-network. + +
+ )} + + {!availableNetworks || availableNetworks.length === 0 ? ( +
+

+ No custom networks available. Create a network first. +

+
+ ) : null} +
+ ); + + if (showCard) { + return ( + + + Network Configuration + + Manage network connectivity for this {resourceTypeName} + + + {content} + + ); + } + + return content; +}; diff --git a/apps/dokploy/components/dashboard/network/create-network.tsx b/apps/dokploy/components/dashboard/network/create-network.tsx new file mode 100644 index 0000000000..ae14527f68 --- /dev/null +++ b/apps/dokploy/components/dashboard/network/create-network.tsx @@ -0,0 +1,424 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { Network } from "lucide-react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; + +const IPV4_REGEX = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; +const CIDR_REGEX = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/(?:[0-9]|[1-2][0-9]|3[0-2])$/; + +const CreateNetworkSchema = z.object({ + name: z.string().min(1, { + message: "Name is required", + }), + networkName: z + .string() + .min(1, { message: "Network name is required" }) + .max(63, { message: "Network name must be 63 characters or less" }) + .regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/, { + message: + "Network name must start with alphanumeric and contain only alphanumeric, underscore, period, or hyphen", + }), + description: z.string().optional(), + driver: z.enum(["bridge", "overlay"]), + subnet: z + .string() + .regex(CIDR_REGEX, "Invalid subnet format (e.g., 172.20.0.0/16)") + .optional() + .or(z.literal("")), + gateway: z + .string() + .regex(IPV4_REGEX, "Invalid gateway IP address") + .optional() + .or(z.literal("")), + ipRange: z + .string() + .regex(CIDR_REGEX, "Invalid IP range format") + .optional() + .or(z.literal("")), + internal: z.boolean().default(false), + encrypted: z.boolean().default(false), + serverId: z.string().nullable().optional(), +}); + +type CreateNetwork = z.infer; + +interface Props { + projectId?: string | null; +} + +export const CreateNetwork = ({ projectId }: Props) => { + const [visible, setVisible] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const utils = api.useUtils(); + + const { mutateAsync, isLoading } = api.network.create.useMutation(); + const { data: servers } = api.server.all.useQuery(); + + const form = useForm({ + defaultValues: { + name: "", + networkName: "", + description: "", + driver: "bridge", + subnet: "", + gateway: "", + ipRange: "", + internal: false, + encrypted: false, + serverId: null, + }, + resolver: zodResolver(CreateNetworkSchema), + }); + + const onSubmit = async (data: CreateNetwork) => { + setErrorMessage(null); + try { + await mutateAsync({ + ...data, + projectId: projectId || undefined, + serverId: + data.serverId === null ? undefined : data.serverId || undefined, + subnet: data.subnet || undefined, + gateway: data.gateway || undefined, + ipRange: data.ipRange || undefined, + }); + + toast.success("Network created successfully"); + await utils.network.all.invalidate(); + form.reset(); + setVisible(false); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to create network"; + setErrorMessage(message); + console.error("Failed to create network:", error); + } + }; + + const handleOpenChange = (open: boolean) => { + setVisible(open); + if (!open) { + setErrorMessage(null); + form.reset(); + } + }; + + return ( + + + + + + + Create Custom Network + + Create an isolated Docker network for your services + + + + {errorMessage && {errorMessage}} + +
+ +
+ ( + + Name + + + + + A friendly name for this network + + + + )} + /> + + ( + + Docker Network Name + + + + + The actual Docker network name (lowercase, alphanumeric, + dashes, dots, underscores) + + + + )} + /> + + ( + + Description + +