Skip to content

Commit 068e563

Browse files
committed
fix: force Launchpad API requests over IPv6
1 parent ca04396 commit 068e563

5 files changed

Lines changed: 171 additions & 59 deletions

File tree

app/models/users.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ if (config.env === 'production') {
159159
// cache the ubuntu members map immediately
160160
(async () => {
161161
try {
162-
map = await getUbuntuMembersMap(resolver);
162+
map = await getUbuntuMembersMap();
163163
} catch (err) {
164164
logger.fatal(err);
165165
}
@@ -168,7 +168,7 @@ if (config.env === 'production') {
168168
// every 5 minutes update the ubuntu members map
169169
setInterval(async () => {
170170
try {
171-
map = await getUbuntuMembersMap(resolver);
171+
map = await getUbuntuMembersMap();
172172
} catch (err) {
173173
logger.fatal(err);
174174
}
@@ -1220,7 +1220,7 @@ Users.pre('save', async function (next) {
12201220
return next();
12211221

12221222
try {
1223-
if (!(map instanceof Map)) map = await getUbuntuMembersMap(resolver);
1223+
if (!(map instanceof Map)) map = await getUbuntuMembersMap();
12241224
await syncUbuntuUser(this, map);
12251225
this.last_ubuntu_sync = new Date();
12261226
} catch (err) {

helpers/get-ubuntu-members-map.js

Lines changed: 107 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ const _ = require('#helpers/lodash');
1212
const config = require('#config');
1313
const isRetryableError = require('#helpers/is-retryable-error');
1414
const logger = require('#helpers/logger');
15-
const retryRequest = require('#helpers/retry-request');
15+
const retryLaunchpadRequest = require('#helpers/retry-launchpad-request');
16+
17+
const { LAUNCHPAD_ADDRESS_FAMILY } = retryLaunchpadRequest;
1618

1719
const PAGE_RETRIES = 2;
1820

@@ -54,8 +56,8 @@ function addToSet(entries, set) {
5456
}
5557
}
5658

57-
async function fetchPage(url, resolver, name, pageCount) {
58-
return pRetry(() => retryRequest(url, { resolver }), {
59+
async function fetchPage(url, name, pageCount) {
60+
return pRetry(() => retryLaunchpadRequest(url), {
5961
retries: PAGE_RETRIES,
6062
async onFailedAttempt(err) {
6163
if (!isRetryableError(err)) throw err;
@@ -64,74 +66,139 @@ async function fetchPage(url, resolver, name, pageCount) {
6466
err,
6567
url,
6668
attemptNumber: err.attemptNumber,
67-
retriesLeft: err.retriesLeft
69+
retriesLeft: err.retriesLeft,
70+
launchpadAddressFamily: LAUNCHPAD_ADDRESS_FAMILY
6871
});
6972
}
7073
});
7174
}
7275

73-
async function getUbuntuMembersMap(resolver) {
76+
async function getUbuntuMembersMap() {
7477
const map = new Map();
7578

7679
// set a date so we can use it for cache checks
7780
map[Symbol.for('createdAt')] = new Date();
7881

7982
await pMapSeries(Object.keys(config.ubuntuTeamMapping), async (name) => {
8083
const set = new Set();
84+
const teamPath = config.ubuntuTeamMapping[name];
85+
const totalSizes = new Set();
86+
const seenUrls = new Set();
8187

8288
// Initialize pagination variables
83-
let url = `https://api.launchpad.net/1.0/${config.ubuntuTeamMapping[name]}/participants`;
89+
let url = `https://api.launchpad.net/1.0/${teamPath}/participants`;
8490
let totalProcessed = 0;
8591
let pageCount = 0;
8692
const maxPages = 1000; // Safety limit to prevent infinite loops
8793

94+
logger.debug(`starting ubuntu membership fetch for ${name}`, {
95+
team: name,
96+
teamPath,
97+
url,
98+
launchpadAddressFamily: LAUNCHPAD_ADDRESS_FAMILY
99+
});
100+
88101
// Paginate through all results using next_collection_link
89102
while (url && pageCount < maxPages) {
90-
pageCount++;
91-
logger.debug(
92-
`${name} fetching page ${pageCount}, processed ${totalProcessed} entries`
93-
);
94-
95-
const response = await fetchPage(url, resolver, name, pageCount);
96-
97-
const json = await response.body.json();
98-
99-
if (!Number.isFinite(json.total_size) || json.total_size < 0)
100-
throw new TypeError('Property "total_size" was invalid');
101-
102-
// Add entries from current page
103-
addToSet(json.entries, set);
104-
totalProcessed += json.entries.length;
103+
const currentUrl = url;
104+
if (seenUrls.has(currentUrl)) {
105+
const err = new TypeError('Property "next_collection_link" repeated');
106+
err.team = name;
107+
err.teamPath = teamPath;
108+
err.pageCount = pageCount;
109+
err.totalProcessed = totalProcessed;
110+
err.url = currentUrl;
111+
throw err;
112+
}
105113

106-
// Check if there's a next page
107-
if (json.next_collection_link && isURL(json.next_collection_link)) {
108-
// Safeguard - ensure it's a valid Launchpad API URL
109-
if (!json.next_collection_link.startsWith('https://api.launchpad.net/'))
110-
throw new TypeError(
111-
'Property "next_collection_link" is not a valid API link'
112-
);
114+
seenUrls.add(currentUrl);
115+
pageCount++;
116+
logger.debug(`${name} fetching page ${pageCount}`, {
117+
team: name,
118+
teamPath,
119+
pageCount,
120+
totalProcessed,
121+
url: currentUrl,
122+
launchpadAddressFamily: LAUNCHPAD_ADDRESS_FAMILY
123+
});
113124

114-
url = json.next_collection_link;
115-
} else {
116-
url = null; // No more pages
125+
try {
126+
const response = await fetchPage(currentUrl, name, pageCount);
127+
const json = await response.body.json();
128+
129+
if (!Number.isFinite(json.total_size) || json.total_size < 0)
130+
throw new TypeError('Property "total_size" was invalid');
131+
132+
totalSizes.add(json.total_size);
133+
134+
// Add entries from current page
135+
addToSet(json.entries, set);
136+
totalProcessed += json.entries.length;
137+
138+
// Check if there's a next page
139+
if (json.next_collection_link && isURL(json.next_collection_link)) {
140+
// Safeguard - ensure it's a valid Launchpad API URL
141+
if (
142+
!json.next_collection_link.startsWith('https://api.launchpad.net/')
143+
)
144+
throw new TypeError(
145+
'Property "next_collection_link" is not a valid API link'
146+
);
147+
148+
url = json.next_collection_link;
149+
} else {
150+
url = null; // No more pages
151+
}
152+
153+
logger.debug(`${name} page ${pageCount} fetched`, {
154+
team: name,
155+
teamPath,
156+
pageCount,
157+
entries: json.entries.length,
158+
totalProcessed,
159+
totalSize: json.total_size,
160+
nextCollectionLink: url,
161+
uniqueMembers: set.size,
162+
launchpadAddressFamily: LAUNCHPAD_ADDRESS_FAMILY
163+
});
164+
} catch (err) {
165+
err.team = name;
166+
err.teamPath = teamPath;
167+
err.pageCount = pageCount;
168+
err.totalProcessed = totalProcessed;
169+
err.url = currentUrl;
170+
err.isRetryable = isRetryableError(err);
171+
throw err;
117172
}
118-
119-
// Log progress and detect total_size changes
120-
logger.debug(
121-
`${name} page ${pageCount}: processed ${json.entries.length} entries, total: ${totalProcessed}, API total_size: ${json.total_size}`
122-
);
123173
}
124174

125175
// Warn if we hit the safety limit
126176
if (pageCount >= maxPages) {
127-
logger.warn(
177+
await logger.warn(
128178
`${name} hit maximum page limit (${maxPages}), may have incomplete data`
129179
);
130180
}
131181

132-
logger.debug(
133-
`${name} completed: ${totalProcessed} total entries processed across ${pageCount} pages`
134-
);
182+
if (totalSizes.size > 1) {
183+
await logger.warn(`${name} total_size changed during pagination`, {
184+
team: name,
185+
teamPath,
186+
totalSizes: [...totalSizes],
187+
pages: pageCount,
188+
totalProcessed,
189+
uniqueMembers: set.size
190+
});
191+
}
192+
193+
logger.debug(`${name} completed ubuntu membership fetch`, {
194+
team: name,
195+
teamPath,
196+
totalProcessed,
197+
uniqueMembers: set.size,
198+
pages: pageCount,
199+
totalSizes: [...totalSizes],
200+
launchpadAddressFamily: LAUNCHPAD_ADDRESS_FAMILY
201+
});
135202
map.set(name, set);
136203
});
137204

helpers/retry-launchpad-request.js

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Copyright (c) Forward Email LLC
3+
* SPDX-License-Identifier: BUSL-1.1
4+
*/
5+
6+
const undici = require('undici');
7+
8+
const retryRequest = require('./retry-request');
9+
10+
const LAUNCHPAD_API_HOSTNAME = 'api.launchpad.net';
11+
const LAUNCHPAD_ADDRESS_FAMILY = 6;
12+
13+
const launchpadDispatcher = new undici.Agent({
14+
connect: {
15+
family: LAUNCHPAD_ADDRESS_FAMILY
16+
}
17+
});
18+
19+
function retryLaunchpadRequest(url, opts = {}) {
20+
const parsed = new URL(url);
21+
22+
if (parsed.hostname !== LAUNCHPAD_API_HOSTNAME)
23+
throw new TypeError(
24+
`Expected Launchpad API hostname "${LAUNCHPAD_API_HOSTNAME}"`
25+
);
26+
27+
// Bypass Tangerine/custom resolver for Launchpad.
28+
// On the affected production host, direct IPv4 TCP/443 to Launchpad times out,
29+
// while IPv6 succeeds. Forcing family 6 keeps these requests on the healthy
30+
// network path instead of repeatedly retrying a broken IPv4 connect.
31+
const { resolver: _resolver, ...requestOpts } = opts;
32+
33+
return retryRequest(url, {
34+
...requestOpts,
35+
dispatcher: requestOpts.dispatcher || launchpadDispatcher
36+
});
37+
}
38+
39+
module.exports = retryLaunchpadRequest;
40+
module.exports.LAUNCHPAD_ADDRESS_FAMILY = LAUNCHPAD_ADDRESS_FAMILY;
41+
module.exports.LAUNCHPAD_API_HOSTNAME = LAUNCHPAD_API_HOSTNAME;

helpers/sync-ubuntu-user.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,10 @@ const isEmail = require('#helpers/is-email');
1616
const Aliases = require('#models/aliases');
1717
const Domains = require('#models/domains');
1818
const config = require('#config');
19-
const createTangerine = require('#helpers/create-tangerine');
2019
const emailHelper = require('#helpers/email');
2120
const getUbuntuMembersMap = require('#helpers/get-ubuntu-members-map');
2221
const logger = require('#helpers/logger');
23-
const retryRequest = require('#helpers/retry-request');
22+
const retryLaunchpadRequest = require('#helpers/retry-launchpad-request');
2423
const { emoji } = require('#config/utilities');
2524

2625
const { fields } = config.passport;
@@ -29,7 +28,6 @@ const { fields } = config.passport;
2928
const breeSharedConfig = sharedConfig('BREE');
3029
const client = new Redis(breeSharedConfig.redis, logger);
3130
client.setMaxListeners(0);
32-
const resolver = createTangerine(client, logger);
3331

3432
class InvalidUbuntuUserError extends TypeError {
3533
constructor(message, options) {
@@ -115,7 +113,7 @@ async function syncUbuntuUser(user, map) {
115113
)
116114
throw new TypeError('Invalid user object');
117115

118-
if (!(map instanceof Map)) map = await getUbuntuMembersMap(resolver);
116+
if (!(map instanceof Map)) map = await getUbuntuMembersMap();
119117

120118
if (map.size === 0)
121119
throw new TypeError('Map supplied was missing or empty');
@@ -152,7 +150,7 @@ async function syncUbuntuUser(user, map) {
152150
// - ensure `is_ubuntu_coc_signer`
153151
//
154152
const url = `https://api.launchpad.net/1.0/~${user[fields.ubuntuUsername]}`;
155-
const response = await retryRequest(url, { resolver });
153+
const response = await retryLaunchpadRequest(url);
156154
const json = await response.body.json();
157155

158156
// validate booleans

jobs/ubuntu-sync-memberships.js

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,25 @@ const { parentPort } = require('node:worker_threads');
1515
require('#config/mongoose');
1616

1717
const Graceful = require('@ladjs/graceful');
18-
const Redis = require('@ladjs/redis');
1918
const mongoose = require('mongoose');
2019
const parseErr = require('parse-err');
2120
const pMapSeries = require('p-map-series');
2221
const pRetry = require('p-retry');
23-
const sharedConfig = require('@ladjs/shared-config');
2422
const safeStringify = require('fast-safe-stringify');
2523
const { encode } = require('html-entities');
2624

2725
const Users = require('#models/users');
2826
const config = require('#config');
29-
const createTangerine = require('#helpers/create-tangerine');
3027
const emailHelper = require('#helpers/email');
3128
const getUbuntuMembersMap = require('#helpers/get-ubuntu-members-map');
3229
const isRetryableError = require('#helpers/is-retryable-error');
3330
const logger = require('#helpers/logger');
31+
const retryLaunchpadRequest = require('#helpers/retry-launchpad-request');
3432
const setupMongoose = require('#helpers/setup-mongoose');
3533
const syncUbuntuUser = require('#helpers/sync-ubuntu-user');
3634

35+
const { LAUNCHPAD_ADDRESS_FAMILY } = retryLaunchpadRequest;
36+
3737
const JOB_RETRIES = 2;
3838

3939
const graceful = new Graceful({
@@ -43,19 +43,20 @@ const graceful = new Graceful({
4343

4444
graceful.listen();
4545

46-
// TODO: re-use existing connection from web
47-
const breeSharedConfig = sharedConfig('BREE');
48-
const client = new Redis(breeSharedConfig.redis, logger);
49-
client.setMaxListeners(0);
50-
51-
const resolver = createTangerine(client, logger);
52-
5346
function shouldRetry(err) {
5447
return err?.message === 'Mapping outdated' || isRetryableError(err);
5548
}
5649

5750
async function syncMemberships() {
58-
const map = await getUbuntuMembersMap(resolver);
51+
const map = await getUbuntuMembersMap();
52+
53+
await logger.info('Ubuntu membership map fetched', {
54+
teams: [...map.entries()].map(([name, set]) => ({
55+
name,
56+
members: set.size
57+
})),
58+
launchpadAddressFamily: LAUNCHPAD_ADDRESS_FAMILY
59+
});
5960

6061
const ids = await Users.distinct('_id', {
6162
[config.passport.fields.ubuntuProfileID]: {
@@ -66,6 +67,11 @@ async function syncMemberships() {
6667
}
6768
});
6869

70+
await logger.info('Ubuntu membership sync user scan starting', {
71+
totalUsers: ids.length,
72+
launchpadAddressFamily: LAUNCHPAD_ADDRESS_FAMILY
73+
});
74+
6975
//
7076
// should be done in series otherwise domain update could conflict
7177
//

0 commit comments

Comments
 (0)