From 67a4a8c8e801d6bafbcd4483b713c629ad7fc029 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:54:53 +0700 Subject: [PATCH] Scaffold hatchet worker service --- .env.example | 9 ++ .gitignore | 2 + Dockerfile | 13 +++ README.md | 10 ++- package-lock.json | 27 ++++++ package.json | 13 +++ scripts/load-vault-env.sh | 170 ++++++++++++++++++++++++++++++++++++++ src/worker.js | 17 ++++ 8 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 package-lock.json create mode 100644 package.json create mode 100755 scripts/load-vault-env.sh create mode 100644 src/worker.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..44d99d5 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +VAULT_ENABLED=auto +VAULT_ADDR= +VAULT_TOKEN= +VAULT_KV_MOUNT=secret +VAULT_SHARED_PATH= +VAULT_PROJECT_PATH= + +HATCHET_CLIENT_TOKEN= +HATCHET_CLIENT_HOST_PORT=fregat-hatchet-engine:7070 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37d7e73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4344864 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-bookworm-slim + +WORKDIR /app + +RUN apt-get update -y && apt-get install -y --no-install-recommends curl jq ca-certificates && rm -rf /var/lib/apt/lists/* + +COPY package*.json ./ +RUN npm ci + +COPY src ./src +COPY scripts ./scripts + +CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && node src/worker.js"] diff --git a/README.md b/README.md index c806006..8735478 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,10 @@ -# hatchet-worker +# fregat-hatchet-worker +Separate worker service for Hatchet workflows in the Fregat project. + +## Run + +```bash +npm ci +npm run start +``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..251d874 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "fregat-hatchet-worker", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fregat-hatchet-worker", + "version": "0.1.0", + "dependencies": { + "dotenv": "^17.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cdcd879 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "fregat-hatchet-worker", + "version": "0.1.0", + "type": "module", + "private": true, + "scripts": { + "start": "node src/worker.js", + "dev": "node --watch src/worker.js" + }, + "dependencies": { + "dotenv": "^17.3.1" + } +} diff --git a/scripts/load-vault-env.sh b/scripts/load-vault-env.sh new file mode 100755 index 0000000..8ab349c --- /dev/null +++ b/scripts/load-vault-env.sh @@ -0,0 +1,170 @@ +#!/bin/sh +set -eu + +log() { + printf '%s\n' "$*" >&2 +} + +is_enabled() { + case "${1:-}" in + 1|true|TRUE|yes|YES|on|ON) return 0 ;; + *) return 1 ;; + esac +} + +wait_for_http() { + name="$1" + url="$2" + timeout="$3" + interval="$4" + mode="${5:-strict}" + start_ts="$(date +%s)" + + while :; do + if [ "$mode" = "connect" ]; then + if curl -sS --max-time 3 "$url" >/dev/null 2>&1; then + log "$name is reachable at $url." + return 0 + fi + else + if curl -fsS --max-time 3 "$url" >/dev/null 2>&1; then + log "$name is reachable at $url." + return 0 + fi + fi + + now_ts="$(date +%s)" + if [ $((now_ts - start_ts)) -ge "$timeout" ]; then + log "Timeout waiting for $name at $url after ${timeout}s." + return 1 + fi + + sleep "$interval" + done +} + +wait_for_tcp() { + name="$1" + host="$2" + port="$3" + timeout="$4" + interval="$5" + start_ts="$(date +%s)" + + while :; do + if node -e "const net=require('net');const host=process.argv[1];const port=Number(process.argv[2]);const socket=net.connect({host,port});const fail=()=>{socket.destroy();process.exit(1)};socket.setTimeout(2000,fail);socket.on('connect',()=>{socket.end();process.exit(0)});socket.on('error',fail);" "$host" "$port" >/dev/null 2>&1; then + log "$name is reachable at ${host}:${port}." + return 0 + fi + + now_ts="$(date +%s)" + if [ $((now_ts - start_ts)) -ge "$timeout" ]; then + log "Timeout waiting for $name at ${host}:${port} after ${timeout}s." + return 1 + fi + + sleep "$interval" + done +} + +VAULT_ENABLED="${VAULT_ENABLED:-auto}" +if [ "$VAULT_ENABLED" = "false" ] || [ "$VAULT_ENABLED" = "0" ]; then + exit 0 +fi + +if [ -z "${VAULT_ADDR:-}" ] || [ -z "${VAULT_TOKEN:-}" ]; then + if [ "$VAULT_ENABLED" = "true" ] || [ "$VAULT_ENABLED" = "1" ]; then + log "Vault bootstrap is required but VAULT_ADDR or VAULT_TOKEN is missing." + exit 1 + fi + exit 0 +fi + +if ! command -v curl >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then + log "Vault bootstrap requires curl and jq." + exit 1 +fi + +VAULT_KV_MOUNT="${VAULT_KV_MOUNT:-secret}" +VAULT_WAIT_TIMEOUT_SECONDS="${VAULT_WAIT_TIMEOUT_SECONDS:-90}" +VAULT_WAIT_INTERVAL_SECONDS="${VAULT_WAIT_INTERVAL_SECONDS:-2}" + +wait_for_http \ + "Vault" \ + "${VAULT_ADDR%/}/v1/sys/health?standbyok=true&perfstandbyok=true" \ + "$VAULT_WAIT_TIMEOUT_SECONDS" \ + "$VAULT_WAIT_INTERVAL_SECONDS" \ + "strict" + +load_secret_path() { + path="$1" + source_name="$2" + if [ -z "$path" ]; then + return 0 + fi + + url="${VAULT_ADDR%/}/v1/${VAULT_KV_MOUNT}/data/${path}" + response="$(curl -fsS -H "X-Vault-Token: $VAULT_TOKEN" "$url")" || { + log "Failed to load Vault path ${VAULT_KV_MOUNT}/${path}." + return 1 + } + + encoded_items="$(printf '%s' "$response" | jq -r '.data.data // {} | to_entries[]? | @base64')" + if [ -z "$encoded_items" ]; then + return 0 + fi + + old_ifs="${IFS}" + IFS=' +' + for encoded_item in $encoded_items; do + key="$(printf '%s' "$encoded_item" | base64 -d | jq -r '.key')" + value="$(printf '%s' "$encoded_item" | base64 -d | jq -r '.value | tostring')" + export "$key=$value" + done + IFS="${old_ifs}" + + log "Loaded Vault ${source_name} secrets from ${VAULT_KV_MOUNT}/${path}." +} + +load_secret_path "${VAULT_SHARED_PATH:-}" "shared" +load_secret_path "${VAULT_PROJECT_PATH:-}" "project" + +if is_enabled "${WAIT_FOR_HATCHET_ENGINE:-0}"; then + HATCHET_WAIT_TIMEOUT_SECONDS="${HATCHET_WAIT_TIMEOUT_SECONDS:-120}" + HATCHET_WAIT_INTERVAL_SECONDS="${HATCHET_WAIT_INTERVAL_SECONDS:-2}" + hatchet_wait_host_port="${HATCHET_WAIT_HOST_PORT:-${HATCHET_CLIENT_HOST_PORT:-}}" + hatchet_wait_url="${HATCHET_WAIT_URL:-${HATCHET_CLIENT_API_URL:-}}" + + if [ -n "$hatchet_wait_host_port" ]; then + case "$hatchet_wait_host_port" in + *:*) + hatchet_wait_host="${hatchet_wait_host_port%:*}" + hatchet_wait_port="${hatchet_wait_host_port##*:}" + ;; + *) + log "Invalid HATCHET wait host:port value: ${hatchet_wait_host_port}" + exit 1 + ;; + esac + + wait_for_tcp \ + "Hatchet engine" \ + "$hatchet_wait_host" \ + "$hatchet_wait_port" \ + "$HATCHET_WAIT_TIMEOUT_SECONDS" \ + "$HATCHET_WAIT_INTERVAL_SECONDS" + else + if [ -z "$hatchet_wait_url" ]; then + log "WAIT_FOR_HATCHET_ENGINE is enabled but no HATCHET wait target is configured." + exit 1 + fi + + wait_for_http \ + "Hatchet engine" \ + "${hatchet_wait_url%/}/" \ + "$HATCHET_WAIT_TIMEOUT_SECONDS" \ + "$HATCHET_WAIT_INTERVAL_SECONDS" \ + "connect" + fi +fi diff --git a/src/worker.js b/src/worker.js new file mode 100644 index 0000000..45b5199 --- /dev/null +++ b/src/worker.js @@ -0,0 +1,17 @@ +import 'dotenv/config'; + +const requiredEnv = [ + 'HATCHET_CLIENT_HOST_PORT', +]; + +const missing = requiredEnv.filter((name) => !process.env[name]); +if (missing.length > 0) { + throw new Error(`Missing required env vars: ${missing.join(', ')}`); +} + +console.log('fregat-hatchet-worker started'); +console.log(`Hatchet endpoint: ${process.env.HATCHET_CLIENT_HOST_PORT}`); + +setInterval(() => { + console.log('worker heartbeat'); +}, 60_000);