Skip to content

Commit de2863e

Browse files
authored
chore: Add domainName to instance configuration, part of #421. (#425)
Update InstanceConnectionName and parseInstanceConnectionName() to allow DNS names as well as instance connection names. Add test cases to cover DNS Names. Add new dns-lookup.ts that looks up the instance connection name from a DNS name using the TXT record. Part of #421
1 parent 0e4853a commit de2863e

6 files changed

+566
-84
lines changed

src/dns-lookup.ts

+50
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import dns from 'node:dns';
16+
import {CloudSQLConnectorError} from './errors';
17+
18+
export async function resolveTxtRecord(name: string): Promise<string> {
19+
return new Promise((resolve, reject) => {
20+
dns.resolveTxt(name, (err, addresses) => {
21+
if (err) {
22+
reject(
23+
new CloudSQLConnectorError({
24+
code: 'EDOMAINNAMELOOKUPERROR',
25+
message: 'Error looking up TXT record for domain ' + name,
26+
errors: [err],
27+
})
28+
);
29+
return;
30+
}
31+
32+
if (!addresses || addresses.length === 0) {
33+
reject(
34+
new CloudSQLConnectorError({
35+
code: 'EDOMAINNAMELOOKUPFAILED',
36+
message: 'No records returned for domain ' + name,
37+
})
38+
);
39+
return;
40+
}
41+
42+
// Each result may be split into multiple strings. Join the strings.
43+
const joinedAddresses = addresses.map(strs => strs.join(''));
44+
// Sort the results alphabetically for consistency,
45+
joinedAddresses.sort((a, b) => a.localeCompare(b));
46+
// Return the first result.
47+
resolve(joinedAddresses[0]);
48+
});
49+
});
50+
}

src/instance-connection-info.ts

+1
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@ export interface InstanceConnectionInfo {
1616
projectId: string;
1717
regionId: string;
1818
instanceId: string;
19+
domainName?: string;
1920
}

src/parse-instance-connection-name.ts

+79-13
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,83 @@
1414

1515
import {InstanceConnectionInfo} from './instance-connection-info';
1616
import {CloudSQLConnectorError} from './errors';
17+
import {resolveTxtRecord} from './dns-lookup';
18+
19+
export function isSameInstance(
20+
a: InstanceConnectionInfo,
21+
b: InstanceConnectionInfo
22+
): boolean {
23+
return (
24+
a.instanceId === b.instanceId &&
25+
a.regionId === b.regionId &&
26+
a.projectId === b.projectId &&
27+
a.domainName === b.domainName
28+
);
29+
}
30+
31+
export async function resolveInstanceName(
32+
instanceConnectionName?: string,
33+
domainName?: string
34+
): Promise<InstanceConnectionInfo> {
35+
if (!instanceConnectionName && !domainName) {
36+
throw new CloudSQLConnectorError({
37+
message:
38+
'Missing instance connection name, expected: "PROJECT:REGION:INSTANCE" or a valid domain name.',
39+
code: 'ENOCONNECTIONNAME',
40+
});
41+
} else if (
42+
instanceConnectionName &&
43+
isInstanceConnectionName(instanceConnectionName)
44+
) {
45+
return parseInstanceConnectionName(instanceConnectionName);
46+
} else if (domainName && isValidDomainName(domainName)) {
47+
return await resolveDomainName(domainName);
48+
} else {
49+
throw new CloudSQLConnectorError({
50+
message:
51+
'Malformed Instance connection name, expected an instance connection name in the form "PROJECT:REGION:INSTANCE" or a valid domain name',
52+
code: 'EBADCONNECTIONNAME',
53+
});
54+
}
55+
}
56+
57+
const connectionNameRegex =
58+
/^(?<projectId>[^:]+(:[^:]+)?):(?<regionId>[^:]+):(?<instanceId>[^:]+)$/;
59+
60+
// The domain name pattern in accordance with RFC 1035, RFC 1123 and RFC 2181.
61+
// From Go Connector:
62+
const domainNameRegex =
63+
/^(?:[_a-z0-9](?:[_a-z0-9-]{0,61}[a-z0-9])?\.)+(?:[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?)?$/;
64+
65+
export function isValidDomainName(name: string): boolean {
66+
const matches = String(name).match(domainNameRegex);
67+
return Boolean(matches);
68+
}
69+
70+
export function isInstanceConnectionName(name: string): boolean {
71+
const matches = String(name).match(connectionNameRegex);
72+
return Boolean(matches);
73+
}
74+
75+
export async function resolveDomainName(
76+
name: string
77+
): Promise<InstanceConnectionInfo> {
78+
const icn = await resolveTxtRecord(name);
79+
if (!isInstanceConnectionName(icn)) {
80+
throw new CloudSQLConnectorError({
81+
message:
82+
'Malformed instance connection name returned for domain ' +
83+
name +
84+
' : ' +
85+
icn,
86+
code: 'EBADDOMAINCONNECTIONNAME',
87+
});
88+
}
89+
90+
const info = parseInstanceConnectionName(icn);
91+
info.domainName = name;
92+
return info;
93+
}
1794

1895
export function parseInstanceConnectionName(
1996
instanceConnectionName: string | undefined
@@ -26,20 +103,8 @@ export function parseInstanceConnectionName(
26103
});
27104
}
28105

29-
const connectionNameRegex =
30-
/(?<projectId>[^:]+(:[^:]+)?):(?<regionId>[^:]+):(?<instanceId>[^:]+)/;
31106
const matches = String(instanceConnectionName).match(connectionNameRegex);
32-
if (!matches) {
33-
throw new CloudSQLConnectorError({
34-
message:
35-
'Malformed instance connection name provided: expected format ' +
36-
`of "PROJECT:REGION:INSTANCE", got ${instanceConnectionName}`,
37-
code: 'EBADCONNECTIONNAME',
38-
});
39-
}
40-
41-
const unmatchedItems = matches[0] !== matches.input;
42-
if (unmatchedItems || !matches.groups) {
107+
if (!matches || !matches.groups) {
43108
throw new CloudSQLConnectorError({
44109
message:
45110
'Malformed instance connection name provided: expected format ' +
@@ -52,5 +117,6 @@ export function parseInstanceConnectionName(
52117
projectId: matches.groups.projectId,
53118
regionId: matches.groups.regionId,
54119
instanceId: matches.groups.instanceId,
120+
domainName: undefined,
55121
};
56122
}

test/cloud-sql-instance.ts

+1
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ t.test('CloudSQLInstance', async t => {
8080
projectId: 'my-project',
8181
regionId: 'us-east1',
8282
instanceId: 'my-instance',
83+
domainName: undefined,
8384
},
8485
'should have expected connection info'
8586
);

test/dns-lookup.ts

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import t from 'tap';
16+
17+
t.test('lookup dns with mock responses', async t => {
18+
const {resolveTxtRecord} = t.mockRequire('../src/dns-lookup.ts', {
19+
'node:dns': {
20+
resolveTxt: (name, callback) => {
21+
switch (name) {
22+
case 'db.example.com':
23+
callback(null, [['my-project:region-1:instance']]);
24+
return;
25+
case 'multiple.example.com':
26+
callback(null, [
27+
['my-project:region-1:instance'],
28+
['another-project:region-1:instance'],
29+
]);
30+
return;
31+
case 'split.example.com':
32+
callback(null, [['my-project:', 'region-1:instance']]);
33+
return;
34+
case 'empty.example.com':
35+
callback(null, []);
36+
return;
37+
default:
38+
callback(new Error('not found'), null);
39+
return;
40+
}
41+
},
42+
},
43+
});
44+
45+
t.same(
46+
await resolveTxtRecord('db.example.com'),
47+
'my-project:region-1:instance',
48+
'valid domain name'
49+
);
50+
t.same(
51+
await resolveTxtRecord('split.example.com'),
52+
'my-project:region-1:instance',
53+
'valid domain name'
54+
);
55+
t.same(
56+
await resolveTxtRecord('multiple.example.com'),
57+
'another-project:region-1:instance',
58+
'valid domain name'
59+
);
60+
t.rejects(
61+
async () => await resolveTxtRecord('not-found.example.com'),
62+
{code: 'EDOMAINNAMELOOKUPERROR'},
63+
'should throw type error if an extra item is provided'
64+
);
65+
t.rejects(
66+
async () => await resolveTxtRecord('empty.example.com'),
67+
{code: 'EDOMAINNAMELOOKUPFAILED'},
68+
'should throw type error if an extra item is provided'
69+
);
70+
});
71+
72+
t.test('lookup dns with real responses', async t => {
73+
const {resolveTxtRecord} = t.mockRequire('../src/dns-lookup.ts', {});
74+
t.same(
75+
await resolveTxtRecord('valid-san-test.csqlconnectortest.com'),
76+
'cloud-sql-connector-testing:us-central1:postgres-customer-cas-test',
77+
'valid domain name'
78+
);
79+
});

0 commit comments

Comments
 (0)