Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
7ee3f4d
feat: add network detector that uses notices endpoint
kaizencc Nov 4, 2025
7718108
feat(toolkit-lib): network detector
kaizencc Nov 4, 2025
91d3441
chore: refactor network detector to ping once an hour and write to disk
kaizencc Nov 5, 2025
51ffbf6
Merge branch 'main' into conroy/ping
kaizencc Nov 5, 2025
d7dcdc6
update funnle test
kaizencc Nov 5, 2025
f69b420
mock network detector in notices
kaizencc Nov 5, 2025
f7cd018
chore: self mutation
invalid-email-address Nov 5, 2025
d0d4e93
merge
kaizencc Nov 6, 2025
ec0768f
chore: self mutation
invalid-email-address Nov 6, 2025
60f2c12
udpate tests
kaizencc Nov 6, 2025
c342cc2
skip network check property
kaizencc Nov 11, 2025
671b1ee
update network-detector
kaizencc Nov 11, 2025
995765b
actually skip cache
kaizencc Nov 11, 2025
5365dfb
one line
kaizencc Nov 11, 2025
d037ec8
Merge branch 'main' into conroy/ping
kaizencc Nov 11, 2025
2cbbc6b
delete connection cache
kaizencc Nov 12, 2025
22f49d4
add logs
kaizencc Nov 12, 2025
4e65441
eslint
kaizencc Nov 13, 2025
c05df0e
logs
kaizencc Nov 13, 2025
0d975e9
await
kaizencc Nov 13, 2025
e647834
type
kaizencc Nov 13, 2025
34683ea
omg
kaizencc Nov 13, 2025
12e07e2
reverse
kaizencc Nov 13, 2025
0fc2d90
chore: self mutation
invalid-email-address Nov 13, 2025
ae56f62
merge
kaizencc Nov 13, 2025
0818fb2
omgggg
kaizencc Nov 13, 2025
7f2d4ea
update call
kaizencc Nov 13, 2025
c33ec6c
hail mary
kaizencc Nov 14, 2025
b2822d2
add back timeout
kaizencc Nov 14, 2025
0dd91d8
refactor back to what i want to merge
kaizencc Nov 14, 2025
ae20ea5
add back head request
kaizencc Nov 14, 2025
2adf47e
fix test
kaizencc Nov 14, 2025
d1355f1
add reasonable timeout
kaizencc Nov 14, 2025
1dc3a75
fix tests
kaizencc Nov 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ integTest('requests go through a proxy when configured',
// Delete notices cache if it exists
await fs.rm(path.join(cdkCacheDir, 'notices.json'), { force: true });

// Delete connection cache if it exists
await fs.rm(path.join(cdkCacheDir, 'connection.json'), { force: true });

await fixture.cdkDeploy('test-2', {
captureStderr: true,
options: [
Expand All @@ -26,7 +29,6 @@ integTest('requests go through a proxy when configured',
});

const requests = await proxyServer.getSeenRequests();

expect(requests.map(req => req.url))
.toContain('https://cli.cdk.dev-tools.aws.dev/notices.json');

Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/toolkit-lib/lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export * from './garbage-collection';
export * from './hotswap';
export * from './io';
export * from './logs-monitor';
export * from './network-detector';
export * from './notices';
export * from './plugin';
export * from './refactoring';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './network-detector';
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import * as https from 'node:https';
import type { RequestOptions } from 'node:https';
import * as path from 'path';
import * as fs from 'fs-extra';
import { cdkCacheDir } from '../../util';

interface CachedConnectivity {
expiration: number;
hasConnectivity: boolean;
}

const TIME_TO_LIVE_SUCCESS = 60 * 60 * 1000; // 1 hour
const CACHE_FILE_PATH = path.join(cdkCacheDir(), 'connection.json');

/**
* Detects internet connectivity by making a lightweight request to the notices endpoint
*/
export class NetworkDetector {
/**
* Check if internet connectivity is available
*/
public static async hasConnectivity(agent?: https.Agent): Promise<boolean> {
const cachedData = await this.load();
const expiration = cachedData.expiration ?? 0;

if (Date.now() > expiration) {
try {
const connected = await this.ping(agent);
const updatedData = {
expiration: Date.now() + TIME_TO_LIVE_SUCCESS,
hasConnectivity: connected,
};
await this.save(updatedData);
return connected;
} catch {
return false;
}
} else {
return cachedData.hasConnectivity;
}
}

// We are observing lots of timeouts when running in a massively parallel
// integration test environment, so wait for a longer timeout there.
//
// In production, have a short timeout to not hold up the user experience.
private static readonly TIMEOUT = process.env.TESTING_CDK ? 30_000 : 3_000;
private static readonly URL = 'https://cli.cdk.dev-tools.aws.dev/notices.json';

private static async load(): Promise<CachedConnectivity> {
const defaultValue = {
expiration: 0,
hasConnectivity: false,
};

try {
return fs.existsSync(CACHE_FILE_PATH)
? await fs.readJSON(CACHE_FILE_PATH) as CachedConnectivity
: defaultValue;
} catch {
return defaultValue;
}
}

private static async save(cached: CachedConnectivity): Promise<void> {
try {
await fs.ensureFile(CACHE_FILE_PATH);
await fs.writeJSON(CACHE_FILE_PATH, cached);
} catch {
// Silently ignore cache save errors
}
}

private static ping(agent?: https.Agent): Promise<boolean> {
const options: RequestOptions = {
method: 'HEAD',
agent: agent,
timeout: this.TIMEOUT,
};

return new Promise((resolve) => {
const req = https.request(
NetworkDetector.URL,
options,
(res) => {
resolve(res.statusCode !== undefined && res.statusCode < 500);
},
);
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});

req.end();
});
}
}
14 changes: 12 additions & 2 deletions packages/@aws-cdk/toolkit-lib/lib/api/notices/web-data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Notice, NoticeDataSource } from './types';
import { ToolkitError } from '../../toolkit/toolkit-error';
import { formatErrorMessage, humanHttpStatusError, humanNetworkError } from '../../util';
import type { IoHelper } from '../io/private';
import { NetworkDetector } from '../network-detector/network-detector';

/**
* A data source that fetches notices from the CDK notices data source
Expand All @@ -20,6 +21,7 @@ export class WebsiteNoticeDataSourceProps {
* @default - Official CDK notices
*/
readonly url?: string | URL;

/**
* The agent responsible for making the network requests.
*
Expand All @@ -44,6 +46,12 @@ export class WebsiteNoticeDataSource implements NoticeDataSource {
}

async fetch(): Promise<Notice[]> {
// Check connectivity before attempting network request
const hasConnectivity = await NetworkDetector.hasConnectivity(this.agent);
if (!hasConnectivity) {
throw new ToolkitError('No internet connectivity detected');
}

// We are observing lots of timeouts when running in a massively parallel
// integration test environment, so wait for a longer timeout there.
//
Expand All @@ -66,7 +74,8 @@ export class WebsiteNoticeDataSource implements NoticeDataSource {
timer.unref();

try {
req = https.get(this.url,
req = https.get(
this.url,
options,
res => {
if (res.statusCode === 200) {
Expand All @@ -92,7 +101,8 @@ export class WebsiteNoticeDataSource implements NoticeDataSource {
} else {
reject(new ToolkitError(`${humanHttpStatusError(res.statusCode!)} (Status code: ${res.statusCode})`));
}
});
},
);
req.on('error', e => {
reject(ToolkitError.withCause(humanNetworkError(e), e));
});
Expand Down
19 changes: 19 additions & 0 deletions packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as fs from 'fs-extra';
import * as nock from 'nock';
import { Context } from '../../lib/api/context';
import { asIoHelper } from '../../lib/api/io/private';
import { NetworkDetector } from '../../lib/api/network-detector/network-detector';
import { Notices } from '../../lib/api/notices';
import { CachedDataSource } from '../../lib/api/notices/cached-data-source';
import { FilteredNotice, NoticesFilter } from '../../lib/api/notices/filter';
Expand Down Expand Up @@ -540,6 +541,24 @@ function parseTestComponent(x: string): Component {
describe(WebsiteNoticeDataSource, () => {
const dataSource = new WebsiteNoticeDataSource(ioHelper);

beforeEach(() => {
// Mock NetworkDetector to return true by default for existing tests
jest.spyOn(NetworkDetector, 'hasConnectivity').mockResolvedValue(true);
});

afterEach(() => {
jest.restoreAllMocks();
});

test('throws error when no connectivity detected', async () => {
const mockHasConnectivity = jest.spyOn(NetworkDetector, 'hasConnectivity').mockResolvedValue(false);

await expect(dataSource.fetch()).rejects.toThrow('No internet connectivity detected');
expect(mockHasConnectivity).toHaveBeenCalledWith(undefined);

mockHasConnectivity.mockRestore();
});

test('returns data when download succeeds', async () => {
const result = await mockCall(200, {
notices: [BASIC_NOTICE, MULTIPLE_AFFECTED_VERSIONS_NOTICE],
Expand Down
169 changes: 169 additions & 0 deletions packages/@aws-cdk/toolkit-lib/test/util/network-detector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import * as https from 'node:https';
import * as fs from 'fs-extra';
import { NetworkDetector } from '../../lib/api/network-detector/network-detector';

// Mock the https module
jest.mock('node:https');
const mockHttps = https as jest.Mocked<typeof https>;

// Mock fs-extra
jest.mock('fs-extra');
const mockFs = fs as jest.Mocked<typeof fs>;

// Mock cdkCacheDir
jest.mock('../../lib/util', () => ({
cdkCacheDir: jest.fn(() => '/mock/cache/dir'),
}));

describe('NetworkDetector', () => {
let mockRequest: jest.Mock;

beforeEach(() => {
jest.clearAllMocks();
mockRequest = jest.fn();
mockHttps.request.mockImplementation(mockRequest);
});

test('returns true when server responds with success status', async () => {
const mockReq = {
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};

mockRequest.mockImplementation((_url, _options, callback) => {
setTimeout(() => callback({ statusCode: 200 }), 0);
return mockReq;
});

mockFs.existsSync.mockReturnValue(false);
(mockFs.ensureFile as jest.Mock).mockResolvedValue(undefined);
(mockFs.writeJSON as jest.Mock).mockResolvedValue(undefined);

const result = await NetworkDetector.hasConnectivity();
expect(result).toBe(true); // Should return true for successful HTTP response
});

test('returns false when server responds with server error', async () => {
const mockReq = {
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};

mockRequest.mockImplementation((_url, _options, callback) => {
setTimeout(() => callback({ statusCode: 500 }), 0);
return mockReq;
});

mockFs.existsSync.mockReturnValue(false);
(mockFs.ensureFile as jest.Mock).mockResolvedValue(undefined);
(mockFs.writeJSON as jest.Mock).mockResolvedValue(undefined);

const result = await NetworkDetector.hasConnectivity();
expect(result).toBe(false); // Should return false for server error status codes
});

test('returns false on network error', async () => {
const mockReq = {
on: jest.fn((event, handler) => {
if (event === 'error') {
setTimeout(() => handler(new Error('Network error')), 0);
}
}),
end: jest.fn(),
destroy: jest.fn(),
};

mockRequest.mockReturnValue(mockReq);
mockFs.existsSync.mockReturnValue(false);

const result = await NetworkDetector.hasConnectivity();
expect(result).toBe(false); // Should return false when network request fails
});

test('returns cached result from disk when not expired', async () => {
const cachedData = {
expiration: Date.now() + 30000, // 30 seconds in future
hasConnectivity: true,
};

mockFs.existsSync.mockReturnValue(true);
(mockFs.readJSON as jest.Mock).mockResolvedValue(cachedData);

const result = await NetworkDetector.hasConnectivity();

expect(result).toBe(true); // Should return cached connectivity result
expect(mockRequest).not.toHaveBeenCalled(); // Should not make network request when cache is valid
});

test('performs ping when disk cache is expired', async () => {
const expiredData = {
expiration: Date.now() - 1000, // 1 second ago
hasConnectivity: true,
};

const mockReq = {
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};

mockRequest.mockImplementation((_url, _options, callback) => {
setTimeout(() => callback({ statusCode: 200 }), 0);
return mockReq;
});

mockFs.existsSync.mockReturnValue(true);
(mockFs.readJSON as jest.Mock).mockResolvedValue(expiredData);
(mockFs.ensureFile as jest.Mock).mockResolvedValue(undefined);
(mockFs.writeJSON as jest.Mock).mockResolvedValue(undefined);

const result = await NetworkDetector.hasConnectivity();

expect(result).toBe(true); // Should return fresh connectivity result
expect(mockRequest).toHaveBeenCalledTimes(1); // Should make network request when cache is expired
});

test('handles cache save errors gracefully', async () => {
const mockReq = {
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};

mockRequest.mockImplementation((_url, _options, callback) => {
setTimeout(() => callback({ statusCode: 200 }), 0);
return mockReq;
});

mockFs.existsSync.mockReturnValue(false);
(mockFs.ensureFile as jest.Mock).mockRejectedValue(new Error('Disk full'));

const result = await NetworkDetector.hasConnectivity();

expect(result).toBe(true); // Should still return connectivity result despite cache save failure
});

test('handles cache load errors gracefully', async () => {
const mockReq = {
on: jest.fn(),
end: jest.fn(),
destroy: jest.fn(),
};

mockRequest.mockImplementation((_url, _options, callback) => {
setTimeout(() => callback({ statusCode: 200 }), 0);
return mockReq;
});

mockFs.existsSync.mockReturnValue(true);
(mockFs.readJSON as jest.Mock).mockRejectedValue(new Error('Read failed'));
(mockFs.ensureFile as jest.Mock).mockResolvedValue(undefined);
(mockFs.writeJSON as jest.Mock).mockResolvedValue(undefined);

const result = await NetworkDetector.hasConnectivity();

expect(result).toBe(true); // Should still return connectivity result despite cache load failure
});
});
2 changes: 2 additions & 0 deletions packages/aws-cdk/lib/api/network-detector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* eslint-disable import/no-relative-packages */
export * from '../../../@aws-cdk/toolkit-lib/lib/api/network-detector';
Loading
Loading