diff --git a/.github/workflows/rebalancer-docker.yml b/.github/workflows/rebalancer-docker.yml new file mode 100644 index 00000000000..ead9771adb2 --- /dev/null +++ b/.github/workflows/rebalancer-docker.yml @@ -0,0 +1,125 @@ +name: Build and Push Rebalancer Image to GCR +on: + push: + branches: [main] + tags: + - '**' + pull_request: + paths: + - 'typescript/rebalancer/**' + - '.github/workflows/rebalancer-docker.yml' + workflow_dispatch: + inputs: + include_arm64: + description: 'Include arm64 in the build' + required: false + default: 'false' + +concurrency: + group: build-push-rebalancer-${{ github.ref }} + cancel-in-progress: true + +jobs: + check-env: + runs-on: ubuntu-latest + outputs: + gcloud-service-key: ${{ steps.gcloud-service-key.outputs.defined }} + steps: + - id: gcloud-service-key + env: + GCLOUD_SERVICE_KEY: ${{ secrets.GCLOUD_SERVICE_KEY }} + if: "${{ env.GCLOUD_SERVICE_KEY != '' }}" + run: echo "defined=true" >> $GITHUB_OUTPUT + + build-and-push-to-gcr: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + pull-requests: write + + needs: [check-env] + if: needs.check-env.outputs.gcloud-service-key == 'true' + + steps: + - name: Generate GitHub App Token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HYPER_GONK_APP_ID }} + private-key: ${{ secrets.HYPER_GONK_PRIVATE_KEY }} + + - uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + submodules: recursive + fetch-depth: 0 + + - name: Generate tag data + id: taggen + run: | + echo "TAG_DATE=$(date +'%Y%m%d-%H%M%S')" >> $GITHUB_OUTPUT + echo "TAG_SHA=$(echo '${{ github.event.pull_request.head.sha || github.sha }}' | cut -b 1-7)" >> $GITHUB_OUTPUT + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: | + gcr.io/abacus-labs-dev/hyperlane-rebalancer + tags: | + type=ref,event=branch + type=ref,event=pr + type=raw,value=${{ steps.taggen.outputs.TAG_SHA }}-${{ steps.taggen.outputs.TAG_DATE }} + + - name: Set up Depot CLI + uses: depot/setup-action@v1 + + - name: Login to GCR + uses: docker/login-action@v3 + with: + registry: gcr.io + username: _json_key + password: ${{ secrets.GCLOUD_SERVICE_KEY }} + + - name: Determine platforms + id: determine-platforms + run: | + if [ "${{ github.event.inputs.include_arm64 }}" == "true" ]; then + echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT + else + echo "platforms=linux/amd64" >> $GITHUB_OUTPUT + fi + + - name: Get Foundry version + id: foundry-version + run: | + FOUNDRY_VERSION=$(cat solidity/.foundryrc) + echo "FOUNDRY_VERSION=$FOUNDRY_VERSION" >> $GITHUB_OUTPUT + + - name: Build and push + id: build + uses: depot/build-push-action@v1 + with: + project: 3cpjhx94qv + context: ./ + file: ./typescript/rebalancer/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + platforms: ${{ steps.determine-platforms.outputs.platforms }} + build-args: | + FOUNDRY_VERSION=${{ steps.foundry-version.outputs.FOUNDRY_VERSION }} + SERVICE_VERSION=${{ steps.taggen.outputs.TAG_SHA }}-${{ steps.taggen.outputs.TAG_DATE }} + + - name: Comment image tags on PR + if: github.event_name == 'pull_request' + uses: ./.github/actions/docker-image-comment + with: + comment_tag: rebalancer-docker-image + image_name: Rebalancer Docker Image + emoji: ♻️ + image_tags: ${{ steps.meta.outputs.tags }} + pr_number: ${{ github.event.pull_request.number }} + github_token: ${{ steps.generate-token.outputs.token }} + job_status: ${{ job.status }} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3d6101b4700..fb7f873a80c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -147,6 +147,9 @@ catalogs: '@typescript-eslint/parser': specifier: ^8.1.6 version: 8.47.0 + '@vercel/ncc': + specifier: ^0.38.3 + version: 0.38.4 asn1.js: specifier: ^5.4.1 version: 5.4.1 @@ -798,7 +801,7 @@ importers: specifier: 'catalog:' version: 8.47.0(eslint@9.31.0(jiti@2.6.1))(typescript@5.8.3) '@vercel/ncc': - specifier: ^0.38.3 + specifier: 'catalog:' version: 0.38.4 ansi-escapes: specifier: ^7.0.0 @@ -1781,6 +1784,9 @@ importers: '@types/sinon': specifier: 'catalog:' version: 17.0.4 + '@vercel/ncc': + specifier: 'catalog:' + version: 0.38.4 chai: specifier: 'catalog:' version: 4.5.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8041d89eeeb..f70f34a1f4c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -94,6 +94,7 @@ catalog: typescript: 5.8.3 tsx: ^4.19.1 ts-node: ^10.9.2 + '@vercel/ncc': ^0.38.3 # Linting eslint: ^9.31.0 diff --git a/typescript/cli/package.json b/typescript/cli/package.json index d7ddcdbc142..d39e7d8c734 100644 --- a/typescript/cli/package.json +++ b/typescript/cli/package.json @@ -36,7 +36,7 @@ "@types/yargs": "catalog:", "@typescript-eslint/eslint-plugin": "catalog:", "@typescript-eslint/parser": "catalog:", - "@vercel/ncc": "^0.38.3", + "@vercel/ncc": "catalog:", "ansi-escapes": "^7.0.0", "asn1.js": "catalog:", "bignumber.js": "catalog:", diff --git a/typescript/cli/scripts/ncc.post-bundle.mjs b/typescript/cli/scripts/ncc.post-bundle.mjs index 337fe95835f..17c0fcb69de 100644 --- a/typescript/cli/scripts/ncc.post-bundle.mjs +++ b/typescript/cli/scripts/ncc.post-bundle.mjs @@ -31,6 +31,7 @@ async function prepend() { } } catch (err) { console.error('Error processing output file:', err); + process.exit(1); } } diff --git a/typescript/infra/helm/rebalancer/templates/_helpers.tpl b/typescript/infra/helm/rebalancer/templates/_helpers.tpl index 16dbbbf703f..08584d764ab 100644 --- a/typescript/infra/helm/rebalancer/templates/_helpers.tpl +++ b/typescript/infra/helm/rebalancer/templates/_helpers.tpl @@ -70,32 +70,31 @@ The rebalancer container image: {{ .Values.image.repository }}:{{ .Values.image.tag }} imagePullPolicy: IfNotPresent env: - - name: INSTALL_GCP_LOGGER_CLI - value: "true" - name: LOG_FORMAT value: json - - name: REGISTRY_COMMIT - value: {{ .Values.hyperlane.registryCommit }} + - name: LOG_LEVEL + value: info + {{- if .Values.hyperlane.registryUri }} + - name: REGISTRY_URI + value: {{ .Values.hyperlane.registryUri }} + {{- end }} - name: HYP_KEY value: $(REBALANCER_KEY) - name: COINGECKO_API_KEY value: $(COINGECKO_API_KEY) - args: - - "pnpm" - - "-C" - - "typescript/cli" - - "hyperlane" - - "warp" - - "rebalancer" - - "--checkFrequency" - - "60000" - - "--withMetrics" - - "true" - - "--configFile" - - "{{ .Values.hyperlane.rebalancerConfigFile }}" - - "--registry" - - "/hyperlane-registry" + - name: REBALANCER_CONFIG_FILE + value: "/config/rebalancer-config.yaml" + - name: CHECK_FREQUENCY + value: "60000" + - name: WITH_METRICS + value: "true" + - name: MONITOR_ONLY + value: "false" envFrom: - secretRef: name: {{ include "hyperlane.fullname" . }}-secret + volumeMounts: + - name: config + mountPath: /config + readOnly: true {{- end }} diff --git a/typescript/infra/helm/rebalancer/templates/configmap.yaml b/typescript/infra/helm/rebalancer/templates/configmap.yaml new file mode 100644 index 00000000000..5a75a5391b4 --- /dev/null +++ b/typescript/infra/helm/rebalancer/templates/configmap.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "hyperlane.fullname" . }}-config + labels: + {{- include "hyperlane.labels" . | nindent 4 }} +data: + rebalancer-config.yaml: | +{{ .Values.hyperlane.rebalancerConfig | indent 4 }} diff --git a/typescript/infra/helm/rebalancer/templates/stateful-set.yaml b/typescript/infra/helm/rebalancer/templates/stateful-set.yaml index 38b543de771..e3d5b100ca5 100644 --- a/typescript/infra/helm/rebalancer/templates/stateful-set.yaml +++ b/typescript/infra/helm/rebalancer/templates/stateful-set.yaml @@ -18,4 +18,8 @@ spec: spec: containers: {{- include "hyperlane.rebalancer.container" . | indent 6 }} + volumes: + - name: config + configMap: + name: {{ include "hyperlane.fullname" . }}-config diff --git a/typescript/infra/helm/rebalancer/values.yaml b/typescript/infra/helm/rebalancer/values.yaml index 34c48355144..46326345e20 100644 --- a/typescript/infra/helm/rebalancer/values.yaml +++ b/typescript/infra/helm/rebalancer/values.yaml @@ -1,11 +1,11 @@ image: - repository: gcr.io/abacus-labs-dev/hyperlane-monorepo + repository: gcr.io/abacus-labs-dev/hyperlane-rebalancer tag: hyperlane: runEnv: context: hyperlane - registryCommit: '' - rebalancerConfigFile: '' + registryUri: '' + rebalancerConfig: '' withMetrics: true nameOverride: '' fullnameOverride: '' diff --git a/typescript/infra/src/rebalancer/helm.ts b/typescript/infra/src/rebalancer/helm.ts index 1f7ccec2fbf..1d81decabf2 100644 --- a/typescript/infra/src/rebalancer/helm.ts +++ b/typescript/infra/src/rebalancer/helm.ts @@ -1,6 +1,8 @@ +import fs from 'fs'; import path from 'path'; import { fromZodError } from 'zod-validation-error'; +import { DEFAULT_GITHUB_REGISTRY } from '@hyperlane-xyz/registry'; import { type RebalancerConfigFileInput, RebalancerConfigSchema, @@ -22,6 +24,8 @@ export class RebalancerHelmManager extends HelmManager { './helm/rebalancer', ); + private rebalancerConfigContent: string = ''; + constructor( readonly warpRouteId: string, readonly environment: DeployEnvironment, @@ -66,6 +70,12 @@ export class RebalancerHelmManager extends HelmManager { if (isObjEmpty(chains)) { throw new Error('No chains configured'); } + + // Store the config file content for helm values + this.rebalancerConfigContent = fs.readFileSync( + rebalancerConfigFile, + 'utf8', + ); } get namespace() { @@ -73,17 +83,20 @@ export class RebalancerHelmManager extends HelmManager { } async helmValues() { + // Build registry URI with commit embedded in /tree/{commit} format + const registryUri = `${DEFAULT_GITHUB_REGISTRY}/tree/${this.registryCommit}`; + return { image: { - repository: 'gcr.io/abacus-labs-dev/hyperlane-monorepo', - tag: '8da6852-20251215-172511', + repository: 'gcr.io/abacus-labs-dev/hyperlane-rebalancer', + tag: 'be84fc0-20251229-194426', }, withMetrics: this.withMetrics, fullnameOverride: this.helmReleaseName, hyperlane: { runEnv: this.environment, - registryCommit: this.registryCommit, - rebalancerConfigFile: this.rebalancerConfigFile, + registryUri, + rebalancerConfig: this.rebalancerConfigContent, withMetrics: this.withMetrics, }, }; diff --git a/typescript/rebalancer/.gitignore b/typescript/rebalancer/.gitignore index f85021fc8a3..574bce1b9ab 100644 --- a/typescript/rebalancer/.gitignore +++ b/typescript/rebalancer/.gitignore @@ -1,3 +1,4 @@ .env dist cache +bundle diff --git a/typescript/rebalancer/Dockerfile b/typescript/rebalancer/Dockerfile new file mode 100644 index 00000000000..902a80edd6d --- /dev/null +++ b/typescript/rebalancer/Dockerfile @@ -0,0 +1,88 @@ +FROM node:20-slim AS builder + +WORKDIR /hyperlane-monorepo + +RUN apt-get update && apt-get install -y --no-install-recommends \ + git g++ make python3 python3-pip jq bash curl ca-certificates unzip \ + && rm -rf /var/lib/apt/lists/* + +# Install Foundry (Linux binaries) - pinned version for reproducibility +ARG FOUNDRY_VERSION +ARG SERVICE_VERSION=dev +ARG TARGETARCH +SHELL ["/bin/bash", "-c"] +RUN set -o pipefail && \ + ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "arm64" || echo "amd64") && \ + curl --fail -L "https://github.com/foundry-rs/foundry/releases/download/${FOUNDRY_VERSION}/foundry_${FOUNDRY_VERSION}_linux_${ARCH}.tar.gz" | tar -xzC /usr/local/bin forge cast +SHELL ["/bin/sh", "-c"] + +# Copy package.json first for corepack to read packageManager field +COPY package.json ./ +RUN corepack enable && corepack install + +# Copy pnpm config files +COPY pnpm-lock.yaml pnpm-workspace.yaml ./ + +# Copy patches directory (required for pnpm install) +COPY patches ./patches + +# Copy only the packages needed for rebalancer +COPY typescript/rebalancer/package.json ./typescript/rebalancer/ +COPY typescript/deploy-sdk/package.json ./typescript/deploy-sdk/ +COPY typescript/sdk/package.json ./typescript/sdk/ +COPY typescript/provider-sdk/package.json ./typescript/provider-sdk/ +COPY typescript/utils/package.json ./typescript/utils/ +COPY typescript/cosmos-sdk/package.json ./typescript/cosmos-sdk/ +COPY typescript/cosmos-types/package.json ./typescript/cosmos-types/ +COPY typescript/radix-sdk/package.json ./typescript/radix-sdk/ +COPY typescript/tsconfig/package.json ./typescript/tsconfig/ +COPY typescript/eslint-config/package.json ./typescript/eslint-config/ +COPY solidity/package.json ./solidity/ +COPY solhint-plugin/package.json ./solhint-plugin/ +COPY starknet/package.json ./starknet/ + +RUN pnpm install --frozen-lockfile + +# Copy source files +COPY turbo.json ./ +COPY typescript/rebalancer ./typescript/rebalancer +COPY typescript/deploy-sdk ./typescript/deploy-sdk +COPY typescript/sdk ./typescript/sdk +COPY typescript/provider-sdk ./typescript/provider-sdk +COPY typescript/utils ./typescript/utils +COPY typescript/cosmos-sdk ./typescript/cosmos-sdk +COPY typescript/cosmos-types ./typescript/cosmos-types +COPY typescript/radix-sdk ./typescript/radix-sdk +COPY typescript/tsconfig ./typescript/tsconfig +COPY typescript/eslint-config ./typescript/eslint-config +COPY solidity ./solidity +COPY solhint-plugin ./solhint-plugin +COPY starknet ./starknet + +# Build and bundle the rebalancer (ncc creates a single-file bundle with all deps) +RUN pnpm turbo run bundle --filter=@hyperlane-xyz/rebalancer + +# Production stage - minimal Alpine image with just the bundled code +FROM node:20-alpine AS runner + +WORKDIR /app + +RUN apk add --no-cache ca-certificates + +# Copy only the bundled output (includes all dependencies) +COPY --from=builder /hyperlane-monorepo/typescript/rebalancer/bundle ./bundle + +# Install external dependencies that ncc doesn't bundle (dynamic imports) +RUN npm install @google-cloud/pino-logging-gcp-config + +# Environment variables +ARG SERVICE_VERSION=dev +ENV NODE_ENV=production +ENV LOG_LEVEL=info +ENV SERVICE_VERSION=${SERVICE_VERSION} + +# Expose metrics port +EXPOSE 9090 + +# Run the rebalancer service from the bundle +CMD ["node", "bundle/index.js"] diff --git a/typescript/rebalancer/package.json b/typescript/rebalancer/package.json index b8f2f1df54c..f50269e8a72 100644 --- a/typescript/rebalancer/package.json +++ b/typescript/rebalancer/package.json @@ -14,7 +14,8 @@ ], "scripts": { "build": "tsc", - "clean": "rm -rf dist cache", + "bundle": "rm -rf ./bundle && ncc build ./dist/service.js -o bundle -e @google-cloud/pino-logging-gcp-config && node ./scripts/ncc.post-bundle.mjs", + "clean": "rm -rf dist cache bundle", "dev": "tsc --watch", "lint": "eslint -c ./eslint.config.mjs ./src", "prettier": "prettier --write ./src", @@ -50,6 +51,7 @@ "@types/mocha": "catalog:", "@types/node": "catalog:", "@types/sinon": "catalog:", + "@vercel/ncc": "catalog:", "chai": "catalog:", "chai-as-promised": "catalog:", "eslint": "catalog:", diff --git a/typescript/rebalancer/scripts/ncc.post-bundle.mjs b/typescript/rebalancer/scripts/ncc.post-bundle.mjs new file mode 100644 index 00000000000..2eefba7fd6e --- /dev/null +++ b/typescript/rebalancer/scripts/ncc.post-bundle.mjs @@ -0,0 +1,38 @@ +import path from 'path'; +import { fileURLToPath } from 'url'; + +import { readFile, writeFile } from 'fs/promises'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const outputFile = path.join(__dirname, '..', 'bundle', 'index.js'); + +const shebang = '#!/usr/bin/env node'; +const dirnameDef = `import { fileURLToPath } from 'url'; +import path from 'path'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename);`; + +async function prepend() { + try { + const content = await readFile(outputFile, 'utf8'); + + // Assume the 'service.ts' file entry point already has the shebang + if (!content.startsWith(shebang)) { + throw new Error('Missing shebang from service entry point'); + } + + if (!content.includes(dirnameDef)) { + const [, executable] = content.split(shebang); + const newContent = `${shebang}\n${dirnameDef}\n${executable}`; + await writeFile(outputFile, newContent, 'utf8'); + console.log('Adding missing __dirname definition to service executable'); + } + } catch (err) { + console.error('Error processing output file:', err); + process.exit(1); + } +} + +await prepend(); diff --git a/typescript/rebalancer/src/service.ts b/typescript/rebalancer/src/service.ts index 2f93f39df54..df68333ebc4 100644 --- a/typescript/rebalancer/src/service.ts +++ b/typescript/rebalancer/src/service.ts @@ -14,16 +14,15 @@ * - WITH_METRICS: Enable Prometheus metrics (default: "true") * - MONITOR_ONLY: Run in monitor-only mode without executing transactions (default: "false") * - LOG_LEVEL: Logging level (default: "info") - supported by pino + * - REGISTRY_URI: Registry URI for chain metadata. Can include /tree/{commit} to pin version (default: GitHub registry) * * Usage: * node dist/service.js * REBALANCER_CONFIG_FILE=/config/rebalancer.yaml HYP_KEY=0x... node dist/service.js */ import { Wallet } from 'ethers'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; +import { DEFAULT_GITHUB_REGISTRY } from '@hyperlane-xyz/registry'; import { getRegistry } from '@hyperlane-xyz/registry/fs'; import { MultiProtocolProvider, MultiProvider } from '@hyperlane-xyz/sdk'; import { createServiceLogger, rootLogger } from '@hyperlane-xyz/utils'; @@ -31,21 +30,8 @@ import { createServiceLogger, rootLogger } from '@hyperlane-xyz/utils'; import { RebalancerConfig } from './config/RebalancerConfig.js'; import { RebalancerService } from './core/RebalancerService.js'; -function getVersion(): string { - try { - const __dirname = fileURLToPath(new URL('.', import.meta.url)); - const packageJson = JSON.parse( - fs.readFileSync(path.join(__dirname, '../package.json'), 'utf-8'), - ); - return packageJson.version; - } catch (error) { - rootLogger.warn({ error }, 'Could not read version from package.json'); - return 'unknown'; - } -} - async function main(): Promise { - const VERSION = getVersion(); + const VERSION = process.env.SERVICE_VERSION || 'dev'; // Validate required environment variables const configFile = process.env.REBALANCER_CONFIG_FILE; if (!configFile) { @@ -98,13 +84,15 @@ async function main(): Promise { const rebalancerConfig = RebalancerConfig.load(configFile); logger.info('✅ Loaded rebalancer configuration'); - // Initialize registry (uses default registry URIs) + // Initialize registry (uses env var or defaults to GitHub registry) + // For GitHub registries, REGISTRY_URI can include /tree/{commit} to pin to a specific version + const registryUri = process.env.REGISTRY_URI || DEFAULT_GITHUB_REGISTRY; const registry = getRegistry({ - registryUris: [], - enableProxy: false, + registryUris: [registryUri], + enableProxy: true, logger: rootLogger, }); - logger.info('✅ Initialized registry'); + logger.info({ registryUri }, '✅ Initialized registry'); // Get chain metadata from registry const chainMetadata = await registry.getMetadata(); @@ -142,13 +130,18 @@ async function main(): Promise { // Start the service await service.start(); } catch (error) { - logger.error({ error }, 'Failed to start rebalancer service'); + const err = error as Error; + logger.error( + { error: err.message, stack: err.stack }, + 'Failed to start rebalancer service', + ); process.exit(1); } } // Run the service main().catch((error) => { - rootLogger.error({ error }, 'Fatal error'); + const err = error as Error; + rootLogger.error({ error: err.message, stack: err.stack }, 'Fatal error'); process.exit(1); }); diff --git a/typescript/rebalancer/turbo.json b/typescript/rebalancer/turbo.json new file mode 100644 index 00000000000..eda75172045 --- /dev/null +++ b/typescript/rebalancer/turbo.json @@ -0,0 +1,9 @@ +{ + "extends": ["//"], + "tasks": { + "bundle": { + "dependsOn": ["build"], + "outputs": ["bundle/**"] + } + } +} diff --git a/typescript/utils/src/fs/utils.ts b/typescript/utils/src/fs/utils.ts index c8f6f463805..5d7014bec7a 100644 --- a/typescript/utils/src/fs/utils.ts +++ b/typescript/utils/src/fs/utils.ts @@ -25,11 +25,12 @@ export function resolvePath(filePath: string): string { /** * Checks if a path points to an existing file. + * Uses statSync to follow symlinks (important for ConfigMap mounts in Kubernetes). */ export function isFile(filepath: string): boolean { if (!filepath) return false; try { - return fs.existsSync(filepath) && fs.lstatSync(filepath).isFile(); + return fs.existsSync(filepath) && fs.statSync(filepath).isFile(); } catch { return false; }