Skip to content
Open
Show file tree
Hide file tree
Changes from 28 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 @@ -25,9 +28,22 @@ integTest('requests go through a proxy when configured',
},
});

const connections = JSON.parse(await fs.readFile(path.join(cdkCacheDir, 'connection.json'), 'utf8'));
// eslint-disable-next-line no-console
console.log('connections', connections);

const requests = await proxyServer.getSeenRequests();
const urls = requests.map(req => req.url);
// eslint-disable-next-line no-console
console.log('1', JSON.stringify(urls));
// eslint-disable-next-line no-console
console.log('2', JSON.stringify(urls.reverse()));

const urls2 = urls.filter(u => u.startsWith('https://cli.cdk.dev'));
// eslint-disable-next-line no-console
console.log('3', urls2);

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

const actionsUsed = awsActionsFromRequests(requests);
Expand Down
11 changes: 10 additions & 1 deletion packages/@aws-cdk/toolkit-lib/lib/api/notices/notices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,16 @@ 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);
await this.ioHelper.notify({
message: `notices refresh starting, ${JSON.stringify(options)}`,
time: new Date(Date.now()),
level: 'info',
data: undefined,
});
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);
Expand Down
32 changes: 30 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 @@ -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';

/**
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 @@ -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 {
Expand All @@ -37,13 +47,29 @@ 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<Notice[]> {
if (!this.skipNetworkCache) {
await this.ioHelper.notify({
message: `website data source fetch starting, ${this.agent !== undefined}}`,
time: new Date(Date.now()),
level: 'info',
data: undefined,
});
// 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 +92,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 +119,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
3 changes: 3 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be a public package?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will be reverted

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually i do need this so endpoint-sink can access network detector in aws-cdk. but ive updated how its imported to mirror that of notices

100 changes: 100 additions & 0 deletions packages/@aws-cdk/toolkit-lib/lib/util/network-detector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
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 './';

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, ioHelper?: any): Promise<boolean> {
const cachedData = await this.load();
const expiration = cachedData.expiration ?? 0;
await ioHelper?.notify({
message: `hasconnectivity, ${JSON.stringify(cachedData)}`,
time: new Date(Date.now()),
level: 'info',
data: undefined,
});

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 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_MS,
};

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();
});
}
}
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 @@ -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 = {
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
Loading
Loading