diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/notices/notices.ts b/packages/@aws-cdk/toolkit-lib/lib/api/notices/notices.ts index 5afb83df6..9477dd1e2 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/notices/notices.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/notices/notices.ts @@ -155,7 +155,10 @@ export class Notices { * @throws on failure to refresh the data source */ public async refresh(options: NoticesRefreshOptions = {}) { - const innerDataSource = options.dataSource ?? new WebsiteNoticeDataSource(this.ioHelper, this.httpOptions); + const innerDataSource = options.dataSource ?? new WebsiteNoticeDataSource(this.ioHelper, { + ...this.httpOptions, + skipNetworkCache: options.force ?? false, + }); const dataSource = new CachedDataSource(this.ioHelper, CACHE_FILE_PATH, innerDataSource, options.force ?? false); const notices = await dataSource.fetch(); this.data = new Set(notices); diff --git a/packages/@aws-cdk/toolkit-lib/lib/api/notices/web-data-source.ts b/packages/@aws-cdk/toolkit-lib/lib/api/notices/web-data-source.ts index 6dcf4cc72..7af498d95 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/api/notices/web-data-source.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/api/notices/web-data-source.ts @@ -4,6 +4,7 @@ import * as https from 'node:https'; import type { Notice, NoticeDataSource } from './types'; import { ToolkitError } from '../../toolkit/toolkit-error'; import { formatErrorMessage, humanHttpStatusError, humanNetworkError } from '../../util'; +import { NetworkDetector } from '../../util/network-detector'; import type { IoHelper } from '../io/private'; /** @@ -20,6 +21,7 @@ export class WebsiteNoticeDataSourceProps { * @default - Official CDK notices */ readonly url?: string | URL; + /** * The agent responsible for making the network requests. * @@ -28,6 +30,14 @@ export class WebsiteNoticeDataSourceProps { * @default - Uses the shared global node agent */ readonly agent?: https.Agent; + + /** + * Whether or not we want to skip the check for if we have already determined we are in + * a network-less environment. Forces WebsiteNoticeDataSource to make a network call. + * + * @default false + */ + readonly skipNetworkCache?: boolean; } export class WebsiteNoticeDataSource implements NoticeDataSource { @@ -37,13 +47,23 @@ export class WebsiteNoticeDataSource implements NoticeDataSource { public readonly url: any; private readonly agent?: https.Agent; + private readonly skipNetworkCache?: boolean; constructor(private readonly ioHelper: IoHelper, props: WebsiteNoticeDataSourceProps = {}) { this.agent = props.agent; this.url = props.url ?? 'https://cli.cdk.dev-tools.aws.dev/notices.json'; + this.skipNetworkCache = props.skipNetworkCache; } async fetch(): Promise { + if (!this.skipNetworkCache) { + // 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. // diff --git a/packages/@aws-cdk/toolkit-lib/lib/index.ts b/packages/@aws-cdk/toolkit-lib/lib/index.ts index 6de451e35..72bc89cb5 100644 --- a/packages/@aws-cdk/toolkit-lib/lib/index.ts +++ b/packages/@aws-cdk/toolkit-lib/lib/index.ts @@ -17,3 +17,6 @@ export * from './api/cloud-assembly'; export * from './api/io'; export * from './api/tags'; export * from './api/plugin'; + +// Utilities +export { NetworkDetector } from './util/network-detector'; diff --git a/packages/@aws-cdk/toolkit-lib/lib/util/network-detector.ts b/packages/@aws-cdk/toolkit-lib/lib/util/network-detector.ts new file mode 100644 index 000000000..94786afaa --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/lib/util/network-detector.ts @@ -0,0 +1,89 @@ +import * as https from 'node:https'; +import * as path from 'path'; +import * as fs from 'fs-extra'; +import { cdkCacheDir } from './'; + +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 { + 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; + } + } + + private static readonly TIMEOUT_MS = 500; + + private static async load(): Promise { + 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 { + 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 { + return new Promise((resolve) => { + const req = https.request({ + hostname: 'cli.cdk.dev-tools.aws.dev', + path: '/notices.json', + method: 'HEAD', + agent, + timeout: this.TIMEOUT_MS, + }, (res) => { + resolve(res.statusCode !== undefined && res.statusCode < 500); + }); + + req.on('error', () => resolve(false)); + req.on('timeout', () => { + req.destroy(); + resolve(false); + }); + + req.end(); + }); + } +} diff --git a/packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts b/packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts index e79c15e42..cbdcc1a36 100644 --- a/packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts +++ b/packages/@aws-cdk/toolkit-lib/test/api/notices.test.ts @@ -11,6 +11,7 @@ import { FilteredNotice, NoticesFilter } from '../../lib/api/notices/filter'; import type { BootstrappedEnvironment, Component, Notice } from '../../lib/api/notices/types'; import { WebsiteNoticeDataSource } from '../../lib/api/notices/web-data-source'; import { Settings } from '../../lib/api/settings'; +import { NetworkDetector } from '../../lib/util/network-detector'; import { TestIoHost } from '../_helpers'; const BASIC_BOOTSTRAP_NOTICE = { @@ -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], diff --git a/packages/@aws-cdk/toolkit-lib/test/util/network-detector.test.ts b/packages/@aws-cdk/toolkit-lib/test/util/network-detector.test.ts new file mode 100644 index 000000000..6000ffdbe --- /dev/null +++ b/packages/@aws-cdk/toolkit-lib/test/util/network-detector.test.ts @@ -0,0 +1,169 @@ +import * as https from 'node:https'; +import * as fs from 'fs-extra'; +import { NetworkDetector } from '../../lib/util/network-detector'; + +// Mock the https module +jest.mock('node:https'); +const mockHttps = https as jest.Mocked; + +// Mock fs-extra +jest.mock('fs-extra'); +const mockFs = fs as jest.Mocked; + +// 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((_options, callback) => { + callback({ statusCode: 200 }); + 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((_options, callback) => { + callback({ statusCode: 500 }); + 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((_options, callback) => { + callback({ statusCode: 200 }); + 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((_options, callback) => { + callback({ statusCode: 200 }); + 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((_options, callback) => { + callback({ statusCode: 200 }); + 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 + }); +}); diff --git a/packages/aws-cdk/lib/cli/telemetry/sink/endpoint-sink.ts b/packages/aws-cdk/lib/cli/telemetry/sink/endpoint-sink.ts index 772089a61..2a193aa7c 100644 --- a/packages/aws-cdk/lib/cli/telemetry/sink/endpoint-sink.ts +++ b/packages/aws-cdk/lib/cli/telemetry/sink/endpoint-sink.ts @@ -2,7 +2,7 @@ import type { IncomingMessage } from 'http'; import type { Agent } from 'https'; import { request } from 'https'; import { parse, type UrlWithStringQuery } from 'url'; -import { ToolkitError } from '@aws-cdk/toolkit-lib'; +import { ToolkitError, NetworkDetector } from '@aws-cdk/toolkit-lib'; import { IoHelper } from '../../../api-private'; import type { IIoHost } from '../../io-host'; import type { TelemetrySchema } from '../schema'; @@ -89,6 +89,13 @@ export class EndpointTelemetrySink implements ITelemetrySink { url: UrlWithStringQuery, body: { events: TelemetrySchema[] }, ): Promise { + // Check connectivity before attempting network request + const hasConnectivity = await NetworkDetector.hasConnectivity(this.agent); + if (!hasConnectivity) { + await this.ioHelper.defaults.trace('No internet connectivity detected, skipping telemetry'); + return false; + } + try { const res = await doRequest(url, body, this.agent); diff --git a/packages/aws-cdk/test/cli/telemetry/sink/endpoint-sink.test.ts b/packages/aws-cdk/test/cli/telemetry/sink/endpoint-sink.test.ts index db7d8e182..cf0291bbf 100644 --- a/packages/aws-cdk/test/cli/telemetry/sink/endpoint-sink.test.ts +++ b/packages/aws-cdk/test/cli/telemetry/sink/endpoint-sink.test.ts @@ -1,4 +1,5 @@ import * as https from 'https'; +import { NetworkDetector } from '@aws-cdk/toolkit-lib'; import { createTestEvent } from './util'; import { IoHelper } from '../../../../lib/api-private'; import { CliIoHost } from '../../../../lib/cli/io-host'; @@ -9,12 +10,23 @@ jest.mock('https', () => ({ request: jest.fn(), })); +// Mock NetworkDetector +jest.mock('@aws-cdk/toolkit-lib', () => ({ + ...jest.requireActual('@aws-cdk/toolkit-lib'), + NetworkDetector: { + hasConnectivity: jest.fn(), + }, +})); + describe('EndpointTelemetrySink', () => { let ioHost: CliIoHost; beforeEach(() => { jest.resetAllMocks(); + // Mock NetworkDetector to return true by default for existing tests + (NetworkDetector.hasConnectivity as jest.Mock).mockResolvedValue(true); + ioHost = CliIoHost.instance(); }); @@ -312,4 +324,20 @@ describe('EndpointTelemetrySink', () => { expect.stringContaining('Telemetry Error: POST example.com/telemetry:'), ); }); + + test('skips request when no connectivity detected', async () => { + // GIVEN + (NetworkDetector.hasConnectivity as jest.Mock).mockResolvedValue(false); + + const testEvent = createTestEvent('INVOKE', { foo: 'bar' }); + const client = new EndpointTelemetrySink({ endpoint: 'https://example.com/telemetry', ioHost }); + + // WHEN + await client.emit(testEvent); + await client.flush(); + + // THEN + expect(NetworkDetector.hasConnectivity).toHaveBeenCalledWith(undefined); + expect(https.request).not.toHaveBeenCalled(); + }); }); diff --git a/packages/aws-cdk/test/cli/telemetry/sink/funnel.test.ts b/packages/aws-cdk/test/cli/telemetry/sink/funnel.test.ts index cb3c0766f..bb1ab715a 100644 --- a/packages/aws-cdk/test/cli/telemetry/sink/funnel.test.ts +++ b/packages/aws-cdk/test/cli/telemetry/sink/funnel.test.ts @@ -1,6 +1,7 @@ import * as https from 'https'; import * as os from 'os'; import * as path from 'path'; +import { NetworkDetector } from '@aws-cdk/toolkit-lib'; import * as fs from 'fs-extra'; import { createTestEvent } from './util'; import { IoHelper } from '../../../../lib/api-private'; @@ -14,6 +15,14 @@ jest.mock('https', () => ({ request: jest.fn(), })); +// Mock NetworkDetector +jest.mock('@aws-cdk/toolkit-lib', () => ({ + ...jest.requireActual('@aws-cdk/toolkit-lib'), + NetworkDetector: { + hasConnectivity: jest.fn(), + }, +})); + describe('Funnel', () => { let tempDir: string; let logFilePath: string; @@ -22,6 +31,9 @@ describe('Funnel', () => { beforeEach(() => { jest.resetAllMocks(); + // Mock NetworkDetector to return true by default for all tests + (NetworkDetector.hasConnectivity as jest.Mock).mockResolvedValue(true); + // Create a fresh temp directory for each test tempDir = path.join(os.tmpdir(), `telemetry-test-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`); fs.mkdirSync(tempDir, { recursive: true }); diff --git a/packages/aws-cdk/test/commands/notices.test.ts b/packages/aws-cdk/test/commands/notices.test.ts index 4ed6ee976..5c4ed0b84 100644 --- a/packages/aws-cdk/test/commands/notices.test.ts +++ b/packages/aws-cdk/test/commands/notices.test.ts @@ -1,3 +1,10 @@ +// Mock NetworkDetector before any imports +jest.mock('../../../@aws-cdk/toolkit-lib/lib/util/network-detector', () => ({ + NetworkDetector: { + hasConnectivity: jest.fn(() => Promise.resolve(true)), + }, +})); + import * as nock from 'nock'; import { exec } from '../../lib/cli/cli';