diff --git a/CHANGELOG.md b/CHANGELOG.md index aca26d63..322c9cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and how to configure it, visit the ### New features: +* Added a `doctor` task that outputs helpful troubleshooting information. Try it like this: `cap production doctor`. (@mattbrictson) * Added a `dry_run?` helper method * `remove` DSL method for removing values like from arrays like `linked_dirs` * `append` DSL method for pushing values like `linked_dirs` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8cfba12e..74a5544b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,9 +25,15 @@ As much the Capistrano community tries to write good, well-tested code, bugs sti **In case you’ve run across an already-known issue, check the FAQs first on the [official Capistrano site](http://capistranorb.com).** -When opening a bug report, please include the following: +When opening a bug report, please include the output of the `cap doctor` task, e.g.: -* Versions of Ruby, Capistrano, and any plugins you’re using +``` +cap production doctor +``` + +Also include in your report: + +* Versions of Ruby, Capistrano, and any plugins you’re using (if `doctor` didn't already do this for you) * A description of the troubleshooting steps you’ve taken * Logs and backtraces * Sections of your `deploy.rb` that may be relevant diff --git a/features/doctor.feature b/features/doctor.feature new file mode 100644 index 00000000..692fffd7 --- /dev/null +++ b/features/doctor.feature @@ -0,0 +1,11 @@ +Feature: Doctor + + Background: + Given a test app with the default configuration + + Scenario: Running the doctor task + When I run cap "doctor" + Then the task is successful + And contains "Environment" in the output + And contains "Gems" in the output + And contains "Variables" in the output diff --git a/issue_template.md b/issue_template.md index 5e4c0d5f..c7d5e322 100644 --- a/issue_template.md +++ b/issue_template.md @@ -3,22 +3,19 @@ --- #### Steps to reproduce + 1. Lorem. 2. Ipsum.. 3. Dolor... #### Expected behaviour + Tell us what should happen #### Actual behaviour + Tell us what happens instead #### Your configuration -**Your Operating system (`$ uname -a` if on Linux/Mac)**: - -**Your Ruby Version (`$ ruby -v`):** - -**Your Capistrano version (`$ cap --version`):** - -**Your Capistrano Plugins (`$ bundle list | grep capistrano-`): ** +Paste Capistrano's `doctor` output here (`cap doctor`): diff --git a/lib/capistrano/configuration.rb b/lib/capistrano/configuration.rb index bb5d86fc..66cfff34 100644 --- a/lib/capistrano/configuration.rb +++ b/lib/capistrano/configuration.rb @@ -3,15 +3,12 @@ require_relative "configuration/question" require_relative "configuration/plugin_installer" require_relative "configuration/server" require_relative "configuration/servers" +require_relative "configuration/variables" module Capistrano class ValidationError < Exception; end class Configuration - def initialize(config=nil) - @config ||= config - end - def self.env @env ||= new end @@ -20,22 +17,22 @@ module Capistrano @env = new end + extend Forwardable + attr_reader :variables + def_delegators :variables, + :set, :fetch, :fetch_for, :delete, :keys, :validate + + def initialize(values={}) + @variables = Variables.new(values) + end + def ask(key, default=nil, options={}) question = Question.new(key, default, options) set(key, question) end - def set(key, value=nil, &block) - invoke_validations(key, value, &block) - config[key] = block || value - - puts "Config variable set: #{key.inspect} => #{config[key].inspect}" if fetch(:print_config_variables, false) - - config[key] - end - def set_if_empty(key, value=nil, &block) - set(key, value, &block) unless config.key? key + set(key, value, &block) unless keys.include?(key) end def append(key, *values) @@ -46,16 +43,6 @@ module Capistrano set(key, Array(fetch(key)) - values) end - def delete(key) - config.delete(key) - end - - def fetch(key, default=nil, &block) - value = fetch_for(key, default, &block) - value = set(key, value.call) while callable_without_parameters?(value) - value - end - def any?(key) value = fetch(key) if value && value.respond_to?(:any?) @@ -65,16 +52,6 @@ module Capistrano end end - def validate(key, &validator) - vs = (validators[key] || []) - vs << validator - validators[key] = vs - end - - def keys - config.keys - end - def is_question?(key) value = fetch_for(key, nil) !value.nil? && value.is_a?(Question) @@ -166,42 +143,10 @@ module Capistrano @servers ||= Servers.new end - def config - @config ||= {} - end - - def validators - @validators ||= {} - end - def installer @installer ||= PluginInstaller.new end - def fetch_for(key, default, &block) - if block_given? - config.fetch(key, &block) - else - config.fetch(key, default) - end - end - - def callable_without_parameters?(x) - x.respond_to?(:call) && (!x.respond_to?(:arity) || x.arity == 0) - end - - def invoke_validations(key, value, &block) - unless value.nil? || block.nil? - raise Capistrano::ValidationError, "Value and block both passed to Configuration#set" - end - - return unless validators.key? key - - validators[key].each do |validator| - validator.call(key, block || value) - end - end - def configure_sshkit_output(sshkit) format_args = [fetch(:format)] format_args.push(fetch(:format_options)) if any?(:format_options) diff --git a/lib/capistrano/configuration/variables.rb b/lib/capistrano/configuration/variables.rb new file mode 100644 index 00000000..bbe4c2dc --- /dev/null +++ b/lib/capistrano/configuration/variables.rb @@ -0,0 +1,136 @@ +module Capistrano + class Configuration + # Holds the variables assigned at Capistrano runtime via `set` and retrieved + # with `fetch`. Does internal bookkeeping to help identify user mistakes + # like spelling errors or unused variables that may lead to unexpected + # behavior. Also allows validation rules to be registered with `validate`. + class Variables + CAPISTRANO_LOCATION = File.expand_path("../..", __FILE__).freeze + IGNORED_LOCATIONS = [ + "#{CAPISTRANO_LOCATION}/configuration/variables.rb:", + "#{CAPISTRANO_LOCATION}/configuration.rb:", + "#{CAPISTRANO_LOCATION}/dsl/env.rb:", + "/dsl.rb:", + "/forwardable.rb:" + ].freeze + private_constant :CAPISTRANO_LOCATION, :IGNORED_LOCATIONS + + def initialize(values={}) + @trusted_keys = [] + @fetched_keys = [] + @locations = {} + @values = values + @trusted = true + end + + def untrusted! + @trusted = false + yield + ensure + @trusted = true + end + + def set(key, value=nil, &block) + invoke_validations(key, value, &block) + @trusted_keys << key if trusted? + remember_location(key) + values[key] = block || value + trace_set(key) + values[key] + end + + def fetch(key, default=nil, &block) + fetched_keys << key + peek(key, default, &block) + end + + # Internal use only. + def peek(key, default=nil, &block) + value = fetch_for(key, default, &block) + while callable_without_parameters?(value) + value = (values[key] = value.call) + end + value + end + + def fetch_for(key, default, &block) + block ? values.fetch(key, &block) : values.fetch(key, default) + end + + def delete(key) + values.delete(key) + end + + def validate(key, &validator) + vs = (validators[key] || []) + vs << validator + validators[key] = vs + end + + def trusted_keys + @trusted_keys.dup + end + + def untrusted_keys + keys - @trusted_keys + end + + def keys + values.keys + end + + # Keys that have been set, but which have never been fetched. + def unused_keys + keys - fetched_keys + end + + # Returns an array of source file location(s) where the given key was + # assigned (i.e. where `set` was called). If the key was never assigned, + # returns `nil`. + def source_locations(key) + locations[key] + end + + private + + attr_reader :locations, :values, :fetched_keys + + def trusted? + @trusted + end + + def remember_location(key) + location = caller.find do |line| + IGNORED_LOCATIONS.none? { |i| line.include?(i) } + end + (locations[key] ||= []) << location + end + + def callable_without_parameters?(x) + x.respond_to?(:call) && (!x.respond_to?(:arity) || x.arity == 0) + end + + def validators + @validators ||= {} + end + + def invoke_validations(key, value, &block) + unless value.nil? || block.nil? + raise Capistrano::ValidationError, + "Value and block both passed to Configuration#set" + end + + return unless validators.key? key + + validators[key].each do |validator| + validator.call(key, block || value) + end + end + + def trace_set(key) + return unless fetch(:print_config_variables, false) + puts "Config variable set: #{key.inspect} => #{values[key].inspect}" + end + end + end +end diff --git a/lib/capistrano/doctor.rb b/lib/capistrano/doctor.rb new file mode 100644 index 00000000..f38af290 --- /dev/null +++ b/lib/capistrano/doctor.rb @@ -0,0 +1,5 @@ +require "capistrano/doctor/environment_doctor" +require "capistrano/doctor/gems_doctor" +require "capistrano/doctor/variables_doctor" + +load File.expand_path("../tasks/doctor.rake", __FILE__) diff --git a/lib/capistrano/doctor/environment_doctor.rb b/lib/capistrano/doctor/environment_doctor.rb new file mode 100644 index 00000000..460a4d5f --- /dev/null +++ b/lib/capistrano/doctor/environment_doctor.rb @@ -0,0 +1,19 @@ +require "capistrano/doctor/output_helpers" + +module Capistrano + module Doctor + class EnvironmentDoctor + include Capistrano::Doctor::OutputHelpers + + def call + title("Environment") + puts <<-OUT.gsub(/^\s+/, "") + Ruby #{RUBY_DESCRIPTION} + Rubygems #{Gem::VERSION} + Bundler #{defined?(Bundler::VERSION) ? Bundler::VERSION : 'N/A'} + Command #{$PROGRAM_NAME} #{ARGV.join(' ')} + OUT + end + end + end +end diff --git a/lib/capistrano/doctor/gems_doctor.rb b/lib/capistrano/doctor/gems_doctor.rb new file mode 100644 index 00000000..06685021 --- /dev/null +++ b/lib/capistrano/doctor/gems_doctor.rb @@ -0,0 +1,45 @@ +require "capistrano/doctor/output_helpers" + +module Capistrano + module Doctor + # Prints table of all Capistrano-related gems and their version numbers. If + # there is a newer version of a gem available, call attention to it. + class GemsDoctor + include Capistrano::Doctor::OutputHelpers + + def call + title("Gems") + table(all_gem_names) do |gem, row| + row.yellow if update_available?(gem) + row << gem + row << installed_gem_version(gem) + row << "(update available)" if update_available?(gem) + end + end + + private + + def installed_gem_version(gem_name) + Gem.loaded_specs[gem_name].version + end + + def update_available?(gem_name) + latest = Gem.latest_version_for(gem_name) + return false if latest.nil? + latest > installed_gem_version(gem_name) + end + + def all_gem_names + core_gem_names + plugin_gem_names + end + + def core_gem_names + %w(capistrano airbrussh rake sshkit) & Gem.loaded_specs.keys + end + + def plugin_gem_names + (Gem.loaded_specs.keys - ["capistrano"]).grep(/capistrano/).sort + end + end + end +end diff --git a/lib/capistrano/doctor/output_helpers.rb b/lib/capistrano/doctor/output_helpers.rb new file mode 100644 index 00000000..7e035e9c --- /dev/null +++ b/lib/capistrano/doctor/output_helpers.rb @@ -0,0 +1,79 @@ +module Capistrano + module Doctor + # Helper methods for pretty-printing doctor output to stdout. All output + # (other than `title`) is indented by four spaces to facilitate copying and + # pasting this output into e.g. GitHub or Stack Overflow to achieve code + # formatting. + module OutputHelpers + class Row + attr_reader :color + attr_reader :values + + def initialize + @values = [] + end + + def <<(value) + values << value + end + + def yellow + @color = :yellow + end + end + + # Prints a table for a given array of records. For each record, the block + # is yielded two arguments: the record and a Row object. To print values + # for that record, add values using `row << "some value"`. A row can + # optionally be highlighted in yellow using `row.yellow`. + def table(records, &block) + return if records.empty? + rows = collect_rows(records, &block) + col_widths = calculate_column_widths(rows) + + rows.each do |row| + line = row.values.each_with_index.map do |value, col| + value.to_s.ljust(col_widths[col]) + end.join(" ").rstrip + line = color.colorize(line, row.color) if row.color + puts line + end + end + + # Prints a title in blue with surrounding newlines. + def title(text) + # Use $stdout directly to bypass the indentation that our `puts` does. + $stdout.puts(color.colorize("\n#{text}\n", :blue)) + end + + # Prints text in yellow. + def warning(text) + puts color.colorize(text, :yellow) + end + + # Override `Kernel#puts` to prepend four spaces to each line. + def puts(string=nil) + $stdout.puts(string.to_s.gsub(/^/, " ")) + end + + private + + def collect_rows(records) + records.map do |rec| + Row.new.tap { |row| yield(rec, row) } + end + end + + def calculate_column_widths(rows) + num_columns = rows.map { |row| row.values.length }.max + Array.new(num_columns) do |col| + rows.map { |row| row.values[col].to_s.length }.max + end + end + + def color + @color ||= SSHKit::Color.new($stdout) + end + end + end +end diff --git a/lib/capistrano/doctor/variables_doctor.rb b/lib/capistrano/doctor/variables_doctor.rb new file mode 100644 index 00000000..66e14ca1 --- /dev/null +++ b/lib/capistrano/doctor/variables_doctor.rb @@ -0,0 +1,66 @@ +require "capistrano/doctor/output_helpers" + +module Capistrano + module Doctor + # Prints a table of all Capistrano variables and their current values. If + # there are unrecognized variables, print warnings for them. + class VariablesDoctor + # These are keys that have no default values in Capistrano, but are + # nonetheless expected to be set. + WHITELIST = [:application, :repo_url].freeze + private_constant :WHITELIST + + include Capistrano::Doctor::OutputHelpers + + def initialize(env=Capistrano::Configuration.env) + @env = env + end + + def call + title("Variables") + values = inspect_all_values + + table(variables.keys.sort) do |key, row| + row.yellow if suspicious_keys.include?(key) + row << ":#{key}" + row << values[key] + end + + puts if suspicious_keys.any? + + suspicious_keys.sort.each do |key| + warning( + ":#{key} is not a recognized Capistrano setting (#{location(key)})" + ) + end + end + + private + + attr_reader :env + + def variables + env.variables + end + + def inspect_all_values + variables.keys.each_with_object({}) do |key, inspected| + inspected[key] = if env.is_question?(key) + "" + else + variables.peek(key).inspect + end + end + end + + def suspicious_keys + (variables.untrusted_keys & variables.unused_keys) - WHITELIST + end + + def location(key) + loc = variables.source_locations(key).first + loc && loc.sub(/^#{Regexp.quote(Dir.pwd)}/, "").sub(/:in.*/, "") + end + end + end +end diff --git a/lib/capistrano/setup.rb b/lib/capistrano/setup.rb index ad641f05..e35cfece 100644 --- a/lib/capistrano/setup.rb +++ b/lib/capistrano/setup.rb @@ -1,3 +1,4 @@ +require "capistrano/doctor" require "capistrano/immutable_task" include Capistrano::DSL @@ -22,8 +23,10 @@ stages.each do |stage| invoke "load:defaults" Rake.application["load:defaults"].extend(Capistrano::ImmutableTask) - load deploy_config_path - load stage_config_path.join("#{stage}.rb") + env.variables.untrusted! do + load deploy_config_path + load stage_config_path.join("#{stage}.rb") + end load "capistrano/#{fetch(:scm)}.rb" I18n.locale = fetch(:locale, :en) configure_backend diff --git a/lib/capistrano/tasks/doctor.rake b/lib/capistrano/tasks/doctor.rake new file mode 100644 index 00000000..dbddff48 --- /dev/null +++ b/lib/capistrano/tasks/doctor.rake @@ -0,0 +1,19 @@ +desc "Display a Capistrano troubleshooting report (all doctor: tasks)" +task doctor: ["doctor:environment", "doctor:gems", "doctor:variables"] + +namespace :doctor do + desc "Display Ruby environment details" + task :environment do + Capistrano::Doctor::EnvironmentDoctor.new.call + end + + desc "Display Capistrano gem versions" + task :gems do + Capistrano::Doctor::GemsDoctor.new.call + end + + desc "Display the values of all Capistrano variables" + task :variables do + Capistrano::Doctor::VariablesDoctor.new.call + end +end diff --git a/spec/lib/capistrano/doctor/environment_doctor_spec.rb b/spec/lib/capistrano/doctor/environment_doctor_spec.rb new file mode 100644 index 00000000..9b4ffc24 --- /dev/null +++ b/spec/lib/capistrano/doctor/environment_doctor_spec.rb @@ -0,0 +1,44 @@ +require "spec_helper" +require "capistrano/doctor/environment_doctor" + +module Capistrano + module Doctor + describe EnvironmentDoctor do + let(:doc) { EnvironmentDoctor.new } + + it "prints using 4-space indentation" do + expect { doc.call }.to output(/^ {4}/).to_stdout + end + + it "prints the Ruby version" do + expect { doc.call }.to\ + output(/#{Regexp.quote(RUBY_DESCRIPTION)}/).to_stdout + end + + it "prints the Rubygems version" do + expect { doc.call }.to output(/#{Regexp.quote(Gem::VERSION)}/).to_stdout + end + + describe "Rake" do + before do + load File.expand_path("../../../../../lib/capistrano/doctor.rb", + __FILE__) + end + + after do + Rake::Task.clear + end + + it "has an doctor:environment task that calls EnvironmentDoctor" do + EnvironmentDoctor.any_instance.expects(:call) + Rake::Task["doctor:environment"].invoke + end + + it "has a doctor task that depends on doctor:environment" do + expect(Rake::Task["doctor"].prerequisites).to \ + include("doctor:environment") + end + end + end + end +end diff --git a/spec/lib/capistrano/doctor/gems_doctor_spec.rb b/spec/lib/capistrano/doctor/gems_doctor_spec.rb new file mode 100644 index 00000000..3157715b --- /dev/null +++ b/spec/lib/capistrano/doctor/gems_doctor_spec.rb @@ -0,0 +1,61 @@ +require "spec_helper" +require "capistrano/doctor/gems_doctor" +require "airbrussh/version" +require "sshkit/version" + +module Capistrano + module Doctor + describe GemsDoctor do + let(:doc) { GemsDoctor.new } + + it "prints using 4-space indentation" do + expect { doc.call }.to output(/^ {4}/).to_stdout + end + + it "prints the Capistrano version" do + expect { doc.call }.to\ + output(/capistrano\s+#{Regexp.quote(Capistrano::VERSION)}/).to_stdout + end + + it "prints the Rake version" do + expect { doc.call }.to\ + output(/rake\s+#{Regexp.quote(Rake::VERSION)}/).to_stdout + end + + it "prints the SSHKit version" do + expect { doc.call }.to\ + output(/sshkit\s+#{Regexp.quote(SSHKit::VERSION)}/).to_stdout + end + + it "prints the Airbrussh version" do + expect { doc.call }.to\ + output(/airbrussh\s+#{Regexp.quote(Airbrussh::VERSION)}/).to_stdout + end + + it "warns that new version is available" do + Gem.stubs(:latest_version_for).returns(Gem::Version.new("99.0.0")) + expect { doc.call }.to output(/\(update available\)/).to_stdout + end + + describe "Rake" do + before do + load File.expand_path("../../../../../lib/capistrano/doctor.rb", + __FILE__) + end + + after do + Rake::Task.clear + end + + it "has an doctor:gems task that calls GemsDoctor" do + GemsDoctor.any_instance.expects(:call) + Rake::Task["doctor:gems"].invoke + end + + it "has a doctor task that depends on doctor:gems" do + expect(Rake::Task["doctor"].prerequisites).to include("doctor:gems") + end + end + end + end +end diff --git a/spec/lib/capistrano/doctor/output_helpers_spec.rb b/spec/lib/capistrano/doctor/output_helpers_spec.rb new file mode 100644 index 00000000..cd01a217 --- /dev/null +++ b/spec/lib/capistrano/doctor/output_helpers_spec.rb @@ -0,0 +1,47 @@ +require "spec_helper" +require "capistrano/doctor/output_helpers" + +module Capistrano + module Doctor + describe OutputHelpers do + include OutputHelpers + + # Force color for the purpose of these tests + before { ENV.stubs(:[]).with("SSHKIT_COLOR").returns("1") } + + it "prints titles in blue with newlines and without indentation" do + expect { title("Hello!") }.to\ + output("\e[0;34;49m\nHello!\n\e[0m\n").to_stdout + end + + it "prints warnings in yellow with 4-space indentation" do + expect { warning("Yikes!") }.to\ + output(" \e[0;33;49mYikes!\e[0m\n").to_stdout + end + + it "overrides puts to indent 4 spaces per line" do + expect { puts("one\ntwo") }.to output(" one\n two\n").to_stdout + end + + it "formats tables with indent, aligned columns and per-row color" do + data = [ + ["one", ".", "1"], + ["two", "..", "2"], + ["three", "...", "3"] + ] + block = proc do |record, row| + row.yellow if record.first == "two" + row << record[0] + row << record[1] + row << record[2] + end + expected_output = <<-OUT + one . 1 + \e[0;33;49mtwo .. 2\e[0m + three ... 3 + OUT + expect { table(data, &block) }.to output(expected_output).to_stdout + end + end + end +end diff --git a/spec/lib/capistrano/doctor/variables_doctor_spec.rb b/spec/lib/capistrano/doctor/variables_doctor_spec.rb new file mode 100644 index 00000000..0bec8386 --- /dev/null +++ b/spec/lib/capistrano/doctor/variables_doctor_spec.rb @@ -0,0 +1,79 @@ +require "spec_helper" +require "capistrano/doctor/variables_doctor" + +module Capistrano + module Doctor + describe VariablesDoctor do + include Capistrano::DSL + + let(:doc) { VariablesDoctor.new } + + before do + set :branch, "master" + set :pty, false + + env.variables.untrusted! do + set :application, "my_app" + set :repo_url, ".git" + set :copy_strategy, :scp + set :custom_setting, "hello" + ask :secret + end + + fetch :custom_setting + end + + after { Capistrano::Configuration.reset! } + + it "prints using 4-space indentation" do + expect { doc.call }.to output(/^ {4}/).to_stdout + end + + it "prints variable names and values" do + expect { doc.call }.to output(/:branch\s+"master"$/).to_stdout + expect { doc.call }.to output(/:pty\s+false$/).to_stdout + expect { doc.call }.to output(/:application\s+"my_app"$/).to_stdout + expect { doc.call }.to output(/:repo_url\s+".git"$/).to_stdout + expect { doc.call }.to output(/:copy_strategy\s+:scp$/).to_stdout + expect { doc.call }.to output(/:custom_setting\s+"hello"$/).to_stdout + end + + it "prints unanswered question variable as " do + expect { doc.call }.to output(/:secret\s+$/).to_stdout + end + + it "prints warning for unrecognized variable" do + expect { doc.call }.to \ + output(/:copy_strategy is not a recognized Capistrano setting/)\ + .to_stdout + end + + it "does not print warning for unrecognized variable that is fetched" do + expect { doc.call }.not_to \ + output(/:custom_setting is not a recognized Capistrano setting/)\ + .to_stdout + end + + describe "Rake" do + before do + load File.expand_path("../../../../../lib/capistrano/doctor.rb", + __FILE__) + end + + after do + Rake::Task.clear + end + + it "has an doctor:variables task that calls VariablesDoctor" do + VariablesDoctor.any_instance.expects(:call) + Rake::Task["doctor:variables"].invoke + end + + it "has a doctor task that depends on doctor:variables" do + expect(Rake::Task["doctor"].prerequisites).to \ + include("doctor:variables") + end + end + end + end +end