diff --git a/CHANGELOG.md b/CHANGELOG.md index 024cc2df58..10dfc46ef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,10 @@ After a release, please make sure to run `bundle exec rake update_changelog`. Th Changes since the last non-beta release. +#### Added + +- **Service Dependency Checking for bin/dev**: Added optional `.dev-services.yml` configuration to validate required external services (Redis, PostgreSQL, Elasticsearch, etc.) are running before `bin/dev` starts the development server. Provides clear error messages with start commands and install hints when services are missing. Zero impact if not configured - backwards compatible with all existing installations. [PR 2098](https://github.com/shakacode/react_on_rails/pull/2098) by [justin808](https://github.com/justin808). + #### Improved - **Automatic Precompile Hook Coordination in bin/dev**: The `bin/dev` command now automatically runs Shakapacker's `precompile_hook` once before starting development processes and sets `SHAKAPACKER_SKIP_PRECOMPILE_HOOK=true` to prevent duplicate execution in spawned webpack processes. diff --git a/Steepfile b/Steepfile index 0689f7f82d..0d9d79e5f2 100644 --- a/Steepfile +++ b/Steepfile @@ -32,6 +32,7 @@ target :lib do check "lib/react_on_rails/dev/pack_generator.rb" check "lib/react_on_rails/dev/process_manager.rb" check "lib/react_on_rails/dev/server_manager.rb" + check "lib/react_on_rails/dev/service_checker.rb" check "lib/react_on_rails/git_utils.rb" check "lib/react_on_rails/helper.rb" check "lib/react_on_rails/packer_utils.rb" diff --git a/docs/building-features/process-managers.md b/docs/building-features/process-managers.md index 84abf8e956..48c7c70b63 100644 --- a/docs/building-features/process-managers.md +++ b/docs/building-features/process-managers.md @@ -16,11 +16,12 @@ React on Rails includes `bin/dev` which automatically uses Overmind or Foreman: This script will: -1. Run Shakapacker's `precompile_hook` once (if configured in `config/shakapacker.yml`) -2. Set `SHAKAPACKER_SKIP_PRECOMPILE_HOOK=true` to prevent duplicate execution -3. Try to use Overmind (if installed) -4. Fall back to Foreman (if installed) -5. Show installation instructions if neither is found +1. Check required external services (if `.dev-services.yml` exists) +2. Run Shakapacker's `precompile_hook` once (if configured in `config/shakapacker.yml`) +3. Set `SHAKAPACKER_SKIP_PRECOMPILE_HOOK=true` to prevent duplicate execution +4. Try to use Overmind (if installed) +5. Fall back to Foreman (if installed) +6. Show installation instructions if neither is found ### Precompile Hook Integration @@ -57,6 +58,111 @@ default: &default See the [i18n documentation](./i18n.md#internationalization) for more details on configuring the precompile hook. +### Service Dependency Checking + +`bin/dev` can automatically verify that required external services (like Redis, PostgreSQL, Elasticsearch) are running before starting your development server. This prevents cryptic error messages and provides clear instructions on how to start missing services. + +#### Configuration + +Create a `.dev-services.yml` file in your project root: + +```yaml +services: + redis: + check_command: 'redis-cli ping' + expected_output: 'PONG' + start_command: 'redis-server' + install_hint: 'brew install redis (macOS) or apt-get install redis-server (Linux)' + description: 'Redis (for caching and background jobs)' + + postgresql: + check_command: 'pg_isready' + expected_output: 'accepting connections' + start_command: 'pg_ctl -D /usr/local/var/postgres start' + install_hint: 'brew install postgresql (macOS)' + description: 'PostgreSQL database' +``` + +A `.dev-services.yml.example` file with common service configurations is created when you run the React on Rails generator. + +#### Configuration Fields + +- **check_command** (required): Shell command to check if the service is running +- **expected_output** (optional): String that must appear in the command output +- **start_command** (optional): Command to start the service (shown in error messages) +- **install_hint** (optional): How to install the service if not found +- **description** (optional): Human-readable description of the service + +#### Behavior + +If `.dev-services.yml` exists, `bin/dev` will: + +1. Check each configured service before starting +2. Show a success message if all services are running +3. Show helpful error messages with start commands if any service is missing +4. Exit before starting the Procfile if services are unavailable + +If `.dev-services.yml` doesn't exist, `bin/dev` works exactly as before (zero impact on existing installations). + +#### Example Output + +**When services are running:** + +``` +🔍 Checking required services (.dev-services.yml)... + + ✓ redis - Redis (for caching and background jobs) + ✓ postgresql - PostgreSQL database + +✅ All services are running +``` + +**When services are missing:** + +``` +🔍 Checking required services (.dev-services.yml)... + + ✗ redis - Redis (for caching and background jobs) + +❌ Some services are not running + +Please start these services before running bin/dev: + +redis + Redis (for caching and background jobs) + + To start: + redis-server + + Not installed? brew install redis (macOS) or apt-get install redis-server (Linux) + +💡 Tips: + • Start services manually, then run bin/dev again + • Or remove service from .dev-services.yml if not needed + • Or add service to Procfile.dev to start automatically +``` + +#### Security Note + +⚠️ **IMPORTANT**: Commands in `.dev-services.yml` are executed during `bin/dev` startup without shell expansion for safety. However, you should still: + +- **Only add commands from trusted sources** +- **Avoid shell metacharacters** (&&, ||, ;, |, $, etc.) - they won't work and indicate an anti-pattern +- **Review changes carefully** if .dev-services.yml is committed to version control +- **Consider adding to .gitignore** if it contains machine-specific paths or sensitive information + +**Recommended approach:** + +- Commit `.dev-services.yml.example` to version control (safe, documentation) +- Add `.dev-services.yml` to `.gitignore` (developers copy from example) +- This prevents accidental execution of untrusted commands from compromised dependencies + +**Execution order:** + +1. Service dependency checks (`.dev-services.yml`) +2. Precompile hook (if configured in `config/shakapacker.yml`) +3. Process manager starts processes from Procfile + ## Installing a Process Manager ### Overmind (Recommended) diff --git a/lib/generators/react_on_rails/base_generator.rb b/lib/generators/react_on_rails/base_generator.rb index deea5c0ad7..962beed065 100644 --- a/lib/generators/react_on_rails/base_generator.rb +++ b/lib/generators/react_on_rails/base_generator.rb @@ -46,6 +46,7 @@ def copy_base_files Procfile.dev Procfile.dev-static-assets Procfile.dev-prod-assets + .dev-services.yml.example bin/shakapacker-precompile-hook] base_templates = %w[config/initializers/react_on_rails.rb] base_files.each { |file| copy_file("#{base_path}#{file}", file) } diff --git a/lib/generators/react_on_rails/templates/base/base/.dev-services.yml.example b/lib/generators/react_on_rails/templates/base/base/.dev-services.yml.example new file mode 100644 index 0000000000..da3a53e7b7 --- /dev/null +++ b/lib/generators/react_on_rails/templates/base/base/.dev-services.yml.example @@ -0,0 +1,76 @@ +# Service Dependencies Configuration +# +# This file defines external services that must be running before bin/dev starts. +# Copy this file to .dev-services.yml and customize for your application. +# +# bin/dev will check each service before starting the development server. +# If any service is not running, it will display helpful error messages with +# instructions on how to start the service. +# +# ⚠️ SECURITY WARNING: +# Commands in this file are executed during bin/dev startup. Only add commands +# from trusted sources. This file should not be committed if it contains +# sensitive information or custom paths specific to your machine. Consider +# adding .dev-services.yml to .gitignore if it contains machine-specific config. +# +# Security best practices: +# - Commands are executed without shell expansion (shell metacharacters won't work) +# - Use simple, single commands (e.g., "redis-cli ping", "pg_isready") +# - Do NOT use shell features: &&, ||, |, $, ;, backticks, etc. will fail +# - Only include services you trust +# - Keep commands simple and focused on service health checks +# - Consider adding .dev-services.yml to .gitignore (commit .example instead) +# +# Example configuration: +# +# services: +# redis: +# check_command: "redis-cli ping" +# expected_output: "PONG" +# start_command: "redis-server" +# install_hint: "brew install redis (macOS) or apt-get install redis-server (Linux)" +# description: "Redis (for caching and background jobs)" +# +# postgresql: +# check_command: "pg_isready" +# expected_output: "accepting connections" +# start_command: "pg_ctl -D /usr/local/var/postgres start" +# install_hint: "brew install postgresql (macOS) or apt-get install postgresql (Linux)" +# description: "PostgreSQL database" +# +# elasticsearch: +# check_command: "curl -s http://localhost:9200" +# expected_output: "cluster_name" +# start_command: "brew services start elasticsearch-full" +# install_hint: "brew install elasticsearch-full" +# description: "Elasticsearch (for search)" +# +# Field descriptions: +# check_command: Shell command to check if service is running (required) +# expected_output: String that must appear in command output (optional) +# start_command: Command to start the service (shown in error messages) +# install_hint: How to install the service if not found +# description: Human-readable description of the service +# +# To use this file: +# 1. Copy to .dev-services.yml: cp .dev-services.yml.example .dev-services.yml +# 2. Uncomment and configure the services your app needs +# 3. Add .dev-services.yml to .gitignore if it contains sensitive info +# 4. Run bin/dev - it will check services before starting + +services: + # Uncomment and configure the services your application requires: + + # redis: + # check_command: "redis-cli ping" + # expected_output: "PONG" + # start_command: "redis-server" + # install_hint: "brew install redis (macOS) or apt-get install redis-server (Linux)" + # description: "Redis (for caching and background jobs)" + + # postgresql: + # check_command: "pg_isready" + # expected_output: "accepting connections" + # start_command: "pg_ctl -D /usr/local/var/postgres start" # macOS; Linux: sudo service postgresql start + # install_hint: "brew install postgresql (macOS) or apt-get install postgresql (Linux)" + # description: "PostgreSQL database" diff --git a/lib/react_on_rails/dev/server_manager.rb b/lib/react_on_rails/dev/server_manager.rb index ce4c7c87be..473abb8c13 100644 --- a/lib/react_on_rails/dev/server_manager.rb +++ b/lib/react_on_rails/dev/server_manager.rb @@ -4,6 +4,7 @@ require "open3" require "rainbow" require_relative "../packer_utils" +require_relative "service_checker" module ReactOnRails module Dev @@ -330,6 +331,7 @@ def help_options end # rubocop:enable Metrics/AbcSize + # rubocop:disable Metrics/AbcSize def help_customization <<~CUSTOMIZATION #{Rainbow('🔧 CUSTOMIZATION:').cyan.bold} @@ -340,8 +342,24 @@ def help_customization #{Rainbow('•').yellow} #{Rainbow('Procfile.dev-prod-assets').green.bold} - Production-optimized assets (port 3001) #{Rainbow('Edit these files to customize the development environment for your needs.').white} + + #{Rainbow('🔍 SERVICE DEPENDENCIES:').cyan.bold} + #{Rainbow('Configure required external services in').white} #{Rainbow('.dev-services.yml').green.bold}#{Rainbow(':').white} + + #{Rainbow('•').yellow} #{Rainbow('bin/dev').white} #{Rainbow('checks services before starting (optional)').white} + #{Rainbow('•').yellow} #{Rainbow('Copy from').white} #{Rainbow('.dev-services.yml.example').green.bold} #{Rainbow('to get started').white} + #{Rainbow('•').yellow} #{Rainbow('Supports Redis, PostgreSQL, Elasticsearch, and custom services').white} + #{Rainbow('•').yellow} #{Rainbow('Shows helpful errors with start commands if services are missing').white} + + #{Rainbow('Example .dev-services.yml:').white} + #{Rainbow(' services:').cyan} + #{Rainbow(' redis:').cyan} + #{Rainbow(' check_command: "redis-cli ping"').cyan} + #{Rainbow(' expected_output: "PONG"').cyan} + #{Rainbow(' start_command: "redis-server"').cyan} CUSTOMIZATION end + # rubocop:enable Metrics/AbcSize # rubocop:disable Metrics/AbcSize def help_mode_details @@ -392,6 +410,10 @@ def run_production_like(_verbose: false, route: nil, rails_env: nil) # either via precompile hook or via the configuration.rb adjust_precompile_task print_procfile_info(procfile, route: route) + + # Check required services before starting + exit 1 unless ServiceChecker.check_services + print_server_info( "🏭 Starting production-like development server...", features, @@ -514,6 +536,9 @@ def run_production_like(_verbose: false, route: nil, rails_env: nil) def run_static_development(procfile, verbose: false, route: nil) print_procfile_info(procfile, route: route) + # Check required services before starting + exit 1 unless ServiceChecker.check_services + features = [ "Using shakapacker --watch (no HMR)", "CSS extracted to separate files (no FOUC)", @@ -539,6 +564,10 @@ def run_static_development(procfile, verbose: false, route: nil) def run_development(procfile, verbose: false, route: nil) print_procfile_info(procfile, route: route) + + # Check required services before starting + exit 1 unless ServiceChecker.check_services + PackGenerator.generate(verbose: verbose) ProcessManager.ensure_procfile(procfile) ProcessManager.run_with_process_manager(procfile) diff --git a/lib/react_on_rails/dev/service_checker.rb b/lib/react_on_rails/dev/service_checker.rb new file mode 100644 index 0000000000..c82f5cd536 --- /dev/null +++ b/lib/react_on_rails/dev/service_checker.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require "yaml" +require "English" +require "rainbow" + +module ReactOnRails + module Dev + # ServiceChecker validates that required external services are running + # before starting the development server. + # + # Configuration is read from .dev-services.yml in the app root: + # + # services: + # redis: + # check_command: "redis-cli ping" + # expected_output: "PONG" + # start_command: "redis-server" + # description: "Redis (for caching and background jobs)" + # postgresql: + # check_command: "pg_isready" + # expected_output: "accepting connections" + # start_command: "pg_ctl -D /usr/local/var/postgres start" + # description: "PostgreSQL database" + # + class ServiceChecker + # Configuration file keys + CONFIG_KEYS = { + services: "services", + check_command: "check_command", + expected_output: "expected_output", + start_command: "start_command", + install_hint: "install_hint", + description: "description" + }.freeze + + class << self + # Check all required services and provide helpful output + # + # @param config_path [String] Path to .dev-services.yml (default: ./.dev-services.yml) + # @return [Boolean] true if all services are running or no config exists + def check_services(config_path: ".dev-services.yml") + return true unless File.exist?(config_path) + + config = load_config(config_path) + return true unless config_has_services?(config) + + check_and_report_services(config, config_path) + end + + private + + def config_has_services?(config) + config && + config[CONFIG_KEYS[:services]].is_a?(Hash) && + !config[CONFIG_KEYS[:services]].empty? + end + + def check_and_report_services(config, config_path) + print_services_header(config_path) + + failures = collect_service_failures(config[CONFIG_KEYS[:services]]) + + report_results(failures) + end + + def collect_service_failures(services) + failures = [] + + services.each do |name, service_config| + if check_service(name, service_config) + print_service_ok(name, service_config[CONFIG_KEYS[:description]]) + else + failures << { name: name, config: service_config } + print_service_failed(name, service_config[CONFIG_KEYS[:description]]) + end + end + + failures + end + + def report_results(failures) + if failures.empty? + print_all_services_ok + true + else + print_failures_summary(failures) + false + end + end + + def load_config(config_path) + YAML.load_file(config_path) + rescue StandardError => e + puts Rainbow("⚠️ Failed to load #{config_path}: #{e.message}").yellow + puts Rainbow(" Continuing without service checks...").yellow + puts "" + nil + end + + def check_service(_name, config) + check_command = config[CONFIG_KEYS[:check_command]] + expected_output = config[CONFIG_KEYS[:expected_output]] + + return false if check_command.nil? + + output, status = run_check_command(check_command) + + return false if status.nil? + + return status.success? if expected_output.nil? + + # Safe nil check for output before calling include? + status.success? && output&.include?(expected_output) + end + + def run_check_command(command) + require "open3" + # Execute command as-is. Commands are from local .dev-services.yml config file + # which should be trusted. Shell metacharacters won't work as expected since + # Open3.capture3 doesn't invoke a shell by default for simple command strings. + stdout, stderr, status = Open3.capture3(command, err: %i[child out]) + output = stdout + stderr + [output, status] + rescue Errno::ENOENT + # Command not found - service is not available + ["", nil] + rescue ArgumentError => e + # Invalid command format + warn "Invalid command format: #{e.message}" if ENV["DEBUG"] + ["", nil] + rescue StandardError => e + # Log unexpected errors for debugging + warn "Unexpected error checking service: #{e.message}" if ENV["DEBUG"] + ["", nil] + end + + def print_services_header(config_path) + puts "" + puts Rainbow("🔍 Checking required services (#{config_path})...").cyan.bold + puts "" + end + + def print_service_ok(name, description) + desc = description ? " - #{description}" : "" + puts " #{Rainbow('✓').green} #{name}#{desc}" + end + + def print_service_failed(name, description) + desc = description ? " - #{description}" : "" + puts " #{Rainbow('✗').red} #{name}#{desc}" + end + + def print_all_services_ok + puts "" + puts Rainbow("✅ All services are running").green.bold + puts "" + end + + # rubocop:disable Metrics/AbcSize + def print_failures_summary(failures) + puts "" + puts Rainbow("❌ Some services are not running").red.bold + puts "" + puts Rainbow("Please start these services before running bin/dev:").yellow + puts "" + + failures.each do |failure| + name = failure[:name] + config = failure[:config] + description = config[CONFIG_KEYS[:description]] || name + + puts Rainbow(name.to_s).cyan.bold + puts " #{description}" if config[CONFIG_KEYS[:description]] + + if config[CONFIG_KEYS[:start_command]] + puts "" + puts " #{Rainbow('To start:').yellow}" + puts " #{Rainbow(config[CONFIG_KEYS[:start_command]]).green}" + end + + if config[CONFIG_KEYS[:install_hint]] + puts "" + puts " #{Rainbow('Not installed?').yellow} #{config[CONFIG_KEYS[:install_hint]]}" + end + + puts "" + end + + puts Rainbow("💡 Tips:").blue.bold + puts " • Start services manually, then run bin/dev again" + puts " • Or remove service from .dev-services.yml if not needed" + puts " • Or add service to Procfile.dev to start automatically" + puts "" + end + # rubocop:enable Metrics/AbcSize + end + end + end +end diff --git a/sig/react_on_rails/dev/service_checker.rbs b/sig/react_on_rails/dev/service_checker.rbs new file mode 100644 index 0000000000..7534a376de --- /dev/null +++ b/sig/react_on_rails/dev/service_checker.rbs @@ -0,0 +1,22 @@ +module ReactOnRails + module Dev + class ServiceChecker + def self.check_services: (?config_path: String) -> bool + + private + + def self.config_has_services?: (Hash[String, untyped]?) -> bool + def self.check_and_report_services: (Hash[String, untyped], String) -> bool + def self.collect_service_failures: (Hash[String, untyped]) -> Array[Hash[Symbol, untyped]] + def self.report_results: (Array[Hash[Symbol, untyped]]) -> bool + def self.load_config: (String) -> Hash[String, untyped]? + def self.check_service: (String, Hash[String, untyped]) -> bool + def self.run_check_command: (String) -> [String, Process::Status?] + def self.print_services_header: (String) -> void + def self.print_service_ok: (String, String?) -> void + def self.print_service_failed: (String, String?) -> void + def self.print_all_services_ok: () -> void + def self.print_failures_summary: (Array[Hash[Symbol, untyped]]) -> void + end + end +end diff --git a/spec/dummy/.dev-services.yml.example b/spec/dummy/.dev-services.yml.example new file mode 100644 index 0000000000..361e049c2b --- /dev/null +++ b/spec/dummy/.dev-services.yml.example @@ -0,0 +1,50 @@ +# Service Dependencies Configuration +# +# This file defines external services that must be running before bin/dev starts. +# Copy this file to .dev-services.yml and customize for your application. +# +# bin/dev will check each service before starting the development server. +# If any service is not running, it will display helpful error messages with +# instructions on how to start the service. +# +# ⚠️ SECURITY WARNING: +# Commands in this file are executed during bin/dev startup. Only add commands +# from trusted sources. This file should not be committed if it contains +# sensitive information or custom paths specific to your machine. Consider +# adding .dev-services.yml to .gitignore if it contains machine-specific config. +# +# Security best practices: +# - Commands are executed without shell expansion (shell metacharacters won't work) +# - Use simple, single commands (e.g., "redis-cli ping", "pg_isready") +# - Do NOT use shell features: &&, ||, |, $, ;, backticks, etc. will fail +# - Only include services you trust +# - Keep commands simple and focused on service health checks +# - Consider adding .dev-services.yml to .gitignore (commit .example instead) +# +# For the React on Rails dummy app, we typically don't require external services, +# but here are some common examples you might use in a real application: + +services: + # Uncomment if your app uses Redis for caching or background jobs: + # redis: + # check_command: "redis-cli ping" + # expected_output: "PONG" + # start_command: "redis-server" + # install_hint: "brew install redis (macOS) or apt-get install redis-server (Linux)" + # description: "Redis (for caching and background jobs)" + + # Uncomment if your app uses PostgreSQL: + # postgresql: + # check_command: "pg_isready" + # expected_output: "accepting connections" + # start_command: "pg_ctl -D /usr/local/var/postgres start" # macOS; Linux: sudo service postgresql start + # install_hint: "brew install postgresql (macOS) or apt-get install postgresql (Linux)" + # description: "PostgreSQL database" + + # Uncomment if your app uses Elasticsearch: + # elasticsearch: + # check_command: "curl -s http://localhost:9200" + # expected_output: "cluster_name" + # start_command: "brew services start elasticsearch-full" + # install_hint: "brew install elasticsearch-full" + # description: "Elasticsearch (for search)" diff --git a/spec/react_on_rails/dev/service_checker_spec.rb b/spec/react_on_rails/dev/service_checker_spec.rb new file mode 100644 index 0000000000..ce1db5d2e3 --- /dev/null +++ b/spec/react_on_rails/dev/service_checker_spec.rb @@ -0,0 +1,256 @@ +# frozen_string_literal: true + +require "react_on_rails/dev/service_checker" +require "tempfile" +require "yaml" +require "stringio" + +RSpec.describe ReactOnRails::Dev::ServiceChecker do + describe ".check_services" do + context "when config file does not exist" do + it "returns true without checking services" do + expect(described_class.check_services(config_path: "nonexistent.yml")).to be true + end + end + + context "when config file is empty" do + it "returns true" do + with_temp_config({}) do |path| + expect(described_class.check_services(config_path: path)).to be true + end + end + end + + context "when config file has no services" do + it "returns true" do + with_temp_config({ "services" => {} }) do |path| + expect(described_class.check_services(config_path: path)).to be true + end + end + end + + context "when config file has invalid YAML" do + it "prints warning and returns true" do + Tempfile.create(["invalid", ".yml"]) do |file| + file.write("invalid: yaml: content:") + file.flush + + expect { described_class.check_services(config_path: file.path) } + .to output(/Failed to load/).to_stdout + expect(described_class.check_services(config_path: file.path)).to be true + end + end + end + + context "when all services are running" do + it "returns true and prints success messages" do + config = { + "services" => { + "test_service" => { + "check_command" => "echo 'RUNNING'", + "expected_output" => "RUNNING", + "description" => "Test service" + } + } + } + + with_temp_config(config) do |path| + output = capture_stdout do + expect(described_class.check_services(config_path: path)).to be true + end + + expect(output).to include("Checking required services") + expect(output).to include("test_service") + expect(output).to include("All services are running") + end + end + end + + context "when some services are not running" do + it "returns false and prints failure messages with start commands" do + config = { + "services" => { + "failing_service" => { + "check_command" => "false", + "description" => "Service that fails", + "start_command" => "start-service", + "install_hint" => "brew install service" + } + } + } + + with_temp_config(config) do |path| + output = capture_stdout do + expect(described_class.check_services(config_path: path)).to be false + end + + expect(output).to include("Some services are not running") + expect(output).to include("failing_service") + expect(output).to include("Service that fails") + expect(output).to include("start-service") + expect(output).to include("brew install service") + end + end + end + + context "when check_command succeeds without expected_output" do + it "returns true if command exits successfully" do + config = { + "services" => { + "test_service" => { + "check_command" => "true" + } + } + } + + with_temp_config(config) do |path| + expect(described_class.check_services(config_path: path)).to be true + end + end + end + + context "when check_command output does not match expected_output" do + it "returns false" do + config = { + "services" => { + "test_service" => { + "check_command" => "echo 'WRONG'", + "expected_output" => "RIGHT" + } + } + } + + with_temp_config(config) do |path| + expect(described_class.check_services(config_path: path)).to be false + end + end + end + + context "when check_command is missing" do + it "treats the service as failed" do + config = { + "services" => { + "test_service" => { + "description" => "Service without check command" + } + } + } + + with_temp_config(config) do |path| + expect(described_class.check_services(config_path: path)).to be false + end + end + end + + context "with multiple services" do + it "checks all services and reports failures" do + config = { + "services" => { + "passing_service" => { + "check_command" => "true", + "description" => "This passes" + }, + "failing_service" => { + "check_command" => "false", + "description" => "This fails" + } + } + } + + with_temp_config(config) do |path| + output = capture_stdout do + expect(described_class.check_services(config_path: path)).to be false + end + + expect(output).to include("passing_service") + expect(output).to include("failing_service") + expect(output).to include("Some services are not running") + end + end + end + + context "when check_command raises an error" do + it "treats the service as failed" do + config = { + "services" => { + "test_service" => { + "check_command" => "nonexistent-command-xyz123", + "description" => "Service with invalid command" + } + } + } + + with_temp_config(config) do |path| + expect(described_class.check_services(config_path: path)).to be false + end + end + end + + context "when check_command outputs to stderr" do + it "captures stderr in output" do + config = { + "services" => { + "test_service" => { + "check_command" => "echo 'ERROR' >&2", + "expected_output" => "ERROR" + } + } + } + + with_temp_config(config) do |path| + expect(described_class.check_services(config_path: path)).to be true + end + end + end + + context "when check_command outputs to both stdout and stderr" do + it "captures both streams" do + config = { + "services" => { + "test_service" => { + "check_command" => "echo 'OUT' && echo 'ERR' >&2", + "expected_output" => "OUT" + } + } + } + + with_temp_config(config) do |path| + expect(described_class.check_services(config_path: path)).to be true + end + end + + it "can match against stderr output" do + config = { + "services" => { + "test_service" => { + "check_command" => "echo 'OUT' && echo 'ERR' >&2", + "expected_output" => "ERR" + } + } + } + + with_temp_config(config) do |path| + expect(described_class.check_services(config_path: path)).to be true + end + end + end + end + + # Helper methods + def with_temp_config(config) + Tempfile.create(["test-services", ".yml"]) do |file| + file.write(YAML.dump(config)) + file.flush + yield file.path + end + end + + def capture_stdout + old_stdout = $stdout + $stdout = StringIO.new + yield + $stdout.string + ensure + $stdout = old_stdout + end +end