diff --git a/typescript/infra/helm/helloworld-kathy/templates/_helpers.tpl b/typescript/infra/helm/helloworld-kathy/templates/_helpers.tpl index 286fd126a..2ebf25a27 100644 --- a/typescript/infra/helm/helloworld-kathy/templates/_helpers.tpl +++ b/typescript/infra/helm/helloworld-kathy/templates/_helpers.tpl @@ -36,6 +36,8 @@ Common labels {{- define "hyperlane.labels" -}} helm.sh/chart: {{ include "hyperlane.chart" . }} hyperlane/deployment: {{ .Values.hyperlane.runEnv | quote }} +hyperlane/context: {{ .Values.hyperlane.context | quote }} +app.kubernetes.io/component: kathy {{ include "hyperlane.selectorLabels" . }} {{- if .Chart.AppVersion }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} diff --git a/typescript/infra/helm/helloworld-kathy/templates/cycle-once-pod.yaml b/typescript/infra/helm/helloworld-kathy/templates/cycle-once-pod.yaml index b1fc69d74..bc40b8038 100644 --- a/typescript/infra/helm/helloworld-kathy/templates/cycle-once-pod.yaml +++ b/typescript/infra/helm/helloworld-kathy/templates/cycle-once-pod.yaml @@ -4,9 +4,7 @@ kind: Pod metadata: name: {{ include "hyperlane.fullname" . }}-cycle-once-{{ (randAlphaNum 4 | nospace | lower) }} labels: &metadata_labels - hyperlane/deployment: {{ .Values.hyperlane.runEnv | quote }} - hyperlane/context: {{ .Values.hyperlane.context | quote }} - app.kubernetes.io/component: kathy + {{- include "hyperlane.labels" . | nindent 4 }} spec: restartPolicy: Never containers: diff --git a/typescript/infra/helm/helloworld-kathy/templates/stateful-set.yaml b/typescript/infra/helm/helloworld-kathy/templates/stateful-set.yaml index a8dbaab21..d53d69eb0 100644 --- a/typescript/infra/helm/helloworld-kathy/templates/stateful-set.yaml +++ b/typescript/infra/helm/helloworld-kathy/templates/stateful-set.yaml @@ -4,9 +4,7 @@ kind: StatefulSet metadata: name: {{ include "hyperlane.fullname" . }} labels: &metadata_labels - hyperlane/deployment: {{ .Values.hyperlane.runEnv | quote }} - hyperlane/context: {{ .Values.hyperlane.context | quote }} - app.kubernetes.io/component: kathy + {{- include "hyperlane.labels" . | nindent 4 }} spec: selector: matchLabels: *metadata_labels diff --git a/typescript/infra/helm/key-funder/templates/cron-job.yaml b/typescript/infra/helm/key-funder/templates/cron-job.yaml index 5587d4f3c..a94884272 100644 --- a/typescript/infra/helm/key-funder/templates/cron-job.yaml +++ b/typescript/infra/helm/key-funder/templates/cron-job.yaml @@ -12,6 +12,9 @@ spec: backoffLimit: 0 activeDeadlineSeconds: 14400 # 60 * 60 * 4 seconds = 4 hours template: + metadata: + labels: + {{- include "hyperlane.labels" . | nindent 12 }} spec: restartPolicy: Never containers: diff --git a/typescript/infra/helm/warp-routes/templates/_helpers.tpl b/typescript/infra/helm/warp-routes/templates/_helpers.tpl index 29afca86f..17a9174d5 100644 --- a/typescript/infra/helm/warp-routes/templates/_helpers.tpl +++ b/typescript/infra/helm/warp-routes/templates/_helpers.tpl @@ -36,6 +36,8 @@ Common labels {{- define "hyperlane.labels" -}} helm.sh/chart: {{ include "hyperlane.chart" . }} hyperlane/deployment: {{ .Values.hyperlane.runEnv | quote }} +hyperlane/context: {{ .Values.hyperlane.context | quote }} +app.kubernetes.io/component: warp-routes {{ include "hyperlane.selectorLabels" . }} {{- if .Chart.AppVersion }} app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} diff --git a/typescript/infra/helm/warp-routes/templates/stateful-set.yaml b/typescript/infra/helm/warp-routes/templates/stateful-set.yaml index f29a7e947..d2f7c0c57 100644 --- a/typescript/infra/helm/warp-routes/templates/stateful-set.yaml +++ b/typescript/infra/helm/warp-routes/templates/stateful-set.yaml @@ -4,9 +4,7 @@ kind: StatefulSet metadata: name: {{ include "hyperlane.fullname" . }} labels: &metadata_labels - hyperlane/deployment: {{ .Values.hyperlane.runEnv | quote }} - hyperlane/context: {{ .Values.hyperlane.context | quote }} - app.kubernetes.io/component: warp-routes + {{- include "hyperlane.labels" . | nindent 4 }} spec: selector: matchLabels: *metadata_labels diff --git a/typescript/infra/package.json b/typescript/infra/package.json index ddfc7478b..03db2be61 100644 --- a/typescript/infra/package.json +++ b/typescript/infra/package.json @@ -17,6 +17,7 @@ "@hyperlane-xyz/registry": "2.5.0", "@hyperlane-xyz/sdk": "5.1.0", "@hyperlane-xyz/utils": "5.1.0", + "@inquirer/prompts": "^5.3.8", "@nomiclabs/hardhat-etherscan": "^3.0.3", "@safe-global/api-kit": "1.3.0", "@safe-global/protocol-kit": "1.3.0", diff --git a/typescript/infra/scripts/agent-utils.ts b/typescript/infra/scripts/agent-utils.ts index 1e4ccf8e7..eeeceda2a 100644 --- a/typescript/infra/scripts/agent-utils.ts +++ b/typescript/infra/scripts/agent-utils.ts @@ -168,6 +168,10 @@ export function withChains(args: Argv) { ); } +export function withChainsRequired(args: Argv) { + return withChains(args).demandOption('chains'); +} + export function withWarpRouteId(args: Argv) { return args.describe('warpRouteId', 'warp route id').string('warpRouteId'); } diff --git a/typescript/infra/scripts/funding/deploy-key-funder.ts b/typescript/infra/scripts/funding/deploy-key-funder.ts index b7bb9bc86..87ac67d7c 100644 --- a/typescript/infra/scripts/funding/deploy-key-funder.ts +++ b/typescript/infra/scripts/funding/deploy-key-funder.ts @@ -1,14 +1,12 @@ import { Contexts } from '../../config/contexts.js'; -import { - getKeyFunderConfig, - runKeyFunderHelmCommand, -} from '../../src/funding/key-funder.js'; +import { environment } from '../../config/environments/mainnet3/chains.js'; +import { KeyFunderHelmManager } from '../../src/funding/key-funder.js'; import { HelmCommand } from '../../src/utils/helm.js'; import { assertCorrectKubeContext } from '../agent-utils.js'; import { getConfigsBasedOnArgs } from '../core-utils.js'; async function main() { - const { agentConfig, envConfig } = await getConfigsBasedOnArgs(); + const { agentConfig, envConfig, context } = await getConfigsBasedOnArgs(); if (agentConfig.context != Contexts.Hyperlane) throw new Error( `Invalid context ${agentConfig.context}, must be ${Contexts.Hyperlane}`, @@ -16,13 +14,8 @@ async function main() { await assertCorrectKubeContext(envConfig); - const keyFunderConfig = getKeyFunderConfig(envConfig); - - await runKeyFunderHelmCommand( - HelmCommand.InstallOrUpgrade, - agentConfig, - keyFunderConfig, - ); + const manager = KeyFunderHelmManager.forEnvironment(environment); + await manager.runHelmCommand(HelmCommand.InstallOrUpgrade); } main() diff --git a/typescript/infra/scripts/helloworld/deploy-kathy.ts b/typescript/infra/scripts/helloworld/deploy-kathy.ts index 6e451862c..3462bc921 100644 --- a/typescript/infra/scripts/helloworld/deploy-kathy.ts +++ b/typescript/infra/scripts/helloworld/deploy-kathy.ts @@ -1,20 +1,14 @@ -import { runHelloworldKathyHelmCommand } from '../../src/helloworld/kathy.js'; +import { KathyHelmManager } from '../../src/helloworld/kathy.js'; import { HelmCommand } from '../../src/utils/helm.js'; import { assertCorrectKubeContext } from '../agent-utils.js'; import { getConfigsBasedOnArgs } from '../core-utils.js'; -import { getHelloWorldConfig } from './utils.js'; - async function main() { - const { agentConfig, envConfig, context } = await getConfigsBasedOnArgs(); + const { envConfig, environment, context } = await getConfigsBasedOnArgs(); await assertCorrectKubeContext(envConfig); - const kathyConfig = getHelloWorldConfig(envConfig, context).kathy; - await runHelloworldKathyHelmCommand( - HelmCommand.InstallOrUpgrade, - agentConfig, - kathyConfig, - ); + const manager = KathyHelmManager.forEnvironment(environment, context); + await manager.runHelmCommand(HelmCommand.InstallOrUpgrade); } main() diff --git a/typescript/infra/scripts/secret-rpc-urls/set-rpc-urls-from-registry.ts b/typescript/infra/scripts/secret-rpc-urls/set-rpc-urls-from-registry.ts deleted file mode 100644 index 22a24dcde..000000000 --- a/typescript/infra/scripts/secret-rpc-urls/set-rpc-urls-from-registry.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { getRegistryForEnvironment } from '../../src/config/chain.js'; -import { setAndVerifyRpcUrls } from '../../src/utils/rpcUrls.js'; -import { getArgs, withChains } from '../agent-utils.js'; - -async function main() { - const { environment, chains } = await withChains(getArgs()).argv; - - if (!chains || chains.length === 0) { - console.error('No chains provided, Exiting.'); - process.exit(1); - } - - console.log( - `Setting RPC URLs for chains: ${chains.join( - ', ', - )} in ${environment} environment`, - ); - - const registry = await getRegistryForEnvironment( - environment, - chains, - undefined, - false, - ); - - for (const chain of chains) { - console.log(`\nSetting RPC URLs for chain: ${chain}`); - const chainMetadata = await registry.getChainMetadata(chain); - if (!chainMetadata) { - console.error(`Chain ${chain} not found in registry. Continuing...`); - continue; - } - - const rpcUrlsArray = chainMetadata.rpcUrls.map((rpc) => rpc.http); - await setAndVerifyRpcUrls(environment, chain, rpcUrlsArray); - } -} - -main() - .then() - .catch((e) => { - console.error(e); - process.exit(1); - }); diff --git a/typescript/infra/scripts/secret-rpc-urls/set-rpc-urls.ts b/typescript/infra/scripts/secret-rpc-urls/set-rpc-urls.ts index 93215c9cd..7d71204a9 100644 --- a/typescript/infra/scripts/secret-rpc-urls/set-rpc-urls.ts +++ b/typescript/infra/scripts/secret-rpc-urls/set-rpc-urls.ts @@ -1,22 +1,28 @@ -import { setAndVerifyRpcUrls } from '../../src/utils/rpcUrls.js'; -import { getArgs, withChainRequired, withRpcUrls } from '../agent-utils.js'; +import { setRpcUrlsInteractive } from '../../src/utils/rpcUrls.js'; +import { + assertCorrectKubeContext, + getArgs, + withChainsRequired, +} from '../agent-utils.js'; +import { getEnvironmentConfig } from '../core-utils.js'; async function main() { - const { environment, chain, rpcUrls } = await withRpcUrls( - withChainRequired(getArgs()), - ).argv; + const { environment, chains } = await withChainsRequired(getArgs()) + // For ease of use and backward compatibility, we allow the `chain` argument to be + // singular or plural. + .alias('chain', 'chains').argv; - const rpcUrlsArray = rpcUrls - .split(/,\s*/) - .filter(Boolean) // filter out empty strings - .map((url) => url.trim()); + await assertCorrectKubeContext(getEnvironmentConfig(environment)); - if (!rpcUrlsArray.length) { - console.error('No rpc urls provided, Exiting.'); + if (!chains || chains.length === 0) { + console.error('No chains provided, Exiting.'); process.exit(1); } - await setAndVerifyRpcUrls(environment, chain, rpcUrlsArray); + for (const chain of chains) { + console.log(`Setting RPC URLs for chain: ${chain}`); + await setRpcUrlsInteractive(environment, chain); + } } main() diff --git a/typescript/infra/scripts/warp-routes/deploy-warp-monitor.ts b/typescript/infra/scripts/warp-routes/deploy-warp-monitor.ts index 7fb351bb8..009323ad6 100644 --- a/typescript/infra/scripts/warp-routes/deploy-warp-monitor.ts +++ b/typescript/infra/scripts/warp-routes/deploy-warp-monitor.ts @@ -1,8 +1,9 @@ import yargs from 'yargs'; import { HelmCommand } from '../../src/utils/helm.js'; - -import { runWarpRouteHelmCommand } from './helm.js'; +import { WarpRouteMonitorHelmManager } from '../../src/warp/helm.js'; +import { assertCorrectKubeContext } from '../agent-utils.js'; +import { getEnvironmentConfig } from '../core-utils.js'; async function main() { const { filePath } = await yargs(process.argv.slice(2)) @@ -15,11 +16,10 @@ async function main() { .string('filePath') .parse(); - await runWarpRouteHelmCommand( - HelmCommand.InstallOrUpgrade, - 'mainnet3', - filePath, - ); + await assertCorrectKubeContext(getEnvironmentConfig('mainnet3')); + + const helmManager = new WarpRouteMonitorHelmManager(filePath, 'mainnet3'); + await helmManager.runHelmCommand(HelmCommand.InstallOrUpgrade); } main() diff --git a/typescript/infra/scripts/warp-routes/helm.ts b/typescript/infra/scripts/warp-routes/helm.ts deleted file mode 100644 index f8a7883ef..000000000 --- a/typescript/infra/scripts/warp-routes/helm.ts +++ /dev/null @@ -1,44 +0,0 @@ -import path from 'path'; - -import { DeployEnvironment } from '../../src/config/environment.js'; -import { HelmCommand, helmifyValues } from '../../src/utils/helm.js'; -import { execCmd } from '../../src/utils/utils.js'; -import { assertCorrectKubeContext } from '../agent-utils.js'; -import { getEnvironmentConfig } from '../core-utils.js'; - -export async function runWarpRouteHelmCommand( - helmCommand: HelmCommand, - runEnv: DeployEnvironment, - configFilePath: string, -) { - const envConfig = getEnvironmentConfig(runEnv); - await assertCorrectKubeContext(envConfig); - const values = getWarpRoutesHelmValues(configFilePath); - const releaseName = getHelmReleaseName(configFilePath); - return execCmd( - `helm ${helmCommand} ${releaseName} ./helm/warp-routes --namespace ${runEnv} ${values.join( - ' ', - )} --set fullnameOverride="${releaseName}"`, - ); -} - -function getHelmReleaseName(route: string): string { - const match = route.match(/\/([^/]+)-deployments\.yaml$/); - const name = match ? match[1] : route; - return `hyperlane-warp-route-${name.toLowerCase()}`; // helm requires lower case release names -} - -function getWarpRoutesHelmValues(configFilePath: string) { - // The path should be relative to the monorepo root - const pathRelativeToMonorepoRoot = configFilePath.includes('typescript/infra') - ? configFilePath - : path.join('typescript/infra', configFilePath); - const values = { - image: { - repository: 'gcr.io/abacus-labs-dev/hyperlane-monorepo', - tag: '38ff1c4-20240823-093934', - }, - configFilePath: pathRelativeToMonorepoRoot, - }; - return helmifyValues(values); -} diff --git a/typescript/infra/src/agents/index.ts b/typescript/infra/src/agents/index.ts index 87873de13..533e1407a 100644 --- a/typescript/infra/src/agents/index.ts +++ b/typescript/infra/src/agents/index.ts @@ -24,11 +24,7 @@ import { getGcpSecretLatestVersionName, setGCPSecretUsingClient, } from '../utils/gcloud.js'; -import { - HelmCommand, - buildHelmChartDependencies, - helmifyValues, -} from '../utils/helm.js'; +import { HelmManager } from '../utils/helm.js'; import { execCmd, getInfraPath, @@ -47,9 +43,8 @@ if (!fs.existsSync(HELM_CHART_PATH + 'Chart.yaml')) `Could not find helm chart at ${HELM_CHART_PATH}; the relative path may have changed.`, ); -export abstract class AgentHelmManager { +export abstract class AgentHelmManager extends HelmManager { abstract readonly role: AgentRole; - abstract readonly helmReleaseName: string; readonly helmChartPath: string = HELM_CHART_PATH; protected abstract readonly config: AgentConfigHelper; @@ -70,55 +65,8 @@ export abstract class AgentHelmManager { return this.config.namespace; } - async runHelmCommand(action: HelmCommand, dryRun?: boolean): Promise { - const cmd = ['helm', action]; - if (dryRun) cmd.push('--dry-run'); - - if (action == HelmCommand.Remove) { - if (dryRun) cmd.push('--dry-run'); - cmd.push(this.helmReleaseName, this.namespace); - await execCmd(cmd, {}, false, true); - return; - } - - const values = helmifyValues(await this.helmValues()); - if (action == HelmCommand.InstallOrUpgrade && !dryRun) { - // Delete secrets to avoid them being stale - const cmd = [ - 'kubectl', - 'delete', - 'secrets', - '--namespace', - this.namespace, - '--selector', - `app.kubernetes.io/instance=${this.helmReleaseName}`, - ]; - try { - await execCmd(cmd, {}, false, false); - } catch (e) { - console.error(e); - } - } - - await buildHelmChartDependencies(this.helmChartPath); - cmd.push( - this.helmReleaseName, - this.helmChartPath, - '--create-namespace', - '--namespace', - this.namespace, - ...values, - ); - if (action == HelmCommand.UpgradeDiff) { - cmd.push( - `| kubectl diff --namespace ${this.namespace} --field-manager="Go-http-client" -f - || true`, - ); - } - await execCmd(cmd, {}, false, true); - } - async helmValues(): Promise { - const dockerImage = this.dockerImage(); + const dockerImage = this.dockerImage; return { image: { repository: dockerImage.repo, @@ -155,21 +103,7 @@ export abstract class AgentHelmManager { return this.config.agentRoleConfig.rpcConsensusType; } - async doesAgentReleaseExist() { - try { - await execCmd( - `helm status ${this.helmReleaseName} --namespace ${this.namespace}`, - {}, - false, - false, - ); - return true; - } catch (error) { - return false; - } - } - - dockerImage(): DockerConfig { + get dockerImage(): DockerConfig { return this.config.agentRoleConfig.docker; } @@ -201,7 +135,7 @@ abstract class MultichainAgentHelmManager extends AgentHelmManager { return parts.join('-'); } - dockerImage(): DockerConfig { + get dockerImage(): DockerConfig { return this.config.dockerImageForChain(this.chainName); } } diff --git a/typescript/infra/src/funding/key-funder.ts b/typescript/infra/src/funding/key-funder.ts index 423b87c37..8ecbea981 100644 --- a/typescript/infra/src/funding/key-funder.ts +++ b/typescript/infra/src/funding/key-funder.ts @@ -1,66 +1,61 @@ +import { join } from 'path'; + +import { Contexts } from '../../config/contexts.js'; +import { getAgentConfig } from '../../scripts/agent-utils.js'; +import { getEnvironmentConfig } from '../../scripts/core-utils.js'; import { AgentContextConfig } from '../config/agent/agent.js'; -import { EnvironmentConfig } from '../config/environment.js'; +import { DeployEnvironment, EnvironmentConfig } from '../config/environment.js'; import { KeyFunderConfig } from '../config/funding.js'; -import { HelmCommand, helmifyValues } from '../utils/helm.js'; -import { execCmd } from '../utils/utils.js'; +import { HelmCommand, HelmManager, helmifyValues } from '../utils/helm.js'; +import { execCmd, getInfraPath } from '../utils/utils.js'; + +export class KeyFunderHelmManager extends HelmManager { + readonly helmReleaseName: string = 'key-funder'; + readonly helmChartPath: string = join(getInfraPath(), './helm/key-funder/'); -export async function runKeyFunderHelmCommand( - helmCommand: HelmCommand, - agentConfig: AgentContextConfig, - keyFunderConfig: KeyFunderConfig, -) { - const values = getKeyFunderHelmValues(agentConfig, keyFunderConfig); - if (helmCommand === HelmCommand.InstallOrUpgrade) { - // Delete secrets to avoid them being stale - try { - await execCmd( - `kubectl delete secrets --namespace ${agentConfig.namespace} --selector app.kubernetes.io/instance=key-funder`, - {}, - false, - false, - ); - } catch (e) { - console.error(e); - } + constructor( + readonly config: KeyFunderConfig, + readonly agentConfig: AgentContextConfig, + ) { + super(); } - return execCmd( - `helm ${helmCommand} key-funder ./helm/key-funder --namespace ${ - keyFunderConfig.namespace - } ${values.join(' ')}`, - {}, - false, - true, - ); -} + static forEnvironment(environment: DeployEnvironment): KeyFunderHelmManager { + const envConfig = getEnvironmentConfig(environment); + const keyFunderConfig = getKeyFunderConfig(envConfig); + // Always use Hyperlane context for key funder + const agentConfig = getAgentConfig(Contexts.Hyperlane, environment); + return new KeyFunderHelmManager(keyFunderConfig, agentConfig); + } -function getKeyFunderHelmValues( - agentConfig: AgentContextConfig, - keyFunderConfig: KeyFunderConfig, -) { - const values = { - cronjob: { - schedule: keyFunderConfig.cronSchedule, - }, - hyperlane: { - runEnv: agentConfig.runEnv, - // Only used for fetching RPC urls as env vars - chains: agentConfig.environmentChainNames, - contextFundingFrom: keyFunderConfig.contextFundingFrom, - contextsAndRolesToFund: keyFunderConfig.contextsAndRolesToFund, - desiredBalancePerChain: keyFunderConfig.desiredBalancePerChain, - desiredKathyBalancePerChain: keyFunderConfig.desiredKathyBalancePerChain, - igpClaimThresholdPerChain: keyFunderConfig.igpClaimThresholdPerChain, - }, - image: { - repository: keyFunderConfig.docker.repo, - tag: keyFunderConfig.docker.tag, - }, - infra: { - prometheusPushGateway: keyFunderConfig.prometheusPushGateway, - }, - }; - return helmifyValues(values); + get namespace() { + return this.config.namespace; + } + + async helmValues() { + return { + cronjob: { + schedule: this.config.cronSchedule, + }, + hyperlane: { + runEnv: this.agentConfig.runEnv, + // Only used for fetching RPC urls as env vars + chains: this.agentConfig.environmentChainNames, + contextFundingFrom: this.config.contextFundingFrom, + contextsAndRolesToFund: this.config.contextsAndRolesToFund, + desiredBalancePerChain: this.config.desiredBalancePerChain, + desiredKathyBalancePerChain: this.config.desiredKathyBalancePerChain, + igpClaimThresholdPerChain: this.config.igpClaimThresholdPerChain, + }, + image: { + repository: this.config.docker.repo, + tag: this.config.docker.tag, + }, + infra: { + prometheusPushGateway: this.config.prometheusPushGateway, + }, + }; + } } export function getKeyFunderConfig( diff --git a/typescript/infra/src/helloworld/kathy.ts b/typescript/infra/src/helloworld/kathy.ts index 6366049fa..47dafc851 100644 --- a/typescript/infra/src/helloworld/kathy.ts +++ b/typescript/infra/src/helloworld/kathy.ts @@ -1,97 +1,117 @@ +import { join } from 'path'; + import { Contexts } from '../../config/contexts.js'; +import { getAgentConfig } from '../../scripts/agent-utils.js'; +import { getEnvironmentConfig } from '../../scripts/core-utils.js'; +import { getHelloWorldConfig } from '../../scripts/helloworld/utils.js'; import { AgentAwsUser } from '../agents/aws/user.js'; import { AgentGCPKey } from '../agents/gcp.js'; import { AgentContextConfig } from '../config/agent/agent.js'; +import { DeployEnvironment } from '../config/environment.js'; import { HelloWorldKathyConfig, HelloWorldKathyRunMode, } from '../config/helloworld/types.js'; import { Role } from '../roles.js'; -import { HelmCommand, helmifyValues } from '../utils/helm.js'; -import { execCmd } from '../utils/utils.js'; +import { + HelmCommand, + HelmManager, + HelmValues, + helmifyValues, +} from '../utils/helm.js'; +import { execCmd, getInfraPath } from '../utils/utils.js'; -export async function runHelloworldKathyHelmCommand( - helmCommand: HelmCommand, - agentConfig: AgentContextConfig, - kathyConfig: HelloWorldKathyConfig, -) { - // If using AWS keys, ensure the Kathy user and key has been created - if (agentConfig.aws) { - const awsUser = new AgentAwsUser( - agentConfig.runEnv, - agentConfig.context, - Role.Kathy, - agentConfig.aws.region, - ); - await awsUser.createIfNotExists(); - await awsUser.createKeyIfNotExists(agentConfig); +export class KathyHelmManager extends HelmManager { + readonly helmChartPath: string = join( + getInfraPath(), + './helm/helloworld-kathy/', + ); + + constructor( + readonly config: HelloWorldKathyConfig, + readonly agentConfig: AgentContextConfig, + ) { + super(); } - // Also ensure a GCP key exists, which is used for non-EVM chains even if - // the agent config is AWS-based - const kathyKey = new AgentGCPKey( - agentConfig.runEnv, - agentConfig.context, - Role.Kathy, - ); - await kathyKey.createIfNotExists(); + static forEnvironment( + environment: DeployEnvironment, + context: Contexts, + ): KathyHelmManager { + const envConfig = getEnvironmentConfig(environment); + const helloWorldConfig = getHelloWorldConfig(envConfig, context); + const agentConfig = getAgentConfig(Contexts.Hyperlane, environment); + return new KathyHelmManager(helloWorldConfig.kathy, agentConfig); + } + + get namespace() { + return this.config.namespace; + } - const values = getHelloworldKathyHelmValues(agentConfig, kathyConfig); + get helmReleaseName(): string { + // For backward compatibility, keep the hyperlane context release name as + // 'helloworld-kathy', and add `-${context}` as a suffix for any other contexts + return `helloworld-kathy${ + this.agentConfig.context === Contexts.Hyperlane ? '' : `-${context}` + }`; + } - return execCmd( - `helm ${helmCommand} ${getHelmReleaseName( - agentConfig.context, - )} ./helm/helloworld-kathy --namespace ${ - kathyConfig.namespace - } ${values.join(' ')}`, - {}, - false, - true, - ); -} + async helmValues(): Promise { + const cycleOnce = + this.config.runConfig.mode === HelloWorldKathyRunMode.CycleOnce; + const fullCycleTime = + this.config.runConfig.mode === HelloWorldKathyRunMode.Service + ? this.config.runConfig.fullCycleTime + : ''; -function getHelmReleaseName(context: Contexts): string { - // For backward compatibility, keep the hyperlane context release name as - // 'helloworld-kathy', and add `-${context}` as a suffix for any other contexts - return `helloworld-kathy${ - context === Contexts.Hyperlane ? '' : `-${context}` - }`; -} + return { + hyperlane: { + runEnv: this.config.runEnv, + context: this.agentConfig.context, + // This is just used for fetching secrets, and is not actually + // the list of chains that kathy will send to. Because Kathy + // will fetch secrets for all chains in the environment, regardless + // of skipping them or not, we pass in all chains + chains: this.agentConfig.environmentChainNames, + aws: this.agentConfig.aws !== undefined, -function getHelloworldKathyHelmValues( - agentConfig: AgentContextConfig, - kathyConfig: HelloWorldKathyConfig, -) { - const cycleOnce = - kathyConfig.runConfig.mode === HelloWorldKathyRunMode.CycleOnce; - const fullCycleTime = - kathyConfig.runConfig.mode === HelloWorldKathyRunMode.Service - ? kathyConfig.runConfig.fullCycleTime - : ''; + chainsToSkip: this.config.chainsToSkip, + messageSendTimeout: this.config.messageSendTimeout, + messageReceiptTimeout: this.config.messageReceiptTimeout, + cycleOnce, + fullCycleTime, + cyclesBetweenEthereumMessages: + this.config.cyclesBetweenEthereumMessages, + }, + image: { + repository: this.config.docker.repo, + tag: this.config.docker.tag, + }, + }; + } - const values = { - hyperlane: { - runEnv: kathyConfig.runEnv, - context: agentConfig.context, - // This is just used for fetching secrets, and is not actually - // the list of chains that kathy will send to. Because Kathy - // will fetch secrets for all chains in the environment, regardless - // of skipping them or not, we pass in all chains - chains: agentConfig.environmentChainNames, - aws: agentConfig.aws !== undefined, + async runHelmCommand(action: HelmCommand, dryRun?: boolean): Promise { + // If using AWS keys, ensure the Kathy user and key has been created + if (this.agentConfig.aws) { + const awsUser = new AgentAwsUser( + this.agentConfig.runEnv, + this.agentConfig.context, + Role.Kathy, + this.agentConfig.aws.region, + ); + await awsUser.createIfNotExists(); + await awsUser.createKeyIfNotExists(this.agentConfig); + } - chainsToSkip: kathyConfig.chainsToSkip, - messageSendTimeout: kathyConfig.messageSendTimeout, - messageReceiptTimeout: kathyConfig.messageReceiptTimeout, - cycleOnce, - fullCycleTime, - cyclesBetweenEthereumMessages: kathyConfig.cyclesBetweenEthereumMessages, - }, - image: { - repository: kathyConfig.docker.repo, - tag: kathyConfig.docker.tag, - }, - }; + // Also ensure a GCP key exists, which is used for non-EVM chains even if + // the agent config is AWS-based + const kathyKey = new AgentGCPKey( + this.agentConfig.runEnv, + this.agentConfig.context, + Role.Kathy, + ); + await kathyKey.createIfNotExists(); - return helmifyValues(values); + super.runHelmCommand(action, dryRun); + } } diff --git a/typescript/infra/src/utils/helm.ts b/typescript/infra/src/utils/helm.ts index 0b5c1653d..2ec09385e 100644 --- a/typescript/infra/src/utils/helm.ts +++ b/typescript/infra/src/utils/helm.ts @@ -1,3 +1,4 @@ +import { DockerConfig } from '../config/agent/agent.js'; import { HelmChartConfig, HelmChartRepositoryConfig, @@ -91,3 +92,97 @@ export function getDeployableHelmChartName(helmChartConfig: HelmChartConfig) { export function buildHelmChartDependencies(chartPath: string) { return execCmd(`cd ${chartPath} && helm dependency build`, {}, false, true); } + +export type HelmValues = Record; + +export abstract class HelmManager { + abstract readonly helmReleaseName: string; + abstract readonly helmChartPath: string; + abstract readonly namespace: string; + + /** + * Returns the values to be passed to the helm chart. + * Expected to be an object of values. + */ + abstract helmValues(): Promise; + + async runHelmCommand(action: HelmCommand, dryRun?: boolean): Promise { + const cmd = ['helm', action]; + if (dryRun) cmd.push('--dry-run'); + + if (action == HelmCommand.Remove) { + if (dryRun) cmd.push('--dry-run'); + cmd.push(this.helmReleaseName, this.namespace); + await execCmd(cmd, {}, false, true); + return; + } + + const values = helmifyValues(await this.helmValues()); + if (action == HelmCommand.InstallOrUpgrade && !dryRun) { + // Delete secrets to avoid them being stale + const cmd = [ + 'kubectl', + 'delete', + 'secrets', + '--namespace', + this.namespace, + '--selector', + `app.kubernetes.io/instance=${this.helmReleaseName}`, + ]; + try { + await execCmd(cmd, {}, false, false); + } catch (e) { + console.error(e); + } + } + + await buildHelmChartDependencies(this.helmChartPath); + cmd.push( + this.helmReleaseName, + this.helmChartPath, + '--create-namespace', + '--namespace', + this.namespace, + ...values, + ); + if (action == HelmCommand.UpgradeDiff) { + cmd.push( + `| kubectl diff --namespace ${this.namespace} --field-manager="Go-http-client" -f - || true`, + ); + } + await execCmd(cmd, {}, false, true); + } + + async doesHelmReleaseExist() { + try { + await execCmd( + `helm status ${this.helmReleaseName} --namespace ${this.namespace}`, + {}, + false, + false, + ); + return true; + } catch (error) { + return false; + } + } + + async getExistingK8sSecrets(): Promise { + const [output] = await execCmd( + `kubectl get secret --selector=app.kubernetes.io/instance=${this.helmReleaseName} -o jsonpath='{.items[*].metadata.name}' -n ${this.namespace}`, + ); + // Split on spaces and remove empty strings + return output.split(' ').filter(Boolean); + } + + // Returns the names of all pods managed by a Statefulset in the helm release + async getManagedK8sPods() { + // Consider supporting Deployments in the future. For now, we only support StatefulSets because + // jsonpath doesn't support or operators well. + const [output] = await execCmd( + `kubectl get pods --selector=app.kubernetes.io/instance=${this.helmReleaseName} -o jsonpath='{range .items[?(@.metadata.ownerReferences[0].kind=="StatefulSet")]}{.metadata.name}{"\\n"}{end}' -n ${this.namespace}`, + ); + // Split on new lines and remove empty strings + return output.split('\n').filter(Boolean); + } +} diff --git a/typescript/infra/src/utils/k8s.ts b/typescript/infra/src/utils/k8s.ts new file mode 100644 index 000000000..2b23f3647 --- /dev/null +++ b/typescript/infra/src/utils/k8s.ts @@ -0,0 +1,140 @@ +import { confirm } from '@inquirer/prompts'; + +import { pollAsync } from '@hyperlane-xyz/utils'; + +import { HelmManager } from './helm.js'; +import { execCmd } from './utils.js'; + +export enum K8sResourceType { + SECRET = 'secret', + POD = 'pod', +} + +export async function refreshK8sResources( + helmManagers: HelmManager[], + resourceType: K8sResourceType, + namespace: string, +) { + const resourceNames = ( + await Promise.all( + helmManagers.map(async (helmManager) => { + if (resourceType === K8sResourceType.SECRET) { + return helmManager.getExistingK8sSecrets(); + } else if (resourceType === K8sResourceType.POD) { + return helmManager.getManagedK8sPods(); + } else { + throw new Error(`Unknown resource type: ${resourceType}`); + } + }), + ) + ).flat(); + + console.log(`Ready to delete ${resourceType}s: ${resourceNames.join(', ')}`); + + const cont = await confirm({ + message: `Proceed and delete ${resourceNames.length} ${resourceType}s?`, + }); + if (!cont) { + throw new Error('Aborting'); + } + + await execCmd( + `kubectl delete ${resourceType} ${resourceNames.join(' ')} -n ${namespace}`, + ); + console.log( + `🏗 Deleted ${resourceNames.length} ${resourceType}s, waiting for them to be recreated...`, + ); + + await waitForK8sResources(resourceType, resourceNames, namespace); +} + +// Polls until all resources are ready. +// For secrets, this means they exist. +// For pods, this means they exist and are running. +async function waitForK8sResources( + resourceType: K8sResourceType, + resourceNames: string[], + namespace: string, +) { + const resourceGetter = + resourceType === K8sResourceType.SECRET + ? getExistingK8sSecrets + : getRunningK8sPods; + + try { + const pollDelayMs = 2000; + const pollAttempts = 30; + await pollAsync( + async () => { + const { missing } = await resourceGetter(resourceNames, namespace); + if (missing.length > 0) { + console.log( + `⏳ ${resourceNames.length - missing.length} of ${ + resourceNames.length + } ${resourceType}s up, waiting for ${missing.length} more`, + ); + throw new Error( + `${resourceType}s not ready, ${missing.length} missing`, + ); + } + }, + pollDelayMs, + pollAttempts, + ); + console.log(`✅ All ${resourceNames.length} ${resourceType}s exist`); + } catch (e) { + console.error(`Error waiting for ${resourceType}s to exist: ${e}`); + } +} + +async function getExistingK8sSecrets( + resourceNames: string[], + namespace: string, +): Promise<{ + existing: string[]; + missing: string[]; +}> { + const [output] = await execCmd( + `kubectl get secret ${resourceNames.join( + ' ', + )} -n ${namespace} --ignore-not-found -o jsonpath='{.items[*].metadata.name}'`, + ); + const existing = output.split(' ').filter(Boolean); + const missing = resourceNames.filter( + (resource) => !existing.includes(resource), + ); + return { existing, missing }; +} + +async function getRunningK8sPods( + resourceNames: string[], + namespace: string, +): Promise<{ + existing: string[]; + missing: string[]; +}> { + // Returns a newline separated list of pod names and their statuses, e.g.: + // pod1:Running + // pod2:Pending + // pod3:Running + // Interestingly, providing names here is incompatible with the jsonpath range syntax. So we get all pods + // and filter. + const [output] = await execCmd( + `kubectl get pods -n ${namespace} --ignore-not-found -o jsonpath='{range .items[*]}{.metadata.name}:{.status.phase}{"\\n"}{end}'`, + ); + // Filter out pods that are not in the list of resource names or are not running + const running = output + .split('\n') + .map((line) => { + const [pod, status] = line.split(':'); + return resourceNames.includes(pod) && status === 'Running' + ? pod + : undefined; + }) + // TS isn't smart enough to know that the filter removes undefineds + .filter((pod) => pod !== undefined) as string[]; + const missing = resourceNames.filter( + (resource) => !running.includes(resource), + ); + return { existing: running, missing }; +} diff --git a/typescript/infra/src/utils/rpcUrls.ts b/typescript/infra/src/utils/rpcUrls.ts index 6338a3b90..d24a31e77 100644 --- a/typescript/infra/src/utils/rpcUrls.ts +++ b/typescript/infra/src/utils/rpcUrls.ts @@ -1,46 +1,57 @@ -import { confirm } from '@inquirer/prompts'; +import input from '@inquirer/input'; +import { Separator, checkbox, confirm } from '@inquirer/prompts'; +import select from '@inquirer/select'; import { ethers } from 'ethers'; -import { timeout } from '@hyperlane-xyz/utils'; +import { ChainName } from '@hyperlane-xyz/sdk'; +import { ProtocolType, timeout } from '@hyperlane-xyz/utils'; +import { Contexts } from '../../config/contexts.js'; +import { getChain } from '../../config/registry.js'; +import { getEnvironmentConfig } from '../../scripts/core-utils.js'; import { + RelayerHelmManager, + ScraperHelmManager, + ValidatorHelmManager, getSecretRpcEndpoints, getSecretRpcEndpointsLatestVersionName, secretRpcEndpointsExist, setSecretRpcEndpoints, } from '../agents/index.js'; +import { DeployEnvironment } from '../config/environment.js'; +import { KeyFunderHelmManager } from '../funding/key-funder.js'; +import { KathyHelmManager } from '../helloworld/kathy.js'; import { disableGCPSecretVersion } from './gcloud.js'; -import { isEthereumProtocolChain } from './utils.js'; +import { HelmManager } from './helm.js'; +import { K8sResourceType, refreshK8sResources } from './k8s.js'; -export async function testProviders(rpcUrlsArray: string[]): Promise { - let providersSucceeded = true; - for (const url of rpcUrlsArray) { - const provider = new ethers.providers.StaticJsonRpcProvider(url); - try { - const blockNumber = await timeout(provider.getBlockNumber(), 5000); - console.log(`Valid provider for ${url} with block number ${blockNumber}`); - } catch (e) { - console.error(`Provider failed: ${url}`); - providersSucceeded = false; - } - } - - return providersSucceeded; -} - -export async function setAndVerifyRpcUrls( +/** + * Set the RPC URLs for the given chain in the given environment interactively. + * Includes an interactive experience for selecting new RPC URLs, confirming the change, + * updating the secret, and refreshing dependent k8s resources. + * @param environment The environment to set the RPC URLs in + * @param chain The chain to set the RPC URLs for + */ +export async function setRpcUrlsInteractive( environment: string, chain: string, - rpcUrlsArray: string[], ): Promise { - const secretPayload = JSON.stringify(rpcUrlsArray); - try { - await displayCurrentSecrets(environment, chain); - await confirmSetSecrets(environment, chain, secretPayload); - await testProvidersIfNeeded(chain, rpcUrlsArray); + const currentSecrets = await getAndDisplayCurrentSecrets( + environment, + chain, + ); + const newRpcUrls = await inputRpcUrls(chain, currentSecrets); + console.log(`Selected RPC URLs: ${formatRpcUrls(newRpcUrls)}\n`); + + const secretPayload = JSON.stringify(newRpcUrls); + await confirmSetSecretsInteractive(environment, chain, secretPayload); await updateSecretAndDisablePrevious(environment, chain, secretPayload); + await refreshDependentK8sResourcesInteractive( + environment as DeployEnvironment, + chain, + ); } catch (error: any) { console.error( `Error occurred while setting RPC URLs for ${chain}:`, @@ -50,28 +61,167 @@ export async function setAndVerifyRpcUrls( } } -async function displayCurrentSecrets( +async function getAndDisplayCurrentSecrets( environment: string, chain: string, -): Promise { +): Promise { const secretExists = await secretRpcEndpointsExist(environment, chain); if (!secretExists) { console.log( `No secret rpc urls found for ${chain} in ${environment} environment\n`, ); - } else { - const currentSecrets = await getSecretRpcEndpoints(environment, chain); - console.log( - `Current secrets found for ${chain} in ${environment} environment:\n${JSON.stringify( - currentSecrets, - null, - 2, - )}\n`, - ); + return []; + } + + const currentSecrets = await getSecretRpcEndpoints(environment, chain); + console.log( + `Current secrets found for ${chain} in ${environment} environment:\n${formatRpcUrls( + currentSecrets, + )}\n`, + ); + return currentSecrets; +} + +// Copied from @inquirer/prompts as it's not exported :( +type Choice = { + value: Value; + name?: string; + description?: string; + short?: string; + disabled?: boolean | string; + type?: never; +}; + +/** + * Prompt the user to input RPC URLs for the given chain. + * The user can choose to input new URLs, use all registry URLs, or use existing URLs + * from secrets or the registry. + * @param chain The chain to input RPC URLs for + * @param existingUrls The existing RPC URLs for the chain + * @returns The selected RPC URLs + */ +async function inputRpcUrls( + chain: string, + existingUrls: string[], +): Promise { + const selectedUrls: string[] = []; + + const registryUrls = getChain(chain).rpcUrls.map((rpc) => rpc.http); + + const existingUrlChoices: Array> = existingUrls.map( + (url, i) => { + return { + value: url, + name: `${url} (existing index ${i})`, + }; + }, + ); + const registryUrlChoices: Array> = registryUrls.map( + (url, i) => { + return { + value: url, + name: `[PUBLIC] ${url} (registry index ${i})`, + }; + }, + ); + + enum SystemChoice { + ADD_NEW = 'Add new RPC URL', + DONE = 'Done', + USE_REGISTRY_URLS = 'Use all registry URLs', + REMOVE_LAST = 'Remove last RPC URL', } + + const pushSelectedUrl = async (newUrl: string) => { + const providerHealthy = await testProvider(chain, newUrl); + if (!providerHealthy) { + const yes = await confirm({ + message: `Provider at ${newUrl} is not healthy. Do you want to continue adding it?\n`, + }); + if (!yes) { + console.log('Skipping provider'); + return; + } + } + selectedUrls.push(newUrl); + }; + const separator = new Separator('-----'); + + while (true) { + console.log(`Selected RPC URLs: ${formatRpcUrls(selectedUrls)}\n`); + + // Sadly @inquirer/prompts doesn't expose the types needed here + const choices: (Separator | Choice)[] = [ + ...[SystemChoice.DONE, SystemChoice.ADD_NEW].map((choice) => ({ + value: choice, + })), + { + value: SystemChoice.USE_REGISTRY_URLS, + name: `Use registry URLs (${JSON.stringify(registryUrls)})`, + }, + separator, + ...existingUrlChoices, + separator, + ...registryUrlChoices, + ]; + if (selectedUrls.length > 0) { + choices.push(separator); + choices.push({ + value: SystemChoice.REMOVE_LAST, + }); + } + + const selection = await select({ + message: 'Select RPC URL', + choices, + pageSize: 30, + }); + + if (selection === SystemChoice.DONE) { + console.log('Done selecting RPC URLs'); + break; + } else if (selection === SystemChoice.ADD_NEW) { + const newUrl = await input({ + message: 'Enter new RPC URL', + }); + await pushSelectedUrl(newUrl); + } else if (selection === SystemChoice.REMOVE_LAST) { + selectedUrls.pop(); + } else if (selection === SystemChoice.USE_REGISTRY_URLS) { + for (const url of registryUrls) { + await pushSelectedUrl(url); + } + console.log('Added all registry URLs'); + break; + } else { + // If none of the above, a URL was chosen + + let index = existingUrlChoices.findIndex( + (choice) => choice.value === selection, + ); + if (index !== -1) { + existingUrlChoices.splice(index, 1); + } + + index = registryUrlChoices.findIndex( + (choice) => choice.value === selection, + ); + if (index !== -1) { + registryUrlChoices.splice(index, 1); + } + + await pushSelectedUrl(selection); + } + console.log('========'); + } + + return selectedUrls; } -async function confirmSetSecrets( +/** + * A prompt to confirm setting the given secret payload for the given chain in the given environment. + */ +async function confirmSetSecretsInteractive( environment: string, chain: string, secretPayload: string, @@ -86,33 +236,13 @@ async function confirmSetSecrets( } } -async function testProvidersIfNeeded( - chain: string, - rpcUrlsArray: string[], -): Promise { - if (isEthereumProtocolChain(chain)) { - console.log('\nTesting providers...'); - const testPassed = await testProviders(rpcUrlsArray); - if (!testPassed) { - console.error('At least one provider failed.'); - throw new Error('Provider test failed'); - } - - const confirmedProviders = await confirm({ - message: `All providers passed. Do you want to continue setting the secret?\n`, - }); - - if (!confirmedProviders) { - console.log('Continuing without setting secret.'); - throw new Error('User cancelled operation after provider test'); - } - } else { - console.log( - 'Skipping provider testing as chain is not an Ethereum protocol chain.', - ); - } -} - +/** + * Non-interactively updates the secret for the given chain in the given environment with the given payload. + * Disables the previous version of the secret if it exists. + * @param environment The environment to update the secret in + * @param chain The chain to update the secret for + * @param secretPayload The new secret payload to set + */ async function updateSecretAndDisablePrevious( environment: string, chain: string, @@ -139,3 +269,129 @@ async function updateSecretAndDisablePrevious( } } } + +/** + * Interactively refreshes dependent k8s resources for the given chain in the given environment. + * Allows for helm releases to be selected for refreshing. Refreshing involves first deleting + * secrets, expecting them to be recreated by external-secrets, and then deleting pods to restart + * them with the new secrets. + * @param environment The environment to refresh resources in + * @param chain The chain to refresh resources for + */ +async function refreshDependentK8sResourcesInteractive( + environment: DeployEnvironment, + chain: string, +): Promise { + const cont = await confirm({ + message: `Do you want to refresh dependent k8s resources for ${chain} in ${environment}?`, + }); + if (!cont) { + console.log('Skipping refresh of k8s resources'); + return; + } + + const envConfig = getEnvironmentConfig(environment); + const contextHelmManagers: [string, HelmManager][] = []; + const pushContextHelmManager = ( + context: string, + manager: HelmManager, + ) => { + contextHelmManagers.push([context, manager]); + }; + for (const [context, agentConfig] of Object.entries(envConfig.agents)) { + if (agentConfig.relayer) { + pushContextHelmManager(context, new RelayerHelmManager(agentConfig)); + } + if (agentConfig.validators) { + pushContextHelmManager( + context, + new ValidatorHelmManager(agentConfig, chain), + ); + } + if (agentConfig.scraper) { + pushContextHelmManager(context, new ScraperHelmManager(agentConfig)); + } + + if (context == Contexts.Hyperlane) { + // Key funder + pushContextHelmManager( + context, + KeyFunderHelmManager.forEnvironment(environment), + ); + + // Kathy - only expected to be running as a long-running service in the + // Hyperlane context + if (envConfig.helloWorld?.hyperlane?.addresses[chain]) { + pushContextHelmManager( + context, + KathyHelmManager.forEnvironment(environment, context), + ); + } + } + } + + const selection = await checkbox({ + message: + 'Select deployments to refresh (update secrets & restart any pods)', + choices: contextHelmManagers.map(([context, helmManager], i) => ({ + name: `${helmManager.helmReleaseName} (context: ${context})`, + value: i, + // By default, all deployments are selected + checked: true, + })), + }); + const selectedHelmManagers = contextHelmManagers + .map(([_, m]) => m) + .filter((_, m) => selection.includes(m)); + + await refreshK8sResources( + selectedHelmManagers, + K8sResourceType.SECRET, + environment, + ); + await refreshK8sResources( + selectedHelmManagers, + K8sResourceType.POD, + environment, + ); +} + +/** + * Test the provider at the given URL, returning false if the provider is unhealthy + * or related to a different chain. No-op for non-Ethereum chains. + * @param chain The chain to test the provider for + * @param url The URL of the provider + */ +async function testProvider(chain: ChainName, url: string): Promise { + const chainMetadata = getChain(chain); + if (chainMetadata.protocol !== ProtocolType.Ethereum) { + console.log(`Skipping provider test for non-Ethereum chain ${chain}`); + return true; + } + + const provider = new ethers.providers.StaticJsonRpcProvider(url); + const expectedChainId = chainMetadata.chainId; + + try { + const [blockNumber, providerNetwork] = await timeout( + Promise.all([provider.getBlockNumber(), provider.getNetwork()]), + 5000, + ); + if (providerNetwork.chainId !== expectedChainId) { + throw new Error( + `Expected chainId ${expectedChainId}, got ${providerNetwork.chainId}`, + ); + } + console.log( + `✅ Valid provider for ${url} with block number ${blockNumber}`, + ); + return true; + } catch (e) { + console.error(`Provider failed: ${url}\nError: ${e}`); + return false; + } +} + +function formatRpcUrls(rpcUrls: string[]): string { + return JSON.stringify(rpcUrls, null, 2); +} diff --git a/typescript/infra/src/warp/helm.ts b/typescript/infra/src/warp/helm.ts new file mode 100644 index 000000000..779a53584 --- /dev/null +++ b/typescript/infra/src/warp/helm.ts @@ -0,0 +1,45 @@ +import path from 'path'; + +import { DeployEnvironment } from '../../src/config/environment.js'; +import { HelmManager } from '../../src/utils/helm.js'; +import { getInfraPath } from '../../src/utils/utils.js'; + +export class WarpRouteMonitorHelmManager extends HelmManager { + readonly helmChartPath: string = path.join( + getInfraPath(), + './helm/warp-routes', + ); + + constructor( + readonly configFilePath: string, + readonly runEnv: DeployEnvironment, + ) { + super(); + } + + async helmValues() { + const pathRelativeToMonorepoRoot = this.configFilePath.includes( + 'typescript/infra', + ) + ? this.configFilePath + : path.join('typescript/infra', this.configFilePath); + return { + image: { + repository: 'gcr.io/abacus-labs-dev/hyperlane-monorepo', + tag: '38ff1c4-20240823-093934', + }, + configFilePath: pathRelativeToMonorepoRoot, + fullnameOverride: this.helmReleaseName, + }; + } + + get namespace() { + return this.runEnv; + } + + get helmReleaseName(): string { + const match = this.configFilePath.match(/\/([^/]+)-deployments\.yaml$/); + const name = match ? match[1] : this.configFilePath; + return `hyperlane-warp-route-${name.toLowerCase()}`; // helm requires lower case release names + } +} diff --git a/yarn.lock b/yarn.lock index 3352cdfce..a619bd60b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7460,6 +7460,7 @@ __metadata: "@hyperlane-xyz/registry": "npm:2.5.0" "@hyperlane-xyz/sdk": "npm:5.1.0" "@hyperlane-xyz/utils": "npm:5.1.0" + "@inquirer/prompts": "npm:^5.3.8" "@nomiclabs/hardhat-ethers": "npm:^2.2.3" "@nomiclabs/hardhat-etherscan": "npm:^3.0.3" "@nomiclabs/hardhat-waffle": "npm:^2.0.6" @@ -7646,6 +7647,19 @@ __metadata: languageName: node linkType: hard +"@inquirer/checkbox@npm:^2.4.7": + version: 2.4.7 + resolution: "@inquirer/checkbox@npm:2.4.7" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.2" + ansi-escapes: "npm:^4.3.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 9bc0d6e9d6db90bcda3771d6b96e885e8c4e1f03d96a4fcd04b4eab2fafbecfafbced7a5cc24eca73f677452f9e354505f9cfb79a884dcf06772550845014d6f + languageName: node + linkType: hard + "@inquirer/confirm@npm:^2.0.6": version: 2.0.6 resolution: "@inquirer/confirm@npm:2.0.6" @@ -7657,6 +7671,16 @@ __metadata: languageName: node linkType: hard +"@inquirer/confirm@npm:^3.1.22": + version: 3.1.22 + resolution: "@inquirer/confirm@npm:3.1.22" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + checksum: 14e547ae3194c6447d41bb87135c03aa5598fd340fced19e4e8bae1be4ae54a9ad3cf335a9c3c6dc54e2ffb7928319e0f4b428531b8ce720cd23d2444292ca36 + languageName: node + linkType: hard + "@inquirer/core@npm:^3.0.0": version: 3.0.0 resolution: "@inquirer/core@npm:3.0.0" @@ -7679,6 +7703,27 @@ __metadata: languageName: node linkType: hard +"@inquirer/core@npm:^9.0.10": + version: 9.0.10 + resolution: "@inquirer/core@npm:9.0.10" + dependencies: + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.2" + "@types/mute-stream": "npm:^0.0.4" + "@types/node": "npm:^22.1.0" + "@types/wrap-ansi": "npm:^3.0.0" + ansi-escapes: "npm:^4.3.2" + cli-spinners: "npm:^2.9.2" + cli-width: "npm:^4.1.0" + mute-stream: "npm:^1.0.0" + signal-exit: "npm:^4.1.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^6.2.0" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 1bcb1deb7393d78f2dac5b8774d10692ad50b70e3ebc24684d13259d0c6c863dd1bce8ab4d4a806a6e90d5a2517aa8f9981993b1a256c9be68d9ef5e748481c6 + languageName: node + linkType: hard + "@inquirer/editor@npm:^1.2.4": version: 1.2.4 resolution: "@inquirer/editor@npm:1.2.4" @@ -7691,6 +7736,17 @@ __metadata: languageName: node linkType: hard +"@inquirer/editor@npm:^2.1.22": + version: 2.1.22 + resolution: "@inquirer/editor@npm:2.1.22" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + external-editor: "npm:^3.1.0" + checksum: d36255567c88ea48bf1071b00c502d6a32bc1402966db4f9ae1be59d41d64d11e02111317d880d0bdc42fbfb1b819240fb229c89b07dfb804a6d5fb176ab8bb0 + languageName: node + linkType: hard + "@inquirer/expand@npm:^1.1.5": version: 1.1.5 resolution: "@inquirer/expand@npm:1.1.5" @@ -7703,6 +7759,24 @@ __metadata: languageName: node linkType: hard +"@inquirer/expand@npm:^2.1.22": + version: 2.1.22 + resolution: "@inquirer/expand@npm:2.1.22" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: f997ba916d3ddcc6e2563158805e2ae7a7a6f98e24cf0a08e23d4101b7d78f78e7dce28e648b85ca7f41759eeefdf1c6f6abf2bce0f041fbda54aacf68522454 + languageName: node + linkType: hard + +"@inquirer/figures@npm:^1.0.5": + version: 1.0.5 + resolution: "@inquirer/figures@npm:1.0.5" + checksum: 60a51b2cdef03c89be25071c23d8c4ae427c56d8ac1b00bf054ca7be446674adc4edd66c15465fe6a81ff0726b024bf37f8a2903a8387ef968d33058da3e7a15 + languageName: node + linkType: hard + "@inquirer/input@npm:^1.2.5": version: 1.2.5 resolution: "@inquirer/input@npm:1.2.5" @@ -7714,6 +7788,26 @@ __metadata: languageName: node linkType: hard +"@inquirer/input@npm:^2.2.9": + version: 2.2.9 + resolution: "@inquirer/input@npm:2.2.9" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + checksum: 9d0c97da9cc6972d4fb5bcb077e00e581aae90f6891d33c1c5e2f0324023c1772c6d5b03cd30ec7d4f71d22791d38bf45c47bafbe7dd9f74446693e7b120a2b0 + languageName: node + linkType: hard + +"@inquirer/number@npm:^1.0.10": + version: 1.0.10 + resolution: "@inquirer/number@npm:1.0.10" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + checksum: 0f9323b581e1c35ee8fbf2acde301c3e354896aeac83f6854e9672575090e0d092d19aadadb3477659079c403e63a3206bf668dd4c87e86834f775744f57c955 + languageName: node + linkType: hard + "@inquirer/password@npm:^1.1.5": version: 1.1.5 resolution: "@inquirer/password@npm:1.1.5" @@ -7725,6 +7819,17 @@ __metadata: languageName: node linkType: hard +"@inquirer/password@npm:^2.1.22": + version: 2.1.22 + resolution: "@inquirer/password@npm:2.1.22" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + ansi-escapes: "npm:^4.3.2" + checksum: ce4e7c268f267c7436cf3a1b2890a9c92fddc2928bbe141d48f2f5a5daedbb3a2c601e44b0fe4e255792676ed241118ba69756b0d0b7d4492e0b7ee8fc548b90 + languageName: node + linkType: hard + "@inquirer/prompts@npm:^3.0.0": version: 3.0.0 resolution: "@inquirer/prompts@npm:3.0.0" @@ -7742,6 +7847,24 @@ __metadata: languageName: node linkType: hard +"@inquirer/prompts@npm:^5.3.8": + version: 5.3.8 + resolution: "@inquirer/prompts@npm:5.3.8" + dependencies: + "@inquirer/checkbox": "npm:^2.4.7" + "@inquirer/confirm": "npm:^3.1.22" + "@inquirer/editor": "npm:^2.1.22" + "@inquirer/expand": "npm:^2.1.22" + "@inquirer/input": "npm:^2.2.9" + "@inquirer/number": "npm:^1.0.10" + "@inquirer/password": "npm:^2.1.22" + "@inquirer/rawlist": "npm:^2.2.4" + "@inquirer/search": "npm:^1.0.7" + "@inquirer/select": "npm:^2.4.7" + checksum: e60eba0d64590c96fed722107962f433fbd8ff13f5d8a3ad6ba56964db69c8bc6b91a439b4e90209184090aacf73d84b0504e8c5a6a0f778ced70deb580ac1cd + languageName: node + linkType: hard + "@inquirer/rawlist@npm:^1.2.5": version: 1.2.5 resolution: "@inquirer/rawlist@npm:1.2.5" @@ -7753,6 +7876,29 @@ __metadata: languageName: node linkType: hard +"@inquirer/rawlist@npm:^2.2.4": + version: 2.2.4 + resolution: "@inquirer/rawlist@npm:2.2.4" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/type": "npm:^1.5.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: dd9d34a5cca081d53a9798cdfed2fdb61455dcfa856f54bc036dc5f57aceb95a7484487632c157bdba75e50de24990ebb3bb178ee765b8c0a735ff61b29cebf4 + languageName: node + linkType: hard + +"@inquirer/search@npm:^1.0.7": + version: 1.0.7 + resolution: "@inquirer/search@npm:1.0.7" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 3cd401cc1a7b01772e0e50ee27a0560cc647900f475d28a4f9b07843d4a85e1555c6adc1d7bc38ad2ef3546c524ca82c60272490d0bb159632c03cbe01f52bb1 + languageName: node + linkType: hard + "@inquirer/select@npm:^1.2.5": version: 1.2.5 resolution: "@inquirer/select@npm:1.2.5" @@ -7766,6 +7912,19 @@ __metadata: languageName: node linkType: hard +"@inquirer/select@npm:^2.4.7": + version: 2.4.7 + resolution: "@inquirer/select@npm:2.4.7" + dependencies: + "@inquirer/core": "npm:^9.0.10" + "@inquirer/figures": "npm:^1.0.5" + "@inquirer/type": "npm:^1.5.2" + ansi-escapes: "npm:^4.3.2" + yoctocolors-cjs: "npm:^2.1.2" + checksum: 854a3d0393073913f9bd3bf2e4ec7b8d114dfb48308a0a6698cf5c2c627da2700db5bdb69d054eaec89bd4e52a1274e493fa78d4fa26a5893972d91825456047 + languageName: node + linkType: hard + "@inquirer/type@npm:^1.1.1": version: 1.1.1 resolution: "@inquirer/type@npm:1.1.1" @@ -7773,6 +7932,15 @@ __metadata: languageName: node linkType: hard +"@inquirer/type@npm:^1.5.2": + version: 1.5.2 + resolution: "@inquirer/type@npm:1.5.2" + dependencies: + mute-stream: "npm:^1.0.0" + checksum: 90d9203b5d7da8530e210c5421630b577f24554c8b683a4b45ea0f5c6a89c451771170aa34f2b62ca57e4be4de41d6761c941475e25c54c82b527c05644f181f + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -12600,6 +12768,15 @@ __metadata: languageName: node linkType: hard +"@types/mute-stream@npm:^0.0.4": + version: 0.0.4 + resolution: "@types/mute-stream@npm:0.0.4" + dependencies: + "@types/node": "npm:*" + checksum: af8d83ad7b68ea05d9357985daf81b6c9b73af4feacb2f5c2693c7fd3e13e5135ef1bd083ce8d5bdc8e97acd28563b61bb32dec4e4508a8067fcd31b8a098632 + languageName: node + linkType: hard + "@types/node-fetch@npm:^2.6.1": version: 2.6.9 resolution: "@types/node-fetch@npm:2.6.9" @@ -12696,6 +12873,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^22.1.0": + version: 22.4.0 + resolution: "@types/node@npm:22.4.0" + dependencies: + undici-types: "npm:~6.19.2" + checksum: 0b6ccc86856b8473f4d536491edc2ba21386d194219ee84024ef2b2ab054296f0b37a4f52719af797227132853cff065977992e353754a195cd86aea2e128cc7 + languageName: node + linkType: hard + "@types/node@npm:^8.0.0": version: 8.10.66 resolution: "@types/node@npm:8.10.66" @@ -15480,7 +15666,7 @@ __metadata: languageName: node linkType: hard -"cli-spinners@npm:^2.5.0": +"cli-spinners@npm:^2.5.0, cli-spinners@npm:^2.9.2": version: 2.9.2 resolution: "cli-spinners@npm:2.9.2" checksum: a0a863f442df35ed7294424f5491fa1756bd8d2e4ff0c8736531d886cec0ece4d85e8663b77a5afaf1d296e3cbbebff92e2e99f52bbea89b667cbe789b994794 @@ -15561,6 +15747,13 @@ __metadata: languageName: node linkType: hard +"cli-width@npm:^4.1.0": + version: 4.1.0 + resolution: "cli-width@npm:4.1.0" + checksum: b58876fbf0310a8a35c79b72ecfcf579b354e18ad04e6b20588724ea2b522799a758507a37dfe132fafaf93a9922cafd9514d9e1598e6b2cd46694853aed099f + languageName: node + linkType: hard + "cliui@npm:^5.0.0": version: 5.0.0 resolution: "cliui@npm:5.0.0" @@ -30684,6 +30877,13 @@ __metadata: languageName: node linkType: hard +"yoctocolors-cjs@npm:^2.1.2": + version: 2.1.2 + resolution: "yoctocolors-cjs@npm:2.1.2" + checksum: d731e3ba776a0ee19021d909787942933a6c2eafb2bbe85541f0c59aa5c7d475ce86fcb860d5803105e32244c3dd5ba875b87c4c6bf2d6f297da416aa54e556f + languageName: node + linkType: hard + "zksync-web3@npm:^0.14.3": version: 0.14.4 resolution: "zksync-web3@npm:0.14.4"