Resolve merge conflicts in drizzle migrations

This commit is contained in:
Yash Kumar
2026-09-08 12:12:32 +05:30
32 changed files with 20103 additions and 35 deletions
+540
View File
@@ -0,0 +1,540 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mockFetch = vi.fn();
global.fetch = mockFetch as typeof fetch;
import { ovhClient } from "@dokploy/server/utils/dns/ovh";
const textResponse = (body: string, ok = true, status = 200) =>
({
ok,
status,
text: async () => body,
}) as Response;
const ovhSuccess = (data: unknown) =>
textResponse(data === undefined ? "" : JSON.stringify(data));
const ovhError = (message: string, status = 403) =>
textResponse(JSON.stringify({ message }), false, status);
const SERVER_TIME = 1788268225;
const config = {
providerType: "ovh" as const,
endpoint: "ovh-eu" as const,
applicationKey: "app-key",
applicationSecret: "app-secret",
consumerKey: "consumer-key",
};
// Each test uses a distinct endpoint so the module-level clock-skew cache, which
// is keyed by base url, never leaks a measurement between them.
let endpointCursor = 0;
const endpoints = [
"ovh-eu",
"ovh-ca",
"ovh-us",
"kimsufi-eu",
"kimsufi-ca",
"soyoustart-eu",
"soyoustart-ca",
] as const;
const baseUrls: Record<(typeof endpoints)[number], string> = {
"ovh-eu": "https://eu.api.ovh.com/1.0",
"ovh-ca": "https://ca.api.ovh.com/1.0",
"ovh-us": "https://api.us.ovhcloud.com/1.0",
"kimsufi-eu": "https://eu.api.kimsufi.com/1.0",
"kimsufi-ca": "https://ca.api.kimsufi.com/1.0",
"soyoustart-eu": "https://eu.api.soyoustart.com/1.0",
"soyoustart-ca": "https://ca.api.soyoustart.com/1.0",
};
/** A config on a not-yet-used endpoint, so the first call always fetches /auth/time. */
const freshConfig = () => {
const endpoint = endpoints[
endpointCursor % endpoints.length
] as (typeof endpoints)[number];
endpointCursor += 1;
return { ...config, endpoint, baseUrl: baseUrls[endpoint] };
};
/** Replies to /auth/time, then to each queued API response in order. */
const mockApi = (...responses: Response[]) => {
let call = 0;
mockFetch.mockImplementation((url: string) => {
if (url.endsWith("/auth/time")) {
return Promise.resolve(textResponse(String(SERVER_TIME)));
}
const response = responses[call];
call += 1;
return Promise.resolve(response ?? ovhSuccess(null));
});
};
const apiCalls = () =>
mockFetch.mock.calls.filter(
([url]) => !(url as string).endsWith("/auth/time"),
) as [string, RequestInit][];
beforeEach(() => {
mockFetch.mockReset();
});
describe("ovhClient request signing", () => {
it("signs the request with the API server clock, not the local one", async () => {
const { baseUrl, ...cfg } = freshConfig();
mockApi(ovhSuccess(["example.com"]));
vi.spyOn(Date, "now").mockReturnValue((SERVER_TIME - 120) * 1000);
await ovhClient.listZones(cfg);
const [url, init] = apiCalls()[0] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
expect(url).toBe(`${baseUrl}/domain/zone`);
expect(headers["X-Ovh-Timestamp"]).toBe(String(SERVER_TIME));
expect(headers["X-Ovh-Application"]).toBe("app-key");
expect(headers["X-Ovh-Consumer"]).toBe("consumer-key");
const expected = createHash("sha1")
.update(
["app-secret", "consumer-key", "GET", url, "", SERVER_TIME].join("+"),
)
.digest("hex");
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
vi.restoreAllMocks();
});
it("signs a request body when one is sent", async () => {
const { baseUrl, ...cfg } = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 5 }), ovhSuccess(null));
vi.spyOn(Date, "now").mockReturnValue(SERVER_TIME * 1000);
await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
const [url, init] = apiCalls()[1] as [string, RequestInit];
const headers = init.headers as Record<string, string>;
const body = init.body as string;
expect(body).not.toBe("");
const expected = createHash("sha1")
.update(
["app-secret", "consumer-key", "POST", url, body, SERVER_TIME].join(
"+",
),
)
.digest("hex");
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
vi.restoreAllMocks();
});
});
describe("ovhClient.listZones", () => {
it("maps each zone name to a zone", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess(["example.com", "example.fr"]));
const zones = await ovhClient.listZones(cfg);
expect(zones).toEqual([
{ id: "example.com", name: "example.com" },
{ id: "example.fr", name: "example.fr" },
]);
});
it("names the missing root right when OVH refuses the zone listing", async () => {
const cfg = freshConfig();
mockApi(ovhError("This call has not been granted", 403));
// A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`,
// so the raw OVH message would send users looking in the wrong place.
await expect(ovhClient.listZones(cfg)).rejects.toThrow(
/missing the `GET \/domain\/zone` right/,
);
});
it("propagates the API error message", async () => {
const cfg = freshConfig();
mockApi(ovhError("Invalid signature", 403));
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
});
});
describe("ovhClient.listRecords", () => {
it("resolves each id into a full record", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess([1, 2]),
ovhSuccess({
id: 1,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.2.3.4",
ttl: 600,
}),
ovhSuccess({
id: 2,
zone: "example.com",
fieldType: "A",
subDomain: null,
target: "5.6.7.8",
ttl: null,
}),
);
const records = await ovhClient.listRecords(cfg, "example.com");
expect(records).toEqual([
{
id: "1",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
},
{
id: "2",
type: "A",
name: "example.com",
content: "5.6.7.8",
ttl: 0,
},
]);
});
it("keeps the records in the order of the returned ids", async () => {
const cfg = freshConfig();
const record = (id: number, subDomain: string) => ({
id,
zone: "example.com",
fieldType: "A",
subDomain,
target: `10.0.0.${id}`,
ttl: 60,
});
mockApi(
ovhSuccess([1, 2, 3, 4, 5]),
...[1, 2, 3, 4, 5].map((id) => ovhSuccess(record(id, `host${id}`))),
);
const records = await ovhClient.listRecords(cfg, "example.com");
expect(records.map((r) => r.id)).toEqual(["1", "2", "3", "4", "5"]);
});
});
describe("ovhClient.upsertRecord", () => {
it("creates the record then refreshes the zone", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
const result = await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 600,
});
expect(result).toEqual({ id: "9" });
const calls = apiCalls();
expect(calls[0]?.[0]).toContain(
"/domain/zone/example.com/record?fieldType=A&subDomain=app",
);
expect(calls[1]?.[1].method).toBe("POST");
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
fieldType: "A",
subDomain: "app",
target: "1.2.3.4",
ttl: 600,
});
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/refresh");
expect(calls[2]?.[1].method).toBe("POST");
});
it("updates the existing record instead of creating a duplicate", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([4]), ovhSuccess(null), ovhSuccess(null));
const result = await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(result).toEqual({ id: "4" });
const calls = apiCalls();
expect(calls[1]?.[0]).toContain("/domain/zone/example.com/record/4");
expect(calls[1]?.[1].method).toBe("PUT");
expect(calls[2]?.[0]).toContain("/refresh");
});
it("omits the ttl so OVH applies the zone default", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "app.example.com",
content: "1.2.3.4",
});
expect(JSON.parse(apiCalls()[1]?.[1].body as string)).not.toHaveProperty(
"ttl",
);
});
it("writes an empty subDomain for the apex and strips the trailing dot", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
await ovhClient.upsertRecord(cfg, {
zoneId: "example.com",
type: "A",
name: "example.com.",
content: "1.2.3.4",
});
expect(apiCalls()[0]?.[0]).toContain("subDomain=");
expect(JSON.parse(apiCalls()[1]?.[1].body as string).subDomain).toBe("");
});
});
describe("ovhClient.updateRecord", () => {
it("updates in place when the type is unchanged", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess(null),
);
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
type: "A",
name: "app.example.com",
content: "1.2.3.4",
ttl: 300,
});
expect(result).toEqual({ id: "4" });
const calls = apiCalls();
expect(calls[1]?.[1].method).toBe("PUT");
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
subDomain: "app",
target: "1.2.3.4",
ttl: 300,
});
expect(calls[2]?.[0]).toContain("/refresh");
});
it("replaces the record when the type changes, since PUT carries no fieldType", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess({ id: 11 }),
ovhSuccess(null),
);
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "example.com",
});
expect(result).toEqual({ id: "11" });
const calls = apiCalls();
expect(calls[1]?.[1].method).toBe("DELETE");
expect(calls[2]?.[1].method).toBe("POST");
expect(JSON.parse(calls[2]?.[1].body as string).fieldType).toBe("CNAME");
expect(calls[3]?.[0]).toContain("/refresh");
});
it("restores the original record when the replacement fails", async () => {
const cfg = freshConfig();
const original = {
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
};
mockApi(
ovhSuccess(original),
ovhSuccess(null),
ovhError("Invalid target", 400),
ovhSuccess({ id: 12 }),
ovhSuccess(null),
);
await expect(
ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "not a valid target",
}),
).rejects.toThrow("Invalid target");
const calls = apiCalls();
expect(calls[1]?.[1].method).toBe("DELETE");
expect(calls[2]?.[1].method).toBe("POST");
// The original record is put back with its own type, target and ttl.
expect(JSON.parse(calls[3]?.[1].body as string)).toEqual({
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
});
expect(calls[4]?.[0]).toContain("/refresh");
});
it("reports the lost record when the restore also fails", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhError("Invalid target", 400),
ovhError("Service unavailable", 503),
);
await expect(
ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "not a valid target",
}),
).rejects.toThrow(
/Recreate it manually: A app\.example\.com -> 1\.1\.1\.1/,
);
});
it("says the change was applied when only the zone refresh fails", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null),
ovhSuccess({ id: 11 }),
ovhError("Service unavailable", 503),
);
// The replacement succeeded, so the record exists at the provider — only
// publishing failed. Rolling back would destroy correct state.
await expect(
ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "example.com",
}),
).rejects.toThrow(/was applied, but refreshing zone "example\.com" failed/);
});
it("does not tell the user to recreate a record that was restored but not published", async () => {
const cfg = freshConfig();
mockApi(
ovhSuccess({
id: 4,
zone: "example.com",
fieldType: "A",
subDomain: "app",
target: "1.1.1.1",
ttl: 60,
}),
ovhSuccess(null), // DELETE de l'ancien
ovhError("Invalid target", 400), // POST de remplacement -> échec
ovhSuccess({ id: 12 }), // POST de restauration -> succès
ovhError("Service unavailable", 503), // refresh -> échec
);
const attempt = ovhClient.updateRecord(cfg, "example.com", "4", {
type: "CNAME",
name: "app.example.com",
content: "not a valid target",
});
// L'enregistrement existe de nouveau chez OVH : le recréer le dupliquerait.
await expect(attempt).rejects.toThrow(/was restored, but refreshing zone/);
await expect(attempt).rejects.not.toThrow(/Recreate it manually/);
});
});
describe("ovhClient.deleteRecord", () => {
it("deletes the record then refreshes the zone", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess(null), ovhSuccess(null));
await ovhClient.deleteRecord(cfg, "example.com", "4");
const calls = apiCalls();
expect(calls[0]?.[0]).toContain("/domain/zone/example.com/record/4");
expect(calls[0]?.[1].method).toBe("DELETE");
expect(calls[1]?.[0]).toContain("/domain/zone/example.com/refresh");
});
it("does not refresh the zone when the delete fails", async () => {
const cfg = freshConfig();
mockApi(ovhError("This object does not exist", 404));
await expect(
ovhClient.deleteRecord(cfg, "example.com", "4"),
).rejects.toThrow("This object does not exist");
expect(apiCalls()).toHaveLength(1);
});
});
describe("ovhClient.testConnection", () => {
it("resolves when the zone listing succeeds", async () => {
const cfg = freshConfig();
mockApi(ovhSuccess([]));
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
});
it("rejects on invalid credentials", async () => {
const cfg = freshConfig();
mockApi(ovhError("Invalid signature", 403));
await expect(ovhClient.testConnection(cfg)).rejects.toThrow(
"Invalid signature",
);
});
});
@@ -0,0 +1,149 @@
import os from "node:os";
import {
getServerIpCandidates,
validateDomain,
} from "@dokploy/server/services/domain";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execAsyncRemote: vi.fn(),
findServerById: vi.fn(),
getPublicIpWithFallback: vi.fn(),
getWebServerSettings: vi.fn(),
resolve4: vi.fn(),
resolve6: vi.fn(),
}));
vi.mock("node:dns", () => ({
default: {
resolve4: mocks.resolve4,
resolve6: mocks.resolve6,
},
}));
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsyncRemote: mocks.execAsyncRemote,
}));
vi.mock("@dokploy/server/services/server", () => ({
findServerById: mocks.findServerById,
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: mocks.getWebServerSettings,
}));
vi.mock("@dokploy/server/wss/utils", () => ({
getPublicIpWithFallback: mocks.getPublicIpWithFallback,
}));
describe("getServerIpCandidates", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("includes every address reported by a multi-homed remote server", async () => {
mocks.findServerById.mockResolvedValue({
ipAddress: "10.0.0.10",
});
mocks.execAsyncRemote.mockResolvedValue({
stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"),
stderr: "",
});
await expect(getServerIpCandidates("server-id")).resolves.toEqual([
"10.0.0.10",
"192.0.2.10",
"2001:db8::10",
]);
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
"server-id",
expect.stringContaining("ip -o addr show scope global"),
);
});
it("includes every address assigned to the local Dokploy host", async () => {
mocks.getWebServerSettings.mockResolvedValue({
serverIp: "10.0.0.10",
});
mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10");
vi.spyOn(os, "networkInterfaces").mockReturnValue({
eth0: [
{
address: "192.0.2.10",
netmask: "255.255.255.0",
family: "IPv4",
mac: "00:00:00:00:00:00",
internal: false,
cidr: "192.0.2.10/24",
},
],
});
await expect(getServerIpCandidates()).resolves.toEqual([
"10.0.0.10",
"192.0.2.10",
"2001:db8::10",
]);
});
it("retains remote interface addresses when public IP detection times out", async () => {
vi.useFakeTimers();
mocks.findServerById.mockResolvedValue({
ipAddress: "10.0.0.10",
});
mocks.execAsyncRemote.mockImplementation(
(_serverId: string, command: string) => {
if (command.includes("curl")) {
return new Promise(() => undefined);
}
return Promise.resolve({
stdout: "192.0.2.10\n",
stderr: "",
});
},
);
const candidatesPromise = getServerIpCandidates("server-id");
await vi.advanceTimersByTimeAsync(7000);
await expect(candidatesPromise).resolves.toEqual([
"10.0.0.10",
"192.0.2.10",
]);
});
});
describe("validateDomain", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("validates an IPv6-only domain against an IPv6 server address", async () => {
const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), {
code: "ENODATA",
});
mocks.resolve4.mockImplementation(
(_domain: string, callback: (error: Error | null) => void) =>
callback(noIpv4),
);
mocks.resolve6.mockImplementation(
(
_domain: string,
callback: (error: Error | null, addresses?: string[]) => void,
) => callback(null, ["2001:db8::10"]),
);
await expect(
validateDomain("example.com", ["2001:db8::10"]),
).resolves.toMatchObject({
isValid: true,
resolvedIp: "2001:db8::10",
});
});
});
+195
View File
@@ -0,0 +1,195 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type HasInput = { input: Record<string, unknown> };
const findMany = vi.hoisted(() => vi.fn());
const {
send,
paginate,
SSMClient,
GetParametersCommand,
DescribeParametersCommand,
} = vi.hoisted(() => {
class FakeCommand {
input: Record<string, unknown>;
constructor(input: Record<string, unknown>) {
this.input = input;
}
}
const send = vi.fn();
const paginate = vi.fn();
class SSMClient {
send(command: unknown) {
return send(command);
}
}
return {
send,
paginate,
SSMClient,
GetParametersCommand: class extends FakeCommand {},
DescribeParametersCommand: class extends FakeCommand {},
};
});
vi.mock("@aws-sdk/client-ssm", () => ({
SSMClient,
GetParametersCommand,
DescribeParametersCommand,
paginateDescribeParameters: paginate,
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
vaultProvider: {
findMany: (...args: unknown[]) => findMany(...args),
},
},
},
}));
import { resolveVaultReferences } from "@dokploy/server/utils/vault";
import { awsParameterStoreClient } from "@dokploy/server/utils/vault/aws-parameter-store";
const config = {
providerType: "aws-parameter-store" as const,
region: "eu-central-1",
accessKeyId: "AKIA_TEST",
secretAccessKey: "secret",
};
beforeEach(() => {
send.mockReset();
paginate.mockReset();
findMany.mockReset();
});
describe("awsParameterStoreClient", () => {
it("decrypts parameters in batches of ten and preserves selectors", async () => {
const refs = [
"/prod/database:CURRENT",
...Array.from({ length: 10 }, (_, index) => `/prod/secret-${index}`),
];
send.mockImplementation(async (command: HasInput) => ({
Parameters: (command.input.Names as string[]).map((ref) => {
if (ref === "/prod/database:CURRENT") {
return {
Name: "/prod/database",
Selector: ":CURRENT",
Value: "selected-value",
};
}
return { Name: ref, Value: `value-for-${ref}` };
}),
}));
const result = await awsParameterStoreClient.getSecrets(config, refs);
expect(send).toHaveBeenCalledTimes(2);
expect((send.mock.calls[0]?.[0] as HasInput).input).toMatchObject({
WithDecryption: true,
});
expect(
((send.mock.calls[0]?.[0] as HasInput).input.Names as string[]).length,
).toBe(10);
expect(
((send.mock.calls[1]?.[0] as HasInput).input.Names as string[]).length,
).toBe(1);
expect(result["/prod/database:CURRENT"]).toBe("selected-value");
expect(result["/prod/secret-9"]).toBe("value-for-/prod/secret-9");
});
it("reports a missing parameter without exposing other values", async () => {
send.mockResolvedValue({ Parameters: [], InvalidParameters: ["/missing"] });
await expect(
awsParameterStoreClient.getSecrets(config, ["/missing"]),
).rejects.toThrow('AWS Parameter Store: parameter "/missing" not found');
});
it("tests the connection within the configured hierarchy", async () => {
send.mockResolvedValue({ Parameters: [] });
await awsParameterStoreClient.testConnection({
...config,
parameterPath: "/production/my-app/",
});
expect(send).toHaveBeenCalledTimes(1);
expect((send.mock.calls[0]?.[0] as HasInput).input).toEqual({
ParameterFilters: [
{
Key: "Path",
Option: "Recursive",
Values: ["/production/my-app"],
},
],
MaxResults: 1,
});
});
it("explains the discovery permission when connection testing is denied", async () => {
const error = new Error("not authorized");
error.name = "AccessDeniedException";
send.mockRejectedValue(error);
await expect(
awsParameterStoreClient.testConnection(config),
).rejects.toThrow("ssm:DescribeParameters");
});
it("lists parameter names across pages within the configured hierarchy", async () => {
paginate.mockReturnValue(
(async function* () {
yield { Parameters: [{ Name: "/prod/db" }] };
yield { Parameters: [{ Name: "/prod/api" }] };
})(),
);
const names = await awsParameterStoreClient.listSecretNames?.({
...config,
parameterPath: " /production/my-app/ ",
});
expect(names).toEqual(["/prod/db", "/prod/api"]);
expect(paginate).toHaveBeenCalledWith(
expect.objectContaining({ pageSize: 50 }),
{
ParameterFilters: [
{
Key: "Path",
Option: "Recursive",
Values: ["/production/my-app"],
},
],
},
);
});
it("resolves a vault reference through the registered provider", async () => {
findMany.mockResolvedValue([
{
name: "ssm-prod",
providerType: "aws-parameter-store",
config,
assignments: [{ projectId: "project-1", environmentIds: [] }],
},
]);
send.mockResolvedValue({
Parameters: [{ Name: "/prod/database-password", Value: "resolved" }],
});
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.ssm-prod./prod/database-password}}",
{
organizationId: "organization-1",
projectId: "project-1",
environmentId: "environment-1",
},
);
expect(result).toBe("DB_PASSWORD=resolved");
});
});
@@ -56,7 +56,7 @@ export const PlanStep = ({ onNext }: Props) => {
try {
await startFreeTrial();
await utils.project.onboardingStatus.invalidate();
toast.success("Your 14-day trial has started");
toast.success("Your 7-day trial has started");
onNext();
} catch (error) {
toast.error(
@@ -90,7 +90,7 @@ export const PlanStep = ({ onNext }: Props) => {
Recommended
</span>
<p className={`${displayFont.className} text-2xl mt-3`}>
14-day free trial
7-day free trial
</p>
<p className="text-sm text-zinc-400 dark:text-zinc-600 mt-1 max-w-xs">
No card required — cancel anytime.
@@ -117,7 +117,7 @@ export const ShowBilling = () => {
utils.stripe.getProducts.invalidate(),
utils.user.get.invalidate(),
]);
toast.success("Your 14-day trial has started");
toast.success("Your 7-day trial has started");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error starting trial",
@@ -326,7 +326,7 @@ export const ShowBilling = () => {
<Clock className="h-5 w-5 text-primary shrink-0" />
<div className="flex flex-col gap-1.5">
<span className="text-sm font-medium">
14-day free trial
7-day free trial
</span>
<span className="text-sm text-muted-foreground">
No credit card required — cancel anytime.
@@ -45,6 +45,17 @@ const providerLabels = {
route53: "AWS Route53",
porkbun: "Porkbun",
infomaniak: "Infomaniak",
ovh: "OVHcloud",
} as const;
const ovhEndpointLabels = {
"ovh-eu": "OVHcloud Europe",
"ovh-ca": "OVHcloud Canada",
"ovh-us": "OVHcloud US",
"kimsufi-eu": "Kimsufi Europe",
"kimsufi-ca": "Kimsufi Canada",
"soyoustart-eu": "So you Start Europe",
"soyoustart-ca": "So you Start Canada",
} as const;
type ProviderType = keyof typeof providerLabels;
@@ -56,12 +67,27 @@ const DnsProviderSchema = z.object({
.regex(/^[a-zA-Z0-9_-]+$/, {
message: "Only letters, numbers, dashes and underscores",
}),
providerType: z.enum(["cloudflare", "route53", "porkbun", "infomaniak"]),
providerType: z.enum([
"cloudflare",
"route53",
"porkbun",
"infomaniak",
"ovh",
]),
apiToken: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
apiKey: z.string(),
secretApiKey: z.string(),
endpoint: z.enum(
Object.keys(ovhEndpointLabels) as [
keyof typeof ovhEndpointLabels,
...(keyof typeof ovhEndpointLabels)[],
],
),
applicationKey: z.string(),
applicationSecret: z.string(),
consumerKey: z.string(),
});
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
@@ -74,6 +100,10 @@ const defaultValues: DnsProviderForm = {
secretAccessKey: "",
apiKey: "",
secretApiKey: "",
endpoint: "ovh-eu",
applicationKey: "",
applicationSecret: "",
consumerKey: "",
};
const buildConfig = (data: DnsProviderForm) => {
@@ -100,6 +130,14 @@ const buildConfig = (data: DnsProviderForm) => {
providerType: "infomaniak" as const,
apiToken: data.apiToken,
};
case "ovh":
return {
providerType: "ovh" as const,
endpoint: data.endpoint,
applicationKey: data.applicationKey,
applicationSecret: data.applicationSecret,
consumerKey: data.consumerKey,
};
}
};
@@ -164,6 +202,12 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
...(provider.config.providerType === "infomaniak" && {
apiToken: provider.config.apiToken,
}),
...(provider.config.providerType === "ovh" && {
endpoint: provider.config.endpoint,
applicationKey: provider.config.applicationKey,
applicationSecret: provider.config.applicationSecret,
consumerKey: provider.config.consumerKey,
}),
});
} else if (!dnsProviderId) {
form.reset(defaultValues);
@@ -413,6 +457,97 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
/>
)}
{providerType === "ovh" && (
<>
<FormField
control={form.control}
name="endpoint"
render={({ field }) => (
<FormItem>
<FormLabel>API Endpoint</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an endpoint" />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(ovhEndpointLabels).map(
([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="applicationKey"
render={({ field }) => (
<FormItem>
<FormLabel>Application Key</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="applicationSecret"
render={({ field }) => (
<FormItem>
<FormLabel>Application Secret</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="consumerKey"
render={({ field }) => (
<FormItem>
<FormLabel>Consumer Key</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormDescription>
Create the three keys at once on
api.ovh.com/createToken, with exactly these five rights:
<br />
<code>GET /domain/zone</code>
<br />
<code>GET /domain/zone/*</code>
<br />
<code>POST /domain/zone/*</code>
<br />
<code>PUT /domain/zone/*</code>
<br />
<code>DELETE /domain/zone/*</code>
<br />
The first one lists your zones and has to be granted on
its own: OVH matches rights per exact path, so{" "}
<code>/domain/zone/*</code> does not cover it.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<DialogFooter className="flex w-full flex-row justify-between gap-2 sm:justify-between">
<Button
type="button"
@@ -25,6 +25,7 @@ const providerLabels: Record<string, string> = {
route53: "AWS Route53",
porkbun: "Porkbun",
infomaniak: "Infomaniak",
ovh: "OVHcloud",
};
export const ShowDnsProviders = () => {
@@ -40,6 +40,7 @@ const providerLabels = {
hashicorp: "HashiCorp Vault / OpenBao",
infisical: "Infisical",
aws: "AWS Secrets Manager",
"aws-parameter-store": "AWS Parameter Store",
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
@@ -61,6 +62,7 @@ const VaultProviderSchema = z
"hashicorp",
"infisical",
"aws",
"aws-parameter-store",
"doppler",
"azure",
"scaleway",
@@ -79,6 +81,7 @@ const VaultProviderSchema = z
region: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
parameterPath: z.string(),
serviceToken: z.string(),
awsEndpoint: z.string(),
vaultUri: z.string(),
@@ -136,7 +139,8 @@ const VaultProviderSchema = z
});
}
if (
data.providerType === "aws" &&
(data.providerType === "aws" ||
data.providerType === "aws-parameter-store") &&
data.awsEndpoint &&
!isValidUrl(data.awsEndpoint)
) {
@@ -146,6 +150,17 @@ const VaultProviderSchema = z
path: ["awsEndpoint"],
});
}
if (
data.providerType === "aws-parameter-store" &&
data.parameterPath &&
!data.parameterPath.trim().startsWith("/")
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Parameter discovery path must start with /",
path: ["parameterPath"],
});
}
if (
data.providerType === "scaleway" &&
data.scalewayApiUrl &&
@@ -200,6 +215,11 @@ const VaultProviderSchema = z
["accessKeyId", "Access Key ID is required"],
["secretAccessKey", "Secret Access Key is required"],
],
"aws-parameter-store": [
["region", "Region is required"],
["accessKeyId", "Access Key ID is required"],
["secretAccessKey", "Secret Access Key is required"],
],
doppler: [["serviceToken", "Service Token is required"]],
azure: [
["vaultUri", "Vault URI is required"],
@@ -265,6 +285,7 @@ const defaultValues: VaultProviderForm = {
region: "",
accessKeyId: "",
secretAccessKey: "",
parameterPath: "",
serviceToken: "",
awsEndpoint: "",
vaultUri: "",
@@ -313,6 +334,15 @@ const buildConfig = (data: VaultProviderForm) => {
secretAccessKey: data.secretAccessKey,
endpoint: data.awsEndpoint || undefined,
};
case "aws-parameter-store":
return {
providerType: "aws-parameter-store" as const,
region: data.region,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
endpoint: data.awsEndpoint || undefined,
parameterPath: data.parameterPath.trim() || undefined,
};
case "doppler":
return {
providerType: "doppler" as const,
@@ -443,12 +473,16 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
environmentSlug: provider.config.environmentSlug,
secretPath: provider.config.secretPath,
}),
...(provider.config.providerType === "aws" && {
...((provider.config.providerType === "aws" ||
provider.config.providerType === "aws-parameter-store") && {
region: provider.config.region,
accessKeyId: provider.config.accessKeyId,
secretAccessKey: provider.config.secretAccessKey,
awsEndpoint: provider.config.endpoint ?? "",
}),
...(provider.config.providerType === "aws-parameter-store" && {
parameterPath: provider.config.parameterPath ?? "",
}),
...(provider.config.providerType === "doppler" && {
serviceToken: provider.config.serviceToken,
dopplerProject: provider.config.project ?? "",
@@ -757,7 +791,8 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
</>
)}
{providerType === "aws" && (
{(providerType === "aws" ||
providerType === "aws-parameter-store") && (
<>
<FormField
control={form.control}
@@ -815,12 +850,44 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
</FormItem>
)}
/>
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
secrets
</FormDescription>
{providerType === "aws-parameter-store" && (
<FormField
control={form.control}
name="parameterPath"
render={({ field }) => (
<FormItem>
<FormLabel>Discovery path (optional)</FormLabel>
<FormControl>
<Input placeholder="/production/my-app" {...field} />
</FormControl>
<FormDescription>
Limits parameter browsing and autocomplete to this
hierarchy. IAM permissions remain the security
boundary.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{providerType === "aws" ? (
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
<code>{"${{vault.<name>.secret-name:field}}"}</code> for
JSON secrets
</FormDescription>
) : (
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>./path/to/parameter}}"}</code>.
SecureString parameters are decrypted automatically.
Resolution requires <code>ssm:GetParameters</code>.
Discovery requires <code>ssm:DescribeParameters</code>.
Customer-managed KMS keys also require{" "}
<code>kms:Decrypt</code>.
</FormDescription>
)}
</>
)}
@@ -18,6 +18,7 @@ const providerLabels: Record<string, string> = {
hashicorp: "HashiCorp Vault",
infisical: "Infisical",
aws: "AWS Secrets Manager",
"aws-parameter-store": "AWS Parameter Store",
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
@@ -7,6 +7,7 @@ import {
Server,
Sparkles,
Stars,
X,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
@@ -14,6 +15,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogTitle,
DialogTrigger,
@@ -126,13 +128,13 @@ export const UpdateServer = ({
</TooltipProvider>
)}
</DialogTrigger>
<DialogContent className="max-w-lg">
<div className="flex items-center justify-between mb-8">
<DialogTitle className="text-2xl font-semibold">
<DialogContent className="max-w-lg" showCloseButton={false}>
<div className="flex items-center gap-2 mb-8">
<DialogTitle className="text-2xl font-semibold mr-auto">
Web Server Update
</DialogTitle>
{dokployVersion && (
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 mr-2 bg-muted">
<div className="flex items-center gap-1.5 rounded-full px-3 py-1 bg-muted">
<Server className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{dokployVersion}{" "}
@@ -141,6 +143,12 @@ export const UpdateServer = ({
</span>
</div>
)}
<DialogClose asChild>
<Button variant="ghost" size="icon-sm" className="shrink-0">
<X />
<span className="sr-only">Close</span>
</Button>
</DialogClose>
</div>
{/* Initial state */}
@@ -102,9 +102,21 @@ export const InfomaniakIcon = ({ className }: Props) => (
</svg>
);
export const OvhIcon = ({ className }: Props) => (
<svg
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M19.881 10.095l2.563-4.45C23.434 7.389 24 9.404 24 11.555c0 2.88-1.017 5.523-2.71 7.594h-6.62l2.04-3.541h-2.696l3.176-5.513h2.691zm-2.32-5.243L9.333 19.14l.003.009H2.709C1.014 17.077 0 14.435 0 11.555c0-2.152.57-4.17 1.561-5.918L5.855 13.1 10.6 4.852h6.961z" />
</svg>
);
export const dnsProviderIcons = {
cloudflare: CloudflareIcon,
route53: Route53Icon,
porkbun: PorkbunIcon,
infomaniak: InfomaniakIcon,
ovh: OvhIcon,
} as const;
@@ -608,6 +608,7 @@ export const vaultProviderIcons = {
hashicorp: HashicorpVaultIcon,
infisical: InfisicalIcon,
aws: AwsIcon,
"aws-parameter-store": AwsIcon,
doppler: DopplerIcon,
azure: AzureIcon,
scaleway: ScalewayIcon,
+1
View File
@@ -0,0 +1 @@
ALTER TYPE "public"."DnsProviderType" ADD VALUE 'ovh';
@@ -0,0 +1 @@
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'aws-parameter-store' BEFORE 'doppler';
+4 -3
View File
@@ -1,5 +1,5 @@
{
"id": "1be26190-277c-42b3-94d8-1b3f5ed9f4ae",
"id": "a92cac11-c5cf-404a-989f-352dfa0f5cf1",
"prevId": "3245801b-bd78-477e-baf0-2a5f7a3a2071",
"version": "7",
"dialect": "postgresql",
@@ -8803,7 +8803,7 @@
"type": "jsonb",
"primaryKey": false,
"notNull": false,
"default": "'{\"appName\":null,\"appDescription\":null,\"logoUrl\":null,\"faviconUrl\":null,\"customCss\":null,\"loginLogoUrl\":null,\"supportUrl\":null,\"docsUrl\":null,\"errorPageTitle\":null,\"errorPageDescription\":null,\"footerText\":null,\"ogImageUrl\":null}'::jsonb"
"default": "'{\"appName\":null,\"appDescription\":null,\"logoUrl\":null,\"faviconUrl\":null,\"customCss\":null,\"loginLogoUrl\":null,\"supportUrl\":null,\"docsUrl\":null,\"errorPageTitle\":null,\"errorPageDescription\":null,\"metaTitle\":null,\"footerText\":null}'::jsonb"
},
"remoteServersOnly": {
"name": "remoteServersOnly",
@@ -8954,7 +8954,8 @@
"cloudflare",
"route53",
"porkbun",
"infomaniak"
"infomaniak",
"ovh"
]
},
"public.domainType": {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -1349,8 +1349,22 @@
{
"idx": 192,
"version": "7",
"when": 1788586419726,
"tag": "0192_nappy_tempest",
"when": 1788600069255,
"tag": "0192_light_lake",
"breakpoints": true
},
{
"idx": 193,
"version": "7",
"when": 1788802431830,
"tag": "0193_chemical_the_liberteens",
"breakpoints": true
},
{
"idx": 194,
"version": "7",
"when": 1788849747825,
"tag": "0194_freezing_skin",
"breakpoints": true
}
]
+1
View File
@@ -50,6 +50,7 @@
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-route-53": "^3.1108.0",
"@aws-sdk/client-secrets-manager": "^3.1108.0",
"@aws-sdk/client-ssm": "3.1108.0",
"@better-auth/api-key": "1.6.23",
"@better-auth/passkey": "1.6.23",
"@better-auth/scim": "1.6.23",
+1 -1
View File
@@ -77,7 +77,7 @@ export const getCurrentPlan = async (
return getCurrentPlanForUser(ownerId);
};
export const TRIAL_DURATION_DAYS = 14;
export const TRIAL_DURATION_DAYS = 7;
export const TRIAL_SERVER_LIMIT = 1;
export interface BillingStatus {
+1
View File
@@ -39,6 +39,7 @@
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-route-53": "^3.1108.0",
"@aws-sdk/client-secrets-manager": "^3.1108.0",
"@aws-sdk/client-ssm": "3.1108.0",
"@better-auth/api-key": "1.6.23",
"@better-auth/passkey": "1.6.23",
"@better-auth/scim": "1.6.23",
@@ -9,6 +9,7 @@ export const dnsProviderType = pgEnum("DnsProviderType", [
"route53",
"porkbun",
"infomaniak",
"ovh",
]);
export const cloudflareDnsConfigSchema = z.object({
@@ -33,11 +34,30 @@ export const infomaniakDnsConfigSchema = z.object({
apiToken: z.string().trim().min(1),
});
export const ovhApiEndpoints = [
"ovh-eu",
"ovh-ca",
"ovh-us",
"kimsufi-eu",
"kimsufi-ca",
"soyoustart-eu",
"soyoustart-ca",
] as const;
export const ovhDnsConfigSchema = z.object({
providerType: z.literal("ovh"),
endpoint: z.enum(ovhApiEndpoints).default("ovh-eu"),
applicationKey: z.string().trim().min(1),
applicationSecret: z.string().trim().min(1),
consumerKey: z.string().trim().min(1),
});
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
cloudflareDnsConfigSchema,
route53DnsConfigSchema,
porkbunDnsConfigSchema,
infomaniakDnsConfigSchema,
ovhDnsConfigSchema,
]);
export type DnsProviderConfig = z.infer<typeof dnsProviderConfigSchema>;
@@ -8,6 +8,7 @@ export const vaultProviderType = pgEnum("VaultProviderType", [
"hashicorp",
"infisical",
"aws",
"aws-parameter-store",
"doppler",
"azure",
"scaleway",
@@ -40,6 +41,21 @@ export const awsVaultConfigSchema = z.object({
endpoint: z.string().url().optional(),
});
export const awsParameterStoreVaultConfigSchema = z.object({
providerType: z.literal("aws-parameter-store"),
region: z.string().min(1),
accessKeyId: z.string().min(1),
secretAccessKey: z.string().min(1),
endpoint: z.string().url().optional(),
parameterPath: z
.string()
.trim()
.refine((path) => path === "" || path.startsWith("/"), {
message: "Parameter discovery path must start with /",
})
.optional(),
});
export const dopplerVaultConfigSchema = z.object({
providerType: z.literal("doppler"),
serviceToken: z.string().min(1),
@@ -76,6 +92,7 @@ export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
hashicorpVaultConfigSchema,
infisicalVaultConfigSchema,
awsVaultConfigSchema,
awsParameterStoreVaultConfigSchema,
dopplerVaultConfigSchema,
azureVaultConfigSchema,
scalewayVaultConfigSchema,
@@ -19,6 +19,7 @@ const SENSITIVE_FIELDS: Record<DnsProviderConfig["providerType"], string[]> = {
route53: ["secretAccessKey"],
porkbun: ["secretApiKey"],
infomaniak: ["apiToken"],
ovh: ["applicationSecret", "consumerKey"],
};
export const maskDnsProviderConfig = (
+56 -11
View File
@@ -1,4 +1,6 @@
import dns from "node:dns";
import { isIP } from "node:net";
import os from "node:os";
import { promisify } from "node:util";
import { db } from "@dokploy/server/db";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
@@ -152,7 +154,27 @@ export const getDomainHost = (domain: Domain) => {
return `${domain.https ? "https" : "http"}://${domain.host}`;
};
const resolveDns = promisify(dns.resolve4);
const resolveDns4 = promisify(dns.resolve4);
const resolveDns6 = promisify(dns.resolve6);
const resolveDns = async (domain: string): Promise<string[]> => {
const results = await Promise.allSettled([
resolveDns4(domain),
resolveDns6(domain),
]);
const ips = results.flatMap((result) =>
result.status === "fulfilled" ? result.value : [],
);
if (ips.length > 0) {
return ips;
}
const failure = results.find((result) => result.status === "rejected");
throw failure?.reason instanceof Error
? failure.reason
: new Error("Failed to resolve domain");
};
export const validateDomain = async (
domain: string,
@@ -224,25 +246,42 @@ export const getServerIpCandidates = async (
candidates.add(server.ipAddress);
}
const publicIp = await withTimeout(
execAsyncRemote(
serverId,
"curl -s -m 5 https://ifconfig.me || curl -s -m 5 https://icanhazip.com",
const [interfaceIps, publicIp] = await Promise.all([
withTimeout(
execAsyncRemote(
serverId,
"ip -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1",
),
7000,
),
7000,
);
const detectedIp = publicIp?.stdout?.trim();
if (detectedIp) {
candidates.add(detectedIp);
withTimeout(
execAsyncRemote(
serverId,
"curl -fsS -m 5 https://ifconfig.me || curl -fsS -m 5 https://icanhazip.com",
),
7000,
),
]);
for (const output of [interfaceIps?.stdout, publicIp?.stdout]) {
for (const detectedIp of parseIpCandidates(output)) {
candidates.add(detectedIp);
}
}
} else {
const settings = await getWebServerSettings();
if (settings?.serverIp) {
candidates.add(settings.serverIp);
}
for (const addresses of Object.values(os.networkInterfaces())) {
for (const address of addresses ?? []) {
if (!address.internal && isIP(address.address)) {
candidates.add(address.address);
}
}
}
const publicIp = await withTimeout(getPublicIpWithFallback(), 7000);
if (publicIp) {
if (publicIp && isIP(publicIp)) {
candidates.add(publicIp);
}
}
@@ -250,6 +289,12 @@ export const getServerIpCandidates = async (
return Array.from(candidates);
};
const parseIpCandidates = (output?: string): string[] =>
(output ?? "")
.split(/\s+/)
.map((candidate) => candidate.trim())
.filter((candidate) => isIP(candidate) !== 0);
const withTimeout = <T>(promise: Promise<T>, ms: number): Promise<T | null> => {
return Promise.race([
promise,
@@ -20,6 +20,7 @@ const SENSITIVE_FIELDS: Record<VaultProviderConfig["providerType"], string[]> =
hashicorp: ["token"],
infisical: ["clientSecret"],
aws: ["secretAccessKey"],
"aws-parameter-store": ["secretAccessKey"],
doppler: ["serviceToken"],
azure: ["clientSecret"],
scaleway: ["secretKey"],
+2
View File
@@ -1,6 +1,7 @@
import type { DnsProviderConfig } from "@dokploy/server/db/schema";
import { cloudflareClient } from "./cloudflare";
import { infomaniakClient } from "./infomaniak";
import { ovhClient } from "./ovh";
import { porkbunClient } from "./porkbun";
import { route53Client } from "./route53";
import type { DnsClient } from "./types";
@@ -10,6 +11,7 @@ const clients: Record<DnsProviderConfig["providerType"], DnsClient> = {
route53: route53Client as DnsClient,
porkbun: porkbunClient as DnsClient,
infomaniak: infomaniakClient as DnsClient,
ovh: ovhClient as DnsClient,
};
export const getDnsClient = (providerType: DnsProviderConfig["providerType"]) =>
+366
View File
@@ -0,0 +1,366 @@
import { createHash } from "node:crypto";
import type { ovhDnsConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type DnsClient, dnsFetch } from "./types";
type OvhConfig = z.infer<typeof ovhDnsConfigSchema>;
interface OvhRecord {
id: number;
zone: string;
fieldType: string;
subDomain: string | null;
target: string;
ttl: number | null;
}
const OVH_ENDPOINTS: Record<OvhConfig["endpoint"], string> = {
"ovh-eu": "https://eu.api.ovh.com/1.0",
"ovh-ca": "https://ca.api.ovh.com/1.0",
"ovh-us": "https://api.us.ovhcloud.com/1.0",
"kimsufi-eu": "https://eu.api.kimsufi.com/1.0",
"kimsufi-ca": "https://ca.api.kimsufi.com/1.0",
"soyoustart-eu": "https://eu.api.soyoustart.com/1.0",
"soyoustart-ca": "https://ca.api.soyoustart.com/1.0",
};
// Fetching every record of a zone takes one call per record, so cap how many of
// them are in flight at once.
const RECORD_CONCURRENCY = 8;
// Requests are signed with the API's own clock: a local clock more than a few
// seconds off would get every call rejected. The drift is re-measured
// periodically in case the host clock is corrected under us.
const CLOCK_SKEW_TTL_MS = 60 * 60 * 1000;
const clockSkews = new Map<
string,
{ deltaSeconds: number; measuredAt: number }
>();
const localTimestamp = () => Math.floor(Date.now() / 1000);
const getTimestamp = async (baseUrl: string) => {
const cached = clockSkews.get(baseUrl);
if (cached && Date.now() - cached.measuredAt < CLOCK_SKEW_TTL_MS) {
return localTimestamp() + cached.deltaSeconds;
}
const response = await dnsFetch(`${baseUrl}/auth/time`);
const serverTime = Number(await response.text());
if (!response.ok || !Number.isFinite(serverTime)) {
throw new Error(
`OVH: could not read the API server time (status ${response.status})`,
);
}
const deltaSeconds = serverTime - localTimestamp();
clockSkews.set(baseUrl, { deltaSeconds, measuredAt: Date.now() });
return localTimestamp() + deltaSeconds;
};
const sign = (
config: OvhConfig,
method: string,
url: string,
body: string,
timestamp: number,
) => {
const digest = createHash("sha1")
.update(
[
config.applicationSecret,
config.consumerKey,
method,
url,
body,
timestamp,
].join("+"),
)
.digest("hex");
return `$1$${digest}`;
};
const ovhFetch = async <T>(
config: OvhConfig,
path: string,
init: { method?: string; body?: unknown } = {},
): Promise<T> => {
const baseUrl = OVH_ENDPOINTS[config.endpoint];
const url = `${baseUrl}${path}`;
const method = init.method ?? "GET";
const body = init.body === undefined ? "" : JSON.stringify(init.body);
const timestamp = await getTimestamp(baseUrl);
const response = await dnsFetch(url, {
method,
...(body ? { body } : {}),
headers: {
"Content-Type": "application/json",
"X-Ovh-Application": config.applicationKey,
"X-Ovh-Consumer": config.consumerKey,
"X-Ovh-Timestamp": String(timestamp),
"X-Ovh-Signature": sign(config, method, url, body, timestamp),
},
});
const text = await response.text();
let payload: unknown = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = null;
}
}
if (!response.ok) {
const detail =
payload && typeof payload === "object" && "message" in payload
? String((payload as { message: unknown }).message)
: undefined;
throw new Error(
`OVH: request to ${method} ${path} failed${
detail ? `: ${detail}` : ` (status ${response.status})`
}`,
);
}
return payload as T;
};
// OVH addresses records by their subdomain, relative to the zone and empty for
// the apex, while Dokploy works with fully-qualified names.
const toSubDomain = (name: string, zone: string) => {
const fqdn = name.replace(/\.$/, "");
if (fqdn === zone) {
return "";
}
const suffix = `.${zone}`;
return fqdn.endsWith(suffix) ? fqdn.slice(0, -suffix.length) : fqdn;
};
const toFqdn = (subDomain: string | null, zone: string) =>
subDomain ? `${subDomain}.${zone}` : zone;
const mapWithConcurrency = async <T, R>(
items: T[],
limit: number,
run: (item: T) => Promise<R>,
) => {
const results = new Array<R>(items.length);
let cursor = 0;
const workers = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
while (cursor < items.length) {
const index = cursor;
cursor += 1;
results[index] = await run(items[index] as T);
}
},
);
await Promise.all(workers);
return results;
};
// OVH only applies zone changes once the zone is explicitly refreshed. This runs
// after the record write has already succeeded, so a failure here means the
// change exists at the provider but is not being served yet. Rolling the write
// back would destroy correct state over a publish failure, so say what actually
// happened instead of letting the caller read it as "nothing was applied".
const refreshZone = async (config: OvhConfig, zone: string) => {
try {
await ovhFetch(config, `/domain/zone/${encodeURIComponent(zone)}/refresh`, {
method: "POST",
});
} catch (error) {
throw new Error(
`OVH: the record change was applied, but refreshing zone "${zone}" failed, so it is not served yet. The next successful change to this zone will publish it, or you can refresh the zone from the OVH manager. Cause: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
};
// Used to undo the delete half of a type change when the replacement fails.
// The restore and its publication are reported separately: a failed POST means
// the record is really gone, whereas a failed refresh means it is back but not
// served yet. Collapsing the two would tell the user to recreate a record that
// already exists, which duplicates it as soon as the zone is refreshed.
const restoreRecord = async (
config: OvhConfig,
zone: string,
record: OvhRecord,
cause: unknown,
) => {
const causeMessage = cause instanceof Error ? cause.message : String(cause);
const name = toFqdn(record.subDomain, zone);
try {
await ovhFetch(config, `/domain/zone/${encodeURIComponent(zone)}/record`, {
method: "POST",
body: {
fieldType: record.fieldType,
subDomain: record.subDomain ?? "",
target: record.target,
...(record.ttl === null ? {} : { ttl: record.ttl }),
},
});
} catch {
throw new Error(
`OVH: could not replace the record and could not restore the original one, which has been deleted. Recreate it manually: ${record.fieldType} ${name} -> ${record.target}. Original failure: ${causeMessage}`,
);
}
try {
await refreshZone(config, zone);
} catch {
throw new Error(
`OVH: the replacement failed and the original record was restored, but refreshing zone "${zone}" failed, so the restore is not served yet. Do not recreate it — the next successful change to this zone will publish it. Original failure: ${causeMessage}`,
);
}
};
const recordBody = (
record: { name: string; content: string; ttl?: number },
zone: string,
) => ({
subDomain: toSubDomain(record.name, zone),
target: record.content,
// Leaving the ttl out lets OVH apply the zone's default.
...(record.ttl === undefined ? {} : { ttl: record.ttl }),
});
// OVH grants access per exact path: a `/domain/zone/*` rule covers the subtree
// but not the bare `/domain/zone` listing, which needs its own rule. That is an
// easy one to leave out of a token, so say so plainly rather than surfacing a
// bare "This call has not been granted".
const listZoneNames = async (config: OvhConfig) => {
try {
return await ovhFetch<string[]>(config, "/domain/zone");
} catch (error) {
if (
error instanceof Error &&
error.message.includes("has not been granted")
) {
throw new Error(
"OVH: the credentials are missing the `GET /domain/zone` right, which lists your zones. A `GET /domain/zone/*` rule does not cover it — add the rule without the wildcard as well.",
);
}
throw error;
}
};
export const ovhClient: DnsClient<OvhConfig> = {
async listZones(config) {
const zones = await listZoneNames(config);
return zones.map((zone) => ({ id: zone, name: zone }));
},
async listRecords(config, zoneId) {
const zone = encodeURIComponent(zoneId);
// The listing endpoint only returns ids, so each record is fetched on its own.
const ids = await ovhFetch<number[]>(config, `/domain/zone/${zone}/record`);
const records = await mapWithConcurrency(ids, RECORD_CONCURRENCY, (id) =>
ovhFetch<OvhRecord>(config, `/domain/zone/${zone}/record/${id}`),
);
return records.map((record) => ({
id: String(record.id),
type: record.fieldType,
name: toFqdn(record.subDomain, zoneId),
content: record.target,
ttl: record.ttl ?? 0,
}));
},
async upsertRecord(config, record) {
const zone = encodeURIComponent(record.zoneId);
const subDomain = toSubDomain(record.name, record.zoneId);
const existing = await ovhFetch<number[]>(
config,
`/domain/zone/${zone}/record?fieldType=${encodeURIComponent(
record.type,
)}&subDomain=${encodeURIComponent(subDomain)}`,
);
const existingId = existing[0];
if (existingId !== undefined) {
await ovhFetch(config, `/domain/zone/${zone}/record/${existingId}`, {
method: "PUT",
body: recordBody(record, record.zoneId),
});
await refreshZone(config, record.zoneId);
return { id: String(existingId) };
}
const created = await ovhFetch<OvhRecord>(
config,
`/domain/zone/${zone}/record`,
{
method: "POST",
body: { fieldType: record.type, ...recordBody(record, record.zoneId) },
},
);
await refreshZone(config, record.zoneId);
return { id: String(created.id) };
},
async updateRecord(config, zoneId, recordId, record) {
const zone = encodeURIComponent(zoneId);
const existing = await ovhFetch<OvhRecord>(
config,
`/domain/zone/${zone}/record/${recordId}`,
);
// The update payload carries no fieldType, so switching a record's type
// means replacing it. The delete has to come first: OVH rejects a CNAME
// that would sit alongside other data on the same name. If the creation
// then fails, put the original record back rather than leaving the name
// with nothing.
if (existing.fieldType !== record.type) {
await ovhFetch(config, `/domain/zone/${zone}/record/${recordId}`, {
method: "DELETE",
});
let created: OvhRecord;
try {
created = await ovhFetch<OvhRecord>(
config,
`/domain/zone/${zone}/record`,
{
method: "POST",
body: { fieldType: record.type, ...recordBody(record, zoneId) },
},
);
} catch (error) {
await restoreRecord(config, zoneId, existing, error);
throw error;
}
await refreshZone(config, zoneId);
return { id: String(created.id) };
}
await ovhFetch(config, `/domain/zone/${zone}/record/${recordId}`, {
method: "PUT",
body: recordBody(record, zoneId),
});
await refreshZone(config, zoneId);
return { id: recordId };
},
async deleteRecord(config, zoneId, recordId) {
await ovhFetch(
config,
`/domain/zone/${encodeURIComponent(zoneId)}/record/${recordId}`,
{ method: "DELETE" },
);
await refreshZone(config, zoneId);
},
async testConnection(config) {
await listZoneNames(config);
},
};
@@ -0,0 +1,155 @@
import {
DescribeParametersCommand,
GetParametersCommand,
paginateDescribeParameters,
SSMClient,
} from "@aws-sdk/client-ssm";
import type { awsParameterStoreVaultConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import type { VaultClient } from "./types";
type AwsParameterStoreConfig = z.infer<
typeof awsParameterStoreVaultConfigSchema
>;
const MAX_PARAMETERS_PER_REQUEST = 10;
const normalizeParameterPath = (path: string | undefined) => {
const trimmed = path?.trim();
if (!trimmed) {
return undefined;
}
if (trimmed === "/") {
return trimmed;
}
return trimmed.replace(/\/+$/, "");
};
const describeParametersInput = (config: AwsParameterStoreConfig) => {
const parameterPath = normalizeParameterPath(config.parameterPath);
return parameterPath
? {
ParameterFilters: [
{
Key: "Path",
Option: "Recursive",
Values: [parameterPath],
},
],
}
: {};
};
const isAccessDeniedError = (error: unknown) =>
error instanceof Error &&
(error.name === "AccessDeniedException" || error.name === "AccessDenied");
const createClient = (config: AwsParameterStoreConfig) =>
new SSMClient({
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
...(config.endpoint && { endpoint: config.endpoint }),
});
const findRequestedRef = (
refs: string[],
parameter: { Name?: string; ARN?: string; Selector?: string },
) => {
const bases = [parameter.Name, parameter.ARN].filter(
(value): value is string => Boolean(value),
);
if (!parameter.Selector) {
return refs.find((ref) => bases.includes(ref));
}
const selector = parameter.Name
? parameter.Selector.replace(`${parameter.Name}:`, "").replace(/^:/, "")
: parameter.Selector.replace(/^:/, "");
return refs.find((ref) =>
bases.some((base) => ref === `${base}:${selector}`),
);
};
export const awsParameterStoreClient: VaultClient<AwsParameterStoreConfig> = {
async getSecrets(config, refs) {
const client = createClient(config);
const uniqueRefs = [...new Set(refs)];
const result: Record<string, string> = {};
for (
let index = 0;
index < uniqueRefs.length;
index += MAX_PARAMETERS_PER_REQUEST
) {
const batch = uniqueRefs.slice(index, index + MAX_PARAMETERS_PER_REQUEST);
const response = await client.send(
new GetParametersCommand({
Names: batch,
WithDecryption: true,
}),
);
for (const parameter of response.Parameters ?? []) {
const ref = findRequestedRef(batch, parameter);
if (!ref) {
continue;
}
if (parameter.Value === undefined) {
throw new Error(
`AWS Parameter Store: parameter "${ref}" has no value`,
);
}
result[ref] = parameter.Value;
}
}
for (const ref of uniqueRefs) {
if (result[ref] === undefined) {
throw new Error(`AWS Parameter Store: parameter "${ref}" not found`);
}
}
return result;
},
async testConnection(config) {
const client = createClient(config);
try {
await client.send(
new DescribeParametersCommand({
...describeParametersInput(config),
MaxResults: 1,
}),
);
} catch (error) {
if (isAccessDeniedError(error)) {
throw new Error(
"AWS Parameter Store: credentials were accepted, but connection testing and parameter discovery require ssm:DescribeParameters. Manual references can still work when ssm:GetParameters is allowed.",
);
}
throw error;
}
},
async listSecretNames(config) {
const client = createClient(config);
const names: string[] = [];
for await (const page of paginateDescribeParameters(
{ client, pageSize: 50 },
describeParametersInput(config),
)) {
for (const parameter of page.Parameters ?? []) {
if (parameter.Name) {
names.push(parameter.Name);
}
if (names.length >= 500) {
return names;
}
}
}
return names;
},
};
Binary file not shown.
+21
View File
@@ -121,6 +121,9 @@ importers:
'@aws-sdk/client-secrets-manager':
specifier: ^3.1108.0
version: 3.1108.0
'@aws-sdk/client-ssm':
specifier: 3.1108.0
version: 3.1108.0
'@better-auth/api-key':
specifier: 1.6.23
version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(22e2e61d74361da37f96c4dc24795f27))(better-call@1.3.7(zod@4.3.6))
@@ -597,6 +600,9 @@ importers:
'@aws-sdk/client-secrets-manager':
specifier: ^3.1108.0
version: 3.1108.0
'@aws-sdk/client-ssm':
specifier: 3.1108.0
version: 3.1108.0
'@better-auth/api-key':
specifier: 1.6.23
version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(2ed6fd84380fde286153435e0641a0ef))(better-call@1.3.7(zod@4.3.6))
@@ -894,6 +900,10 @@ packages:
resolution: {integrity: sha512-pZtoHWD+WorgM9xK1ikNcKLbtEMIS6wFtELuCX5AOo9v3ZajV8wOEmXz/7JnBHbsLmyBnyv3NCf7whyV++joiw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-ssm@3.1108.0':
resolution: {integrity: sha512-9bKhMapsv17V2lrC/HtjhfqH72EZOWhdJKod8JkITHALLwdCKgsnnOZ1JPWB1bCMx0hWMW5OtQKdyHjGobE4NQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.977.7':
resolution: {integrity: sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==}
engines: {node: '>=20.0.0'}
@@ -9309,6 +9319,17 @@ snapshots:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/client-ssm@3.1108.0':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/credential-provider-node': 3.972.79
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/fetch-http-handler': 5.7.0
'@smithy/node-http-handler': 4.10.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/core@3.977.7':
dependencies:
'@aws-sdk/types': 3.974.3