Skip to content

Commit c9979d9

Browse files
authored
fix(cloudflare): handle multiple same-name dns records in update (#1342)
1 parent a6af539 commit c9979d9

File tree

2 files changed

+151
-115
lines changed

2 files changed

+151
-115
lines changed

alchemy/src/cloudflare/dns-records.ts

Lines changed: 71 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
} from "../dns/record.ts";
77
import { Resource } from "../resource.ts";
88
import { logger } from "../util/logger.ts";
9+
import { extractCloudflareResult } from "./api-response.ts";
910
import {
1011
type CloudflareApi,
1112
type CloudflareApiOptions,
@@ -162,17 +163,16 @@ export const DnsRecords = Resource(
162163

163164
if (this.phase === "update" && this.output?.records) {
164165
// Get current records to compare with desired state
165-
const currentRecords = this.output.records;
166-
const desiredRecords = props.records;
166+
const currentRecords = deduplicateRecords(this.output.records);
167+
const desiredRecords = deduplicateRecords(props.records);
167168

168169
// Find records to delete (exist in current but not in desired)
169-
const recordsToDelete = currentRecords.filter(
170-
(current) =>
171-
!desiredRecords.some(
172-
(desired) =>
173-
desired.name === current.name && desired.type === current.type,
174-
),
175-
);
170+
const recordsToDelete: DnsRecord[] = [];
171+
for (const [key, record] of currentRecords.entries()) {
172+
if (!desiredRecords.has(key)) {
173+
recordsToDelete.push(record);
174+
}
175+
}
176176

177177
// Delete orphaned records
178178
await Promise.all(
@@ -194,12 +194,9 @@ export const DnsRecords = Resource(
194194

195195
// Update or create records
196196
const updatedRecords = await Promise.all(
197-
desiredRecords.map(async (desired) => {
197+
desiredRecords.entries().map(async ([key, desired]) => {
198198
// Find matching existing record
199-
const existing = currentRecords.find(
200-
(current) =>
201-
current.name === desired.name && current.type === desired.type,
202-
);
199+
const existing = currentRecords.get(key);
203200

204201
if (existing) {
205202
// Update if content or other properties changed
@@ -226,46 +223,17 @@ export const DnsRecords = Resource(
226223
}
227224

228225
// Create new records
229-
const uniqueRecords = props.records.reduce(
230-
(acc, record) => {
231-
// For record types that can have multiple entries with the same name (MX, TXT, NS, etc.),
232-
// include content and/or priority in the key to avoid deduplication
233-
let key = `${record.name}-${record.type}`;
234-
235-
// If it's a record type that can have multiple entries with the same name, make the key unique
236-
if (["MX", "TXT", "NS", "SRV", "CAA"].includes(record.type)) {
237-
// For MX, include priority in the key
238-
if (record.type === "MX" || record.type === "SRV") {
239-
key = `${key}-${record.priority}-${record.content}`;
240-
} else {
241-
// For other multi-record types, content is the differentiator
242-
key = `${key}-${record.content}`;
243-
}
244-
}
245-
246-
acc[key] = record;
247-
return acc;
248-
},
249-
{} as Record<string, DnsRecordProps>,
250-
);
226+
const uniqueRecords = deduplicateRecords(props.records);
251227

252228
const createdRecords = await Promise.all(
253-
Object.values(uniqueRecords).map(async (record) => {
254-
// First check if record exists
255-
const listResponse = await api.get(
256-
`/zones/${zoneId}/dns_records?type=${record.type}&name=${record.name}`,
229+
uniqueRecords.entries().map(async ([key, record]) => {
230+
const existingRecords = await listRecords(api, zoneId, {
231+
name: record.name,
232+
type: record.type,
233+
});
234+
const existingRecord = existingRecords.find(
235+
(r) => makeRecordKey(r) === key,
257236
);
258-
if (!listResponse.ok) {
259-
throw new Error(
260-
`Failed to check existing DNS records: ${listResponse.statusText}`,
261-
);
262-
}
263-
264-
const listResult = (await listResponse.json()) as CloudflareResponse<
265-
CloudflareDnsRecord[]
266-
>;
267-
const existingRecord = listResult.result[0];
268-
269237
return createOrUpdateRecord(api, zoneId, record, existingRecord?.id);
270238
}),
271239
);
@@ -277,6 +245,35 @@ export const DnsRecords = Resource(
277245
},
278246
);
279247

248+
function deduplicateRecords<T extends DnsRecordProps>(
249+
records: T[],
250+
): Map<string, T> {
251+
const map = new Map<string, T>();
252+
for (const record of records) {
253+
map.set(makeRecordKey(record), record);
254+
}
255+
return map;
256+
}
257+
258+
function makeRecordKey<T extends DnsRecordProps>(record: T) {
259+
// For record types that can have multiple entries with the same name (MX, TXT, NS, etc.),
260+
// include content and/or priority in the key to avoid deduplication
261+
let key = `${record.name}-${record.type}`;
262+
263+
// If it's a record type that can have multiple entries with the same name, make the key unique
264+
if (["MX", "TXT", "NS", "SRV", "CAA"].includes(record.type)) {
265+
// For MX, include priority in the key
266+
if (record.type === "MX" || record.type === "SRV") {
267+
key = `${key}-${record.priority}-${record.content}`;
268+
} else {
269+
// For other multi-record types, content is the differentiator
270+
key = `${key}-${record.content}`;
271+
}
272+
}
273+
274+
return key;
275+
}
276+
280277
/**
281278
* Create or update a DNS record
282279
*/
@@ -325,6 +322,29 @@ async function createOrUpdateRecord(
325322
return convertCloudflareRecord(result.result, zoneId);
326323
}
327324

325+
export async function listRecords(
326+
api: CloudflareApi,
327+
zoneId: string,
328+
filter: {
329+
type?: DnsRecordType;
330+
name?: string;
331+
} = {},
332+
): Promise<DnsRecord[]> {
333+
const queryParams = new URLSearchParams();
334+
if (filter.type) {
335+
queryParams.set("type", filter.type);
336+
}
337+
if (filter.name) {
338+
queryParams.set("name", filter.name);
339+
}
340+
const queryString = queryParams.size > 0 ? `?${queryParams.toString()}` : "";
341+
const result = await extractCloudflareResult<CloudflareDnsRecord[]>(
342+
`list DNS records for zone ${zoneId}`,
343+
api.get(`/zones/${zoneId}/dns_records${queryString}`),
344+
);
345+
return result.map((record) => convertCloudflareRecord(record, zoneId));
346+
}
347+
328348
/**
329349
* Get the record payload for create/update operations
330350
*/

alchemy/test/cloudflare/dns-records.test.ts

Lines changed: 80 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { afterAll, describe, expect } from "vitest";
1+
import { afterAll, assert, describe, expect } from "vitest";
22
import { alchemy } from "../../src/alchemy.ts";
33
import { createCloudflareApi } from "../../src/cloudflare/api.ts";
4-
import { DnsRecords } from "../../src/cloudflare/dns-records.ts";
4+
import { DnsRecords, listRecords } from "../../src/cloudflare/dns-records.ts";
55
import { Zone } from "../../src/cloudflare/zone.ts";
66
import { destroy } from "../../src/destroy.ts";
77
import { BRANCH_PREFIX } from "../util.ts";
@@ -15,13 +15,11 @@ const test = alchemy.test(import.meta, {
1515

1616
const testDomain = `${BRANCH_PREFIX}-test-2.com`;
1717

18-
const isEnabled = process.env.ALL_TESTS;
19-
18+
const api = await createCloudflareApi();
2019
let zone: Zone;
21-
2220
let scope: Scope | undefined;
21+
2322
test.beforeAll(async (_scope) => {
24-
if (!isEnabled) return;
2523
zone = await Zone(`${BRANCH_PREFIX}-zone`, {
2624
name: testDomain,
2725
});
@@ -34,9 +32,8 @@ afterAll(async () => {
3432
}
3533
});
3634

37-
describe.skipIf(!isEnabled)("DnsRecords Resource", async () => {
35+
describe.sequential("DnsRecords Resource", async () => {
3836
// Use BRANCH_PREFIX for deterministic, non-colliding resource names
39-
const api = await createCloudflareApi();
4037

4138
test("create, update, and delete DNS records", async (scope) => {
4239
let dnsRecords;
@@ -73,39 +70,7 @@ describe.skipIf(!isEnabled)("DnsRecords Resource", async () => {
7370
expect(dnsRecords.records).toHaveLength(3);
7471

7572
// Verify records were created by querying the API directly
76-
for (const record of dnsRecords.records) {
77-
let response;
78-
const start = Date.now();
79-
const timeout = 120_000; // 120 seconds
80-
const interval = 500; // 0.5 seconds
81-
while (true) {
82-
response = await api.get(
83-
`/zones/${dnsRecords.zoneId}/dns_records/${record.id}`,
84-
);
85-
if (response.ok) {
86-
try {
87-
const data: any = await response.json();
88-
expect(data.result.name).toBe(record.name);
89-
expect(data.result.type).toBe(record.type);
90-
expect(data.result.content).toBe(record.content);
91-
expect(data.result.proxied).toBe(record.proxied);
92-
expect(data.result.comment).toBe(record.comment);
93-
if (record.priority) {
94-
expect(data.result.priority).toBe(record.priority);
95-
}
96-
break;
97-
} catch (err) {
98-
console.error("Error parsing response:", err);
99-
}
100-
}
101-
if (Date.now() - start > timeout) {
102-
throw new Error(
103-
`DNS record ${record.id} did not become available within 10s`,
104-
);
105-
}
106-
await new Promise((resolve) => setTimeout(resolve, interval));
107-
}
108-
}
73+
await assertRecordsMatch(dnsRecords);
10974

11075
// Update records - modify one record, add one record, remove one record
11176
dnsRecords = await DnsRecords(`${testDomain}-dns`, {
@@ -162,34 +127,13 @@ describe.skipIf(!isEnabled)("DnsRecords Resource", async () => {
162127
expect(mailRecord).toBeUndefined();
163128

164129
// Verify directly with API
165-
const listResponse = await api.get(
166-
`/zones/${dnsRecords.zoneId}/dns_records`,
167-
);
168-
expect(listResponse.ok).toBe(true);
169-
170-
const listData: any = await listResponse.json();
171-
const apiRecords = listData.result;
172-
173-
// Should find our 3 records
174-
const testRecords = apiRecords.filter((r: any) =>
175-
r.name.includes(testDomain),
176-
);
177-
expect(testRecords).toHaveLength(3);
130+
await assertRecordsMatch(dnsRecords);
178131
} catch (err) {
179132
console.error("Test failed:", err);
180133
throw err;
181134
} finally {
182-
// Clean up all resources
183135
await destroy(scope);
184-
// Verify records were deleted
185-
if (dnsRecords?.records) {
186-
for (const record of dnsRecords.records) {
187-
const response = await api.get(
188-
`/zones/${dnsRecords.zoneId}/dns_records/${record.id}`,
189-
);
190-
expect(response.status).toBe(404);
191-
}
192-
}
136+
await assertRecordsDeleted(zone.id);
193137
}
194138
});
195139

@@ -230,6 +174,78 @@ describe.skipIf(!isEnabled)("DnsRecords Resource", async () => {
230174
expect(dnsRecords.records[0].content).toBe("192.0.2.2");
231175
} finally {
232176
await destroy(scope);
177+
await assertRecordsDeleted(zone.id);
178+
}
179+
});
180+
181+
test("handles multiple TXT records", async (scope) => {
182+
try {
183+
let dnsRecords = await DnsRecords(`${testDomain}-txt-dns`, {
184+
zoneId: zone.id,
185+
records: [
186+
{
187+
name: `www.${testDomain}`,
188+
type: "TXT",
189+
content: "v=spf1 include:_spf.google.com ~all",
190+
},
191+
{
192+
name: `www.${testDomain}`,
193+
type: "TXT",
194+
content: "google-site-verification=1234567890",
195+
},
196+
{
197+
name: `www.${testDomain}`,
198+
type: "A",
199+
content: "192.0.2.1",
200+
},
201+
],
202+
});
203+
await assertRecordsMatch(dnsRecords);
204+
205+
dnsRecords = await DnsRecords(`${testDomain}-txt-dns`, {
206+
zoneId: zone.id,
207+
records: [
208+
{
209+
name: `www.${testDomain}`,
210+
type: "TXT",
211+
content: "v=spf1 include:_spf.google.com ~all",
212+
},
213+
{
214+
name: `www.${testDomain}`,
215+
type: "TXT",
216+
content: "google-site-verification=1234567890",
217+
},
218+
{
219+
name: `www.${testDomain}`,
220+
type: "A",
221+
content: "192.0.2.2",
222+
},
223+
],
224+
});
225+
226+
await assertRecordsMatch(dnsRecords);
227+
} finally {
228+
await destroy(scope);
229+
await assertRecordsDeleted(zone.id);
233230
}
234231
});
235232
});
233+
234+
async function assertRecordsMatch(dnsRecords: DnsRecords) {
235+
const apiRecords = await listRecords(api, dnsRecords.zoneId);
236+
expect(apiRecords).toHaveLength(dnsRecords.records.length);
237+
for (const record of dnsRecords.records) {
238+
const apiRecord = apiRecords.find((r) => r.id === record.id);
239+
assert(apiRecord, `Record ${record.name} (${record.id}) not found in API`);
240+
expect(apiRecord.name).toBe(record.name);
241+
expect(apiRecord.type).toBe(record.type);
242+
expect(apiRecord.content).toBe(record.content);
243+
expect(apiRecord.proxied).toBe(record.proxied);
244+
expect(apiRecord.comment).toBe(record.comment);
245+
}
246+
}
247+
248+
async function assertRecordsDeleted(zoneId: string) {
249+
const apiRecords = await listRecords(api, zoneId);
250+
expect(apiRecords).toHaveLength(0);
251+
}

0 commit comments

Comments
 (0)