From aa9440a3dc3cc641de5c8b42622c7192a16d9e46 Mon Sep 17 00:00:00 2001 From: df Date: Mon, 8 Jun 2026 01:37:41 +0800 Subject: [PATCH 1/3] feat(fan-control): add safe multi-source controller Replace the single-purpose fan scripts with a validated controller that supports auto, once, manual, restore, status, and validate commands. Add fail-safe fan speed handling, hysteresis, ESXi/iDRAC/GPU temperature sources, dry-run support, and shell tests for the core decision logic. --- Makefile | 14 + src/FanControlWithEsxiSmart.sh | 985 +++++++++++++++++++++++++-------- src/setIdracFanSpeed.sh | 29 +- tests/fan-control.test.sh | 228 ++++++++ 4 files changed, 1008 insertions(+), 248 deletions(-) create mode 100644 Makefile mode change 100644 => 100755 src/FanControlWithEsxiSmart.sh mode change 100644 => 100755 src/setIdracFanSpeed.sh create mode 100755 tests/fan-control.test.sh diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1b64e6f --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +.PHONY: test validate docker-build + +test: + bash -n src/FanControlWithEsxiSmart.sh + bash -n src/setIdracFanSpeed.sh + bash tests/fan-control.test.sh + +validate: + DRY_RUN=true OPERATION_MODE=auto TEMPERATURE_SOURCES=idrac \ + IDRAC_IP=192.0.2.10 IDRAC_ID=root IDRAC_PASSWORD=test \ + src/FanControlWithEsxiSmart.sh validate + +docker-build: + docker build -t idrac-fan-control:local . diff --git a/src/FanControlWithEsxiSmart.sh b/src/FanControlWithEsxiSmart.sh old mode 100644 new mode 100755 index 72572a0..5c6e608 --- a/src/FanControlWithEsxiSmart.sh +++ b/src/FanControlWithEsxiSmart.sh @@ -1,270 +1,809 @@ -#!/bin/bash -# Auto fan speed control based on NVMe drive temperature -# Support environment variable configuration - - -# Add path to common command locations in Alpine -PATH="/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:$PATH" - -# Default values and environment variable loading -IDRAC_IP=${IDRAC_IP:-"REPLACE_TO_YOUR_IDRAC_IP"} -IDRAC_ID=${IDRAC_ID:-"REPLACE_TO_YOUR_IDRAC_ID"} -IDRAC_PASSWORD=${IDRAC_PASSWORD:-"REPLACE_TO_YOUR_IDRAC_PASSWORD"} -DRIVE_DEVICE=${DRIVE_DEVICE:-"t10.NVMe____KCD61LUL7T68____________________________015E8306E28EE38C"} - -# Temperature thresholds (Celsius) - 與 .env 文件中的預設值保持一致 -TEMP_LOW=${TEMP_LOW:-65} -TEMP_MEDIUM=${TEMP_MEDIUM:-70} -TEMP_HIGH=${TEMP_HIGH:-75} -TEMP_CRITICAL=${TEMP_CRITICAL:-80} - -# Fan speeds for each temperature range (percentage) - 與 .env 文件中的預設值保持一致 -FAN_SPEED_LOW=${FAN_SPEED_LOW:-30} -FAN_SPEED_MEDIUM=${FAN_SPEED_MEDIUM:-40} -FAN_SPEED_HIGH=${FAN_SPEED_HIGH:-50} -FAN_SPEED_CRITICAL=${FAN_SPEED_CRITICAL:-60} - -# Operation mode: auto or manual -OPERATION_MODE=${OPERATION_MODE:-"manual"} - -# Check interval (seconds) for auto mode -CHECK_INTERVAL=${CHECK_INTERVAL:-60} - -# GPU temperature control flag - 新增 GPU 溫度控制開關 -# 預設為 false,只有當使用者明確設定 WITH_GPU_TEMP=true 時才啟用 GPU 溫度監控 -WITH_GPU_TEMP=${WITH_GPU_TEMP:-"false"} - -GPU_TEMP_OFFSET=${GPU_TEMP_OFFSET:-15} - -# ESXi variables -ESXI_HOST=${ESXI_HOST:-"REPLACE_TO_YOUR_ESXI_HOST"} -ESXI_USERNAME=${ESXI_USERNAME:-"REPLACE_TO_YOUR_ESXI_USERNAME"} -ESXI_PASSWORD=${ESXI_PASSWORD:-"REPLACE_TO_YOUR_ESXI_PASSWORD"} - -# Log directory -LOG_DIR=${LOG_DIR:-.} - -# Function to get current drive temperature from ESXi host -get_drive_temp() { - local temp=$(sshpass -p "${ESXI_PASSWORD}" ssh -o StrictHostKeyChecking=no "${ESXI_USERNAME}@${ESXI_HOST}" \ - "esxcli storage core device smart get -d ${DRIVE_DEVICE}" | \ - awk '/Drive Temperature/ {print $3}') - echo "${temp:-0}" -} - -# Function to get NVIDIA GPU temperature using nvidia-smi -# 這個函數透過 nvidia container toolkit 獲取 GPU 溫度 -# 前提:容器必須使用 --gpus all 或 --runtime=nvidia 執行 -get_nvidia_temp() { - local temp - # 使用 nvidia-smi 獲取第一個 GPU 的溫度 - # --query-gpu=temperature.gpu 只輸出溫度值 - # --format=csv,noheader,nounits 輸出純數字,不含單位和標題 - temp=$(nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader,nounits 2>/dev/null | head -n1) - - # 如果獲取失敗或為空,返回 0 - if [[ -z "$temp" ]] || ! [[ "$temp" =~ ^[0-9]+$ ]]; then - echo "0" - else - echo "$temp" +#!/usr/bin/env bash + +set -Eeuo pipefail + +PATH="/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:${PATH}" + +PROGRAM_NAME="$(basename "$0")" + +: "${IDRAC_IP:=}" +: "${IDRAC_ID:=root}" +: "${IDRAC_PASSWORD:=}" +: "${IPMI_INTERFACE:=lanplus}" +: "${IPMI_TIMEOUT:=5}" +: "${IPMI_RETRIES:=2}" + +: "${ESXI_HOST:=}" +: "${ESXI_USERNAME:=root}" +: "${ESXI_PASSWORD:=}" +: "${ESXI_SSH_KEY:=}" +: "${ESXI_SSH_PORT:=22}" +: "${SSH_CONNECT_TIMEOUT:=10}" +: "${SSH_STRICT_HOST_KEY_CHECKING:=accept-new}" +: "${DRIVE_DEVICE:=}" + +: "${OPERATION_MODE:=manual}" +: "${TEMPERATURE_SOURCES:=esxi}" +: "${WITH_GPU_TEMP:=false}" +: "${GPU_TEMP_OFFSET:=15}" +: "${CHECK_INTERVAL:=60}" +: "${COMMAND_TIMEOUT:=20}" + +: "${TEMP_LOW:=65}" +: "${TEMP_MEDIUM:=70}" +: "${TEMP_HIGH:=75}" +: "${TEMP_CRITICAL:=80}" + +: "${FAN_SPEED_IDLE:=${FAN_SPEED_LOW:-30}}" +: "${FAN_SPEED_LOW:=30}" +: "${FAN_SPEED_MEDIUM:=40}" +: "${FAN_SPEED_HIGH:=50}" +: "${FAN_SPEED_CRITICAL:=60}" +: "${FAILSAFE_FAN_SPEED:=70}" +: "${FAILSAFE_ON_ERROR:=true}" +: "${RESTORE_AUTO_ON_EXIT:=true}" +: "${HYSTERESIS:=2}" +: "${MANUAL_FAN_SPEED:=}" + +: "${IDRAC_SENSOR_INCLUDE_REGEX:=}" +: "${IDRAC_SENSOR_EXCLUDE_REGEX:=no reading|disabled|not readable}" + +: "${LOG_DIR:=/var/log/fan-control}" +: "${LOG_FILE:=fan_control.log}" +: "${DRY_RUN:=false}" + +LAST_LEVEL="" +LAST_SPEED="" + +usage() { + cat <&2 +} + +trim() { + local value="$*" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +is_integer() { + [[ "${1:-}" =~ ^-?[0-9]+$ ]] +} + +is_true() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in + 1|true|yes|y|on) return 0 ;; + *) return 1 ;; + esac +} + +is_bool() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in + 1|0|true|false|yes|no|y|n|on|off) return 0 ;; + *) return 1 ;; + esac +} + +require_command() { + local command="$1" + if ! command -v "$command" >/dev/null 2>&1; then + log "ERROR" "Required command not found: ${command}" + return 1 fi } -# Function to calculate decision temperature -# 決策溫度 = max(磁碟溫度, GPU溫度 - GPU_TEMP_OFFSET) -# 這個邏輯確保風扇轉速基於較高的溫度來源進行調節 -get_decision_temp() { - local disk_temp=$(get_drive_temp) - local decision_temp=$disk_temp - - # 只有在啟用 GPU 溫度監控時才考慮 GPU 溫度 - if [[ "$WITH_GPU_TEMP" == "true" ]]; then - local gpu_temp=$(get_nvidia_temp) - local gpu_adjusted_temp=$((gpu_temp - GPU_TEMP_OFFSET)) - - # 記錄原始溫度用於除錯 - echo "$(date '+%Y-%m-%d %H:%M:%S') - Debug: Disk=${disk_temp}°C, GPU=${gpu_temp}°C, GPU_Adjusted=${gpu_adjusted_temp}°C" >&2 - - # 取較大值作為決策溫度 - if [[ $gpu_adjusted_temp -gt $disk_temp ]]; then - decision_temp=$gpu_adjusted_temp +is_placeholder_value() { + local value="$1" + + case "$value" in + REPLACE_TO_YOUR_*|replace_with_*|your_*|change-me|changeme|t10.NVMe____replace*) + return 0 + ;; + *) + return 1 + ;; + esac +} + +require_value() { + local name="$1" + local value="$2" + if [[ -z "$value" ]]; then + log "ERROR" "Missing required configuration: ${name}" + return 1 + fi + if is_placeholder_value "$value"; then + log "ERROR" "Replace placeholder value for ${name}" + return 1 + fi +} + +validate_integer_range() { + local name="$1" + local value="$2" + local min="$3" + local max="$4" + + if ! is_integer "$value"; then + log "ERROR" "${name} must be an integer, got '${value}'" + return 1 + fi + + if (( value < min || value > max )); then + log "ERROR" "${name} must be between ${min} and ${max}, got ${value}" + return 1 + fi +} + +normalize_sources() { + local raw="${TEMPERATURE_SOURCES//[[:space:]]/}" + local normalized="" + local source + local -a source_list + + IFS=',' read -r -a source_list <<< "$raw" + for source in "${source_list[@]}"; do + [[ -z "$source" ]] && continue + source="$(printf '%s' "$source" | tr '[:upper:]' '[:lower:]')" + if ! source_enabled_in_list "$source" "$normalized"; then + normalized="${normalized:+${normalized},}${source}" fi - - echo "$(date '+%Y-%m-%d %H:%M:%S') - Decision temperature: ${decision_temp}°C (Disk: ${disk_temp}°C, GPU-${GPU_TEMP_OFFSET}: ${gpu_adjusted_temp}°C)" >&2 - else - echo "$(date '+%Y-%m-%d %H:%M:%S') - Decision temperature: ${decision_temp}°C (Disk only mode)" >&2 + done + + if is_true "$WITH_GPU_TEMP" && ! source_enabled_in_list "gpu" "$normalized"; then + normalized="${normalized:+${normalized},}gpu" fi - - echo "$decision_temp" + + printf '%s' "$normalized" } -# Function to set fan speed -set_fan_speed() { - local fan_speed=$1 - - # Convert decimal to hex - local hex_speed=$(echo "obase=16;$fan_speed" | bc) - echo "Setting fan speed to ${fan_speed}% (hex: ${hex_speed})" - - # Set fan control to manual - ipmitool -I lanplus -H ${IDRAC_IP} -U ${IDRAC_ID} -P ${IDRAC_PASSWORD} raw 0x30 0x30 0x01 0x00 - - # Set fan speed - local raw_command="0x30 0x30 0x02 0xff 0x${hex_speed}" - ipmitool -I lanplus -H ${IDRAC_IP} -U ${IDRAC_ID} -P ${IDRAC_PASSWORD} raw ${raw_command} -} - -# Function to restore automatic fan control -restore_auto_control() { - echo "Restoring automatic fan control..." - ipmitool -I lanplus -H ${IDRAC_IP} -U ${IDRAC_ID} -P ${IDRAC_PASSWORD} raw 0x30 0x30 0x01 0x01 +source_enabled_in_list() { + local wanted="$1" + local list="${2:-}" + local source + local -a enabled_sources + + IFS=',' read -r -a enabled_sources <<< "$list" + for source in "${enabled_sources[@]}"; do + [[ "$source" == "$wanted" ]] && return 0 + done + + return 1 +} + +command_needs_ipmi() { + case "$1" in + auto|once|manual|restore|status) return 0 ;; + *) return 1 ;; + esac +} + +command_needs_temperature() { + case "$1" in + auto|once) return 0 ;; + *) return 1 ;; + esac +} + +validate_sources() { + local sources + local source + local error=0 + local -a source_list + + sources="$(normalize_sources)" + if [[ -z "$sources" ]]; then + log "ERROR" "TEMPERATURE_SOURCES must contain at least one source: esxi, idrac, gpu" + return 1 + fi + + IFS=',' read -r -a source_list <<< "$sources" + for source in "${source_list[@]}"; do + case "$source" in + esxi|idrac|gpu) ;; + *) + log "ERROR" "Unsupported temperature source '${source}'. Use esxi, idrac, or gpu." + error=1 + ;; + esac + done + + return "$error" } -# Function to validate configuration validate_config() { + local command="${1:-$OPERATION_MODE}" local error=0 + local sources - # Validate operation mode first so mode-specific requirements cannot be bypassed - case "$OPERATION_MODE" in - "manual"|"auto") - ;; + case "$command" in + auto|once|manual|restore|status|validate|healthcheck) ;; + help|-h|--help) return 0 ;; *) - echo "Invalid operation mode: ${OPERATION_MODE} (must be 'auto' or 'manual')" + log "ERROR" "Invalid command or OPERATION_MODE: ${command}" error=1 ;; esac - # iDRAC credentials are required by both manual and auto modes - if [[ "$IDRAC_IP" == "REPLACE_TO_YOUR_IDRAC_IP" ]]; then - echo "Error: IDRAC_IP not configured" - error=1 + for bool_name in WITH_GPU_TEMP FAILSAFE_ON_ERROR RESTORE_AUTO_ON_EXIT DRY_RUN; do + if ! is_bool "${!bool_name}"; then + log "ERROR" "${bool_name} must be a boolean value" + error=1 + fi + done + + validate_integer_range "TEMP_LOW" "$TEMP_LOW" 0 120 || error=1 + validate_integer_range "TEMP_MEDIUM" "$TEMP_MEDIUM" 0 120 || error=1 + validate_integer_range "TEMP_HIGH" "$TEMP_HIGH" 0 120 || error=1 + validate_integer_range "TEMP_CRITICAL" "$TEMP_CRITICAL" 0 120 || error=1 + validate_integer_range "FAN_SPEED_IDLE" "$FAN_SPEED_IDLE" 1 100 || error=1 + validate_integer_range "FAN_SPEED_LOW" "$FAN_SPEED_LOW" 1 100 || error=1 + validate_integer_range "FAN_SPEED_MEDIUM" "$FAN_SPEED_MEDIUM" 1 100 || error=1 + validate_integer_range "FAN_SPEED_HIGH" "$FAN_SPEED_HIGH" 1 100 || error=1 + validate_integer_range "FAN_SPEED_CRITICAL" "$FAN_SPEED_CRITICAL" 1 100 || error=1 + validate_integer_range "FAILSAFE_FAN_SPEED" "$FAILSAFE_FAN_SPEED" 1 100 || error=1 + validate_integer_range "CHECK_INTERVAL" "$CHECK_INTERVAL" 1 86400 || error=1 + validate_integer_range "COMMAND_TIMEOUT" "$COMMAND_TIMEOUT" 1 300 || error=1 + validate_integer_range "SSH_CONNECT_TIMEOUT" "$SSH_CONNECT_TIMEOUT" 1 300 || error=1 + validate_integer_range "IPMI_TIMEOUT" "$IPMI_TIMEOUT" 1 60 || error=1 + validate_integer_range "IPMI_RETRIES" "$IPMI_RETRIES" 0 20 || error=1 + validate_integer_range "GPU_TEMP_OFFSET" "$GPU_TEMP_OFFSET" 0 120 || error=1 + validate_integer_range "HYSTERESIS" "$HYSTERESIS" 0 30 || error=1 + + if is_integer "$TEMP_LOW" && is_integer "$TEMP_MEDIUM" && is_integer "$TEMP_HIGH" && is_integer "$TEMP_CRITICAL"; then + if (( TEMP_LOW >= TEMP_MEDIUM || TEMP_MEDIUM >= TEMP_HIGH || TEMP_HIGH >= TEMP_CRITICAL )); then + log "ERROR" "Temperature thresholds must increase: TEMP_LOW < TEMP_MEDIUM < TEMP_HIGH < TEMP_CRITICAL" + error=1 + fi fi - if [[ "$IDRAC_ID" == "REPLACE_TO_YOUR_IDRAC_ID" ]]; then - echo "Error: IDRAC_ID not configured" - error=1 + + if command_needs_ipmi "$command"; then + require_value "IDRAC_IP" "$IDRAC_IP" || error=1 + require_value "IDRAC_ID" "$IDRAC_ID" || error=1 + require_value "IDRAC_PASSWORD" "$IDRAC_PASSWORD" || error=1 + if ! is_true "$DRY_RUN"; then + require_command "ipmitool" || error=1 + require_command "timeout" || error=1 + fi fi - if [[ "$IDRAC_PASSWORD" == "REPLACE_TO_YOUR_IDRAC_PASSWORD" ]]; then - echo "Error: IDRAC_PASSWORD not configured" - error=1 + + if command_needs_temperature "$command"; then + validate_sources || error=1 + sources="$(normalize_sources)" + + if source_enabled_in_list "esxi" "$sources"; then + require_value "ESXI_HOST" "$ESXI_HOST" || error=1 + require_value "ESXI_USERNAME" "$ESXI_USERNAME" || error=1 + require_value "DRIVE_DEVICE" "$DRIVE_DEVICE" || error=1 + if [[ -z "$ESXI_PASSWORD" && -z "$ESXI_SSH_KEY" ]]; then + log "ERROR" "Set ESXI_PASSWORD or ESXI_SSH_KEY when TEMPERATURE_SOURCES includes esxi" + error=1 + fi + if [[ -z "$ESXI_SSH_KEY" ]]; then + require_value "ESXI_PASSWORD" "$ESXI_PASSWORD" || error=1 + elif is_placeholder_value "$ESXI_SSH_KEY"; then + log "ERROR" "Replace placeholder value for ESXI_SSH_KEY" + error=1 + fi + if [[ -n "$ESXI_SSH_KEY" && ! -r "$ESXI_SSH_KEY" ]]; then + log "ERROR" "ESXI_SSH_KEY is not readable: ${ESXI_SSH_KEY}" + error=1 + fi + if ! is_true "$DRY_RUN"; then + require_command "ssh" || error=1 + require_command "timeout" || error=1 + if [[ -n "$ESXI_PASSWORD" && -z "$ESXI_SSH_KEY" ]]; then + require_command "sshpass" || error=1 + fi + fi + fi + + if source_enabled_in_list "gpu" "$sources" && ! is_true "$DRY_RUN"; then + if ! command -v nvidia-smi >/dev/null 2>&1; then + log "WARN" "GPU source is enabled but nvidia-smi is not available; fail-safe will be used if no other source succeeds" + fi + fi fi - # ESXi is only used as the temperature source in auto mode - if [[ "$OPERATION_MODE" == "auto" ]]; then - if [[ "$ESXI_HOST" == "REPLACE_TO_YOUR_ESXI_HOST" ]]; then - echo "Error: ESXI_HOST not configured" - error=1 + return "$error" +} + +fan_speed_to_hex() { + local speed="$1" + printf '%02x' "$speed" +} + +run_ipmitool() { + local args=("$@") + + if is_true "$DRY_RUN"; then + log "INFO" "DRY_RUN ipmitool ${args[*]}" + return 0 + fi + + timeout "$COMMAND_TIMEOUT" env IPMI_PASSWORD="$IDRAC_PASSWORD" \ + ipmitool -I "$IPMI_INTERFACE" -H "$IDRAC_IP" -U "$IDRAC_ID" -E \ + -N "$IPMI_TIMEOUT" -R "$IPMI_RETRIES" "${args[@]}" +} + +set_fan_speed() { + local fan_speed="$1" + local hex_speed + + validate_integer_range "fan speed" "$fan_speed" 1 100 || return 1 + hex_speed="$(fan_speed_to_hex "$fan_speed")" + + log "INFO" "Setting fan speed to ${fan_speed}% (0x${hex_speed})" + run_ipmitool raw 0x30 0x30 0x01 0x00 || return 1 + run_ipmitool raw 0x30 0x30 0x02 0xff "0x${hex_speed}" +} + +restore_auto_control() { + log "INFO" "Restoring iDRAC automatic fan control" + run_ipmitool raw 0x30 0x30 0x01 0x01 +} + +remote_quote() { + local value="$1" + printf "'%s'" "${value//\'/\'\\\'\'}" +} + +run_esxi_command() { + local remote_command="$1" + local ssh_command=(ssh -p "$ESXI_SSH_PORT" -o "StrictHostKeyChecking=${SSH_STRICT_HOST_KEY_CHECKING}" -o "ConnectTimeout=${SSH_CONNECT_TIMEOUT}") + + if [[ -n "$ESXI_SSH_KEY" ]]; then + ssh_command+=(-i "$ESXI_SSH_KEY" -o BatchMode=yes) + fi + + ssh_command+=("${ESXI_USERNAME}@${ESXI_HOST}" "$remote_command") + + if [[ -n "$ESXI_PASSWORD" && -z "$ESXI_SSH_KEY" ]]; then + timeout "$COMMAND_TIMEOUT" sshpass -p "$ESXI_PASSWORD" "${ssh_command[@]}" + else + timeout "$COMMAND_TIMEOUT" "${ssh_command[@]}" + fi +} + +get_esxi_drive_temperature() { + local remote_device + local output + local temp + + remote_device="$(remote_quote "$DRIVE_DEVICE")" + if ! output="$(run_esxi_command "esxcli storage core device smart get -d ${remote_device}" 2>/dev/null)"; then + return 1 + fi + + temp="$(awk ' + /Drive Temperature/ { + for (i = 1; i <= NF; i++) { + if ($i ~ /^-?[0-9]+$/) { + print $i + exit + } + } + } + ' <<< "$output")" + + if ! is_integer "$temp"; then + return 1 + fi + + printf 'esxi\t%s\t%s\n' "$DRIVE_DEVICE" "$temp" +} + +parse_idrac_sdr_temperatures() { + awk -F'|' \ + -v include_regex="$IDRAC_SENSOR_INCLUDE_REGEX" \ + -v exclude_regex="$IDRAC_SENSOR_EXCLUDE_REGEX" ' + function trim_value(value) { + gsub(/^[ \t\r\n]+|[ \t\r\n]+$/, "", value) + return value + } + { + sensor = trim_value($1) + line = tolower($0) + if (sensor == "") { + next + } + if (exclude_regex != "" && line ~ exclude_regex) { + next + } + if (include_regex != "" && sensor !~ include_regex) { + next + } + for (i = 2; i <= NF; i++) { + field = trim_value($i) + if (field ~ /^-?[0-9]+[ \t]+degrees C/) { + split(field, parts, /[ \t]+/) + print "idrac\t" sensor "\t" parts[1] + next + } + } + } + ' +} + +get_idrac_temperatures() { + local output + local parsed + + if ! output="$(run_ipmitool sdr type Temperature 2>/dev/null)"; then + return 1 + fi + + parsed="$(parse_idrac_sdr_temperatures <<< "$output")" + [[ -n "$parsed" ]] || return 1 + printf '%s\n' "$parsed" +} + +get_gpu_temperatures() { + local output + local index + local temp + local found=1 + + command -v nvidia-smi >/dev/null 2>&1 || return 1 + + if ! output="$(timeout "$COMMAND_TIMEOUT" nvidia-smi --query-gpu=index,temperature.gpu --format=csv,noheader,nounits 2>/dev/null)"; then + return 1 + fi + + while IFS=',' read -r index temp; do + index="$(trim "$index")" + temp="$(trim "$temp")" + if is_integer "$temp"; then + printf 'gpu\tgpu%s\t%s\n' "$index" "$temp" + found=0 fi - if [[ "$ESXI_USERNAME" == "REPLACE_TO_YOUR_ESXI_USERNAME" ]]; then - echo "Error: ESXI_USERNAME not configured" - error=1 + done <<< "$output" + + return "$found" +} + +collect_temperature_readings() { + local sources + local source + local output + local found=1 + local -a source_list + + sources="$(normalize_sources)" + IFS=',' read -r -a source_list <<< "$sources" + + for source in "${source_list[@]}"; do + case "$source" in + esxi) + if output="$(get_esxi_drive_temperature)"; then + printf '%s\n' "$output" + found=0 + else + log "WARN" "ESXi drive temperature read failed" + fi + ;; + idrac) + if output="$(get_idrac_temperatures)"; then + printf '%s\n' "$output" + found=0 + else + log "WARN" "iDRAC temperature sensor read failed" + fi + ;; + gpu) + if output="$(get_gpu_temperatures)"; then + printf '%s\n' "$output" + found=0 + else + log "WARN" "GPU temperature read failed" + fi + ;; + esac + done + + return "$found" +} + +get_decision_temperature() { + local readings + local source + local label + local temp + local adjusted + local max_temp="" + local detail_text="" + + if ! readings="$(collect_temperature_readings)"; then + return 1 + fi + + while IFS=$'\t' read -r source label temp; do + [[ -z "${source:-}" || -z "${temp:-}" ]] && continue + is_integer "$temp" || continue + + adjusted="$temp" + if [[ "$source" == "gpu" ]]; then + adjusted=$(( temp - GPU_TEMP_OFFSET )) + (( adjusted < 0 )) && adjusted=0 + detail_text="${detail_text:+${detail_text}, }${source}:${label}=${temp}C(adjusted=${adjusted}C)" + else + detail_text="${detail_text:+${detail_text}, }${source}:${label}=${temp}C" fi - if [[ "$ESXI_PASSWORD" == "REPLACE_TO_YOUR_ESXI_PASSWORD" ]]; then - echo "Error: ESXI_PASSWORD not configured" - error=1 + + if [[ -z "$max_temp" || "$adjusted" -gt "$max_temp" ]]; then + max_temp="$adjusted" fi + done <<< "$readings" + + [[ -n "$max_temp" ]] || return 1 + printf '%s\t%s\n' "$max_temp" "$detail_text" +} + +temperature_level() { + local temp="$1" + + if (( temp >= TEMP_CRITICAL )); then + printf 'critical' + elif (( temp >= TEMP_HIGH )); then + printf 'high' + elif (( temp >= TEMP_MEDIUM )); then + printf 'medium' + elif (( temp >= TEMP_LOW )); then + printf 'low' + else + printf 'idle' + fi +} + +level_rank() { + case "$1" in + idle) printf '0' ;; + low) printf '1' ;; + medium) printf '2' ;; + high) printf '3' ;; + critical) printf '4' ;; + *) printf '-1' ;; + esac +} + +threshold_for_level() { + case "$1" in + low) printf '%s' "$TEMP_LOW" ;; + medium) printf '%s' "$TEMP_MEDIUM" ;; + high) printf '%s' "$TEMP_HIGH" ;; + critical) printf '%s' "$TEMP_CRITICAL" ;; + *) printf '%s' '-999' ;; + esac +} + +fan_speed_for_level() { + case "$1" in + idle) printf '%s' "$FAN_SPEED_IDLE" ;; + low) printf '%s' "$FAN_SPEED_LOW" ;; + medium) printf '%s' "$FAN_SPEED_MEDIUM" ;; + high) printf '%s' "$FAN_SPEED_HIGH" ;; + critical) printf '%s' "$FAN_SPEED_CRITICAL" ;; + *) return 1 ;; + esac +} + +apply_hysteresis() { + local temp="$1" + local proposed_level="$2" + local previous_level="${3:-}" + local proposed_rank + local previous_rank + local previous_threshold + + [[ -n "$previous_level" ]] || { + printf '%s' "$proposed_level" + return 0 + } + + proposed_rank="$(level_rank "$proposed_level")" + previous_rank="$(level_rank "$previous_level")" + if (( previous_rank < 0 || proposed_rank < 0 || previous_rank == 0 )); then + printf '%s' "$proposed_level" + return 0 fi - if [ $error -eq 1 ]; then - exit 1 + if (( proposed_rank >= previous_rank )); then + printf '%s' "$proposed_level" + return 0 fi + + previous_threshold="$(threshold_for_level "$previous_level")" + if (( temp > previous_threshold - HYSTERESIS )); then + printf '%s' "$previous_level" + else + printf '%s' "$proposed_level" + fi +} + +choose_fan_speed() { + local temp="$1" + local previous_level="${2:-}" + local raw_level + local level + local speed + + raw_level="$(temperature_level "$temp")" + level="$(apply_hysteresis "$temp" "$raw_level" "$previous_level")" + speed="$(fan_speed_for_level "$level")" + printf '%s\t%s\n' "$level" "$speed" } -# Function to prepare log output for automatic mode prepare_log_dir() { - if ! mkdir -p "$LOG_DIR"; then - echo "Error: failed to create LOG_DIR: ${LOG_DIR}" - exit 1 + [[ -n "$LOG_DIR" ]] || return 0 + mkdir -p "$LOG_DIR" || return 1 + touch "${LOG_DIR}/${LOG_FILE}" || return 1 +} + +write_status_log() { + local temp="$1" + local level="$2" + local speed="$3" + local status="$4" + local details="${5:-}" + + [[ -n "$LOG_DIR" ]] || return 0 + prepare_log_dir || return 1 + printf '%s status=%s temp=%s level=%s fan=%s%% details="%s"\n' \ + "$(timestamp)" "$status" "$temp" "$level" "$speed" "$details" >> "${LOG_DIR}/${LOG_FILE}" +} + +control_cycle() { + local decision_line + local temp + local details + local selected + local level + local speed + + if decision_line="$(get_decision_temperature)"; then + IFS=$'\t' read -r temp details <<< "$decision_line" + selected="$(choose_fan_speed "$temp" "$LAST_LEVEL")" + IFS=$'\t' read -r level speed <<< "$selected" + + if [[ "$speed" != "$LAST_SPEED" ]]; then + set_fan_speed "$speed" || return 1 + fi + + LAST_LEVEL="$level" + LAST_SPEED="$speed" + log "INFO" "Control temp ${temp}C -> ${level} (${speed}%). Sources: ${details}" + write_status_log "$temp" "$level" "$speed" "ok" "$details" + return 0 fi - if ! touch "${LOG_DIR}/fan_control.log"; then - echo "Error: failed to write log file: ${LOG_DIR}/fan_control.log" - exit 1 + log "ERROR" "No valid temperature readings were available" + if is_true "$FAILSAFE_ON_ERROR"; then + if [[ "$LAST_SPEED" != "$FAILSAFE_FAN_SPEED" ]]; then + set_fan_speed "$FAILSAFE_FAN_SPEED" || return 1 + fi + LAST_LEVEL="failsafe" + LAST_SPEED="$FAILSAFE_FAN_SPEED" + log "WARN" "Fail-safe fan speed applied: ${FAILSAFE_FAN_SPEED}%" + write_status_log "unknown" "failsafe" "$FAILSAFE_FAN_SPEED" "failsafe" "temperature read failed" + return 0 fi + + return 1 } -# Function to run in manual mode manual_mode() { - echo "Manual fan speed control mode" - echo "Please input fan speed (1-100):" - read -r fanSpeed - - # Validate input - if ! [[ "$fanSpeed" =~ ^[0-9]+$ ]] || [ "$fanSpeed" -lt 1 ] || [ "$fanSpeed" -gt 100 ]; then - echo "Invalid input: Fan speed must be a number between 1 and 100" - exit 1 + local speed="${1:-$MANUAL_FAN_SPEED}" + + if [[ -z "$speed" ]]; then + if [[ -t 0 ]]; then + printf 'Fan speed percentage (1-100): ' + read -r speed + else + log "ERROR" "Set MANUAL_FAN_SPEED or pass a speed argument when stdin is not interactive" + return 1 + fi fi - # Set the fan speed to the provided value - set_fan_speed "$fanSpeed" + set_fan_speed "$speed" +} + +cleanup_auto_mode() { + trap - EXIT + if is_true "$RESTORE_AUTO_ON_EXIT"; then + restore_auto_control || log "ERROR" "Failed to restore iDRAC automatic fan control" + fi } -# Function to run in automatic mode auto_mode() { - echo "Automatic fan speed control mode" - prepare_log_dir + prepare_log_dir || { + log "ERROR" "Cannot write log file at ${LOG_DIR}/${LOG_FILE}" + return 1 + } + + log "INFO" "Automatic fan control started" + log "INFO" "Temperature sources: $(normalize_sources)" + log "INFO" "Check interval: ${CHECK_INTERVAL}s, hysteresis: ${HYSTERESIS}C, fail-safe: ${FAILSAFE_FAN_SPEED}%" + + trap cleanup_auto_mode EXIT + trap 'exit 130' INT TERM - if [[ "$WITH_GPU_TEMP" == "true" ]]; then - echo "GPU temperature monitoring enabled - using decision temperature logic" - echo "Decision Temperature = max(Disk Temperature, GPU Temperature - ${GPU_TEMP_OFFSET}°C)" - else - echo "GPU temperature monitoring disabled - using disk temperature only" - fi - echo "Monitoring interval: ${CHECK_INTERVAL} seconds" - echo "Press Ctrl+C to exit and restore automatic control" - - # Set up trap for clean exit - trap restore_auto_control EXIT - - local last_speed="" - while true; do - # Get current decision temperature (考慮 GPU 或僅磁碟溫度) - local temp=$(get_decision_temp) - echo "$(date '+%Y-%m-%d %H:%M:%S') - Current control temperature: ${temp}°C" - - # Determine appropriate fan speed based on temperature - local target_speed - if [ "$temp" -ge "$TEMP_CRITICAL" ]; then - target_speed=$FAN_SPEED_CRITICAL - elif [ "$temp" -ge "$TEMP_HIGH" ]; then - target_speed=$FAN_SPEED_HIGH - elif [ "$temp" -ge "$TEMP_MEDIUM" ]; then - target_speed=$FAN_SPEED_MEDIUM - else - target_speed=$FAN_SPEED_LOW - fi - - # Only change speed if it's different from last setting - if [ "$target_speed" != "$last_speed" ]; then - set_fan_speed "$target_speed" - last_speed="$target_speed" - echo "Fan speed adjusted to ${target_speed}%" - fi - - # Log the status - echo "$(date '+%Y-%m-%d %H:%M:%S') - Temp: ${temp}°C, Fan: ${target_speed}%" >> "${LOG_DIR}/fan_control.log" - + control_cycle || log "ERROR" "Control cycle failed" sleep "$CHECK_INTERVAL" done } -# Main script -echo "iDRAC Fan Control Script" -echo "Operation Mode: ${OPERATION_MODE}" - -# Validate configuration -validate_config - -# Run in specified mode -case $OPERATION_MODE in - "manual") - manual_mode - ;; - "auto") - auto_mode - ;; - *) - # validate_config already reported the invalid mode. - exit 1 - ;; -esac +once_mode() { + prepare_log_dir || { + log "ERROR" "Cannot write log file at ${LOG_DIR}/${LOG_FILE}" + return 1 + } + control_cycle +} + +status_mode() { + log "INFO" "iDRAC chassis status" + run_ipmitool chassis status + printf '\n' + log "INFO" "iDRAC temperature sensors" + run_ipmitool sdr type Temperature +} + +main() { + local command="${1:-$OPERATION_MODE}" + local manual_speed="${2:-}" + + case "$command" in + help|-h|--help) + usage + return 0 + ;; + validate|healthcheck) + validate_config "$OPERATION_MODE" + return $? + ;; + auto|once|manual|restore|status) + validate_config "$command" || return 1 + ;; + *) + log "ERROR" "Unknown command: ${command}" + usage + return 1 + ;; + esac + + case "$command" in + auto) auto_mode ;; + once) once_mode ;; + manual) manual_mode "$manual_speed" ;; + restore) restore_auto_control ;; + status) status_mode ;; + esac +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/src/setIdracFanSpeed.sh b/src/setIdracFanSpeed.sh old mode 100644 new mode 100755 index 8989864..c01bb45 --- a/src/setIdracFanSpeed.sh +++ b/src/setIdracFanSpeed.sh @@ -1,28 +1,7 @@ -#!/bin/bash -# 2023.7.22 D.F. -# This is for setting up idrac 8 fan speed. -# input a 1~100 decimal to determin the fan duty cycle -# example: -# iDRAC_IP=192.168.10.9 -IDRAC_IP=REPLACE_TO_YOUR_IDRAC_IP -IDRAC_ID=REPLACE_TO_YOUR_IDARC_ID -IDRAC_PASSWORD=REPLACE_TO_YOUR_IDRAC_PASSWORD +#!/usr/bin/env bash -echo "This is for setting up idrac 8 fan speed." -echo "input a 1~100 decimal to determin the fan duty cycle." -read fanSpeed -# convert decimal to hex -hexFanSpeed=$(echo "obase=16;$fanSpeed" | bc) -echo "Your input is ${fanSpeed}. The hexadecimal value is ${hexFanSpeed}." +set -Eeuo pipefail -# auto fan speed control raw command: 0x30 0x30 0x01 0x01 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -#set fan control to manual -ipmitool -I lanplus -H ${IDRAC_IP} -U ${IDRAC_ID} -P ${IDRAC_PASSWORD} raw 0x30 0x30 0x01 0x00 -# replace raw config -rawConfigCommand="0x30 0x30 0x02 0xff 0x"${hexFanSpeed} -#set fan speed to target duty cycle -# ipmitool -I lanplus -H {IP to iDRAC} -U {ID} -P {PASSWORD} raw 0x30 0x30 0x02 0xff 0x25 -ipmitool -I lanplus -H ${IDRAC_IP} -U ${IDRAC_ID} -P ${IDRAC_PASSWORD} raw ${rawConfigCommand} -echo "done" +exec "${SCRIPT_DIR}/FanControlWithEsxiSmart.sh" manual "$@" diff --git a/tests/fan-control.test.sh b/tests/fan-control.test.sh new file mode 100755 index 0000000..281205e --- /dev/null +++ b/tests/fan-control.test.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +export LOG_DIR="" +export DRY_RUN=true +export IDRAC_IP=192.0.2.10 +export IDRAC_ID=root +export IDRAC_PASSWORD=test-password +export ESXI_HOST=192.0.2.20 +export ESXI_USERNAME=root +export ESXI_PASSWORD=test-password +export DRIVE_DEVICE=test-drive + +# shellcheck source=../src/FanControlWithEsxiSmart.sh +source "${ROOT_DIR}/src/FanControlWithEsxiSmart.sh" + +LOG_DIR="" +PASS_COUNT=0 + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) +} + +fail() { + printf 'not ok - %s\n' "$*" >&2 + exit 1 +} + +assert_eq() { + local expected="$1" + local actual="$2" + local message="$3" + + if [[ "$expected" != "$actual" ]]; then + fail "${message}: expected '${expected}', got '${actual}'" + fi + pass +} + +assert_contains() { + local needle="$1" + local haystack="$2" + local message="$3" + + if [[ "$haystack" != *"$needle"* ]]; then + fail "${message}: expected output to contain '${needle}', got '${haystack}'" + fi + pass +} + +test_normalize_sources() { + TEMPERATURE_SOURCES=" esxi, idrac, esxi " + WITH_GPU_TEMP=false + assert_eq "esxi,idrac" "$(normalize_sources)" "deduplicates and trims temperature sources" + + TEMPERATURE_SOURCES="idrac" + WITH_GPU_TEMP=true + assert_eq "idrac,gpu" "$(normalize_sources)" "WITH_GPU_TEMP appends gpu source" +} + +test_hex_formatting() { + assert_eq "1e" "$(fan_speed_to_hex 30)" "formats 30 percent as IPMI hex" + assert_eq "64" "$(fan_speed_to_hex 100)" "formats 100 percent as IPMI hex" + assert_eq "05" "$(fan_speed_to_hex 5)" "pads single digit fan speeds for IPMI raw command" +} + +test_remote_quote_handles_single_quotes() { + assert_eq "'abc'\\''def'" "$(remote_quote "abc'def")" "quotes ESXi device IDs for remote shell" +} + +test_temperature_levels() { + TEMP_LOW=65 + TEMP_MEDIUM=70 + TEMP_HIGH=75 + TEMP_CRITICAL=80 + FAN_SPEED_IDLE=25 + FAN_SPEED_LOW=30 + FAN_SPEED_MEDIUM=40 + FAN_SPEED_HIGH=50 + FAN_SPEED_CRITICAL=60 + HYSTERESIS=2 + + assert_eq $'idle\t25' "$(choose_fan_speed 64)" "uses idle speed below TEMP_LOW" + assert_eq $'low\t30' "$(choose_fan_speed 65)" "uses low speed at TEMP_LOW" + assert_eq $'medium\t40' "$(choose_fan_speed 70)" "uses medium speed at TEMP_MEDIUM" + assert_eq $'high\t50' "$(choose_fan_speed 75)" "uses high speed at TEMP_HIGH" + assert_eq $'critical\t60' "$(choose_fan_speed 80)" "uses critical speed at TEMP_CRITICAL" +} + +test_hysteresis_only_delays_downshift() { + TEMP_LOW=65 + TEMP_MEDIUM=70 + TEMP_HIGH=75 + TEMP_CRITICAL=80 + HYSTERESIS=2 + + assert_eq $'high\t50' "$(choose_fan_speed 74 high)" "keeps previous high level inside hysteresis band" + assert_eq $'medium\t40' "$(choose_fan_speed 73 high)" "downshifts after leaving hysteresis band" + assert_eq $'critical\t60' "$(choose_fan_speed 80 high)" "does not delay upshift" +} + +test_idrac_sensor_parsing() { + local input + local output + + input=$'Inlet Temp | 04h | ok | 7.1 | 23 degrees C\nCPU1 Temp | na | ns | 3.1 | No Reading\nExhaust Temp | 05h | ok | 7.1 | 41 degrees C' + IDRAC_SENSOR_INCLUDE_REGEX="" + IDRAC_SENSOR_EXCLUDE_REGEX="no reading|disabled|not readable" + output="$(parse_idrac_sdr_temperatures <<< "$input")" + + assert_eq $'idrac\tInlet Temp\t23\nidrac\tExhaust Temp\t41' "$output" "parses readable iDRAC temperature sensors" +} + +test_decision_temperature_uses_max_adjusted_source() { + GPU_TEMP_OFFSET=15 + collect_temperature_readings() { + printf 'esxi\tdrive0\t65\n' + printf 'gpu\tgpu0\t90\n' + printf 'idrac\tInlet Temp\t30\n' + } + + local decision + decision="$(get_decision_temperature)" + assert_contains $'75\t' "$decision" "uses max of disk, iDRAC, and adjusted GPU temperature" + assert_contains "gpu:gpu0=90C(adjusted=75C)" "$decision" "records adjusted GPU detail" +} + +test_fail_safe_when_all_sources_fail() { + FAILSAFE_ON_ERROR=true + FAILSAFE_FAN_SPEED=70 + LAST_SPEED="" + LAST_LEVEL="" + + get_decision_temperature() { + return 1 + } + + set_fan_speed() { + LAST_SET_SPEED="$1" + } + + control_cycle >/dev/null 2>&1 + assert_eq "70" "$LAST_SET_SPEED" "applies fail-safe speed when readings fail" + assert_eq "failsafe" "$LAST_LEVEL" "records fail-safe level" +} + +test_validate_accepts_idrac_only_auto_mode() { + OPERATION_MODE=auto + TEMPERATURE_SOURCES=idrac + WITH_GPU_TEMP=false + DRY_RUN=true + IDRAC_IP=192.0.2.10 + IDRAC_ID=root + IDRAC_PASSWORD=test-password + ESXI_HOST="" + ESXI_PASSWORD="" + DRIVE_DEVICE="" + + validate_config auto >/dev/null 2>&1 || fail "validate_config should not require ESXi when TEMPERATURE_SOURCES=idrac" + pass +} + +test_validate_rejects_placeholder_values() { + ( + OPERATION_MODE=auto + TEMPERATURE_SOURCES=idrac + WITH_GPU_TEMP=false + DRY_RUN=true + IDRAC_IP=REPLACE_TO_YOUR_IDRAC_IP + IDRAC_ID=root + IDRAC_PASSWORD=REPLACE_TO_YOUR_IDRAC_PASSWORD + validate_config auto >/dev/null 2>&1 + ) && fail "validate_config should reject placeholder iDRAC values" + pass +} + +test_validate_rejects_esxi_password_placeholder() { + ( + OPERATION_MODE=auto + TEMPERATURE_SOURCES=esxi + WITH_GPU_TEMP=false + DRY_RUN=true + IDRAC_IP=192.0.2.10 + IDRAC_ID=root + IDRAC_PASSWORD=test-password + ESXI_HOST=192.0.2.20 + ESXI_USERNAME=root + ESXI_PASSWORD=change-me + ESXI_SSH_KEY="" + DRIVE_DEVICE=test-drive + validate_config auto >/dev/null 2>&1 + ) && fail "validate_config should reject placeholder ESXi password when no SSH key is configured" + pass +} + +test_fail_safe_can_be_disabled() { + FAILSAFE_ON_ERROR=false + LAST_SET_SPEED="" + + get_decision_temperature() { + return 1 + } + + set_fan_speed() { + LAST_SET_SPEED="$1" + } + + control_cycle >/dev/null 2>&1 && fail "control_cycle should fail when fail-safe is disabled and readings fail" + assert_eq "" "$LAST_SET_SPEED" "does not set a fan speed when fail-safe is disabled" +} + +test_normalize_sources +test_hex_formatting +test_remote_quote_handles_single_quotes +test_temperature_levels +test_hysteresis_only_delays_downshift +test_idrac_sensor_parsing +test_decision_temperature_uses_max_adjusted_source +test_fail_safe_when_all_sources_fail +test_validate_accepts_idrac_only_auto_mode +test_validate_rejects_placeholder_values +test_validate_rejects_esxi_password_placeholder +test_fail_safe_can_be_disabled + +printf 'ok - %s assertions passed\n' "$PASS_COUNT" From 20b072a98302f75848a41ad7b183a457b38bd997 Mon Sep 17 00:00:00 2001 From: df Date: Mon, 8 Jun 2026 01:38:20 +0800 Subject: [PATCH 2/3] chore(deploy): harden Docker and repository hygiene Remove tracked environment files, add ignore rules, and refresh the example configuration for the new controller. Update Docker, Compose, and GHCR publishing workflow so builds validate the entrypoint and CI checks the Compose configuration. --- .dockerignore | 17 ++++ .env | 61 ------------- .env.example | 126 +++++++++++++++++---------- .github/workflows/docker-publish.yml | 100 +++++++++++++-------- .gitignore | 11 +++ Dockerfile | 66 +++++--------- LICENSE | 21 +++++ docker-compose.yml | 33 ++----- src/.env | 39 --------- 9 files changed, 223 insertions(+), 251 deletions(-) create mode 100644 .dockerignore delete mode 100644 .env create mode 100644 .gitignore create mode 100644 LICENSE delete mode 100644 src/.env diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..73dc1dd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.github +.sisyphus + +.env +.env.* +!.env.example + +logs +tests +README.md +USAGE_GUIDE.md +images + +*.log +.DS_Store +Thumbs.db diff --git a/.env b/.env deleted file mode 100644 index f72a1e5..0000000 --- a/.env +++ /dev/null @@ -1,61 +0,0 @@ -# .env file for iDRAC Fan Control -# 使用者可以複製此文件並修改相應的參數來控制風扇行為 - -# ============================================================================= -# iDRAC 連接設定 (必須設定) -# ============================================================================= -IDRAC_IP=REPLACE_TO_YOUR_IDRAC_IP -IDRAC_ID=REPLACE_TO_YOUR_IDRAC_ID -IDRAC_PASSWORD=REPLACE_TO_YOUR_IDRAC_PASSWORD - -# ============================================================================= -# ESXi 連接設定 (磁碟溫度讀取用,必須設定) -# ============================================================================= -ESXI_HOST=REPLACE_TO_YOUR_ESXI_HOST -ESXI_USERNAME=REPLACE_TO_YOUR_ESXI_USERNAME -ESXI_PASSWORD=REPLACE_TO_YOUR_ESXI_PASSWORD - -# ============================================================================= -# 磁碟設定 -# ============================================================================= -# NVMe 磁碟識別碼,可通過 ESXi 的 esxcli storage core device list 命令獲得 -DRIVE_DEVICE=t10.NVMe____KCD61LUL7T68____________________________015E8306E28EE38C - -# ============================================================================= -# 溫度控制設定 -# ============================================================================= -# 溫度閾值 (攝氏度) - 已更新為新的預設值 -TEMP_LOW=65 -TEMP_MEDIUM=70 -TEMP_HIGH=75 -TEMP_CRITICAL=80 - -# 各溫度範圍對應的風扇轉速 (百分比) - 已更新為新的預設值 -FAN_SPEED_LOW=30 -FAN_SPEED_MEDIUM=40 -FAN_SPEED_HIGH=50 -FAN_SPEED_CRITICAL=60 - -# ============================================================================= -# 運行模式設定 -# ============================================================================= -# 運行模式: auto (自動控制) 或 manual (手動控制) -OPERATION_MODE=auto - -# 自動模式下的溫度檢查間隔時間 (秒) -CHECK_INTERVAL=60 - - -# ============================================================================= -# GPU 溫度監控設定 (可選) -# ============================================================================= -# 是否啟用 GPU 溫度監控 -# 設為 true 時,使用決策溫度 = max(磁碟溫度, GPU溫度 - GPU_TEMP_OFFSET) -# 設為 false 時,僅使用磁碟溫度 -# 注意:啟用 GPU 監控需要使用 --gpus all 運行容器 -WITH_GPU_TEMP=false - -# GPU 溫度偏移量 (攝氏度) -# 用於調整 GPU 溫度對風扇控制的影響程度 -# 預設值為 15°C,即 GPU 溫度會減去此值後與磁碟溫度比較 -GPU_TEMP_OFFSET=15 \ No newline at end of file diff --git a/.env.example b/.env.example index 708535a..2dcb2df 100644 --- a/.env.example +++ b/.env.example @@ -1,65 +1,95 @@ -# 範例 .env 文件 - 複製為 .env 並修改設定 -# Example .env file - Copy to .env and modify the settings +# Copy this file to .env and edit it for your server. +# Never commit your real .env because it contains management credentials. -# ===== iDRAC 設定 / iDRAC Configuration ===== -# iDRAC IP 位址、使用者 ID 和密碼 -# iDRAC IP address, User ID, and Password -IDRAC_IP=192.168.1.100 +# ----------------------------------------------------------------------------- +# iDRAC / IPMI +# ----------------------------------------------------------------------------- +IDRAC_IP=192.0.2.10 IDRAC_ID=root -IDRAC_PASSWORD=calvin +IDRAC_PASSWORD=change-me +IPMI_INTERFACE=lanplus +IPMI_TIMEOUT=5 +IPMI_RETRIES=2 -# ===== 儲存裝置設定 / Storage Configuration ===== -# NVMe 磁碟識別碼 - 可透過 ESXi 主機上的 esxcli storage core device list 取得 -# NVMe drive identifier - Can be obtained via esxcli storage core device list on ESXi host -DRIVE_DEVICE=t10.NVMe____KCD61LUL7T68____________________________015E8306E28EE38C +# ----------------------------------------------------------------------------- +# Runtime mode +# ----------------------------------------------------------------------------- +# auto: continuously monitor temperatures and adjust fan speed +# once: run one automatic control cycle and exit +# manual: set MANUAL_FAN_SPEED or pass a speed argument +OPERATION_MODE=auto +CHECK_INTERVAL=60 +COMMAND_TIMEOUT=20 +DRY_RUN=false + +# restore iDRAC automatic fan control when auto mode exits +RESTORE_AUTO_ON_EXIT=true + +# ----------------------------------------------------------------------------- +# Temperature sources +# ----------------------------------------------------------------------------- +# Supported values: esxi, idrac, gpu. Use comma-separated values for hybrid mode. +# esxi reads one NVMe SMART temperature through ESXi SSH. +# idrac reads all iDRAC temperature sensors through ipmitool. +# gpu reads NVIDIA GPU temperatures through nvidia-smi. +TEMPERATURE_SOURCES=esxi + +# Backward-compatible GPU switch. If true, gpu is appended to TEMPERATURE_SOURCES. +WITH_GPU_TEMP=false +GPU_TEMP_OFFSET=15 + +# Optional iDRAC sensor filtering. Leave include blank to use all readable +# temperature sensors returned by: ipmitool sdr type Temperature +IDRAC_SENSOR_INCLUDE_REGEX= +IDRAC_SENSOR_EXCLUDE_REGEX=no reading|disabled|not readable -# ===== 溫度閾值設定 / Temperature Thresholds ===== -# 溫度閾值 (攝氏度) -# Temperature thresholds (Celsius) +# ----------------------------------------------------------------------------- +# ESXi source settings +# Required only when TEMPERATURE_SOURCES includes esxi. +# ----------------------------------------------------------------------------- +ESXI_HOST=192.0.2.20 +ESXI_USERNAME=root +ESXI_PASSWORD=change-me +ESXI_SSH_KEY= +ESXI_SSH_PORT=22 +SSH_CONNECT_TIMEOUT=10 +SSH_STRICT_HOST_KEY_CHECKING=accept-new +DRIVE_DEVICE=t10.NVMe____replace_with_your_device_identifier + +# ----------------------------------------------------------------------------- +# Fan curve +# ----------------------------------------------------------------------------- +# The controller chooses the highest matching level: +# idle: temp < TEMP_LOW +# low: TEMP_LOW <= temp < TEMP_MEDIUM +# medium: TEMP_MEDIUM <= temp < TEMP_HIGH +# high: TEMP_HIGH <= temp < TEMP_CRITICAL +# critical: temp >= TEMP_CRITICAL TEMP_LOW=65 TEMP_MEDIUM=70 TEMP_HIGH=75 TEMP_CRITICAL=80 -# ===== 風扇轉速設定 / Fan Speed Configuration ===== -# 各溫度範圍對應的風扇轉速百分比 -# Fan speed percentages for each temperature range +FAN_SPEED_IDLE=25 FAN_SPEED_LOW=30 FAN_SPEED_MEDIUM=40 FAN_SPEED_HIGH=50 FAN_SPEED_CRITICAL=60 -# ===== 操作模式設定 / Operation Mode ===== -# 操作模式:'auto' 或 'manual' -# Operation mode: 'auto' or 'manual' -OPERATION_MODE=auto - -# 檢查間隔 (秒) - 僅在自動模式下有效 -# Check interval in seconds (only applicable in auto mode) -CHECK_INTERVAL=60 +# Prevent fan speed flapping near thresholds. Downshifts wait until the +# temperature falls this many degrees below the previous level threshold. +HYSTERESIS=2 -# ===== GPU 溫度監控設定 / GPU Temperature Monitoring ===== -# GPU 溫度控制開關 -# 設定為 'true' 以啟用 GPU 溫度監控進行決策溫度計算 -# 決策溫度 = max(磁碟溫度, GPU溫度 - GPU_TEMP_OFFSET) -# 預設值:false (僅使用磁碟溫度) -# GPU temperature control flag -# Set to 'true' to enable GPU temperature monitoring for decision temperature calculation -# Decision Temperature = max(Disk Temperature, GPU Temperature - GPU_TEMP_OFFSET) -# Default: false (only use disk temperature) -WITH_GPU_TEMP=false +# If all temperature sources fail, use this conservative fan speed instead of +# dropping to the lowest fan curve level. +FAILSAFE_ON_ERROR=true +FAILSAFE_FAN_SPEED=70 -# GPU 溫度偏移量 (攝氏度) -# 用於調整 GPU 溫度對風扇控制的影響程度 -# 預設值為 15°C,即 GPU 溫度會減去此值後與磁碟溫度比較 -# GPU temperature offset (Celsius) -# Used to adjust GPU temperature's impact on fan control -# Default is 15°C, meaning GPU temperature minus this value will be compared with disk temperature -GPU_TEMP_OFFSET=15 +# Manual mode only. You can also run: fan-control.sh manual 35 +MANUAL_FAN_SPEED=35 -# ===== ESXi 主機設定 / ESXi Host Configuration ===== -# ESXi 主機連線設定 - 用於取得磁碟溫度 -# ESXi host connection settings - Used for retrieving disk temperature -ESXI_HOST=192.168.1.10 -ESXI_USERNAME=root -ESXI_PASSWORD=your_esxi_password +# ----------------------------------------------------------------------------- +# Logging +# ----------------------------------------------------------------------------- +LOG_DIR=/var/log/fan-control +LOG_FILE=fan_control.log diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index e967116..17fa5f1 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,43 +1,73 @@ -name: IdracFanControl Docker Build and Push to GHCR +name: Build and publish Docker image on: push: branches: - - master # Change this to your default branch if it's different + - master + tags: + - "v*" + pull_request: + branches: + - master + +permissions: + contents: read + packages: write jobs: - build: + test: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Run tests + run: make test + + - name: Validate Compose file + run: | + cp .env.example .env + docker compose config >/tmp/compose.yml + + docker: runs-on: ubuntu-latest - permissions: - contents: read - packages: write + needs: test + if: github.event_name == 'push' steps: - # Step 1: Checkout the repository - - name: Checkout code - uses: actions/checkout@v3 - - # Step 2: Log in to GitHub Container Registry - - name: Log in to GHCR - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GH_TOKEN }} - - - name: Convert repository owner to lowercase - id: repo_owner_lowercase - run: echo "REPO_OWNER=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV - - - name: Log repository owner - run: echo "Repository owner in lowercase is $REPO_OWNER" - - - # Step 3: Build the Docker image - - name: Build Docker image - run: | - docker build -t ghcr.io/$REPO_OWNER/idrac-fan-control:latest . - - # Step 4: Push the Docker image to GHCR - - name: Push Docker image - run: | - docker push ghcr.io/$REPO_OWNER/idrac-fan-control:latest + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Normalize image name + id: image + run: | + owner="$(printf '%s' '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" + echo "name=ghcr.io/${owner}/idrac-fan-control" >> "$GITHUB_OUTPUT" + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ steps.image.outputs.name }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=sha,prefix=sha- + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8435fdd --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.env +.env.* +!.env.example + +logs/ +*.log + +.DS_Store +Thumbs.db + +.sisyphus/ diff --git a/Dockerfile b/Dockerfile index 112b2f1..e0fab70 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,56 +1,34 @@ -# Dockerfile for iDRAC Fan Speed Control with GPU Support -# 基於 NVIDIA 的 Ubuntu 24.04 基礎映像檔以支援 GPU 溫度監控 -# 如果不需要 GPU 支援,可以改用 alpine:latest -FROM nvidia/cuda:12.9.0-runtime-ubuntu24.04 - -# 設定維護者資訊 -LABEL maintainer="iDRAC Fan Control Service" -LABEL description="Intelligent fan speed control for Dell servers based on disk and GPU temperatures" - -# 設定環境變數以避免互動式安裝 -ENV DEBIAN_FRONTEND=noninteractive - -# 安裝必要的套件 -# - openssh-client: SSH 客戶端,用於連線到 ESXi 主機 -# - bash: Bash shell,執行腳本所需 -# - ipmitool: IPMI 工具,用於控制 iDRAC 風扇 -# - bc: 基本計算器,用於十進位到十六進位轉換 -# - sshpass: 提供 SSH 密碼認證功能 -# - nvidia-utils-* 已包含在基礎映像檔中,提供 nvidia-smi -RUN apt-get update && apt-get install -y \ - openssh-client \ +ARG BASE_IMAGE=nvidia/cuda:12.9.0-runtime-ubuntu24.04 +FROM ${BASE_IMAGE} + +LABEL org.opencontainers.image.title="iDRAC Fan Speed Control" \ + org.opencontainers.image.description="Automatic Dell iDRAC fan control using IPMI with ESXi, iDRAC, and optional NVIDIA GPU temperature sources" \ + org.opencontainers.image.source="https://github.com/DF-wu/iDRACFanSpeedControl" + +ENV DEBIAN_FRONTEND=noninteractive \ + LOG_DIR=/var/log/fan-control + +RUN apt-get update && apt-get install -y --no-install-recommends \ bash \ + ca-certificates \ + coreutils \ ipmitool \ - bc \ + openssh-client \ + procps \ sshpass \ + tini \ && rm -rf /var/lib/apt/lists/* -# 複製風扇控制腳本到容器內 COPY src/FanControlWithEsxiSmart.sh /usr/local/bin/fan-control.sh -# 設定腳本執行權限 -RUN chmod +x /usr/local/bin/fan-control.sh - -# 建立日誌目錄 -RUN mkdir -p /var/log/fan-control +RUN chmod +x /usr/local/bin/fan-control.sh \ + && bash -n /usr/local/bin/fan-control.sh \ + && /usr/local/bin/fan-control.sh help >/dev/null \ + && mkdir -p /var/log/fan-control -# 設定工作目錄 WORKDIR /var/log/fan-control -# 環境變數說明 (可在執行時覆蓋) -# IDRAC_IP: iDRAC 的 IP 位址 -# IDRAC_ID: iDRAC 登入帳號 -# IDRAC_PASSWORD: iDRAC 登入密碼 -# DRIVE_DEVICE: 要監控的磁碟設備 ID -# OPERATION_MODE: 操作模式 (auto/manual) -# WITH_GPU_TEMP: 是否啟用 GPU 溫度監控 (true/false) -# CHECK_INTERVAL: 自動模式的檢查間隔 (秒) -# LOG_DIR: 日誌輸出目錄 (預設: /var/log/fan-control) -ENV LOG_DIR=/var/log/fan-control - -# 健康檢查 - 檢查腳本是否能正常執行 HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=3 \ - CMD pgrep -f fan-control.sh || exit 1 + CMD /usr/local/bin/fan-control.sh healthcheck || exit 1 -# 設定進入點為風扇控制腳本 -ENTRYPOINT ["/usr/local/bin/fan-control.sh"] \ No newline at end of file +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/fan-control.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0ab4ac7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DF-wu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docker-compose.yml b/docker-compose.yml index d078f32..afbd71e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,30 +1,15 @@ -version: '3.8' - services: idrac-fan-control: - container_name: idrac-fan-control image: ghcr.io/df-wu/idrac-fan-control:latest + container_name: idrac-fan-control env_file: - - .env # 使用者可以將自己的 .env 文件掛載到此位置 + - .env volumes: - # 可選:掛載日誌目錄以保存風扇控制日誌 - ./logs:/var/log/fan-control - # 可選:掛載自定義 .env 文件(取消註解以使用) - # - /path/to/your/custom/.env:/app/.env:ro - networks: - - host # 使用主機網路以便 IPMI 通訊 - restart: always - - # 如果啟用 GPU 溫度監控 (WITH_GPU_TEMP=true),請取消註解下面的 GPU 配置 - # deploy: - # resources: - # reservations: - # devices: - # - driver: nvidia - # count: all - # capabilities: [gpu] - - # 或者使用舊版 docker-compose 語法: - # runtime: nvidia - # environment: - # - NVIDIA_VISIBLE_DEVICES=all \ No newline at end of file + network_mode: host + restart: unless-stopped + init: true + + # Enable this when TEMPERATURE_SOURCES includes gpu or WITH_GPU_TEMP=true. + # Requires NVIDIA Container Toolkit on the Docker host. + # gpus: all diff --git a/src/.env b/src/.env deleted file mode 100644 index 061406f..0000000 --- a/src/.env +++ /dev/null @@ -1,39 +0,0 @@ -# .env file for iDRAC Fan Control - -# iDRAC IP address, ID, and Password -IDRAC_IP=REPLACE_TO_YOUR_IDRAC_IP -IDRAC_ID=REPLACE_TO_YOUR_IDRAC_ID -IDRAC_PASSWORD=REPLACE_TO_YOUR_IDRAC_PASSWORD - -# NVMe drive identifier -DRIVE_DEVICE=t10.NVMe____KCD61LUL7T68_________________________ - -# Temperature thresholds (optional, these are the defaults in the script) -TEMP_LOW=65 -TEMP_MEDIUM=70 -TEMP_HIGH=75 -TEMP_CRITICAL=80 - -# Fan speed percentages for each temperature range (optional, defaults in script) -FAN_SPEED_LOW=30 -FAN_SPEED_MEDIUM=40 -FAN_SPEED_HIGH=50 -FAN_SPEED_CRITICAL=60 - -# Operation mode: 'auto' or 'manual' -OPERATION_MODE=auto - -# Interval to check the temperature in seconds (only applicable in auto mode) -CHECK_INTERVAL=60 - - -# GPU temperature control flag -# Set to 'true' to enable GPU temperature monitoring for decision temperature calculation -# Decision Temperature = max(Disk Temperature, GPU Temperature - 20°C) -# Default: false (only use disk temperature) -WITH_GPU_TEMP=false - -# ESXi variables -ESXI_HOST=REPLACE_TO_YOUR_ESXI_HOST -ESXI_USERNAME=REPLACE_TO_YOUR_ESXI_USERNAME -ESXI_PASSWORD=REPLACE_TO_YOUR_ESXI_PASSWORD From d595be350c4bc0b905e1c07bba4b90685dd56b49 Mon Sep 17 00:00:00 2001 From: df Date: Mon, 8 Jun 2026 01:39:02 +0800 Subject: [PATCH 3/3] docs: rewrite setup guide and runbook Replace the stale README with a complete guide for safe operation, configuration, testing, and troubleshooting. Convert the usage guide into a short day-to-day runbook and move the iDRAC screenshot into a correctly named images directory. --- README.md | 525 ++++++++++++++++++++--------------- USAGE_GUIDE.md | 470 ++++--------------------------- {imgaes => images}/image.png | Bin 3 files changed, 355 insertions(+), 640 deletions(-) rename {imgaes => images}/image.png (100%) diff --git a/README.md b/README.md index 8ed0f65..116f521 100644 --- a/README.md +++ b/README.md @@ -1,330 +1,411 @@ -# 🌡️ iDRAC Fan Speed Control +# iDRAC Fan Speed Control -[![Docker](https://img.shields.io/badge/Docker-Supported-blue?logo=docker)](https://hub.docker.com/) -[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) -[![Tested](https://img.shields.io/badge/Tested-Dell%20R730XD-success)](https://www.dell.com/) +[![Docker image](https://img.shields.io/badge/GHCR-idrac--fan--control-blue)](https://github.com/DF-wu/iDRACFanSpeedControl/pkgs/container/idrac-fan-control) +[![Tests](https://img.shields.io/badge/tests-make%20test-green)](#testing) +[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) -**Intelligent fan speed control for Dell PowerEdge servers** with smart temperature monitoring and GPU support. Control your server fans through IPMI commands over LAN, tested on R730XD servers with iDRAC 8. +Dell PowerEdge 伺服器風扇控制工具。它透過 iDRAC/IPMI raw command 設定風扇轉速,並可依 ESXi NVMe SMART、iDRAC temperature sensors、NVIDIA GPU 溫度自動調整。 -## ✨ Features +> Safety first: 這個專案會覆寫 Dell 原廠風扇控制。設定錯誤可能讓硬體過熱。第一次使用請在旁監看溫度、保留 iDRAC Web UI 存取權,並確認 `restore` 指令可用。 -- 🧠 **Smart Temperature Monitoring**: Automatically adjusts fan speed based on NVMe disk temperatures -- 🎮 **GPU Temperature Support**: Optional GPU temperature monitoring with NVIDIA Container Toolkit -- ⚙️ **Environment Variable Control**: Complete configuration through `.env` files -- 🐳 **Docker Deployment**: One-click deployment with no manual environment setup -- 🔥 **Decision Temperature Algorithm**: `max(disk_temp, gpu_temp - GPU_TEMP_OFFSET)` ensures optimal cooling -- 📊 **Configurable Thresholds**: Customize temperature and fan speed settings -- 🔄 **Auto/Manual Modes**: Flexible operation modes for different use cases +![iDRAC 8 IPMI over LAN setting](images/image.png) -## ⚠️ Known Limitations +## What It Does -### ESXi Dependency for Auto Mode Temperature +- `auto`: 持續讀取溫度,依 fan curve 設定風扇。 +- `once`: 執行一次溫度讀取與風扇調整後離開。 +- `manual`: 手動設定 1-100% 風扇 duty cycle。 +- `restore`: 將 iDRAC 還原為 Dell 自動風扇控制。 +- `status`: 印出 iDRAC chassis status 與 temperature sensors。 +- `validate`: 檢查本機設定是否足以啟動目前模式。 -In `OPERATION_MODE=auto`, this project currently **requires an ESXi host with SSH enabled** to read NVMe disk temperatures. `OPERATION_MODE=manual` only sends iDRAC/IPMI fan commands and does not require ESXi credentials. Auto-mode temperature is retrieved by SSH'ing into ESXi and running `esxcli storage core device smart get` for a single NVMe drive. This means: +## Improvements In This Version -- **No CPU or ambient temperature is monitored** — only one NVMe disk's SMART temperature is used. Server CPU, inlet, and exhaust sensors accessible via iDRAC IPMI are not utilized. -- **If ESXi is unreachable**, the script silently falls back to `0°C`, causing fan speed to drop to `FAN_SPEED_LOW` (30%). This is **not fail-safe** — if the ESXi host becomes unresponsive while the server is under load, fans will run at minimum speed. -- **Password-based SSH only** (`sshpass`). SSH key authentication is not supported. -- **New SSH connection per check cycle** — each `CHECK_INTERVAL` (default 60s) opens a fresh connection, adding latency and auth log noise. +- 溫度來源可選 `esxi`、`idrac`、`gpu`,也可混用。 +- 溫度讀取失敗時套用 `FAILSAFE_FAN_SPEED`,不再掉到最低風扇轉速。 +- 支援 hysteresis,避免溫度卡在閾值附近時風扇反覆跳速。 +- 支援 ESXi SSH password 或 key authentication。 +- IPMI 密碼改用 `IPMI_PASSWORD` environment 傳給 `ipmitool -E`,避免出現在 command line。 +- 舊手動腳本已改成 wrapper,共用同一份核心邏輯。 +- 新增 `make test`、Docker healthcheck、`.dockerignore`、`.gitignore`、MIT `LICENSE`。 -**Planned improvements**: Decouple temperature from ESXi by supporting direct IPMI sensor reading, local SMART via `smartctl`/`nvme`, and adding a fail-safe fallback speed when temperature readings fail. See [TODO](#todo) below. +## Requirements -## 🚀 Quick Start +| Requirement | Why | +| --- | --- | +| Dell PowerEdge with iDRAC | 送出 IPMI fan raw commands | +| iDRAC IPMI over LAN enabled | `ipmitool -I lanplus` 需要 LAN access | +| Docker / Docker Compose | 建議部署方式 | +| ESXi SSH access | 只有 `TEMPERATURE_SOURCES` 包含 `esxi` 時需要 | +| NVIDIA Container Toolkit | 只有 `gpu` 溫度來源需要 | -### Step 1: Setup Environment +Tested target: Dell PowerEdge R730xd/R730XD class servers with iDRAC 8. Other Dell models may support the same OEM raw commands, but you should test with `manual` and `restore` before running unattended. -Clone the repository and copy the example configuration: +## Quick Start + +1. Clone the repository. ```bash -git clone +git clone https://github.com/DF-wu/iDRACFanSpeedControl.git cd iDRACFanSpeedControl -cp .env.example .env ``` -Edit the `.env` file with your iDRAC connection details. ESXi connection details are required when using `OPERATION_MODE=auto`: +2. Create a private environment file. ```bash -# iDRAC Configuration -IDRAC_IP=192.168.1.100 -IDRAC_ID=root -IDRAC_PASSWORD=calvin +cp .env.example .env +chmod 600 .env +``` -# ESXi Configuration (required for auto mode disk temperature) -ESXI_HOST=192.168.1.10 -ESXI_USERNAME=root -ESXI_PASSWORD=your_esxi_password +3. Edit `.env`. -# Drive Identifier -DRIVE_DEVICE=t10.NVMe____KCD61LUL7T68____________________________015E8306E28EE38C +Minimum iDRAC-only auto mode: -# GPU Temperature Monitoring (Optional) -WITH_GPU_TEMP=false # Set to true to enable GPU temperature monitoring +```env +IDRAC_IP=192.0.2.10 +IDRAC_ID=root +IDRAC_PASSWORD=change-me +OPERATION_MODE=auto +TEMPERATURE_SOURCES=idrac ``` -### Step 2: Deploy the Service +ESXi NVMe SMART mode: -Deploy using Docker Compose: - -```bash -docker-compose up -d +```env +TEMPERATURE_SOURCES=esxi +ESXI_HOST=192.0.2.20 +ESXI_USERNAME=root +ESXI_PASSWORD=change-me +DRIVE_DEVICE=t10.NVMe____replace_with_your_device_identifier ``` -### Step 3: Enable GPU Temperature Monitoring (Optional) +4. Validate and start. -To enable GPU temperature monitoring: +```bash +docker compose run --rm idrac-fan-control validate +docker compose up -d +docker logs -f idrac-fan-control +``` -1. Set `WITH_GPU_TEMP=true` in your `.env` file -2. Uncomment the GPU configuration in `docker-compose.yml`: +5. Restore Dell automatic control when needed. -```yaml -deploy: - resources: - reservations: - devices: - - driver: nvidia - count: all - capabilities: [gpu] +```bash +docker compose run --rm idrac-fan-control restore ``` -## ⚙️ Configuration +## Commands -### Temperature Thresholds +Docker Compose passes extra arguments to the container entrypoint: ```bash -TEMP_LOW=65 # Low temperature threshold (°C) -TEMP_MEDIUM=70 # Medium temperature threshold (°C) -TEMP_HIGH=75 # High temperature threshold (°C) -TEMP_CRITICAL=80 # Critical temperature threshold (°C) -``` +# Run continuously according to OPERATION_MODE=auto +docker compose up -d -### Fan Speed Settings +# Run one automatic cycle +docker compose run --rm idrac-fan-control once -```bash -FAN_SPEED_LOW=30 # Fan speed for low temperature (%) -FAN_SPEED_MEDIUM=40 # Fan speed for medium temperature (%) -FAN_SPEED_HIGH=50 # Fan speed for high temperature (%) -FAN_SPEED_CRITICAL=60 # Fan speed for critical temperature (%) +# Set manual fan speed to 35% +docker compose run --rm idrac-fan-control manual 35 + +# Restore Dell automatic fan control +docker compose run --rm idrac-fan-control restore + +# Print chassis and temperature sensor status +docker compose run --rm idrac-fan-control status + +# Validate config without touching fan speed +docker compose run --rm idrac-fan-control validate ``` -### Operation Modes +Local script usage is the same: ```bash -OPERATION_MODE=auto # auto: Automatic mode | manual: Manual mode -CHECK_INTERVAL=60 # Check interval in seconds (auto mode only) +src/FanControlWithEsxiSmart.sh manual 35 +src/FanControlWithEsxiSmart.sh restore ``` -## 🧮 Decision Temperature Algorithm +## Temperature Sources + +`TEMPERATURE_SOURCES` accepts comma-separated values. + +| Source | Reads | Requires | +| --- | --- | --- | +| `esxi` | One NVMe drive's SMART temperature through `esxcli` | ESXi SSH, `DRIVE_DEVICE` | +| `idrac` | iDRAC temperature sensors from `ipmitool sdr type Temperature` | iDRAC credentials | +| `gpu` | NVIDIA GPU temperature from `nvidia-smi` | NVIDIA Container Toolkit | -When GPU temperature monitoring is enabled, the system uses this algorithm to calculate the decision temperature: +Hybrid mode uses the highest decision temperature: ```text -Decision Temperature = max(Disk Temperature, GPU Temperature - GPU_TEMP_OFFSET) +decision_temperature = max(esxi_temp, idrac_sensor_temps..., gpu_temp - GPU_TEMP_OFFSET) ``` -This algorithm ensures: +Examples: -- **Proactive GPU cooling**: High GPU temperatures trigger increased fan speeds -- **Configurable offset compensation**: Adjustable offset (`GPU_TEMP_OFFSET`) accounts for thermal differences between GPU and system -- **Disk temperature baseline**: Disk temperature always serves as the minimum baseline +```env +# No ESXi dependency. Use iDRAC sensors only. +TEMPERATURE_SOURCES=idrac -### Algorithm Examples +# NVMe SMART plus iDRAC ambient/CPU sensors. +TEMPERATURE_SOURCES=esxi,idrac -With default `GPU_TEMP_OFFSET=15°C`: +# NVMe SMART plus GPU compensation. +TEMPERATURE_SOURCES=esxi,gpu +GPU_TEMP_OFFSET=15 -| Disk Temp | GPU Temp | GPU-15 | Decision Temp | Reasoning | -|-----------|----------|--------|---------------|-----------| -| 65°C | 70°C | 55°C | **65°C** | Disk temperature is higher | -| 65°C | 85°C | 70°C | **70°C** | GPU-15 is higher, use adjusted GPU temp | -| 75°C | 80°C | 65°C | **75°C** | Disk temperature remains baseline | +# Backward-compatible switch. This appends gpu to TEMPERATURE_SOURCES. +WITH_GPU_TEMP=true +``` -## 🔧 Troubleshooting +## Fan Curve -### Common Issues +The controller maps the decision temperature to a fan level. -#### GPU Temperature Reading Fails +| Level | Temperature range | Default speed | +| --- | --- | --- | +| `idle` | `< TEMP_LOW` | `FAN_SPEED_IDLE=25` | +| `low` | `TEMP_LOW` to `< TEMP_MEDIUM` | `FAN_SPEED_LOW=30` | +| `medium` | `TEMP_MEDIUM` to `< TEMP_HIGH` | `FAN_SPEED_MEDIUM=40` | +| `high` | `TEMP_HIGH` to `< TEMP_CRITICAL` | `FAN_SPEED_HIGH=50` | +| `critical` | `>= TEMP_CRITICAL` | `FAN_SPEED_CRITICAL=60` | +| `failsafe` | all temperature sources failed | `FAILSAFE_FAN_SPEED=70` | -```bash -# Check if NVIDIA GPU is available -nvidia-smi +Default thresholds: -# Verify Docker GPU support -docker run --rm --gpus all nvidia/cuda:12.9.0-runtime-ubuntu24.04 nvidia-smi +```env +TEMP_LOW=65 +TEMP_MEDIUM=70 +TEMP_HIGH=75 +TEMP_CRITICAL=80 +HYSTERESIS=2 ``` -#### Cannot Connect to ESXi Host +Hysteresis only delays downshifts. For example, if the current level is `high` and `TEMP_HIGH=75`, the controller keeps `high` until the temperature falls to `73C` or lower when `HYSTERESIS=2`. + +## Configuration Reference + +| Variable | Default | Notes | +| --- | --- | --- | +| `IDRAC_IP` | empty | Required for all fan commands | +| `IDRAC_ID` | `root` | iDRAC username | +| `IDRAC_PASSWORD` | empty | iDRAC password | +| `IPMI_INTERFACE` | `lanplus` | Usually keep this value | +| `IPMI_TIMEOUT` | `5` | Seconds per IPMI attempt | +| `IPMI_RETRIES` | `2` | IPMI retry count | +| `OPERATION_MODE` | `manual` | `auto`, `once`, or `manual` | +| `TEMPERATURE_SOURCES` | `esxi` | Comma-separated: `esxi,idrac,gpu` | +| `WITH_GPU_TEMP` | `false` | Backward-compatible GPU switch | +| `GPU_TEMP_OFFSET` | `15` | Subtracted from GPU temperature | +| `CHECK_INTERVAL` | `60` | Seconds between auto cycles | +| `COMMAND_TIMEOUT` | `20` | Wrapper timeout for SSH, IPMI, and GPU reads | +| `ESXI_HOST` | empty | Required when source includes `esxi` | +| `ESXI_USERNAME` | `root` | ESXi SSH username | +| `ESXI_PASSWORD` | empty | Use password auth when `ESXI_SSH_KEY` is empty | +| `ESXI_SSH_KEY` | empty | Optional SSH private key path | +| `ESXI_SSH_PORT` | `22` | ESXi SSH port | +| `SSH_CONNECT_TIMEOUT` | `10` | SSH connection timeout | +| `DRIVE_DEVICE` | empty | ESXi NVMe device identifier | +| `TEMP_LOW` | `65` | Idle to low threshold | +| `TEMP_MEDIUM` | `70` | Low to medium threshold | +| `TEMP_HIGH` | `75` | Medium to high threshold | +| `TEMP_CRITICAL` | `80` | High to critical threshold | +| `FAN_SPEED_IDLE` | `25` | Fan speed below `TEMP_LOW` | +| `FAN_SPEED_LOW` | `30` | Fan speed at low level | +| `FAN_SPEED_MEDIUM` | `40` | Fan speed at medium level | +| `FAN_SPEED_HIGH` | `50` | Fan speed at high level | +| `FAN_SPEED_CRITICAL` | `60` | Fan speed at critical level | +| `HYSTERESIS` | `2` | Degrees C before downshift | +| `FAILSAFE_ON_ERROR` | `true` | Apply fail-safe when all sources fail | +| `FAILSAFE_FAN_SPEED` | `70` | Conservative speed for sensor failure | +| `RESTORE_AUTO_ON_EXIT` | `true` | Restore Dell auto control when auto mode exits | +| `MANUAL_FAN_SPEED` | empty | Used by `manual` mode without an argument | +| `LOG_DIR` | `/var/log/fan-control` | Set empty only for tests | +| `LOG_FILE` | `fan_control.log` | Status log file name | +| `DRY_RUN` | `false` | Log commands without running `ipmitool` | + +## Getting The ESXi Drive Identifier + +SSH to the ESXi host and list storage devices: ```bash -# Test SSH connection -ssh root@your_esxi_host - -# Note: ESXi SSH service must be enabled +ssh root@192.0.2.20 +esxcli storage core device list ``` -#### iDRAC Connection Failed +Find the NVMe device ID and test SMART temperature output: ```bash -# Test iDRAC connection -ipmitool -I lanplus -H your_idrac_ip -U root -P calvin chassis status +esxcli storage core device smart get -d t10.NVMe____replace_with_your_device_identifier ``` -### Monitoring and Logs +Use that full ID as `DRIVE_DEVICE`. + +## GPU Mode + +Install NVIDIA Container Toolkit on the Docker host, then enable GPU access in `docker-compose.yml`: + +```yaml +gpus: all +``` + +Set one of these in `.env`: + +```env +TEMPERATURE_SOURCES=esxi,gpu +# or +WITH_GPU_TEMP=true +``` + +Verify GPU access: ```bash -# View container logs -docker logs idrac-fan-control +docker run --rm --gpus all nvidia/cuda:12.9.0-runtime-ubuntu24.04 nvidia-smi +docker compose run --rm idrac-fan-control status +``` + +## Logs -# View fan control records -docker exec idrac-fan-control tail -f /var/log/fan-control/fan_control.log +Container logs show control decisions: + +```bash +docker logs -f idrac-fan-control ``` -## 📋 Environment Variables Reference - -| Variable | Default | Description | -|----------|---------|-------------| -| `IDRAC_IP` | - | iDRAC IP address | -| `IDRAC_ID` | root | iDRAC username | -| `IDRAC_PASSWORD` | - | iDRAC password | -| `ESXI_HOST` | - | ESXi host IP (required for `OPERATION_MODE=auto`) | -| `ESXI_USERNAME` | root | ESXi username (required for `OPERATION_MODE=auto`) | -| `ESXI_PASSWORD` | - | ESXi password (required for `OPERATION_MODE=auto`) | -| `DRIVE_DEVICE` | - | Disk identifier to monitor | -| `TEMP_LOW` | 65 | Low temperature threshold (°C) | -| `TEMP_MEDIUM` | 70 | Medium temperature threshold (°C) | -| `TEMP_HIGH` | 75 | High temperature threshold (°C) | -| `TEMP_CRITICAL` | 80 | Critical temperature threshold (°C) | -| `FAN_SPEED_LOW` | 30 | Low temperature fan speed (%) | -| `FAN_SPEED_MEDIUM` | 40 | Medium temperature fan speed (%) | -| `FAN_SPEED_HIGH` | 50 | High temperature fan speed (%) | -| `FAN_SPEED_CRITICAL` | 60 | Critical temperature fan speed (%) | -| `OPERATION_MODE` | manual | Operation mode (auto/manual) | -| `CHECK_INTERVAL` | 60 | Check interval in seconds | -| `WITH_GPU_TEMP` | false | Enable GPU temperature monitoring | -| `GPU_TEMP_OFFSET` | 15 | GPU temperature offset for decision algorithm (°C) | - -## 🐳 Docker Compose Examples - -### Basic Configuration (Disk Temperature Only) +Persistent fan-control logs are written to `./logs/fan_control.log` by the Compose volume: -```yaml -version: '3.8' -services: - idrac-fan-control: - container_name: idrac-fan-control - image: ghcr.io/df-wu/idrac-fan-control:latest - env_file: - - .env - volumes: - - ./logs:/var/log/fan-control - network_mode: host - restart: always +```bash +tail -f logs/fan_control.log ``` -### GPU-Enabled Configuration +Each line includes `status`, `temp`, `level`, `fan`, and source details. -```yaml -version: '3.8' -services: - idrac-fan-control: - container_name: idrac-fan-control - image: ghcr.io/df-wu/idrac-fan-control:latest - env_file: - - .env - volumes: - - ./logs:/var/log/fan-control - network_mode: host - restart: always - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: all - capabilities: [gpu] +## Testing + +Run local tests: + +```bash +make test ``` -## 🔍 Performance Tuning +The tests cover: + +- Bash syntax checks. +- Temperature source normalization. +- iDRAC sensor parsing. +- GPU offset decision logic. +- Fan curve and hysteresis behavior. +- Fail-safe behavior when every source fails. +- Validation for iDRAC-only auto mode. -### Adjusting Check Intervals +Run a dry validation target: ```bash -# Reduce system load (slower response) -CHECK_INTERVAL=120 # Check every 2 minutes +make validate +``` -# Increase responsiveness (higher system load) -CHECK_INTERVAL=30 # Check every 30 seconds +Build a local image: + +```bash +make docker-build ``` -### Custom Temperature Profiles +If you do not need GPU support, build with a smaller Ubuntu base: ```bash -# Conservative Profile (fans start early, quieter operation) -TEMP_LOW=60 -TEMP_MEDIUM=65 -TEMP_HIGH=70 -TEMP_CRITICAL=75 +docker build --build-arg BASE_IMAGE=ubuntu:24.04 -t idrac-fan-control:local . +``` -# Aggressive Profile (fans start later, potentially noisier but more efficient) -TEMP_LOW=70 -TEMP_MEDIUM=75 -TEMP_HIGH=80 -TEMP_CRITICAL=85 +## Troubleshooting + +### `validate` says iDRAC config is missing + +Set `IDRAC_IP`, `IDRAC_ID`, and `IDRAC_PASSWORD` in `.env`. Also confirm IPMI over LAN is enabled in iDRAC. + +```bash +ipmitool -I lanplus -H "$IDRAC_IP" -U "$IDRAC_ID" -P "$IDRAC_PASSWORD" chassis status ``` -## 💻 Supported Hardware +### Auto mode fails because ESXi is missing -- ✅ **Tested**: Dell PowerEdge R730XD (iDRAC 8) -- 🔄 **Compatible**: All Dell PowerEdge servers with IPMI 2.0 support -- 🎮 **GPU Support**: NVIDIA GPUs (requires NVIDIA Container Toolkit) +If you do not want ESXi SSH, use iDRAC sensors: -## 📋 TODO / Roadmap +```env +TEMPERATURE_SOURCES=idrac +``` -### High Priority +If you want ESXi SMART temperature, set `ESXI_HOST`, `ESXI_USERNAME`, `ESXI_PASSWORD` or `ESXI_SSH_KEY`, and `DRIVE_DEVICE`. -- [ ] **Decouple temperature from ESXi** — Add support for direct iDRAC IPMI sensor reading (`ipmitool sdr`) as primary or fallback temperature source. This would read CPU, inlet, and exhaust temperatures without needing ESXi SSH. -- [ ] **Fail-safe fallback behavior** — When temperature reading fails (SSH timeout, ESXi unreachable), default to a configurable `TEMP_FAILSAFE_SPEED` (e.g., 60%) instead of silently dropping to `FAN_SPEED_LOW`. -- [ ] **Add CPU temperature monitoring** — Read CPU/ambient/exhaust sensors via IPMI and include them in the decision temperature calculation. +### Temperature reads fail and fans jump to fail-safe -### Medium Priority +That is intentional. The controller uses `FAILSAFE_FAN_SPEED` when no temperature source succeeds. Check the source-specific logs, then test each dependency: -- [ ] **SSH key authentication** — Support `ESXI_SSH_KEY` alongside password-based auth. Remove `sshpass` dependency when using keys. -- [ ] **Connection retry + health check** — Validate ESXi connectivity at startup. Add retry with backoff on SSH failures. -- [ ] **SSH connection reuse** — Use SSH `ControlMaster` to avoid opening a new connection every cycle. -- [ ] **Temperature hysteresis** — Add configurable deadband (e.g., 3°C) to prevent fan speed oscillation at threshold boundaries. -- [ ] **Log rotation** — Add log rotation or size limits for long-running deployments. +```bash +docker compose run --rm idrac-fan-control status +ssh root@your-esxi-host +nvidia-smi +``` -### Low Priority / Nice to Have +### Manual mode works, but auto mode is too noisy -- [ ] **Prometheus metrics endpoint** — Expose temperature and fan speed metrics for monitoring dashboards. -- [ ] **Health endpoint / webhook alerting** — Notify on temperature threshold breaches or connection failures. -- [ ] **Gradual fan ramping** — Smooth fan speed transitions instead of abrupt steps. -- [ ] **Clean up dead code** — Remove `src/.env` and `src/setIdracFanSpeed.sh`, or consolidate with main config. -- [ ] **Fix `imgaes/` directory typo** → `images/`. -- [ ] **Version tagging** — Add semver tags to Docker images instead of only `:latest`. -- [ ] **Tests** — Add basic smoke tests for temperature logic and IPMI command formatting. +Raise the fan curve or add hysteresis: -## 📄 License +```env +TEMP_LOW=70 +TEMP_MEDIUM=75 +TEMP_HIGH=80 +TEMP_CRITICAL=85 +HYSTERESIS=3 +``` -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +### Restore automatic control -## 🤝 Contributing +Run: -Contributions are welcome! Please feel free to submit a Pull Request or open an Issue for bugs and feature requests. +```bash +docker compose run --rm idrac-fan-control restore +``` + +This sends: + +```text +raw 0x30 0x30 0x01 0x01 +``` ---- +## Security Notes -## 🇹🇼 中文說明 +- Keep `.env` private. This repository now ignores `.env`; if your clone still tracks it, run `git rm --cached .env`. +- Prefer a dedicated iDRAC account if your iDRAC version supports suitable privileges. +- Keep iDRAC and ESXi on a management network, not a public network. +- Prefer `ESXI_SSH_KEY` over password auth when possible. +- Review logs after every fan curve change. + +## Repository Layout + +```text +. +├── src/ +│ ├── FanControlWithEsxiSmart.sh # main entrypoint +│ └── setIdracFanSpeed.sh # backward-compatible manual wrapper +├── tests/ +│ └── fan-control.test.sh # shell tests with mocked behavior +├── images/ +│ └── image.png # iDRAC IPMI over LAN screenshot +├── .env.example # documented configuration template +├── docker-compose.yml +├── Dockerfile +├── Makefile +└── README.md +``` -這是一個 Dell PowerEdge 伺服器的智慧風扇控制工具,主要功能包括: +## Roadmap -- **智慧溫度監控**:根據磁碟和 GPU 溫度自動調節風扇轉速 -- **Docker 部署**:使用 Docker Compose 一鍵部署 -- **環境變數控制**:所有設定都可以透過 .env 文件控制 -- **決策溫度算法**:`max(磁碟溫度, GPU溫度-GPU_TEMP_OFFSET)`(預設偏移 15°C,可自訂)確保最佳散熱效果 +- Add structured metrics output for Prometheus or node exporters. +- Add optional fan ramping for smoother transitions. +- Add log rotation examples for long-running hosts. +- Add model-specific notes for more Dell PowerEdge generations. -### 快速使用 +## License -1. 複製 `.env.example` 為 `.env` 並修改設定 -2. 執行 `docker-compose up -d` 啟動服務 -3. 如需 GPU 支援,請設定 `WITH_GPU_TEMP=true` 並開啟 Docker Compose 中的 GPU 配置 +MIT. See [LICENSE](LICENSE). -已在 Dell R730XD (iDRAC 8) 上測試通過。 +Last reviewed: 2026-06-08. diff --git a/USAGE_GUIDE.md b/USAGE_GUIDE.md index 9bb3859..a6e09c5 100644 --- a/USAGE_GUIDE.md +++ b/USAGE_GUIDE.md @@ -1,493 +1,127 @@ -# 📖 iDRAC Fan Control Usage Guide +# iDRAC Fan Control Runbook -A comprehensive guide for deploying and using the iDRAC Fan Speed Control system with Docker. +This runbook is for day-to-day operation. Use [README.md](README.md) for setup, full configuration, and troubleshooting details. -## 🎯 Overview +## Daily Commands -This guide covers everything you need to know about deploying, configuring, and troubleshooting the iDRAC Fan Speed Control system. The system intelligently manages Dell PowerEdge server fan speeds based on disk and optional GPU temperatures. - -## 📋 Prerequisites - -### System Requirements - -- **Docker** and **Docker Compose** installed -- Network access to both **iDRAC** and **ESXi** hosts -- **NVIDIA Container Toolkit** (optional, for GPU temperature monitoring) - -### Required Information - -Before deployment, gather the following information: - -1. **iDRAC Details**: - - IP address - - Username (typically `root`) - - Password - -2. **ESXi Host Details**: - - IP address or hostname - - Username (typically `root`) - - Password - -3. **Drive Identifier**: - - NVMe drive identifier from ESXi - -## 🔍 Getting Drive Identifier - -The drive identifier is crucial for temperature monitoring. Here's how to obtain it: - -### Method 1: SSH to ESXi Host +Start the controller: ```bash -# SSH to your ESXi host -ssh root@your_esxi_host - -# List all storage devices -esxcli storage core device list - -# Look for your NVMe drive and copy the Device UID -# Example output: -# t10.NVMe____KCD61LUL7T68____________________________015E8306E28EE38C +docker compose up -d ``` -### Method 2: vSphere Client - -1. Log into vSphere Client -2. Navigate to **Host** → **Configure** → **Storage Devices** -3. Find your NVMe drive and note the identifier - -## 🚀 Deployment Guide - -### Step 1: Download Project Files +Watch decisions: ```bash -# Clone the repository -git clone -cd iDRACFanSpeedControl - -# Or download specific files -wget https://raw.githubusercontent.com/username/repo/main/docker-compose.yml -wget https://raw.githubusercontent.com/username/repo/main/.env.example -``` - -### Step 2: Configure Environment - -```bash -# Copy example configuration -cp .env.example .env - -# Edit the configuration file -nano .env -``` - -### Step 3: Basic Configuration - -Edit your `.env` file with the following minimum required settings: - -```bash -# ===== Required Settings ===== -IDRAC_IP=192.168.1.100 -IDRAC_ID=root -IDRAC_PASSWORD=your_idrac_password - -ESXI_HOST=192.168.1.10 -ESXI_USERNAME=root -ESXI_PASSWORD=your_esxi_password - -DRIVE_DEVICE=your_drive_identifier_here - -# ===== Optional Settings ===== -WITH_GPU_TEMP=false -OPERATION_MODE=auto -CHECK_INTERVAL=60 -``` - -### Step 4: Deploy the Service - -#### Basic Deployment (Disk Temperature Only) - -```bash -# Start the service -docker-compose up -d - -# Check if it's running -docker ps -``` - -#### GPU-Enhanced Deployment - -If you want GPU temperature monitoring: - -1. **Enable GPU in configuration**: - -```bash -# In .env file -WITH_GPU_TEMP=true -``` - -1. **Update docker-compose.yml**: - -Uncomment the GPU configuration section: - -```yaml -deploy: - resources: - reservations: - devices: - - driver: nvidia - count: all - capabilities: [gpu] -``` - -1. **Deploy with GPU support**: - -```bash -docker-compose up -d -``` - -## 📊 Monitoring and Management - -### Viewing Logs - -```bash -# Real-time log viewing docker logs -f idrac-fan-control - -# View specific number of recent log lines -docker logs --tail 100 idrac-fan-control - -# View logs from a specific time -docker logs --since 30m idrac-fan-control +tail -f logs/fan_control.log ``` -### Checking Service Status +Run one automatic cycle: ```bash -# Check container status -docker ps - -# View container resource usage -docker stats idrac-fan-control - -# Inspect container configuration -docker inspect idrac-fan-control -``` - -### Accessing Log Files - -If you've mounted the logs directory: - -```bash -# View fan control log -tail -f ./logs/fan_control.log - -# View recent entries -cat ./logs/fan_control.log | tail -20 +docker compose run --rm idrac-fan-control once ``` -## ⚙️ Advanced Configuration - -### Temperature Profile Customization - -#### Conservative Profile (Quieter Operation) +Set a manual fan speed: ```bash -TEMP_LOW=60 -TEMP_MEDIUM=65 -TEMP_HIGH=70 -TEMP_CRITICAL=75 - -FAN_SPEED_LOW=25 -FAN_SPEED_MEDIUM=35 -FAN_SPEED_HIGH=45 -FAN_SPEED_CRITICAL=55 +docker compose run --rm idrac-fan-control manual 35 ``` -#### Aggressive Profile (Better Cooling) +Restore Dell automatic fan control: ```bash -TEMP_LOW=70 -TEMP_MEDIUM=75 -TEMP_HIGH=80 -TEMP_CRITICAL=85 - -FAN_SPEED_LOW=35 -FAN_SPEED_MEDIUM=45 -FAN_SPEED_HIGH=60 -FAN_SPEED_CRITICAL=75 +docker compose run --rm idrac-fan-control restore ``` -#### Silent Profile (Maximum Quiet) +Validate configuration: ```bash -TEMP_LOW=65 -TEMP_MEDIUM=72 -TEMP_HIGH=78 -TEMP_CRITICAL=82 - -FAN_SPEED_LOW=20 -FAN_SPEED_MEDIUM=25 -FAN_SPEED_HIGH=35 -FAN_SPEED_CRITICAL=50 +docker compose run --rm idrac-fan-control validate ``` -### Monitoring Frequency Adjustment +Print iDRAC status and temperature sensors: ```bash -# High responsiveness (more frequent checks) -CHECK_INTERVAL=30 - -# Balanced (default) -CHECK_INTERVAL=60 - -# Low impact (less frequent checks) -CHECK_INTERVAL=120 +docker compose run --rm idrac-fan-control status ``` -## 🛠️ Troubleshooting - -### Common Issues and Solutions +## Emergency Procedure -#### Issue: Container Won't Start - -**Symptoms**: Container exits immediately or fails to start - -**Solutions**: +1. Restore Dell automatic fan control. ```bash -# Check container logs -docker logs idrac-fan-control - -# Verify environment variables -docker run --rm --env-file .env ghcr.io/df-wu/idrac-fan-control:latest - -# Check .env file format -cat .env | grep -v '^#' | grep -v '^$' +docker compose run --rm idrac-fan-control restore ``` -#### Issue: Cannot Connect to iDRAC - -**Symptoms**: `Error: IDRAC connection failed` - -**Solutions**: +2. Stop the container. ```bash -# Test IPMI connection manually -ipmitool -I lanplus -H your_idrac_ip -U root -P your_password chassis status - -# Check network connectivity -ping your_idrac_ip - -# Verify iDRAC credentials -# Check iDRAC web interface access +docker compose down ``` -#### Issue: Cannot Connect to ESXi - -**Symptoms**: `SSH connection failed` or `Permission denied` - -**Solutions**: +3. Check temperatures from iDRAC Web UI or `status`. ```bash -# Test SSH connection manually -ssh root@your_esxi_host - -# Enable SSH on ESXi: -# 1. Access ESXi web interface -# 2. Navigate to Manage → Services -# 3. Start SSH service - -# Check firewall settings on ESXi +docker compose run --rm idrac-fan-control status ``` -#### Issue: GPU Temperature Not Reading +4. Raise `FAILSAFE_FAN_SPEED` or the fan curve before starting auto mode again. -**Symptoms**: GPU temperature shows as 0 or error messages about nvidia-smi +## Common Changes -**Solutions**: +Use iDRAC sensors without ESXi: -```bash -# Verify NVIDIA Container Toolkit installation -docker run --rm --gpus all nvidia/cuda:12.9.0-runtime-ubuntu24.04 nvidia-smi - -# Check if GPU is accessible in container -docker exec idrac-fan-control nvidia-smi - -# Verify GPU configuration in docker-compose.yml +```env +TEMPERATURE_SOURCES=idrac ``` -#### Issue: Drive Temperature Not Reading - -**Symptoms**: Disk temperature shows as 0 or cannot get temperature - -**Solutions**: - -```bash -# Verify drive identifier -ssh root@your_esxi_host -esxcli storage core device list - -# Test temperature reading manually -esxcli storage core device smart get -d your_drive_device +Use ESXi NVMe SMART plus iDRAC sensors: -# Check if drive supports SMART monitoring +```env +TEMPERATURE_SOURCES=esxi,idrac ``` -### Debug Mode +Enable GPU temperature: -Enable verbose logging for troubleshooting: - -```bash -# Run container in interactive mode for debugging -docker run --rm -it --env-file .env \ - -e OPERATION_MODE=manual \ - ghcr.io/df-wu/idrac-fan-control:latest +```env +TEMPERATURE_SOURCES=esxi,gpu +GPU_TEMP_OFFSET=15 ``` -### Performance Monitoring - -Monitor system performance impact: - -```bash -# Check system resource usage -docker stats - -# Monitor network connections -netstat -an | grep :623 # IPMI port +Reduce speed changes near thresholds: -# Check disk I/O -iostat -x 1 +```env +HYSTERESIS=3 ``` -## 🔧 Maintenance - -### Regular Tasks - -#### Update Container Image +Use a conservative fail-safe: -```bash -# Pull latest image -docker-compose pull - -# Restart with new image -docker-compose up -d +```env +FAILSAFE_ON_ERROR=true +FAILSAFE_FAN_SPEED=80 ``` -#### Backup Configuration - -```bash -# Backup configuration files -cp .env .env.backup.$(date +%Y%m%d) -cp docker-compose.yml docker-compose.yml.backup.$(date +%Y%m%d) -``` +## Maintenance -#### Log Rotation +Update image and restart: ```bash -# Rotate logs if they become too large -docker-compose exec idrac-fan-control logrotate /etc/logrotate.conf +docker compose pull +docker compose up -d ``` -### Health Checks - -The container includes built-in health checks. Monitor health status: +Run tests after local changes: ```bash -# Check health status -docker inspect idrac-fan-control | grep Health -A 10 - -# View health check logs -docker logs idrac-fan-control 2>&1 | grep health +make test ``` -## 🔒 Security Considerations - -### Network Security - -- **Firewall Rules**: Limit container network access to only required ports -- **VPN Access**: Consider using VPN for remote iDRAC access -- **Network Segmentation**: Isolate management network from production - -### Credential Security - -- **Dedicated Accounts**: Create dedicated monitoring accounts with minimal privileges -- **Password Rotation**: Regularly rotate iDRAC and ESXi passwords -- **File Permissions**: Secure `.env` file permissions +Build a local image: ```bash -# Secure .env file -chmod 600 .env -``` - -### Container Security - -- **Regular Updates**: Keep container images updated -- **Non-root User**: Container runs as non-root user by default -- **Read-only Filesystem**: Mount configuration as read-only where possible - -## 📈 Performance Optimization - -### Resource Limits - -Add resource limits to prevent excessive resource usage: - -```yaml -# In docker-compose.yml -services: - idrac-fan-control: - # ... other configuration ... - deploy: - resources: - limits: - memory: 128M - cpus: '0.5' - reservations: - memory: 64M - cpus: '0.1' +make docker-build ``` -### Network Optimization - -- **Use Host Network**: `network_mode: host` for better IPMI performance -- **Connection Pooling**: Service maintains persistent connections -- **Timeout Configuration**: Adjust IPMI timeouts if needed - -## 📞 Support and Community - -### Getting Help - -1. **Check Documentation**: Review this guide and README.md -2. **Search Issues**: Look for similar issues in the project repository -3. **Create Issue**: Submit detailed bug reports or feature requests -4. **Community Forum**: Participate in community discussions - -### Contributing - -- **Bug Reports**: Include logs, configuration, and environment details -- **Feature Requests**: Describe use case and expected behavior -- **Pull Requests**: Follow coding standards and include tests - ---- - -## 🇹🇼 中文使用說明 - -### 快速部署指南 - -1. **下載配置文件**:複製 `.env.example` 為 `.env` 並填入您的設定 -2. **必要設定**: - - iDRAC IP、帳號、密碼 - - ESXi 主機 IP、帳號、密碼 - - 磁碟識別碼(透過 ESXi 指令取得) -3. **啟動服務**:執行 `docker-compose up -d` -4. **GPU 支援**:如需 GPU 溫度監控,設定 `WITH_GPU_TEMP=true` 並開啟 Docker Compose 中的 GPU 配置 - -### 常見問題 - -- **連線失敗**:檢查網路連線和認證資訊 -- **溫度讀取失敗**:確認磁碟識別碼正確且支援 SMART 監控 -- **GPU 溫度錯誤**:確認已安裝 NVIDIA Container Toolkit - -### 監控和維護 - -- 使用 `docker logs idrac-fan-control` 查看運行狀態 -- 定期備份配置文件 -- 根據需求調整溫度閾值和風扇轉速設定 +Last reviewed: 2026-06-08. diff --git a/imgaes/image.png b/images/image.png similarity index 100% rename from imgaes/image.png rename to images/image.png