Merge pull request #5614 from tomron/feat/ovh-optional-zone

feat(dns): add optional zone to OVH provider
This commit is contained in:
Narciso E. Núñez Arias
2026-10-12 09:38:51 -04:00
committed by GitHub
4 changed files with 106 additions and 0 deletions
+61
View File
@@ -166,6 +166,16 @@ describe("ovhClient.listZones", () => {
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
});
it("returns only the configured zone without listing every zone", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi();
const zones = await ovhClient.listZones(cfg);
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
expect(apiCalls()).toHaveLength(0);
});
});
describe("ovhClient.listRecords", () => {
@@ -578,4 +588,55 @@ describe("ovhClient.testConnection", () => {
"Invalid signature",
);
});
it("checks the configured zone instead of listing all zones", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi(ovhSuccess({ name: "example.com" }));
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
const calls = apiCalls();
expect(calls).toHaveLength(1);
expect(calls[0]?.[0]).toBe(`${cfg.baseUrl}/domain/zone/example.com`);
});
});
describe("ovhClient with a configured zone", () => {
const record = {
type: "A" as const,
name: "app.other.org",
content: "1.2.3.4",
};
it("refuses record operations on any other zone without calling OVH", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi();
const message = /restricted to the zone "example.com"/;
await expect(ovhClient.listRecords(cfg, "other.org")).rejects.toThrow(
message,
);
await expect(
ovhClient.upsertRecord(cfg, { ...record, zoneId: "other.org" }),
).rejects.toThrow(message);
await expect(
ovhClient.updateRecord(cfg, "other.org", "1", record),
).rejects.toThrow(message);
await expect(ovhClient.deleteRecord(cfg, "other.org", "1")).rejects.toThrow(
message,
);
expect(apiCalls()).toHaveLength(0);
});
it("still allows operations on the configured zone", async () => {
const cfg = { ...freshConfig(), zone: "example.com" };
mockApi(ovhSuccess(null), ovhSuccess(null));
await expect(
ovhClient.deleteRecord(cfg, "example.com", "1"),
).resolves.toBeUndefined();
expect(apiCalls()[0]?.[0]).toBe(
`${cfg.baseUrl}/domain/zone/example.com/record/1`,
);
});
});
@@ -88,6 +88,7 @@ const DnsProviderSchema = z.object({
applicationKey: z.string(),
applicationSecret: z.string(),
consumerKey: z.string(),
zone: z.string(),
});
type DnsProviderForm = z.infer<typeof DnsProviderSchema>;
@@ -104,6 +105,7 @@ const defaultValues: DnsProviderForm = {
applicationKey: "",
applicationSecret: "",
consumerKey: "",
zone: "",
};
const buildConfig = (data: DnsProviderForm) => {
@@ -137,6 +139,7 @@ const buildConfig = (data: DnsProviderForm) => {
applicationKey: data.applicationKey,
applicationSecret: data.applicationSecret,
consumerKey: data.consumerKey,
zone: data.zone.trim() || undefined,
};
}
};
@@ -207,6 +210,7 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
applicationKey: provider.config.applicationKey,
applicationSecret: provider.config.applicationSecret,
consumerKey: provider.config.consumerKey,
zone: provider.config.zone ?? "",
}),
});
} else if (!dnsProviderId) {
@@ -545,6 +549,27 @@ export const HandleDnsProvider = ({ dnsProviderId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="zone"
render={({ field }) => (
<FormItem>
<FormLabel>Zone (optional)</FormLabel>
<FormControl>
<Input placeholder="example.com" {...field} />
</FormControl>
<FormDescription>
Restrict this provider to a single DNS zone: Dokploy
will only list and change records in it. Leave empty to
use every zone the credentials can access. When set, the{" "}
<code>GET /domain/zone</code> right is not needed. This
is enforced by Dokploy only, so also limit the OVH token
to that zone if you need a hard guarantee.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
@@ -50,6 +50,7 @@ export const ovhDnsConfigSchema = z.object({
applicationKey: z.string().trim().min(1),
applicationSecret: z.string().trim().min(1),
consumerKey: z.string().trim().min(1),
zone: z.string().trim().min(1).optional(),
});
export const dnsProviderConfigSchema = z.discriminatedUnion("providerType", [
+19
View File
@@ -252,13 +252,25 @@ const listZoneNames = async (config: OvhConfig) => {
}
};
const assertZoneAllowed = (config: OvhConfig, zoneId: string) => {
if (config.zone && zoneId !== config.zone) {
throw new Error(
`OVH: this provider is restricted to the zone "${config.zone}", not "${zoneId}"`,
);
}
};
export const ovhClient: DnsClient<OvhConfig> = {
async listZones(config) {
if (config.zone) {
return [{ id: config.zone, name: config.zone }];
}
const zones = await listZoneNames(config);
return zones.map((zone) => ({ id: zone, name: zone }));
},
async listRecords(config, zoneId) {
assertZoneAllowed(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`);
@@ -276,6 +288,7 @@ export const ovhClient: DnsClient<OvhConfig> = {
},
async upsertRecord(config, record) {
assertZoneAllowed(config, record.zoneId);
const zone = encodeURIComponent(record.zoneId);
const subDomain = toSubDomain(record.name, record.zoneId);
const existing = await ovhFetch<number[]>(
@@ -319,6 +332,7 @@ export const ovhClient: DnsClient<OvhConfig> = {
},
async updateRecord(config, zoneId, recordId, record) {
assertZoneAllowed(config, zoneId);
const zone = encodeURIComponent(zoneId);
const existing = await ovhFetch<OvhRecord>(
config,
@@ -363,6 +377,7 @@ export const ovhClient: DnsClient<OvhConfig> = {
},
async deleteRecord(config, zoneId, recordId) {
assertZoneAllowed(config, zoneId);
await ovhFetch(
config,
`/domain/zone/${encodeURIComponent(zoneId)}/record/${recordId}`,
@@ -372,6 +387,10 @@ export const ovhClient: DnsClient<OvhConfig> = {
},
async testConnection(config) {
if (config.zone) {
await ovhFetch(config, `/domain/zone/${encodeURIComponent(config.zone)}`);
return;
}
await listZoneNames(config);
},
};