diff --git a/.editorconfig b/.editorconfig index 1ed453a..303d029 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,7 +4,7 @@ root = true end_of_line = lf insert_final_newline = true -[*.{js,json,yml}] +[*.{ts,js,json,yml}] charset = utf-8 indent_style = space indent_size = 2 diff --git a/.gitmodules b/.gitmodules index c3d3a1b..e127dc2 100644 --- a/.gitmodules +++ b/.gitmodules @@ -30,3 +30,7 @@ path = ofchain/oracle-v5 url = git@github.com:lidofinance/lido-oracle.git branch = feat/oracle-v5-devnet + [submodule "ofchain/headwatcher"] + path = ofchain/headwatcher + url = git@github.com:lidofinance/ethereum-head-watcher.git + branch = feature/val-1404-eip-7251-head-watcher-alerts-for-new-el-requests diff --git a/cli/commands/headwatcher/docker-compose.yml b/cli/commands/headwatcher/docker-compose.yml new file mode 100644 index 0000000..e3588e2 --- /dev/null +++ b/cli/commands/headwatcher/docker-compose.yml @@ -0,0 +1,38 @@ +networks: + devnet: + name: ${DOCKER_NETWORK_NAME} + external: true + +services: + stub_alertmanager: + container_name: ethereum-head-watcher-stub-alertmanager + build: ./stub_alertmanager + networks: + - devnet + volumes: + - ${ALERTS_OUTPUT_DIR:-../../../artifacts/headwatcher}:/opt/alerts:rw + environment: + - ALERTS_DIR=/opt/alerts + expose: + - 41288 + + app: + container_name: ethereum-head-watcher + build: ${DOCKER_FILE_PATH} + restart: unless-stopped + networks: + - devnet + deploy: + resources: + limits: + memory: 2g + depends_on: + - stub_alertmanager + environment: + - CONSENSUS_CLIENT_URI=${CONSENSUS_CLIENT_URI} + - EXECUTION_CLIENT_URI=${EXECUTION_CLIENT_URI} + - LIDO_LOCATOR_ADDRESS=${LIDO_LOCATOR_ADDRESS} + - KEYS_SOURCE=keys_api + - KEYS_API_URI=${KEYS_API_URI} + - ALERTMANAGER_URI="http://stub_alertmanager:41288" + - LOG_LEVEL=INFO diff --git a/cli/commands/headwatcher/down.ts b/cli/commands/headwatcher/down.ts new file mode 100644 index 0000000..e7742cd --- /dev/null +++ b/cli/commands/headwatcher/down.ts @@ -0,0 +1,26 @@ +import {Command} from "@oclif/core"; +import {execa} from "execa"; +import {getEnv} from "../../lib/headwatcher/env.js"; + +export default class HeadwatcherDown extends Command { + static description = "Shutdown Ethereum Head Watcher"; + + async run() { + this.log("Stopping Ethereum Head Watcher..."); + + try { + await execa( + "docker", + ["compose", "-f", "docker-compose.yml", "down", "-v"], + { + stdio: "inherit", + cwd: import.meta.dirname, + env: await getEnv() + } + ); + this.log("Ethereum Head Watcher stopped successfully."); + } catch (error: any) { + this.error(`Failed to stop Ethereum Head Watcher: ${error.message}`); + } + } +} diff --git a/cli/commands/headwatcher/logs.ts b/cli/commands/headwatcher/logs.ts new file mode 100644 index 0000000..5042189 --- /dev/null +++ b/cli/commands/headwatcher/logs.ts @@ -0,0 +1,19 @@ +import {Command} from "@oclif/core"; +import {execa} from "execa"; +import {getEnv} from "../../lib/headwatcher/env.js"; + +export default class HeadwatcherLogs extends Command { + static description = "Output logs of Ethereum Head Watcher"; + + async run() { + await execa( + "docker", + ["compose", "-f", "docker-compose.yml", "logs", "-f"], + { + stdio: "inherit", + cwd: import.meta.dirname, + env: await getEnv() + } + ); + } +} diff --git a/cli/commands/headwatcher/stub_alertmanager/Dockerfile b/cli/commands/headwatcher/stub_alertmanager/Dockerfile new file mode 100644 index 0000000..76d6109 --- /dev/null +++ b/cli/commands/headwatcher/stub_alertmanager/Dockerfile @@ -0,0 +1,5 @@ +FROM golang:1.23.4 +WORKDIR /app +COPY stub.go ./ +EXPOSE 41288 +CMD ["go", "run", "stub.go"] diff --git a/cli/commands/headwatcher/stub_alertmanager/stub.go b/cli/commands/headwatcher/stub_alertmanager/stub.go new file mode 100644 index 0000000..2e2bf7b --- /dev/null +++ b/cli/commands/headwatcher/stub_alertmanager/stub.go @@ -0,0 +1,50 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" +) + +func main() { + dir := os.Getenv("ALERTS_DIR") + if dir == "" { + fmt.Println("Environment variable ALERTS_DIR is not set") + os.Exit(1) + } + + http.HandleFunc("/api/v1/alerts", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Only POST method is allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusInternalServerError) + return + } + defer r.Body.Close() + + filename := fmt.Sprintf("%s.json", time.Now().Format("20060102_150405")) + filepath := filepath.Join(dir, filename) + + if err := os.WriteFile(filepath, body, 0644); err != nil { + message := fmt.Sprintf("Failed to write file: %s", err.Error()) + http.Error(w, message, http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + fmt.Fprintf(w, "Alert saved to %s\n", filepath) + }) + + port := ":41288" + fmt.Printf("Starting server on port %s\n", port) + if err := http.ListenAndServe(port, nil); err != nil { + fmt.Printf("Server failed: %s\n", err) + } +} diff --git a/cli/commands/headwatcher/up.ts b/cli/commands/headwatcher/up.ts new file mode 100644 index 0000000..4565780 --- /dev/null +++ b/cli/commands/headwatcher/up.ts @@ -0,0 +1,26 @@ +import {Command} from "@oclif/core"; +import {execa} from "execa"; +import {getEnv} from "../../lib/headwatcher/env.js"; + +export default class HeadwatcherUp extends Command { + static description = "Start Ethereum Head Watcher"; + + async run() { + this.log("Starting Ethereum Head Watcher..."); + + try { + await execa( + "docker", + ["compose", "-f", "docker-compose.yml", "up", "--build", "-d"], + { + stdio: "inherit", + cwd: import.meta.dirname, + env: await getEnv() + } + ); + this.log("Ethereum Head Watcher started successfully."); + } catch (error: any) { + this.error(`Failed to start Ethereum Head Watcher: ${error.message}`); + } + } +} diff --git a/cli/config/index.ts b/cli/config/index.ts index 132351f..fa7d705 100644 --- a/cli/config/index.ts +++ b/cli/config/index.ts @@ -1,8 +1,8 @@ -import { readFileSync } from "fs"; +import {readFileSync} from "fs"; import path from "path"; import YAML from "yaml"; -import { JsonDb } from "../lib/state/index.js"; -import { sharedWallet } from "./shared-wallet.js"; +import {JsonDb} from "../lib/state/index.js"; +import {sharedWallet} from "./shared-wallet.js"; import assert from "assert"; const CHAIN_ID = "32382"; @@ -135,6 +135,10 @@ export const baseConfig = { root: BLOCKSCOUT_ROOT, }, }, + headwatcher: { + root: path.join(OFCHAIN_ROOT, "headwatcher"), + alertsOutputPath: path.join(ARTIFACTS_PATH, "headwatcher"), + }, onchain: { lido: { core: { diff --git a/cli/lib/headwatcher/env.ts b/cli/lib/headwatcher/env.ts new file mode 100644 index 0000000..10f72ed --- /dev/null +++ b/cli/lib/headwatcher/env.ts @@ -0,0 +1,27 @@ +import {baseConfig, jsonDb} from "../../config/index.js"; +import {getLidoLocatorAddress} from "../lido/index.js"; +import fs from "node:fs"; + +export async function getEnv() { + const state = await jsonDb.read(); + const {network} = baseConfig; + + const el = state.network?.binding?.elNodesPrivate?.[0] ?? network.el.url; + const cl = state.network?.binding?.clNodesPrivate?.[0] ?? network.cl.url; + const name = state.network?.binding?.name ?? network.name; + const locator = await getLidoLocatorAddress(); + + if (!fs.existsSync(baseConfig.headwatcher.alertsOutputPath)) { + fs.mkdirSync(baseConfig.headwatcher.alertsOutputPath, {recursive: true}); + } + + return { + DOCKER_FILE_PATH: baseConfig.headwatcher.root, + ALERTS_OUTPUT_DIR: baseConfig.headwatcher.alertsOutputPath, + CONSENSUS_CLIENT_URI: cl, + EXECUTION_CLIENT_URI: el, + LIDO_LOCATOR_ADDRESS: locator, + DOCKER_NETWORK_NAME: 'kt-' + name, + KEYS_API_URI: 'http://localhost:9030', + } +}