From 4ff5ce9f744d2267bb67990ac066284f813a5c3e Mon Sep 17 00:00:00 2001 From: Alex Gherghisan Date: Tue, 3 Mar 2026 09:56:37 +0000 Subject: [PATCH] refactor: remove update checker, retain version checks (#20898) This PR removes the update checker with a simpler version checker. It also enables network_config.json to specify a latest node version which the node will check against. The node won't restart automatically if the rollup or node version changes but it will print a warning to logs every 10 minutes to inform the operator to restart. Fix A-193 --- spartan/environments/network-defaults.yml | 13 +- yarn-project/aztec/src/bin/index.ts | 4 +- .../aztec/src/cli/aztec_start_action.ts | 8 +- .../aztec/src/cli/aztec_start_options.ts | 3 +- yarn-project/aztec/src/cli/cmds/start_node.ts | 30 ++- .../aztec/src/cli/cmds/start_prover_broker.ts | 15 +- yarn-project/aztec/src/cli/release_version.ts | 21 -- yarn-project/aztec/src/cli/util.ts | 115 +++------ .../cli/src/config/cached_fetch.test.ts | 243 ++++++++++++++++++ yarn-project/cli/src/config/cached_fetch.ts | 150 ++++++++--- yarn-project/cli/src/config/network_config.ts | 2 - yarn-project/foundation/src/config/env_var.ts | 3 +- .../foundation/src/config/network_config.ts | 1 + yarn-project/node-lib/src/config/index.ts | 24 +- .../stdlib/src/update-checker/index.ts | 3 +- .../src/update-checker/package_version.ts | 17 ++ .../src/update-checker/update-checker.test.ts | 194 -------------- .../src/update-checker/update-checker.ts | 166 ------------ .../update-checker/version_checker.test.ts | 80 ++++++ .../src/update-checker/version_checker.ts | 65 +++++ 20 files changed, 609 insertions(+), 548 deletions(-) delete mode 100644 yarn-project/aztec/src/cli/release_version.ts create mode 100644 yarn-project/cli/src/config/cached_fetch.test.ts create mode 100644 yarn-project/stdlib/src/update-checker/package_version.ts delete mode 100644 yarn-project/stdlib/src/update-checker/update-checker.test.ts delete mode 100644 yarn-project/stdlib/src/update-checker/update-checker.ts create mode 100644 yarn-project/stdlib/src/update-checker/version_checker.test.ts create mode 100644 yarn-project/stdlib/src/update-checker/version_checker.ts diff --git a/spartan/environments/network-defaults.yml b/spartan/environments/network-defaults.yml index 6e8f28bd01bb..ca00e01708b2 100644 --- a/spartan/environments/network-defaults.yml +++ b/spartan/environments/network-defaults.yml @@ -232,9 +232,6 @@ networks: # P2P P2P_MAX_PENDING_TX_COUNT: 1000 P2P_TX_POOL_DELETE_TXS_AFTER_REORG: false - # Auto-update - AUTO_UPDATE: none - AUTO_UPDATE_URL: "" # Telemetry PUBLIC_OTEL_OPT_OUT: true PUBLIC_OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "" @@ -252,6 +249,7 @@ networks: SLASH_UNKNOWN_PENALTY: 10e18 SLASH_INVALID_BLOCK_PENALTY: 10e18 SLASH_GRACE_PERIOD_L2_SLOTS: 0 + ENABLE_VERSION_CHECK: true testnet: <<: *prodlike @@ -296,6 +294,7 @@ networks: SLASH_UNKNOWN_PENALTY: 10e18 SLASH_INVALID_BLOCK_PENALTY: 10e18 SLASH_GRACE_PERIOD_L2_SLOTS: 64 + ENABLE_VERSION_CHECK: true mainnet: <<: *prodlike @@ -338,12 +337,10 @@ networks: # P2P P2P_MAX_PENDING_TX_COUNT: 0 P2P_TX_POOL_DELETE_TXS_AFTER_REORG: true - # Auto-update - AUTO_UPDATE: notify - AUTO_UPDATE_URL: "https://storage.googleapis.com/aztec-mainnet/auto-update/mainnet.json" # Telemetry - PUBLIC_OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "https://telemetry.alpha-testnet.aztec-labs.com/v1/metrics" - PUBLIC_OTEL_COLLECT_FROM: sequencer + PUBLIC_OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "" + PUBLIC_OTEL_COLLECT_FROM: "" + ENABLE_VERSION_CHECK: false # Slasher penalties - more lenient initially SLASH_PRUNE_PENALTY: 0 SLASH_DATA_WITHHOLDING_PENALTY: 0 diff --git a/yarn-project/aztec/src/bin/index.ts b/yarn-project/aztec/src/bin/index.ts index c1565d92576f..55d55831457d 100644 --- a/yarn-project/aztec/src/bin/index.ts +++ b/yarn-project/aztec/src/bin/index.ts @@ -11,6 +11,7 @@ import { injectCommands as injectMiscCommands } from '@aztec/cli/misc'; import { injectCommands as injectValidatorKeysCommands } from '@aztec/cli/validator_keys'; import { getActiveNetworkName } from '@aztec/foundation/config'; import { createConsoleLogger, createLogger } from '@aztec/foundation/log'; +import { getPackageVersion } from '@aztec/stdlib/update-checker'; import { Command } from 'commander'; @@ -18,7 +19,6 @@ import { injectCompileCommand } from '../cli/cmds/compile.js'; import { injectMigrateCommand } from '../cli/cmds/migrate_ha_db.js'; import { injectProfileCommand } from '../cli/cmds/profile.js'; import { injectAztecCommands } from '../cli/index.js'; -import { getCliVersion } from '../cli/release_version.js'; const NETWORK_FLAG = 'network'; @@ -47,7 +47,7 @@ async function main() { await enrichEnvironmentWithNetworkConfig(networkName); enrichEnvironmentWithChainName(networkName); - const cliVersion = getCliVersion(); + const cliVersion = getPackageVersion() ?? 'unknown'; let program = new Command('aztec'); program.description('Aztec command line interface').version(cliVersion).enablePositionalOptions(); program = injectAztecCommands(program, userLog, debugLogger); diff --git a/yarn-project/aztec/src/cli/aztec_start_action.ts b/yarn-project/aztec/src/cli/aztec_start_action.ts index 3b966865084e..4304d7160755 100644 --- a/yarn-project/aztec/src/cli/aztec_start_action.ts +++ b/yarn-project/aztec/src/cli/aztec_start_action.ts @@ -1,3 +1,4 @@ +import { getActiveNetworkName } from '@aztec/foundation/config'; import { type NamespacedApiHandlers, createNamespacedSafeJsonRpcServer, @@ -7,13 +8,13 @@ import { import type { LogFn, Logger } from '@aztec/foundation/log'; import type { ChainConfig } from '@aztec/stdlib/config'; import { AztecNodeApiSchema } from '@aztec/stdlib/interfaces/client'; +import { getPackageVersion } from '@aztec/stdlib/update-checker'; import { getVersioningMiddleware } from '@aztec/stdlib/versioning'; import { getOtelJsonRpcPropagationMiddleware } from '@aztec/telemetry-client'; import { createLocalNetwork } from '../local-network/index.js'; import { github, splash } from '../splash.js'; import { resolveAdminApiKey } from './admin_api_key_store.js'; -import { getCliVersion } from './release_version.js'; import { extractNamespacedOptions, installSignalHandlers } from './util.js'; import { getVersions } from './versioning.js'; @@ -25,7 +26,7 @@ export async function aztecStart(options: any, userLog: LogFn, debugLogger: Logg let config: ChainConfig | undefined = undefined; if (options.localNetwork) { - const cliVersion = getCliVersion(); + const cliVersion = getPackageVersion() ?? 'unknown'; const localNetwork = extractNamespacedOptions(options, 'local-network'); localNetwork.testAccounts = true; userLog(`${splash}\n${github}\n\n`); @@ -57,7 +58,8 @@ export async function aztecStart(options: any, userLog: LogFn, debugLogger: Logg if (options.node) { const { startNode } = await import('./cmds/start_node.js'); - ({ config } = await startNode(options, signalHandlers, services, adminServices, userLog)); + const networkName = getActiveNetworkName(options.network); + ({ config } = await startNode(options, signalHandlers, services, adminServices, userLog, networkName)); } else if (options.bot) { const { startBot } = await import('./cmds/start_bot.js'); await startBot(options, signalHandlers, services, userLog); diff --git a/yarn-project/aztec/src/cli/aztec_start_options.ts b/yarn-project/aztec/src/cli/aztec_start_options.ts index 616613c7fb51..863291bf19c1 100644 --- a/yarn-project/aztec/src/cli/aztec_start_options.ts +++ b/yarn-project/aztec/src/cli/aztec_start_options.ts @@ -105,8 +105,7 @@ export const aztecStartOptions: { [key: string]: AztecStartOption[] } = { env: 'NETWORK', }, - configToFlag('--auto-update', sharedNodeConfigMappings.autoUpdate), - configToFlag('--auto-update-url', sharedNodeConfigMappings.autoUpdateUrl), + configToFlag('--enable-version-check', sharedNodeConfigMappings.enableVersionCheck), configToFlag('--sync-mode', sharedNodeConfigMappings.syncMode), configToFlag('--snapshots-urls', sharedNodeConfigMappings.snapshotsUrls), diff --git a/yarn-project/aztec/src/cli/cmds/start_node.ts b/yarn-project/aztec/src/cli/cmds/start_node.ts index a034cf3a6f5a..b550f4ee03e4 100644 --- a/yarn-project/aztec/src/cli/cmds/start_node.ts +++ b/yarn-project/aztec/src/cli/cmds/start_node.ts @@ -5,8 +5,8 @@ import { getSponsoredFPCAddress } from '@aztec/cli/cli-utils'; import { getL1Config } from '@aztec/cli/config'; import { getPublicClient } from '@aztec/ethereum/client'; import { RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; -import { SecretValue } from '@aztec/foundation/config'; -import { EthAddress } from '@aztec/foundation/eth-address'; +import { type NetworkNames, SecretValue } from '@aztec/foundation/config'; +import type { EthAddress } from '@aztec/foundation/eth-address'; import type { NamespacedApiHandlers } from '@aztec/foundation/json-rpc/server'; import { startHttpRpcServer } from '@aztec/foundation/json-rpc/server'; import { Agent, makeUndiciFetch } from '@aztec/foundation/json-rpc/undici'; @@ -32,7 +32,7 @@ import { extractNamespacedOptions, extractRelevantOptions, preloadCrsDataForVerifying, - setupUpdateMonitor, + setupVersionChecker, } from '../util.js'; import { getVersions } from '../versioning.js'; import { startProverBroker } from './start_prover_broker.js'; @@ -109,6 +109,7 @@ export async function startNode( services: NamespacedApiHandlers, adminServices: NamespacedApiHandlers, userLog: LogFn, + networkName: NetworkNames, ): Promise<{ config: AztecNodeConfig }> { // All options set from environment variables const configFromEnvVars = getConfigEnvVars(); @@ -268,16 +269,19 @@ export async function startNode( await addBot(options, signalHandlers, services, wallet, node, telemetry, undefined); } - if (nodeConfig.autoUpdate !== 'disabled' && nodeConfig.autoUpdateUrl) { - await setupUpdateMonitor( - nodeConfig.autoUpdate, - new URL(nodeConfig.autoUpdateUrl), - followsCanonicalRollup, - getPublicClient(nodeConfig!), - nodeConfig.l1Contracts.registryAddress, - signalHandlers, - async config => node.setConfig((await AztecNodeAdminApiSchema.setConfig.parameters().parseAsync([config]))[0]), - ); + if (nodeConfig.enableVersionCheck && networkName !== 'local') { + const cacheDir = process.env.DATA_DIRECTORY ? `${process.env.DATA_DIRECTORY}/cache` : undefined; + try { + await setupVersionChecker( + networkName, + followsCanonicalRollup, + getPublicClient(nodeConfig!), + signalHandlers, + cacheDir, + ); + } catch { + /* no-op */ + } } return { config: nodeConfig }; diff --git a/yarn-project/aztec/src/cli/cmds/start_prover_broker.ts b/yarn-project/aztec/src/cli/cmds/start_prover_broker.ts index ae3d087b02dd..75c320265f5b 100644 --- a/yarn-project/aztec/src/cli/cmds/start_prover_broker.ts +++ b/yarn-project/aztec/src/cli/cmds/start_prover_broker.ts @@ -1,5 +1,4 @@ import { getL1Config } from '@aztec/cli/config'; -import { getPublicClient } from '@aztec/ethereum/client'; import type { NamespacedApiHandlers } from '@aztec/foundation/json-rpc/server'; import type { LogFn } from '@aztec/foundation/log'; import { @@ -13,7 +12,7 @@ import { getProverNodeBrokerConfigFromEnv } from '@aztec/prover-node'; import type { ProvingJobBroker } from '@aztec/stdlib/interfaces/server'; import { getConfigEnvVars as getTelemetryClientConfig, initTelemetryClient } from '@aztec/telemetry-client'; -import { extractRelevantOptions, setupUpdateMonitor } from '../util.js'; +import { extractRelevantOptions } from '../util.js'; export async function startProverBroker( options: any, @@ -35,7 +34,6 @@ export async function startProverBroker( throw new Error('L1 registry address is required to start Aztec Node without --deploy-aztec-contracts option'); } - const followsCanonicalRollup = typeof config.rollupVersion !== 'number'; const { addresses, config: rollupConfig } = await getL1Config( config.l1Contracts.registryAddress, config.l1RpcUrls, @@ -49,17 +47,6 @@ export async function startProverBroker( const client = await initTelemetryClient(getTelemetryClientConfig()); const broker = await createAndStartProvingBroker(config, client); - if (options.autoUpdate !== 'disabled' && options.autoUpdateUrl) { - await setupUpdateMonitor( - options.autoUpdate, - new URL(options.autoUpdateUrl), - followsCanonicalRollup, - getPublicClient(config), - config.l1Contracts.registryAddress, - signalHandlers, - ); - } - services.proverBroker = [ broker, config.proverBrokerDebugReplayEnabled ? ProvingJobBrokerSchemaWithDebug : ProvingJobBrokerSchema, diff --git a/yarn-project/aztec/src/cli/release_version.ts b/yarn-project/aztec/src/cli/release_version.ts deleted file mode 100644 index fa00edbe31d9..000000000000 --- a/yarn-project/aztec/src/cli/release_version.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { fileURLToPath } from '@aztec/foundation/url'; - -import { readFileSync } from 'fs'; -import { dirname, resolve } from 'path'; - -export const getCliVersion = () => { - const packageJsonPath = resolve(dirname(fileURLToPath(import.meta.url)), '../../package.json'); - const cliVersion: string = JSON.parse(readFileSync(packageJsonPath).toString()).version; - - // If the version is 0.1.0, this is a placeholder version and we are in a docker container; query release please for the latest version - if (cliVersion === '0.1.0') { - const releasePleasePath = resolve( - dirname(fileURLToPath(import.meta.url)), - '../../../../.release-please-manifest.json', - ); - const releaseVersion = JSON.parse(readFileSync(releasePleasePath).toString())['.']; - return releaseVersion; - } - - return cliVersion; -}; diff --git a/yarn-project/aztec/src/cli/util.ts b/yarn-project/aztec/src/cli/util.ts index f8df0184ca63..0b650d7d0fdc 100644 --- a/yarn-project/aztec/src/cli/util.ts +++ b/yarn-project/aztec/src/cli/util.ts @@ -1,17 +1,18 @@ import type { AztecNodeConfig } from '@aztec/aztec-node'; import type { AccountManager } from '@aztec/aztec.js/wallet'; +import { getNetworkConfig } from '@aztec/cli/config'; +import { RegistryContract } from '@aztec/ethereum/contracts'; import type { ViemClient } from '@aztec/ethereum/types'; -import type { ConfigMappingsType } from '@aztec/foundation/config'; -import { EthAddress } from '@aztec/foundation/eth-address'; +import type { ConfigMappingsType, NetworkNames } from '@aztec/foundation/config'; import { jsonStringify } from '@aztec/foundation/json-rpc'; import { type LogFn, createLogger } from '@aztec/foundation/log'; -import type { SharedNodeConfig } from '@aztec/node-lib/config'; import type { ProverConfig } from '@aztec/stdlib/interfaces/server'; -import { getTelemetryClient } from '@aztec/telemetry-client/start'; +import { type VersionCheck, getPackageVersion } from '@aztec/stdlib/update-checker'; import type { EmbeddedWallet } from '@aztec/wallets/embedded'; import chalk from 'chalk'; import type { Command } from 'commander'; +import type { Hex } from 'viem'; import { type AztecStartOption, aztecStartOptions } from './aztec_start_options.js'; @@ -290,92 +291,58 @@ export async function preloadCrsDataForServerSideProving( } } -export async function setupUpdateMonitor( - autoUpdateMode: SharedNodeConfig['autoUpdate'], - updatesLocation: URL, +export async function setupVersionChecker( + network: NetworkNames, followsCanonicalRollup: boolean, publicClient: ViemClient, - registryContractAddress: EthAddress, signalHandlers: Array<() => Promise>, - updateNodeConfig?: (config: object) => Promise, -) { - const logger = createLogger('update-check'); - const { UpdateChecker } = await import('@aztec/stdlib/update-checker'); - const checker = await UpdateChecker.new({ - baseURL: updatesLocation, - publicClient, - registryContractAddress, - }); + cacheDir?: string, +): Promise { + const networkConfig = await getNetworkConfig(network, cacheDir); + if (!networkConfig) { + return; + } - // eslint-disable-next-line @typescript-eslint/no-misused-promises - checker.on('newRollupVersion', async ({ latestVersion, currentVersion }) => { - if (isShuttingDown()) { - return; - } + const { VersionChecker } = await import('@aztec/stdlib/update-checker'); - // if node follows canonical rollup then this is equivalent to a config update - if (!followsCanonicalRollup) { - return; - } + const logger = createLogger('version_check'); + const registry = new RegistryContract(publicClient, networkConfig.registryAddress as Hex); - if (autoUpdateMode === 'config' || autoUpdateMode === 'config-and-version') { - logger.info(`New rollup version detected. Please restart the node`, { latestVersion, currentVersion }); - await shutdown(logger.info, ExitCode.ROLLUP_UPGRADE, signalHandlers); - } else if (autoUpdateMode === 'notify') { - logger.warn(`New rollup detected. Please restart the node`, { latestVersion, currentVersion }); - } + const checks: Array = []; + checks.push({ + name: 'node', + currentVersion: getPackageVersion() ?? 'unknown', + getLatestVersion: async () => { + const cfg = await getNetworkConfig(network, cacheDir); + return cfg?.nodeVersion; + }, }); - // eslint-disable-next-line @typescript-eslint/no-misused-promises - checker.on('newNodeVersion', async ({ latestVersion, currentVersion }) => { - if (isShuttingDown()) { - return; - } - if (autoUpdateMode === 'config-and-version') { - logger.info(`New node version detected. Please update and restart the node`, { latestVersion, currentVersion }); - await shutdown(logger.info, ExitCode.VERSION_UPGRADE, signalHandlers); - } else if (autoUpdateMode === 'notify') { - logger.info(`New node version detected. Please update and restart the node`, { latestVersion, currentVersion }); + if (followsCanonicalRollup) { + const getLatestVersion = async () => { + const version = (await registry.getRollupVersions()).at(-1); + return version !== undefined ? String(version) : undefined; + }; + const currentVersion = await getLatestVersion(); + if (currentVersion !== undefined) { + checks.push({ + name: 'rollup', + currentVersion, + getLatestVersion, + }); } - }); + } - // eslint-disable-next-line @typescript-eslint/no-misused-promises - checker.on('updateNodeConfig', async config => { + const checker = new VersionChecker(checks, 600_000, logger); + checker.on('newVersion', ({ name, latestVersion, currentVersion }) => { if (isShuttingDown()) { return; } - if ((autoUpdateMode === 'config' || autoUpdateMode === 'config-and-version') && updateNodeConfig) { - logger.warn(`Config change detected. Updating node`, config); - try { - await updateNodeConfig(config); - } catch (err) { - logger.warn('Failed to update config', { err }); - } - } - // don't notify on these config changes - }); - - checker.on('updatePublicTelemetryConfig', config => { - if (autoUpdateMode === 'config' || autoUpdateMode === 'config-and-version') { - logger.warn(`Public telemetry config change detected. Updating telemetry client`, config); - try { - const publicIncludeMetrics: unknown = (config as any).publicIncludeMetrics; - if (Array.isArray(publicIncludeMetrics) && publicIncludeMetrics.every(m => typeof m === 'string')) { - getTelemetryClient().setExportedPublicTelemetry(publicIncludeMetrics); - } - const publicMetricsCollectFrom: unknown = (config as any).publicMetricsCollectFrom; - if (Array.isArray(publicMetricsCollectFrom) && publicMetricsCollectFrom.every(m => typeof m === 'string')) { - getTelemetryClient().setPublicTelemetryCollectFrom(publicMetricsCollectFrom); - } - } catch (err) { - logger.warn('Failed to update config', { err }); - } - } - // don't notify on these config changes + logger.warn(`New ${name} version available`, { latestVersion, currentVersion }); }); - checker.start(); + signalHandlers.push(() => checker.stop()); } export function stringifyConfig(config: object): string { diff --git a/yarn-project/cli/src/config/cached_fetch.test.ts b/yarn-project/cli/src/config/cached_fetch.test.ts new file mode 100644 index 000000000000..186bdb4ae2bf --- /dev/null +++ b/yarn-project/cli/src/config/cached_fetch.test.ts @@ -0,0 +1,243 @@ +import { jest } from '@jest/globals'; +import { mkdir, readFile, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { cachedFetch, parseMaxAge } from './cached_fetch.js'; + +describe('cachedFetch', () => { + let tempDir: string; + let cacheFile: string; + let metaFile: string; + let mockFetch: jest.Mock; + const noopLog: any = { trace: () => {}, warn: () => {}, info: () => {} }; + + beforeEach(async () => { + tempDir = join(tmpdir(), `cached-fetch-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + await mkdir(tempDir, { recursive: true }); + cacheFile = join(tempDir, 'cache.json'); + metaFile = cacheFile + '.meta'; + mockFetch = jest.fn(); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + function mockResponse(body: any, init?: { status?: number; headers?: Record }): Response { + const status = init?.status ?? 200; + const headers = new Headers(init?.headers ?? {}); + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 304 ? 'Not Modified' : 'OK', + headers, + json: () => Promise.resolve(body), + } as Response; + } + + async function writeCacheFiles(data: any, opts?: { etag?: string; expiresAt?: number }) { + await writeFile(cacheFile, JSON.stringify(data), 'utf-8'); + await writeFile( + metaFile, + JSON.stringify({ etag: opts?.etag, expiresAt: opts?.expiresAt ?? Date.now() + 60_000 }), + 'utf-8', + ); + } + + it('returns cached data without fetching when cache is fresh', async () => { + const data = { key: 'cached-value' }; + await writeCacheFiles(data, { expiresAt: Date.now() + 60_000 }); + + const result = await cachedFetch('https://example.com/data.json', { cacheFile }, mockFetch, noopLog); + + expect(result).toEqual(data); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('sends conditional request with If-None-Match when cache is stale and has ETag', async () => { + const data = { key: 'stale-value' }; + await writeCacheFiles(data, { etag: '"abc123"', expiresAt: Date.now() - 1000 }); + + mockFetch.mockResolvedValue( + mockResponse(null, { + status: 304, + headers: { 'cache-control': 'max-age=300' }, + }), + ); + + const result = await cachedFetch('https://example.com/data.json', { cacheFile }, mockFetch, noopLog); + + expect(result).toEqual(data); + expect(mockFetch).toHaveBeenCalledWith('https://example.com/data.json', { + headers: { 'If-None-Match': '"abc123"' }, + }); + + // Data file should be unchanged + expect(JSON.parse(await readFile(cacheFile, 'utf-8'))).toEqual(data); + // Meta file should have updated expiry + const meta = JSON.parse(await readFile(metaFile, 'utf-8')); + expect(meta.expiresAt).toBeGreaterThan(Date.now()); + }); + + it('returns new data and stores ETag on 200 response', async () => { + const staleData = { key: 'old' }; + const freshData = { key: 'new' }; + await writeCacheFiles(staleData, { etag: '"old-etag"', expiresAt: Date.now() - 1000 }); + + mockFetch.mockResolvedValue( + mockResponse(freshData, { + status: 200, + headers: { etag: '"new-etag"', 'cache-control': 'max-age=600' }, + }), + ); + + const result = await cachedFetch('https://example.com/data.json', { cacheFile }, mockFetch, noopLog); + + expect(result).toEqual(freshData); + + // Data file should have new data (raw JSON) + expect(JSON.parse(await readFile(cacheFile, 'utf-8'))).toEqual(freshData); + // Meta file should have new ETag and expiry + const meta = JSON.parse(await readFile(metaFile, 'utf-8')); + expect(meta.etag).toBe('"new-etag"'); + expect(meta.expiresAt).toBeGreaterThan(Date.now()); + }); + + it('fetches normally without caching when no cacheFile is provided', async () => { + const data = { key: 'no-cache' }; + mockFetch.mockResolvedValue(mockResponse(data)); + + const result = await cachedFetch('https://example.com/data.json', {}, mockFetch, noopLog); + + expect(result).toEqual(data); + expect(mockFetch).toHaveBeenCalledWith('https://example.com/data.json'); + }); + + it('falls back to normal fetch when metadata file is missing', async () => { + // Write only data file, no meta file (simulates upgrade from old code) + await writeFile(cacheFile, JSON.stringify({ key: 'old-format' }), 'utf-8'); + + const freshData = { key: 'fresh' }; + mockFetch.mockResolvedValue( + mockResponse(freshData, { + status: 200, + headers: { 'cache-control': 'max-age=300' }, + }), + ); + + const result = await cachedFetch('https://example.com/data.json', { cacheFile }, mockFetch, noopLog); + + expect(result).toEqual(freshData); + // Should have fetched without If-None-Match since no meta + expect(mockFetch).toHaveBeenCalledWith('https://example.com/data.json', { headers: {} }); + }); + + it('falls back to normal fetch when metadata file is corrupt', async () => { + await writeFile(cacheFile, JSON.stringify({ key: 'data' }), 'utf-8'); + await writeFile(metaFile, 'not-json!!!', 'utf-8'); + + const freshData = { key: 'fresh' }; + mockFetch.mockResolvedValue( + mockResponse(freshData, { + status: 200, + headers: { 'cache-control': 'max-age=300' }, + }), + ); + + const result = await cachedFetch('https://example.com/data.json', { cacheFile }, mockFetch, noopLog); + + expect(result).toEqual(freshData); + expect(mockFetch).toHaveBeenCalledWith('https://example.com/data.json', { headers: {} }); + }); + + it('falls back to normal fetch when data file is missing but metadata exists', async () => { + await writeFile(metaFile, JSON.stringify({ etag: '"abc"', expiresAt: Date.now() + 60_000 }), 'utf-8'); + + const freshData = { key: 'fresh' }; + mockFetch.mockResolvedValue( + mockResponse(freshData, { + status: 200, + headers: { 'cache-control': 'max-age=300' }, + }), + ); + + const result = await cachedFetch('https://example.com/data.json', { cacheFile }, mockFetch, noopLog); + + expect(result).toEqual(freshData); + // Should not send If-None-Match since data is missing + expect(mockFetch).toHaveBeenCalledWith('https://example.com/data.json', { headers: {} }); + }); + + it('uses defaultMaxAgeMs when server sends no Cache-Control header', async () => { + const data = { key: 'value' }; + mockFetch.mockResolvedValue( + mockResponse(data, { + status: 200, + headers: { etag: '"some-etag"' }, + }), + ); + + const defaultMaxAgeMs = 120_000; // 2 minutes + const before = Date.now(); + await cachedFetch('https://example.com/data.json', { cacheFile, defaultMaxAgeMs }, mockFetch, noopLog); + + const meta = JSON.parse(await readFile(metaFile, 'utf-8')); + expect(meta.expiresAt).toBeGreaterThanOrEqual(before + defaultMaxAgeMs); + expect(meta.expiresAt).toBeLessThanOrEqual(Date.now() + defaultMaxAgeMs); + }); + + it('returns stale cache data when fetch fails', async () => { + const data = { key: 'stale-fallback' }; + await writeCacheFiles(data, { expiresAt: Date.now() - 1000 }); + + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await cachedFetch('https://example.com/data.json', { cacheFile }, mockFetch, noopLog); + + expect(result).toEqual(data); + }); + + it('returns stale cache data when server returns non-ok status', async () => { + const data = { key: 'stale-server-error' }; + await writeCacheFiles(data, { expiresAt: Date.now() - 1000 }); + + mockFetch.mockResolvedValue(mockResponse(null, { status: 500 })); + + const result = await cachedFetch('https://example.com/data.json', { cacheFile }, mockFetch, noopLog); + + expect(result).toEqual(data); + }); + + it('returns undefined when fetch fails and no cache exists', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + const result = await cachedFetch('https://example.com/data.json', { cacheFile }, mockFetch, noopLog); + + expect(result).toBeUndefined(); + }); +}); + +describe('parseMaxAge', () => { + it('extracts max-age from Cache-Control header', () => { + const response = { headers: { get: (name: string) => (name === 'cache-control' ? 'max-age=300' : null) } }; + expect(parseMaxAge(response)).toBe(300_000); + }); + + it('handles max-age with other directives', () => { + const response = { + headers: { get: (name: string) => (name === 'cache-control' ? 'public, max-age=600, must-revalidate' : null) }, + }; + expect(parseMaxAge(response)).toBe(600_000); + }); + + it('returns undefined when no Cache-Control header', () => { + const response = { headers: { get: () => null } }; + expect(parseMaxAge(response)).toBeUndefined(); + }); + + it('returns undefined when no max-age in Cache-Control', () => { + const response = { headers: { get: (name: string) => (name === 'cache-control' ? 'no-cache' : null) } }; + expect(parseMaxAge(response)).toBeUndefined(); + }); +}); diff --git a/yarn-project/cli/src/config/cached_fetch.ts b/yarn-project/cli/src/config/cached_fetch.ts index 74518805c11f..37c745f94a50 100644 --- a/yarn-project/cli/src/config/cached_fetch.ts +++ b/yarn-project/cli/src/config/cached_fetch.ts @@ -1,24 +1,48 @@ import { createLogger } from '@aztec/aztec.js/log'; -import { mkdir, readFile, stat, writeFile } from 'fs/promises'; +import { mkdir, readFile, writeFile } from 'fs/promises'; import { dirname } from 'path'; export interface CachedFetchOptions { - /** Cache duration in milliseconds */ - cacheDurationMs: number; - /** The cache file */ + /** The cache file path for storing data. If not provided, no caching is performed. */ cacheFile?: string; + /** Fallback max-age in milliseconds when server sends no Cache-Control header. Defaults to 5 minutes. */ + defaultMaxAgeMs?: number; +} + +/** Cache metadata stored in a sidecar .meta file alongside the data file. */ +interface CacheMeta { + etag?: string; + expiresAt: number; +} + +const DEFAULT_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes + +/** Extracts max-age value in milliseconds from a Response's Cache-Control header. Returns undefined if not present. */ +export function parseMaxAge(response: { headers: { get(name: string): string | null } }): number | undefined { + const cacheControl = response.headers.get('cache-control'); + if (!cacheControl) { + return undefined; + } + const match = cacheControl.match(/max-age=(\d+)/); + if (!match) { + return undefined; + } + return parseInt(match[1], 10) * 1000; } /** - * Fetches data from a URL with file-based caching support. - * This utility can be used by both remote config and bootnodes fetching. + * Fetches data from a URL with file-based HTTP conditional caching. + * + * Data is stored as raw JSON in the cache file (same format as the server returns). + * Caching metadata (ETag, expiry) is stored in a separate sidecar `.meta` file. + * This keeps the data file human-readable and backward-compatible with older code. * * @param url - The URL to fetch from - * @param networkName - Network name for cache directory structure - * @param options - Caching and error handling options - * @param cacheDir - Optional cache directory (defaults to no caching) - * @returns The fetched and parsed JSON data, or undefined if fetch fails and throwOnError is false + * @param options - Caching options + * @param fetch - Fetch implementation (defaults to globalThis.fetch) + * @param log - Logger instance + * @returns The fetched and parsed JSON data, or undefined if fetch fails */ export async function cachedFetch( url: string, @@ -26,42 +50,106 @@ export async function cachedFetch( fetch = globalThis.fetch, log = createLogger('cached_fetch'), ): Promise { - const { cacheDurationMs, cacheFile } = options; + const { cacheFile, defaultMaxAgeMs = DEFAULT_MAX_AGE_MS } = options; + + // If no cacheFile, just fetch normally without caching + if (!cacheFile) { + return fetchAndParse(url, fetch, log); + } + + const metaFile = cacheFile + '.meta'; - // Try to read from cache first + // Try to read metadata + let meta: CacheMeta | undefined; try { - if (cacheFile) { - const info = await stat(cacheFile); - if (info.mtimeMs + cacheDurationMs > Date.now()) { - const cachedData = JSON.parse(await readFile(cacheFile, 'utf-8')); - return cachedData; - } - } + meta = JSON.parse(await readFile(metaFile, 'utf-8')); } catch { - log.trace('Failed to read data from cache'); + log.trace('No usable cache metadata found'); } + // Try to read cached data + let cachedData: T | undefined; try { - const response = await fetch(url); + cachedData = JSON.parse(await readFile(cacheFile, 'utf-8')); + } catch { + log.trace('No usable cached data found'); + } + + // If metadata and data exist and cache is fresh, return directly + if (meta && cachedData !== undefined && meta.expiresAt > Date.now()) { + return cachedData; + } + + // Cache is stale or missing — make a (possibly conditional) request + try { + const headers: Record = {}; + if (meta?.etag && cachedData !== undefined) { + headers['If-None-Match'] = meta.etag; + } + + const response = await fetch(url, { headers }); + + if (response.status === 304 && cachedData !== undefined) { + // Not modified — recompute expiry from new response headers and return cached data + const maxAgeMs = parseMaxAge(response) ?? defaultMaxAgeMs; + await writeMetaFile(metaFile, { etag: meta?.etag, expiresAt: Date.now() + maxAgeMs }, log); + return cachedData; + } + if (!response.ok) { log.warn(`Failed to fetch from ${url}: ${response.status} ${response.statusText}`); - return undefined; + return cachedData; } - const data = await response.json(); + // 200 — parse new data and cache it + const data = (await response.json()) as T; + const maxAgeMs = parseMaxAge(response) ?? defaultMaxAgeMs; + const etag = response.headers.get('etag') ?? undefined; - try { - if (cacheFile) { - await mkdir(dirname(cacheFile), { recursive: true }); - await writeFile(cacheFile, JSON.stringify(data), 'utf-8'); - } - } catch (err) { - log.warn('Failed to cache data on disk: ' + cacheFile, { cacheFile, err }); - } + await ensureDir(cacheFile, log); + await Promise.all([ + writeFile(cacheFile, JSON.stringify(data), 'utf-8'), + writeFile(metaFile, JSON.stringify({ etag, expiresAt: Date.now() + maxAgeMs }), 'utf-8'), + ]); return data; + } catch (err) { + log.warn(`Failed to fetch from ${url}`, { err }); + return cachedData; + } +} + +async function fetchAndParse( + url: string, + fetch: typeof globalThis.fetch, + log: ReturnType, +): Promise { + try { + const response = await fetch(url); + if (!response.ok) { + log.warn(`Failed to fetch from ${url}: ${response.status} ${response.statusText}`); + return undefined; + } + return (await response.json()) as T; } catch (err) { log.warn(`Failed to fetch from ${url}`, { err }); return undefined; } } + +async function ensureDir(filePath: string, log: ReturnType) { + try { + await mkdir(dirname(filePath), { recursive: true }); + } catch (err) { + log.warn('Failed to create cache directory for: ' + filePath, { err }); + } +} + +async function writeMetaFile(metaFile: string, meta: CacheMeta, log: ReturnType) { + try { + await mkdir(dirname(metaFile), { recursive: true }); + await writeFile(metaFile, JSON.stringify(meta), 'utf-8'); + } catch (err) { + log.warn('Failed to write cache metadata: ' + metaFile, { err }); + } +} diff --git a/yarn-project/cli/src/config/network_config.ts b/yarn-project/cli/src/config/network_config.ts index 820f5f1b5da5..998acadae315 100644 --- a/yarn-project/cli/src/config/network_config.ts +++ b/yarn-project/cli/src/config/network_config.ts @@ -9,7 +9,6 @@ import { enrichEthAddressVar, enrichVar } from './enrich_env.js'; const DEFAULT_CONFIG_URL = 'https://raw.githubusercontent.com/AztecProtocol/networks/refs/heads/main/network_config.json'; const FALLBACK_CONFIG_URL = 'https://metadata.aztec.network/network_config.json'; -const NETWORK_CONFIG_CACHE_DURATION_MS = 60 * 60 * 1000; // 1 hour /** * Fetches remote network configuration from GitHub with caching support. @@ -87,7 +86,6 @@ async function fetchNetworkConfigFromUrl( if (url.protocol === 'http:' || url.protocol === 'https:') { rawConfig = await cachedFetch(url.href, { - cacheDurationMs: NETWORK_CONFIG_CACHE_DURATION_MS, cacheFile: cacheDir ? join(cacheDir, networkName, 'network_config.json') : undefined, }); } else if (url.protocol === 'file:') { diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index 9eb0ef616f8c..85c560a43184 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -340,9 +340,8 @@ export type EnvVar = | 'K8S_POD_NAME' | 'K8S_POD_UID' | 'K8S_NAMESPACE_NAME' + | 'ENABLE_VERSION_CHECK' | 'VALIDATOR_REEXECUTE_DEADLINE_MS' - | 'AUTO_UPDATE' - | 'AUTO_UPDATE_URL' | 'WEB3_SIGNER_URL' | 'SKIP_ARCHIVER_INITIAL_SYNC' | 'BLOB_ALLOW_EMPTY_SOURCES' diff --git a/yarn-project/foundation/src/config/network_config.ts b/yarn-project/foundation/src/config/network_config.ts index 5604eca90ff5..b4cdc8549533 100644 --- a/yarn-project/foundation/src/config/network_config.ts +++ b/yarn-project/foundation/src/config/network_config.ts @@ -9,6 +9,7 @@ export const NetworkConfigSchema = z feeAssetHandlerAddress: z.string().optional(), l1ChainId: z.number(), blockDurationMs: z.number().positive().optional(), + nodeVersion: z.string().optional(), }) .passthrough(); // Allow additional unknown fields to pass through diff --git a/yarn-project/node-lib/src/config/index.ts b/yarn-project/node-lib/src/config/index.ts index c8403d2b09bc..8ae5c66d42f0 100644 --- a/yarn-project/node-lib/src/config/index.ts +++ b/yarn-project/node-lib/src/config/index.ts @@ -9,12 +9,6 @@ export type SharedNodeConfig = { syncMode: 'full' | 'snapshot' | 'force-snapshot'; /** Base URLs for snapshots index. Index file will be searched at `SNAPSHOTS_BASE_URL/aztec-L1_CHAIN_ID-VERSION-ROLLUP_ADDRESS/index.json` */ snapshotsUrls?: string[]; - - /** Auto update mode: disabled - to completely ignore remote signals to update the node. enabled - to respect the signals (potentially shutting this node down). log - check for updates but log a warning instead of applying them*/ - autoUpdate?: 'disabled' | 'notify' | 'config' | 'config-and-version'; - /** The base URL against which to check for updates */ - autoUpdateUrl?: string; - /** URL of the Web3Signer instance */ web3SignerUrl?: string; /** Whether to run in fisherman mode */ @@ -22,6 +16,9 @@ export type SharedNodeConfig = { /** Force verification of tx Chonk proofs. Only used for testnet */ debugForceTxProofVerification: boolean; + + /** Check if the node version matches the latest version for the network */ + enableVersionCheck: boolean; }; export const sharedNodeConfigMappings: ConfigMappingsType = { @@ -52,15 +49,6 @@ export const sharedNodeConfigMappings: ConfigMappingsType = { fallback: ['SYNC_SNAPSHOTS_URL'], defaultValue: [], }, - autoUpdate: { - env: 'AUTO_UPDATE', - description: 'The auto update mode for this node', - defaultValue: 'disabled', - }, - autoUpdateUrl: { - env: 'AUTO_UPDATE_URL', - description: 'Base URL to check for updates', - }, web3SignerUrl: { env: 'WEB3_SIGNER_URL', description: 'URL of the Web3Signer instance', @@ -76,4 +64,10 @@ export const sharedNodeConfigMappings: ConfigMappingsType = { description: 'Whether to force tx proof verification. Only has an effect if real proving is turned off', ...booleanConfigHelper(false), }, + + enableVersionCheck: { + env: 'ENABLE_VERSION_CHECK', + description: 'Check if the node is running the latest version and is following the latest rollup', + ...booleanConfigHelper(true), + }, }; diff --git a/yarn-project/stdlib/src/update-checker/index.ts b/yarn-project/stdlib/src/update-checker/index.ts index 958afdb51dd2..65d4570f3d1a 100644 --- a/yarn-project/stdlib/src/update-checker/index.ts +++ b/yarn-project/stdlib/src/update-checker/index.ts @@ -1 +1,2 @@ -export { UpdateChecker, getPackageVersion } from './update-checker.js'; +export * from './package_version.js'; +export * from './version_checker.js'; diff --git a/yarn-project/stdlib/src/update-checker/package_version.ts b/yarn-project/stdlib/src/update-checker/package_version.ts new file mode 100644 index 000000000000..c186b4de9bba --- /dev/null +++ b/yarn-project/stdlib/src/update-checker/package_version.ts @@ -0,0 +1,17 @@ +import { fileURLToPath } from '@aztec/foundation/url'; + +import { readFileSync } from 'fs'; +import { dirname, resolve } from 'path'; + +/** Returns the package version from the release-please manifest, or undefined if not found. */ +export function getPackageVersion(): string | undefined { + try { + const releasePleaseManifestPath = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../../../.release-please-manifest.json', + ); + return JSON.parse(readFileSync(releasePleaseManifestPath).toString())['.']; + } catch { + return undefined; + } +} diff --git a/yarn-project/stdlib/src/update-checker/update-checker.test.ts b/yarn-project/stdlib/src/update-checker/update-checker.test.ts deleted file mode 100644 index 890a5080d0d3..000000000000 --- a/yarn-project/stdlib/src/update-checker/update-checker.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { randomBigInt } from '@aztec/foundation/crypto/random'; - -import { jest } from '@jest/globals'; - -import { type EventMap, UpdateChecker } from './update-checker.js'; - -describe('UpdateChecker', () => { - let checker: UpdateChecker; - let fetch: jest.Mock; - let getCanonicalRollupVersion: jest.Mock<() => Promise>; - let rollupVersionAtStart: bigint; - let nodeVersionAtStart: string; - let eventHandlers: { - [K in keyof EventMap]: jest.Mock<(...args: EventMap[K]) => void>; - }; - - beforeEach(() => { - nodeVersionAtStart = '0.1.0'; - rollupVersionAtStart = randomBigInt(1000n); - fetch = jest.fn(() => Promise.resolve(new Response(JSON.stringify({ version: nodeVersionAtStart })))); - getCanonicalRollupVersion = jest.fn(() => Promise.resolve(rollupVersionAtStart)); - - checker = new UpdateChecker( - new URL('http://localhost'), - nodeVersionAtStart, - rollupVersionAtStart, - fetch, - getCanonicalRollupVersion, - 100, - ); - - eventHandlers = { - updateNodeConfig: jest.fn(), - newNodeVersion: jest.fn(), - newRollupVersion: jest.fn(), - updatePublicTelemetryConfig: jest.fn(), - }; - - for (const [event, fn] of Object.entries(eventHandlers)) { - checker.on(event as keyof EventMap, fn); - } - }); - - it.each([ - ['it detects no change', () => {}], - [ - 'fetching config fails', - () => { - fetch.mockRejectedValue(new Error('test error')); - }, - ], - [ - 'fetching rollup address fails', - () => { - getCanonicalRollupVersion.mockRejectedValue(new Error('test error')); - }, - ], - [ - 'the config does not match the schema', - () => { - fetch.mockResolvedValue( - new Response( - JSON.stringify({ - foo: 'bar', - }), - ), - ); - }, - ], - [ - 'the config does not match the schema', - () => { - fetch.mockResolvedValue( - new Response( - JSON.stringify({ - version: 1, - }), - ), - ); - }, - ], - ])('does not emit an event if %s', async (_, patchFn) => { - patchFn(); - for (let run = 0; run < 5; run++) { - await expect(checker.trigger()).resolves.toBeUndefined(); - for (const fn of Object.values(eventHandlers)) { - expect(fn).not.toHaveBeenCalled(); - } - } - }); - - it.each<[keyof EventMap, () => void]>([ - [ - 'newRollupVersion', - () => { - // ensure the new version is completely different to the previous one - getCanonicalRollupVersion.mockResolvedValueOnce(1000n + randomBigInt(1000n)); - }, - ], - [ - 'newNodeVersion', - () => { - fetch.mockResolvedValueOnce(new Response(JSON.stringify({ version: '0.1.0-foo' }))); - }, - ], - [ - 'updateNodeConfig', - () => { - fetch.mockResolvedValueOnce(new Response(JSON.stringify({ config: { maxTxsPerBlock: 16 } }))); - }, - ], - [ - 'updatePublicTelemetryConfig', - () => { - fetch.mockResolvedValueOnce( - new Response(JSON.stringify({ publicTelemetry: { publicIncludeMetrics: ['aztec'] } })), - ); - }, - ], - ])('emits event: %s', async (event, patchFn) => { - patchFn(); - await expect(checker.trigger()).resolves.toBeUndefined(); - expect(eventHandlers[event]).toHaveBeenCalled(); - }); - - it('calls updateConfig only when config changes', async () => { - fetch.mockResolvedValue( - new Response( - JSON.stringify({ - version: nodeVersionAtStart, - config: { - foo: 'bar', - }, - }), - ), - ); - - await checker.trigger(); - expect(eventHandlers.updateNodeConfig).toHaveBeenCalledTimes(1); - - await checker.trigger(); - expect(eventHandlers.updateNodeConfig).toHaveBeenCalledTimes(1); - - fetch.mockResolvedValue( - new Response( - JSON.stringify({ - version: nodeVersionAtStart, - config: { - bar: 'baz', - }, - }), - ), - ); - - await checker.trigger(); - expect(eventHandlers.updateNodeConfig).toHaveBeenCalledTimes(2); - }); - - it('calls updatePublicTelemetryConfig only when config changes', async () => { - fetch.mockResolvedValue( - new Response( - JSON.stringify({ - publicTelemetry: { - publicIncludeMetrics: ['aztec'], - }, - }), - ), - ); - - await checker.trigger(); - expect(eventHandlers.updatePublicTelemetryConfig).toHaveBeenCalledTimes(1); - - await checker.trigger(); - expect(eventHandlers.updatePublicTelemetryConfig).toHaveBeenCalledTimes(1); - - fetch.mockResolvedValue( - new Response( - JSON.stringify({ - publicTelemetry: { - publicIncludeMetrics: ['aztec.validator'], - }, - }), - ), - ); - - await checker.trigger(); - expect(eventHandlers.updatePublicTelemetryConfig).toHaveBeenCalledTimes(2); - }); - - it('reaches out to the expected config URL', async () => { - await checker.trigger(); - expect(fetch).toHaveBeenCalledWith(new URL(`http://localhost`)); - }); -}); diff --git a/yarn-project/stdlib/src/update-checker/update-checker.ts b/yarn-project/stdlib/src/update-checker/update-checker.ts deleted file mode 100644 index 3bf27f948599..000000000000 --- a/yarn-project/stdlib/src/update-checker/update-checker.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { RegistryContract } from '@aztec/ethereum/contracts'; -import type { ViemClient } from '@aztec/ethereum/types'; -import { EthAddress } from '@aztec/foundation/eth-address'; -import { createLogger } from '@aztec/foundation/log'; -import { RunningPromise } from '@aztec/foundation/running-promise'; -import { fileURLToPath } from '@aztec/foundation/url'; - -import { EventEmitter } from 'events'; -import { readFileSync } from 'fs'; -import { dirname, resolve } from 'path'; -import { isDeepStrictEqual } from 'util'; -import { z } from 'zod'; - -const updateConfigSchema = z.object({ - version: z.string().optional(), - publicTelemetry: z.any().optional(), - config: z.any().optional(), -}); - -export type EventMap = { - newRollupVersion: [{ currentVersion: bigint; latestVersion: bigint }]; - newNodeVersion: [{ currentVersion: string; latestVersion: string }]; - updateNodeConfig: [object]; - updatePublicTelemetryConfig: [object]; -}; - -type Config = { - baseURL: URL; - nodeVersion?: string; - checkIntervalMs?: number; - registryContractAddress: EthAddress; - publicClient: ViemClient; - fetch?: typeof fetch; -}; - -export class UpdateChecker extends EventEmitter { - private runningPromise: RunningPromise; - private lastPatchedConfig: object = {}; - private lastPatchedPublicTelemetryConfig: object = {}; - - constructor( - private updatesUrl: URL, - private nodeVersion: string | undefined, - private rollupVersion: bigint, - private fetch: typeof globalThis.fetch, - private getLatestRollupVersion: () => Promise, - private checkIntervalMs = 10 * 60_000, // every 10 mins - private log = createLogger('foundation:update-check'), - ) { - super(); - this.runningPromise = new RunningPromise(this.runChecks, this.log, this.checkIntervalMs); - } - - public static async new(config: Config): Promise { - const registryContract = new RegistryContract(config.publicClient, config.registryContractAddress); - const getLatestRollupVersion = () => registryContract.getRollupVersions().then(versions => versions.at(-1)!); - - return new UpdateChecker( - config.baseURL, - config.nodeVersion ?? getPackageVersion(), - await getLatestRollupVersion(), - config.fetch ?? fetch, - getLatestRollupVersion, - config.checkIntervalMs, - ); - } - - public start(): void { - if (this.runningPromise.isRunning()) { - this.log.debug(`Can't start update checker again`); - return; - } - - this.log.info('Starting update checker', { - nodeVersion: this.nodeVersion, - rollupVersion: this.rollupVersion, - }); - this.runningPromise.start(); - } - - public stop(): Promise { - if (!this.runningPromise.isRunning()) { - this.log.debug(`Can't stop update checker because it is not running`); - return Promise.resolve(); - } - return this.runningPromise.stop(); - } - - public trigger(): Promise { - return this.runningPromise.trigger(); - } - - private runChecks = async (): Promise => { - await Promise.all([this.checkRollupVersion(), this.checkConfig()]); - }; - - private async checkRollupVersion(): Promise { - try { - const canonicalRollupVersion = await this.getLatestRollupVersion(); - if (canonicalRollupVersion !== this.rollupVersion) { - this.log.debug('New canonical rollup version', { - currentVersion: this.rollupVersion, - latestVersion: canonicalRollupVersion, - }); - this.emit('newRollupVersion', { currentVersion: this.rollupVersion, latestVersion: canonicalRollupVersion }); - } - } catch (err) { - this.log.warn(`Failed to check if there is a new rollup`, err); - } - } - - private async checkConfig(): Promise { - try { - const response = await this.fetch(this.updatesUrl); - const body = await response.json(); - if (!response.ok) { - this.log.warn(`Unexpected HTTP response checking for updates`, { - status: response.status, - body: await response.text(), - url: this.updatesUrl, - }); - } - - const { version, config, publicTelemetry } = updateConfigSchema.parse(body); - - if (this.nodeVersion && version && version !== this.nodeVersion) { - this.log.debug('New node version', { currentVersion: this.nodeVersion, latestVersion: version }); - this.emit('newNodeVersion', { currentVersion: this.nodeVersion, latestVersion: version }); - } - - if (config && Object.keys(config).length > 0 && !isDeepStrictEqual(config, this.lastPatchedConfig)) { - this.log.debug('New node config', { config }); - this.lastPatchedConfig = config; - this.emit('updateNodeConfig', config); - } - - if ( - publicTelemetry && - Object.keys(publicTelemetry).length > 0 && - !isDeepStrictEqual(publicTelemetry, this.lastPatchedPublicTelemetryConfig) - ) { - this.log.debug('New metrics config', { config }); - this.lastPatchedPublicTelemetryConfig = publicTelemetry; - this.emit('updatePublicTelemetryConfig', publicTelemetry); - } - } catch (err) { - this.log.warn(`Failed to check if there is an update`, err); - } - } -} - -/** - * Returns package version. - */ -export function getPackageVersion(): string | undefined { - try { - const releasePleaseManifestPath = resolve( - dirname(fileURLToPath(import.meta.url)), - '../../../../.release-please-manifest.json', - ); - const version = JSON.parse(readFileSync(releasePleaseManifestPath).toString())['.']; - return version; - } catch { - return undefined; - } -} diff --git a/yarn-project/stdlib/src/update-checker/version_checker.test.ts b/yarn-project/stdlib/src/update-checker/version_checker.test.ts new file mode 100644 index 000000000000..b5f8b8029b5e --- /dev/null +++ b/yarn-project/stdlib/src/update-checker/version_checker.test.ts @@ -0,0 +1,80 @@ +import { jest } from '@jest/globals'; + +import { type EventMap, type VersionCheck, VersionChecker } from './version_checker.js'; + +describe('VersionChecker', () => { + let checker: VersionChecker; + let getLatestNodeVersion: jest.Mock<() => Promise>; + let getLatestRollupVersion: jest.Mock<() => Promise>; + let eventHandler: jest.Mock<(...args: EventMap['newVersion']) => void>; + + beforeEach(() => { + getLatestNodeVersion = jest.fn(() => Promise.resolve('0.1.0')); + getLatestRollupVersion = jest.fn(() => Promise.resolve('42')); + + const checks: VersionCheck[] = [ + { name: 'node', currentVersion: '0.1.0', getLatestVersion: getLatestNodeVersion }, + { name: 'rollup', currentVersion: '42', getLatestVersion: getLatestRollupVersion }, + ]; + + checker = new VersionChecker(checks, 100); + + eventHandler = jest.fn(); + checker.on('newVersion', eventHandler); + }); + + it.each([ + ['it detects no change', () => {}], + [ + 'fetching node version fails', + () => { + getLatestNodeVersion.mockRejectedValue(new Error('test error')); + }, + ], + [ + 'fetching rollup version fails', + () => { + getLatestRollupVersion.mockRejectedValue(new Error('test error')); + }, + ], + [ + 'fetching node version returns undefined', + () => { + getLatestNodeVersion.mockResolvedValue(undefined); + }, + ], + ])('does not emit an event if %s', async (_, patchFn) => { + patchFn(); + for (let run = 0; run < 5; run++) { + await expect(checker.trigger()).resolves.toBeUndefined(); + expect(eventHandler).not.toHaveBeenCalled(); + } + }); + + it('emits newVersion when node version changes', async () => { + getLatestNodeVersion.mockResolvedValueOnce('0.2.0'); + await checker.trigger(); + expect(eventHandler).toHaveBeenCalledWith({ + name: 'node', + currentVersion: '0.1.0', + latestVersion: '0.2.0', + }); + }); + + it('emits newVersion when rollup version changes', async () => { + getLatestRollupVersion.mockResolvedValueOnce('999'); + await checker.trigger(); + expect(eventHandler).toHaveBeenCalledWith({ + name: 'rollup', + currentVersion: '42', + latestVersion: '999', + }); + }); + + it('emits for each changed version independently', async () => { + getLatestNodeVersion.mockResolvedValueOnce('0.2.0'); + getLatestRollupVersion.mockResolvedValueOnce('999'); + await checker.trigger(); + expect(eventHandler).toHaveBeenCalledTimes(2); + }); +}); diff --git a/yarn-project/stdlib/src/update-checker/version_checker.ts b/yarn-project/stdlib/src/update-checker/version_checker.ts new file mode 100644 index 000000000000..b239ad9a2ec1 --- /dev/null +++ b/yarn-project/stdlib/src/update-checker/version_checker.ts @@ -0,0 +1,65 @@ +import { createLogger } from '@aztec/foundation/log'; +import { RunningPromise } from '@aztec/foundation/promise'; + +import { EventEmitter } from 'node:events'; + +export type EventMap = { + newVersion: [{ name: string; currentVersion: string; latestVersion: string }]; +}; + +export type VersionCheck = { + name: string; + currentVersion: string; + getLatestVersion: () => Promise; +}; + +export class VersionChecker extends EventEmitter { + private runningPromise: RunningPromise; + constructor( + private checks: Array, + intervalCheckMs = 60_000, + private logger = createLogger('version_checker'), + ) { + super(); + this.runningPromise = new RunningPromise(this.run, logger, intervalCheckMs); + } + + public start(): void { + if (this.runningPromise.isRunning()) { + this.logger.warn('VersionChecker is already running'); + return; + } + + this.runningPromise.start(); + this.logger.info('Version check started'); + } + + public trigger(): Promise { + return this.runningPromise.trigger(); + } + + public async stop(): Promise { + if (!this.runningPromise.isRunning()) { + this.logger.warn('VersionChecker is not running'); + return; + } + + await this.runningPromise.stop(); + this.logger.info('Version checker stopped'); + } + + private run = async () => { + await Promise.allSettled(this.checks.map(check => this.checkVersion(check))); + }; + + private async checkVersion({ name, currentVersion, getLatestVersion }: VersionCheck): Promise { + try { + const latestVersion = await getLatestVersion(); + if (latestVersion && latestVersion !== currentVersion) { + this.emit('newVersion', { name, latestVersion, currentVersion }); + } + } catch (err) { + this.logger.warn(`Error checking for new ${name} versions: ${err}`, { err }); + } + } +}