diff --git a/apps/dokploy/__test__/compose/compose-project-directory.test.ts b/apps/dokploy/__test__/compose/compose-project-directory.test.ts index a117de5b2..6c409cc77 100644 --- a/apps/dokploy/__test__/compose/compose-project-directory.test.ts +++ b/apps/dokploy/__test__/compose/compose-project-directory.test.ts @@ -55,4 +55,69 @@ describe("compose createCommand --project-directory", () => { ); expect(cmd).toContain("-f docker-compose.yml"); }); + + it("resolves build.context against the compose file's own directory when there are no mounts", () => { + const cmd = createCommand({ + ...base, + composePath: "./backend/docker-compose.yml", + } as any); + + expect(cmd).not.toContain("--project-directory"); + expect(cmd).toContain("-f ./backend/docker-compose.yml"); + }); +}); + +describe("compose createCommand --env-file", () => { + it("points --env-file at the generated .env next to a nested compose file", () => { + const cmd = createCommand( + { + ...base, + composePath: "./deploy/docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain("--env-file deploy/.env"); + expect(cmd).toContain( + "--project-directory /etc/dokploy/compose/compose-app/code", + ); + }); + + it("omits --env-file when createEnvFile is disabled", () => { + const cmd = createCommand( + { ...base, composePath: "./deploy/docker-compose.yml" } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).not.toContain("--env-file"); + }); + + it("uses the code-root .env for raw sourceType", () => { + const cmd = createCommand( + { + ...base, + sourceType: "raw", + composePath: "docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).toContain("--env-file .env"); + }); + + it("does not add --env-file to stack deploy (unsupported flag)", () => { + const cmd = createCommand( + { + ...base, + composeType: "stack", + composePath: "./deploy/docker-compose.yml", + createEnvFile: true, + } as any, + "/etc/dokploy/compose/compose-app/code", + ); + + expect(cmd).not.toContain("--env-file"); + }); }); diff --git a/apps/dokploy/__test__/dns/cloudflare.test.ts b/apps/dokploy/__test__/dns/cloudflare.test.ts index 356d429a4..f27e7bc60 100644 --- a/apps/dokploy/__test__/dns/cloudflare.test.ts +++ b/apps/dokploy/__test__/dns/cloudflare.test.ts @@ -87,6 +87,224 @@ describe("cloudflareClient.listRecords", () => { }); }); +describe("cloudflareClient MX priority", () => { + it("inlines the priority into the content when listing", async () => { + mockFetch.mockResolvedValue( + cfSuccess([ + { + id: "mx-1", + type: "MX", + name: "example.com", + content: "mail.example.com", + ttl: 300, + priority: 20, + }, + ]), + ); + + const records = await cloudflareClient.listRecords(config, "zone-1"); + + expect(records[0]?.content).toBe("20 mail.example.com"); + }); + + it("splits the priority back out when writing", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "mx-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "MX", + name: "example.com", + content: "20 mail.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toMatchObject({ + content: "mail.example.com", + priority: 20, + }); + }); + + it("falls back to priority 10 when the content has no leading number", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "mx-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "MX", + name: "example.com", + content: "mail.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toMatchObject({ + content: "mail.example.com", + priority: 10, + }); + }); + + it("leaves non-MX content untouched", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "txt-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "TXT", + name: "example.com", + content: "10 not-a-priority", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.content).toBe("10 not-a-priority"); + expect(body.priority).toBeUndefined(); + }); +}); + +describe("cloudflareClient structured records", () => { + const stubCreate = () => + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "rec-1" })); + + it("sends SRV values as structured data", async () => { + stubCreate(); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "SRV", + name: "_sip._tcp.example.com", + content: "1 10 5269 talk.example.com", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.data).toEqual({ + priority: 1, + weight: 10, + port: 5269, + target: "talk.example.com", + }); + expect(body.content).toBeUndefined(); + }); + + it("sends CAA values as structured data and drops the quotes", async () => { + stubCreate(); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "CAA", + name: "example.com", + content: '0 issue "letsencrypt.org"', + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(init.body as string); + expect(body.data).toEqual({ + flags: 0, + tag: "issue", + value: "letsencrypt.org", + }); + expect(body.content).toBeUndefined(); + }); + + it("rejects an SRV value that is missing a part", async () => { + await expect( + cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "SRV", + name: "_sip._tcp.example.com", + content: "1 10 5269", + }), + ).rejects.toThrow(/SRV value/); + }); + + it("rejects a CAA value that has no tag", async () => { + await expect( + cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "CAA", + name: "example.com", + content: "0", + }), + ).rejects.toThrow(/CAA value/); + }); +}); + +describe("cloudflareClient proxy status", () => { + it("sends the proxy status for proxiable types", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "a-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + proxied: true, + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string).proxied).toBe(true); + }); + + it("omits the proxy status for types Cloudflare cannot proxy", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "txt-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "TXT", + name: "example.com", + content: "hello", + proxied: true, + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(JSON.parse(init.body as string).proxied).toBeUndefined(); + }); + + it("leaves the proxy status untouched when the caller does not set it", async () => { + mockFetch + .mockResolvedValueOnce(cfSuccess([])) + .mockResolvedValueOnce(cfSuccess({ id: "a-1" })); + + await cloudflareClient.upsertRecord(config, { + zoneId: "zone-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + const [, init] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect("proxied" in JSON.parse(init.body as string)).toBe(false); + }); + + it("returns the proxy status when listing records", async () => { + mockFetch.mockResolvedValue( + cfSuccess([ + { + id: "a-1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 1, + proxied: true, + }, + ]), + ); + + const records = await cloudflareClient.listRecords(config, "zone-1"); + + expect(records[0]?.proxied).toBe(true); + }); +}); + describe("cloudflareClient.upsertRecord", () => { it("creates a record when none exists for the name/type", async () => { mockFetch diff --git a/apps/dokploy/__test__/dns/porkbun.test.ts b/apps/dokploy/__test__/dns/porkbun.test.ts new file mode 100644 index 000000000..2275e9c5f --- /dev/null +++ b/apps/dokploy/__test__/dns/porkbun.test.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetch = vi.fn(); +global.fetch = mockFetch as typeof fetch; + +import { porkbunClient } from "@dokploy/server/utils/dns/porkbun"; + +const jsonResponse = (body: unknown, ok = true, status = 200) => + ({ + ok, + status, + json: async () => body, + }) as Response; + +const pbSuccess = (result: Record = {}) => + jsonResponse({ status: "SUCCESS", ...result }); + +const pbError = (message: string, status = 400) => + jsonResponse({ status: "ERROR", message }, false, status); + +const config = { + providerType: "porkbun" as const, + apiKey: "pk1_test", + secretApiKey: "sk1_test", +}; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("porkbunClient.listZones", () => { + it("lists all domains as zones", async () => { + mockFetch.mockResolvedValue( + pbSuccess({ domains: [{ domain: "example.com" }] }), + ); + + const zones = await porkbunClient.listZones(config); + + expect(zones).toEqual([{ id: "example.com", name: "example.com" }]); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/domain/listAll"); + const body = JSON.parse(init.body as string); + expect(body).toMatchObject({ + apikey: "pk1_test", + secretapikey: "sk1_test", + }); + }); +}); + +describe("porkbunClient.listRecords", () => { + it("lists records for a domain", async () => { + mockFetch.mockResolvedValue( + pbSuccess({ + records: [ + { + id: "1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: "600", + prio: "0", + notes: "", + }, + ], + }), + ); + + const records = await porkbunClient.listRecords(config, "example.com"); + + expect(records).toEqual([ + { + id: "1", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + ttl: 600, + }, + ]); + expect(mockFetch.mock.calls[0]?.[0]).toContain("/dns/retrieve/example.com"); + }); +}); + +describe("porkbunClient.upsertRecord", () => { + it("creates a record when none exists for the name/type", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [] })) + .mockResolvedValueOnce(pbSuccess({ id: "new-1" })); + + const result = await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "1.2.3.4", + }); + + expect(result).toEqual({ id: "new-1" }); + const [lookupUrl] = mockFetch.mock.calls[0] as [string]; + expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/app"); + const [createUrl, createInit] = mockFetch.mock.calls[1] as [ + string, + RequestInit, + ]; + expect(createUrl).toContain("/dns/create/example.com"); + const body = JSON.parse(createInit.body as string); + expect(body).toMatchObject({ name: "app", type: "A", content: "1.2.3.4" }); + }); + + it("resolves the apex domain to an empty subdomain", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [] })) + .mockResolvedValueOnce(pbSuccess({ id: "new-2" })); + + await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "example.com", + content: "1.2.3.4", + }); + + const [lookupUrl] = mockFetch.mock.calls[0] as [string]; + expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/"); + }); + + it("edits the existing record instead of creating a duplicate", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [{ id: "existing-1" }] })) + .mockResolvedValueOnce(pbSuccess({})); + + const result = await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "A", + name: "app.example.com", + content: "5.6.7.8", + }); + + expect(result).toEqual({ id: "existing-1" }); + const [editUrl] = mockFetch.mock.calls[1] as [string, RequestInit]; + expect(editUrl).toContain("/dns/edit/example.com/existing-1"); + }); + + it("defaults ttl to 600 when not provided", async () => { + mockFetch + .mockResolvedValueOnce(pbSuccess({ records: [] })) + .mockResolvedValueOnce(pbSuccess({ id: "new-1" })); + + await porkbunClient.upsertRecord(config, { + zoneId: "example.com", + type: "CNAME", + name: "www.example.com", + content: "example.com", + }); + + const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit]; + const body = JSON.parse(createInit.body as string); + expect(body.ttl).toBe(600); + }); +}); + +describe("porkbunClient.updateRecord", () => { + it("edits the given record id", async () => { + mockFetch.mockResolvedValue(pbSuccess({})); + + const result = await porkbunClient.updateRecord( + config, + "example.com", + "1", + { + type: "A", + name: "app.example.com", + content: "9.9.9.9", + ttl: 300, + }, + ); + + expect(result).toEqual({ id: "1" }); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toContain("/dns/edit/example.com/1"); + expect(JSON.parse(init.body as string)).toMatchObject({ + name: "app", + type: "A", + content: "9.9.9.9", + ttl: 300, + }); + }); +}); + +describe("porkbunClient.deleteRecord", () => { + it("posts to the delete endpoint for the given record id", async () => { + mockFetch.mockResolvedValue(pbSuccess({})); + + await porkbunClient.deleteRecord(config, "example.com", "1"); + + const [url] = mockFetch.mock.calls[0] as [string]; + expect(url).toContain("/dns/delete/example.com/1"); + }); +}); + +describe("porkbunClient.testConnection", () => { + it("succeeds when the credentials can ping the API", async () => { + mockFetch.mockResolvedValue(pbSuccess({})); + await expect(porkbunClient.testConnection(config)).resolves.toBeUndefined(); + }); + + it("surfaces Porkbun's error message on invalid credentials", async () => { + mockFetch.mockResolvedValue(pbError("Invalid API key.")); + + await expect(porkbunClient.testConnection(config)).rejects.toThrow( + "Invalid API key.", + ); + }); +}); diff --git a/apps/dokploy/__test__/dns/route53.test.ts b/apps/dokploy/__test__/dns/route53.test.ts index cd344e201..6b3e0ab62 100644 --- a/apps/dokploy/__test__/dns/route53.test.ts +++ b/apps/dokploy/__test__/dns/route53.test.ts @@ -142,13 +142,15 @@ describe("route53Client.listRecords", () => { const records = await route53Client.listRecords(config, "Z123"); - expect(records[0]?.content).toBe("ns1.example.com, ns2.example.com"); + expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com"); }); }); describe("route53Client.upsertRecord", () => { - it("sends a single UPSERT change", async () => { - send.mockResolvedValueOnce({}); + it("sends a single UPSERT change when nothing exists yet", async () => { + send + .mockResolvedValueOnce({ ResourceRecordSets: [] }) + .mockResolvedValueOnce({}); const result = await route53Client.upsertRecord(config, { zoneId: "Z123", @@ -158,7 +160,7 @@ describe("route53Client.upsertRecord", () => { }); expect(result).toEqual({ id: "A:app.example.com" }); - const command = send.mock.calls[0]?.[0] as HasInput; + const command = send.mock.calls[1]?.[0] as HasInput; expect(command.input.HostedZoneId).toBe("Z123"); expect(command.input.ChangeBatch.Changes).toEqual([ { @@ -172,6 +174,82 @@ describe("route53Client.upsertRecord", () => { }, ]); }); + + it("keeps the values already in the record set", async () => { + send + .mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "app.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }], + }, + ], + }) + .mockResolvedValueOnce({}); + + await route53Client.upsertRecord(config, { + zoneId: "Z123", + type: "A", + name: "app.example.com", + content: "3.3.3.3", + }); + + const command = send.mock.calls[1]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([ + { Value: "1.1.1.1" }, + { Value: "2.2.2.2" }, + { Value: "3.3.3.3" }, + ]); + }); + + it("does not duplicate a value that is already in the record set", async () => { + send + .mockResolvedValueOnce({ + ResourceRecordSets: [ + { + Name: "app.example.com.", + Type: "A", + TTL: 300, + ResourceRecords: [{ Value: "1.1.1.1" }], + }, + ], + }) + .mockResolvedValueOnce({}); + + await route53Client.upsertRecord(config, { + zoneId: "Z123", + type: "A", + name: "app.example.com", + content: "1.1.1.1", + }); + + const command = send.mock.calls[1]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([{ Value: "1.1.1.1" }]); + }); + + it("wraps unquoted TXT values in double quotes", async () => { + send + .mockResolvedValueOnce({ ResourceRecordSets: [] }) + .mockResolvedValueOnce({}); + + await route53Client.upsertRecord(config, { + zoneId: "Z123", + type: "TXT", + name: "example.com", + content: 'v=spf1 ~all\n"already quoted"', + }); + + const command = send.mock.calls[1]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]); + }); }); describe("route53Client.updateRecord", () => { @@ -222,6 +300,25 @@ describe("route53Client.updateRecord", () => { ]); }); + it("keeps every line of a multi-value record set", async () => { + send.mockResolvedValueOnce({}); + + await route53Client.updateRecord(config, "Z123", "NS:example.com", { + type: "NS", + name: "example.com", + content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ", + }); + + const command = send.mock.calls[0]?.[0] as HasInput; + expect( + command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords, + ).toEqual([ + { Value: "ns1.example.com" }, + { Value: "ns2.example.com" }, + { Value: "ns3.example.com" }, + ]); + }); + it("skips the DELETE when the old record no longer exists", async () => { send .mockResolvedValueOnce({ ResourceRecordSets: [] }) diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts index a9e18526c..ea57b6800 100644 --- a/apps/dokploy/__test__/env/vault.test.ts +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -20,6 +20,7 @@ import { import { azureClient } from "@dokploy/server/utils/vault/azure"; import { dopplerClient } from "@dokploy/server/utils/vault/doppler"; import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp"; +import { phaseClient } from "@dokploy/server/utils/vault/phase"; import { scalewayClient } from "@dokploy/server/utils/vault/scaleway"; const mockFetch = vi.fn(); @@ -616,3 +617,129 @@ describe("scaleway client", () => { expect(result).toBe("DB_PASSWORD=s3cret"); }); }); + +describe("phase client", () => { + const config = { + providerType: "phase" as const, + token: "phase-rest-token", + appId: "app-123", + env: "Production", + path: "/", + apiUrl: "https://api.phase.dev", + }; + + it("fetches secrets by key and sends ServiceAccount auth", async () => { + mockFetch.mockResolvedValue( + jsonResponse([ + { key: "DB_URL", value: "postgres://real", path: "/" }, + { key: "API_KEY", value: "key-123", path: "/" }, + ]), + ); + + const result = await phaseClient.getSecrets(config, ["DB_URL", "API_KEY"]); + + expect(result).toEqual({ DB_URL: "postgres://real", API_KEY: "key-123" }); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + "https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F", + ); + expect((init.headers as Record).Authorization).toBe( + "Bearer ServiceAccount phase-rest-token", + ); + }); + + it("throws when a requested secret is missing", async () => { + mockFetch.mockResolvedValue( + jsonResponse([{ key: "DB_URL", value: "postgres://real", path: "/" }]), + ); + + await expect(phaseClient.getSecrets(config, ["MISSING"])).rejects.toThrow( + 'secret "MISSING" not found in environment "Production"', + ); + }); + + it("reports authentication failures with the status code", async () => { + mockFetch.mockResolvedValue( + jsonResponse({ error: "unauthorized" }, false, 401), + ); + + await expect(phaseClient.getSecrets(config, ["DB_URL"])).rejects.toThrow( + "authentication failed (status 401: unauthorized)", + ); + }); + + it("rejects apps without SSE enabled during testConnection", async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ + id: "app-123", + name: "My App", + sseEnabled: false, + }), + ); + + await expect(phaseClient.testConnection(config)).rejects.toThrow( + "enable Server-side Encryption (SSE)", + ); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch.mock.calls[0]?.[0]).toBe( + "https://api.phase.dev/v1/apps/app-123/", + ); + }); + + it("tests connection against the app then secrets listing", async () => { + mockFetch + .mockResolvedValueOnce( + jsonResponse({ + id: "app-123", + name: "My App", + sseEnabled: true, + }), + ) + .mockResolvedValueOnce(jsonResponse([])); + + await phaseClient.testConnection(config); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(mockFetch.mock.calls[0]?.[0]).toBe( + "https://api.phase.dev/v1/apps/app-123/", + ); + expect(mockFetch.mock.calls[1]?.[0]).toBe( + "https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F", + ); + }); + + it("lists secret names from the configured path", async () => { + mockFetch.mockResolvedValue( + jsonResponse([ + { key: "DB_URL", value: "x", path: "/" }, + { key: "API_KEY", value: "y", path: "/" }, + ]), + ); + + const names = await phaseClient.listSecretNames?.(config); + + expect(names).toEqual(["DB_URL", "API_KEY"]); + }); + + it("resolves env refs end to end through a phase provider", async () => { + findMany.mockResolvedValue([ + { + name: "phase-prod", + providerType: "phase", + config, + assignments: assignedEverywhere, + }, + ]); + mockFetch.mockResolvedValue( + jsonResponse([{ key: "DB_PASSWORD", value: "s3cret", path: "/" }]), + ); + + const result = await resolveVaultReferences( + "DB_PASSWORD=${{vault.phase-prod.DB_PASSWORD}}", + scope, + ); + + expect(result).toBe("DB_PASSWORD=s3cret"); + }); +}); diff --git a/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts index d16a75a40..89b8b390d 100644 --- a/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts +++ b/apps/dokploy/__test__/setup/monitoring-setup.real.test.ts @@ -64,6 +64,21 @@ const serviceExists = async (name: string) => { } }; +// Swarm keeps converging a service for a bit after it's created (scheduling +// tasks, resolving endpoints), which bumps Version.Index on its own. Calling +// setupMonitoring again before that settles races that internal bump, so wait +// for two consecutive reads to agree before treating the service as stable. +const waitForServiceConvergence = async (name: string, timeoutMs = 5000) => { + const deadline = Date.now() + timeoutMs; + let lastIndex: string | null = null; + while (Date.now() < deadline) { + const inspect = await docker.getService(name).inspect(); + if (inspect.Version.Index === lastIndex) return; + lastIndex = inspect.Version.Index; + await new Promise((resolve) => setTimeout(resolve, 50)); + } +}; + const swarmTaskNames = async () => { const list = await docker.listContainers({ all: true }); return list @@ -193,6 +208,7 @@ describe.skipIf(hasRealMonitoring())( expect(await containerExists(SERVICE_NAME)).toBe(false); await expect(setupMonitoring("test-server")).resolves.not.toThrow(); + await waitForServiceConvergence(SERVICE_NAME); await expect(setupMonitoring("test-server")).resolves.not.toThrow(); expect(await serviceExists(SERVICE_NAME)).toBe(true); diff --git a/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts b/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts index 90c1ba60a..5b1babb2a 100644 --- a/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts +++ b/apps/dokploy/__test__/traefik/write-app-traefik-config.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ execAsyncRemote: vi.fn(), + writeFileRemote: vi.fn(), })); vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => { @@ -16,6 +17,7 @@ vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => { return { ...actual, execAsyncRemote: mocks.execAsyncRemote, + writeFileRemote: mocks.writeFileRemote, }; }); @@ -91,7 +93,7 @@ describe("writeAppTraefikConfig", () => { }); it("writes the remote file when routers/services are present", async () => { - mocks.execAsyncRemote.mockResolvedValue({ stdout: "", stderr: "" }); + mocks.writeFileRemote.mockResolvedValue(undefined); await writeAppTraefikConfig( { @@ -109,8 +111,11 @@ describe("writeAppTraefikConfig", () => { "server-id", ); - expect(mocks.execAsyncRemote).toHaveBeenCalledOnce(); - const [, command] = mocks.execAsyncRemote.mock.calls[0] ?? []; - expect(command).toMatch(/^echo /); + expect(mocks.writeFileRemote).toHaveBeenCalledOnce(); + const [serverId, remotePath, content] = + mocks.writeFileRemote.mock.calls[0] ?? []; + expect(serverId).toBe("server-id"); + expect(remotePath).toContain("with-domain-app.yml"); + expect(content).toContain("with-domain-app-router-1"); }); }); diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 1a531e24c..b3be371ca 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -342,7 +342,7 @@ export const ShowDeployments = ({ )} {/* Hash (from description) - shown in compact form */} {deployment.description?.trim() && ( - + {deployment.description} )} diff --git a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx index b4bfff617..566dba3bb 100644 --- a/apps/dokploy/components/dashboard/application/environment/show-environment.tsx +++ b/apps/dokploy/components/dashboard/application/environment/show-environment.tsx @@ -6,6 +6,7 @@ import { toast } from "sonner"; import { z } from "zod"; import { CodeEditor } from "@/components/shared/code-editor"; import { useEnvCompletionSource } from "@/components/shared/env-autocomplete"; +import { VaultImportDialog } from "@/components/shared/vault-import-dialog"; import { Button } from "@/components/ui/button"; import { Card, @@ -197,6 +198,18 @@ export const ShowEnvironment = ({ id, type }: Props) => { name="environment" render={({ field }) => ( +
+ + form.setValue("environment", next, { + shouldDirty: true, + }) + } + /> +
{ } placeholder={["NODE_ENV=production", "PORT=3000"].join("\n")} completionSource={completionSource} + projectId={data?.environment?.projectId} + environmentId={data?.environment?.environmentId} /> {data?.buildType === "dockerfile" && ( { -
+
diff --git a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx index 20570b07a..da4c854d0 100644 --- a/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx +++ b/apps/dokploy/components/dashboard/docker/logs/docker-logs-id.tsx @@ -352,11 +352,11 @@ export const DockerLogsId: React.FC = ({ />
-
+
+
+ + {isError && {error?.message}} + +
+ + ( + + Type + + + + )} + /> + ( + + Name + + + + + Use @ for the root domain. + + + + )} + /> + {type === "A" && ipSuggestions.length > 0 && ( + + Fill from server (optional) + + + )} + ( + + {valueFields[type].label} + + {supportsMultipleValues ? ( +