1
0
Fork 0
mirror of https://github.com/capistrano/capistrano synced 2023-03-27 23:21:18 -04:00

Add doctor:variables, :environment, and :gems

This adds various "doctor" tasks that can be used for troubleshooting. To see
all the doctor output, run e.g. `cap production doctor`. This will print a
report like this:

```
Environment

    Ruby     ruby 2.3.0p0 (2015-12-25 revision 53290) [x86_64-darwin14]
    Rubygems 2.6.2
    Bundler  1.11.2
    Command  cap production doctor

Gems

    capistrano         3.4.0
    airbrussh          1.0.1
    rake               10.5.0 (update available)
    sshkit             1.9.0
    capistrano-bundler 1.1.4
    capistrano-rails   1.1.6

Variables

    :application                         "myapp"
    :assets_prefix                       "assets"
    :assets_roles                        [:web]
    :branch                              "master"

... etc.
```

To obtain the variables information in particular, code has been added to audit
the setting and fetching of variables. Variables set by Capistrano itself and
its plugins are whitelisted, but others are "untrusted". If a variable is
untrusted and it seems like it is never used, then `doctor:variables` will print
a warning (include source location) for that variable name, like this:

```
:copy_strategy is not a recognized Capistrano setting (config/deploy.rb:14)
```

Finally, the RubyGems API is used to check the remote gem repository to see if
any newer versions of Capistrano gems are available (this is gracefully skipped
if there is no network connection). Any outdated gems will be indicated in the
`doctor:gems` output.
This commit is contained in:
Matt Brictson 2016-03-09 17:13:17 -08:00 committed by Lee Hambley
parent 66f7bae681
commit b5e4aa6d5e
17 changed files with 640 additions and 77 deletions

View file

@ -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`

View file

@ -25,9 +25,15 @@ As much the Capistrano community tries to write good, well-tested code, bugs sti
**In case youve 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 <stage> doctor` task, e.g.:
* Versions of Ruby, Capistrano, and any plugins youre using
```
cap production doctor
```
Also include in your report:
* Versions of Ruby, Capistrano, and any plugins youre using (if `doctor` didn't already do this for you)
* A description of the troubleshooting steps youve taken
* Logs and backtraces
* Sections of your `deploy.rb` that may be relevant

11
features/doctor.feature Normal file
View file

@ -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

View file

@ -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 <stage> doctor`):

View file

@ -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)

View file

@ -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

5
lib/capistrano/doctor.rb Normal file
View file

@ -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__)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)
"<ask>"
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 <ask>" do
expect { doc.call }.to output(/:secret\s+<ask>$/).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