diff --git a/Dockerfile b/Dockerfile index bb3a52a..ec9b268 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,12 +16,17 @@ FROM node:22-bookworm-slim AS runtime WORKDIR /app +RUN apt-get update -y \ + && apt-get install -y --no-install-recommends curl jq ca-certificates \ + && rm -rf /var/lib/apt/lists/* + ENV NODE_ENV=production ENV NITRO_HOST=0.0.0.0 ENV PORT=3000 COPY --from=build /app/.output ./.output +COPY --from=build /app/scripts ./scripts EXPOSE 3000 -CMD ["node", ".output/server/index.mjs"] +CMD ["sh", "-c", ". /app/scripts/load-vault-env.sh && node .output/server/index.mjs"] diff --git a/scripts/load-vault-env.sh b/scripts/load-vault-env.sh new file mode 100755 index 0000000..fb3e25e --- /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 + return 0 2>/dev/null || 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." + return 1 2>/dev/null || exit 1 + fi + return 0 2>/dev/null || 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." + return 1 2>/dev/null || 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}" + return 1 2>/dev/null || 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." + return 1 2>/dev/null || exit 1 + fi + + wait_for_http \ + "Hatchet engine" \ + "${hatchet_wait_url%/}/" \ + "$HATCHET_WAIT_TIMEOUT_SECONDS" \ + "$HATCHET_WAIT_INTERVAL_SECONDS" \ + "connect" + fi +fi