├── .gitignore ├── .rspec ├── .travis.yml ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin └── hocho ├── hocho.gemspec ├── lib ├── hocho.rb └── hocho │ ├── command.rb │ ├── config.rb │ ├── drivers.rb │ ├── drivers │ ├── base.rb │ ├── bundler.rb │ ├── itamae_ssh.rb │ ├── mitamae.rb │ └── ssh_base.rb │ ├── host.rb │ ├── inventory.rb │ ├── inventory_providers.rb │ ├── inventory_providers │ ├── base.rb │ └── file.rb │ ├── property_providers.rb │ ├── property_providers │ ├── add_default.rb │ ├── base.rb │ └── ruby_script.rb │ ├── runner.rb │ ├── utils │ ├── finder.rb │ └── symbolize.rb │ └── version.rb ├── script ├── console └── setup └── spec ├── property_providers └── ruby_script_spec.rb └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.4.0 4 | before_install: gem install bundler -v 1.10.6 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in hocho.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Shota Fukumori (sora_h) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hocho: an itamae wrapper 2 | 3 | Hocho is a wrapper of the provisioning tool [itamae](https://github.com/itamae-kitchen/itamae). 4 | 5 | ## Features 6 | 7 | - Drivers 8 | - `itamae ssh` support 9 | - remote `itamae local` support on rsync+bundler 10 | - remote `mitamae` support 11 | - Simple pluggable host inventory, discovery 12 | 13 | ## Installation 14 | 15 | 16 | Add this line to your application's Gemfile: 17 | 18 | ```ruby 19 | gem 'hocho' 20 | ``` 21 | 22 | And then execute: 23 | 24 | $ bundle 25 | 26 | Or install it yourself as: 27 | 28 | $ gem install hocho 29 | 30 | ## Usage 31 | 32 | ``` yaml 33 | # hocho.yml 34 | inventory_providers: 35 | file: 36 | path: './hosts' 37 | 38 | property_providers: 39 | ## Provide default values to host properties (reverse_merge). 40 | - add_default: 41 | properties: 42 | blah: blahblah 43 | # preferred_driver: mitamae 44 | attributes: 45 | node_attributes_goes_here: hello 46 | 47 | ## Run ruby script to mutate host properties 48 | - ruby_script: 49 | name: name-for-your-convenience # optional 50 | script: 'host.properties[:hello] = Time.now.xmlschema' 51 | ## or 52 | # file: path/to/script.rb 53 | 54 | # driver_options: 55 | # mitamae: 56 | # mitamae_prepare_script: 'wget -O /usr/local/bin/mitamae https://...' 57 | ``` 58 | 59 | ``` yaml 60 | # ./hosts/test.yml 61 | test.example.org: 62 | # ssh_options: 63 | # user: ... 64 | properties: 65 | # preferred_driver: bundler 66 | # preferred_driver: mitamae 67 | attributes: 68 | node_attributes_goes_here: hello 69 | run_list: 70 | - roles/app/default.rb 71 | ``` 72 | 73 | ``` 74 | $ hocho list 75 | $ hocho show test.example.org 76 | $ hocho apply test.example.org 77 | ``` 78 | 79 | ## Development 80 | 81 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 82 | 83 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 84 | 85 | ## Contributing 86 | 87 | Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/hocho. 88 | 89 | 90 | ## License 91 | 92 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 93 | 94 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /bin/hocho: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'hocho/command' 3 | 4 | Hocho::Command.start 5 | -------------------------------------------------------------------------------- /hocho.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'hocho/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "hocho" 8 | spec.version = Hocho::VERSION 9 | spec.authors = ["sorah (Shota Fukumori)"] 10 | spec.email = ["her@sorah.jp"] 11 | 12 | spec.summary = %q{Server provisioning tool with itamae} 13 | spec.homepage = "https://github.com/sorah/hocho" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 17 | spec.bindir = "bin" 18 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_dependency "thor" 22 | spec.add_dependency "itamae" 23 | spec.add_dependency "net-ssh", ">= 4.1.0" 24 | spec.add_dependency "hashie" 25 | 26 | spec.add_development_dependency "bundler" 27 | spec.add_development_dependency "rake" 28 | spec.add_development_dependency "rspec" 29 | end 30 | -------------------------------------------------------------------------------- /lib/hocho.rb: -------------------------------------------------------------------------------- 1 | require "hocho/version" 2 | 3 | module Hocho 4 | # Your code goes here... 5 | end 6 | -------------------------------------------------------------------------------- /lib/hocho/command.rb: -------------------------------------------------------------------------------- 1 | require 'thor' 2 | require 'yaml' 3 | require 'json' 4 | require 'io/console' 5 | require 'hocho/config' 6 | require 'hocho/inventory' 7 | require 'hocho/runner' 8 | 9 | module Hocho 10 | class Command < Thor 11 | class_option :config, type: :string, desc: 'path to config file (default: ENV["HOCHO_CONFIG"] or ./hocho.yml)' 12 | 13 | desc "list", "" 14 | method_option :verbose, type: :boolean, default: false, alias: %w(-v) 15 | method_option :format, enum: %w(yaml json), default: 'yaml' 16 | def list 17 | hosts = inventory.hosts 18 | 19 | if options[:verbose] 20 | case options[:format] 21 | when 'yaml' 22 | puts hosts.map(&:to_h).to_yaml 23 | when 'json' 24 | puts hosts.map(&:to_h).to_json 25 | end 26 | else 27 | case options[:format] 28 | when 'yaml' 29 | puts hosts.map(&:name).to_yaml 30 | when 'json' 31 | puts hosts.map(&:name).to_json 32 | end 33 | end 34 | end 35 | 36 | desc "show NAME", "" 37 | method_option :format, enum: %w(yaml json), default: 'yaml' 38 | def show(name) 39 | host = inventory.filter({name: name}).first 40 | if host 41 | case options[:format] 42 | when 'yaml' 43 | puts host.to_h.to_yaml 44 | when 'json' 45 | puts host.to_h.to_json 46 | end 47 | else 48 | raise "host name=#{name.inspect} not found" 49 | end 50 | end 51 | 52 | desc "apply HOST", "run itamae" 53 | method_option :sudo, type: :boolean, default: false 54 | method_option :dry_run, type: :boolean, default: false, aliases: %w(-n) 55 | method_option :exclude, type: :string, default: '', aliases: %w(-e) 56 | method_option :driver, type: :string 57 | def apply(name) 58 | hosts = inventory.filter({name: name}, exclude_filters: {name: options[:exclude]}) 59 | if hosts.empty? 60 | raise "host name=#{name.inspect} not found" 61 | end 62 | 63 | if hosts.size > 1 64 | puts "Running sequencial on:" 65 | hosts.each do |host| 66 | puts " * #{host.name}" 67 | end 68 | puts 69 | end 70 | 71 | if config[:ask_sudo_password] || options[:sudo] 72 | print "sudo password: " 73 | sudo_password = $stdin.noecho { $stdin.gets.chomp } 74 | puts 75 | end 76 | 77 | hosts.each do |host| 78 | host.sudo_password = sudo_password if sudo_password 79 | Runner.new( 80 | host, 81 | driver: options[:driver], 82 | base_dir: config[:itamae_dir] || '.', 83 | initializers: config[:initializers] || [], 84 | driver_options: config[:driver_options] || {}, 85 | ).run( 86 | dry_run: options[:dry_run], 87 | ) 88 | end 89 | end 90 | 91 | private 92 | 93 | def inventory 94 | @inventory ||= Hocho::Inventory.new(config.inventory_providers, config.property_providers) 95 | end 96 | 97 | def config 98 | @config ||= Hocho::Config.load(config_file).tap do |c| 99 | Dir.chdir c.base_dir # XXX: 100 | end 101 | end 102 | 103 | def config_file 104 | options[:config] || ENV['HOCHO_CONFIG'] || './hocho.yml' 105 | end 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/hocho/config.rb: -------------------------------------------------------------------------------- 1 | require 'hocho/utils/symbolize' 2 | require 'hocho/property_providers' 3 | require 'hocho/inventory_providers' 4 | require 'pathname' 5 | require 'yaml' 6 | 7 | module Hocho 8 | class Config 9 | DEFAULT_INVENTORY_PROVIDERS_CONFIG = [file: {path: './hosts.yml'}] 10 | 11 | def self.load(path) 12 | new YAML.load_file(path.to_s), base_dir: File.dirname(path.to_s) 13 | end 14 | 15 | def initialize(hash, base_dir: '.') 16 | @config = Hocho::Utils::Symbolize.keys_of(hash) 17 | @base_dir = Pathname(base_dir) 18 | end 19 | 20 | attr_reader :base_dir 21 | 22 | def [](k) 23 | @config[k] 24 | end 25 | 26 | def inventory_providers 27 | @inventory_providers ||= begin 28 | provider_specs = (@config[:inventory_providers] || DEFAULT_INVENTORY_PROVIDERS_CONFIG) 29 | if provider_specs.kind_of?(Hash) 30 | provider_specs = [provider_specs] 31 | end 32 | 33 | provider_specs.flat_map do |spec| 34 | raise TypeError, 'config inventory_providers[] should be an Hash' unless spec.kind_of?(Hash) 35 | spec.map do |name, options| 36 | InventoryProviders.find(name).new(**options) 37 | end 38 | end 39 | end 40 | end 41 | 42 | def property_providers 43 | @property_providers ||= begin 44 | provider_specs = (@config[:property_providers] || []) 45 | raise TypeError, 'config property_providers should be an Array' unless provider_specs.kind_of?(Array) 46 | provider_specs.flat_map do |spec| 47 | raise TypeError, 'config property_providers[] should be an Hash' unless spec.kind_of?(Hash) 48 | spec.map do |name, options| 49 | PropertyProviders.find(name).new(**options) 50 | end 51 | end 52 | end 53 | end 54 | 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/hocho/drivers.rb: -------------------------------------------------------------------------------- 1 | require 'hocho/utils/finder' 2 | 3 | module Hocho 4 | module Drivers 5 | def self.find(name) 6 | Hocho::Utils::Finder.find(self, 'hocho/drivers', name) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/hocho/drivers/base.rb: -------------------------------------------------------------------------------- 1 | require 'tempfile' 2 | require 'shellwords' 3 | require 'json' 4 | 5 | module Hocho 6 | module Drivers 7 | class Base 8 | def initialize(host, base_dir: '.', initializers: []) 9 | @host = host 10 | @base_dir = base_dir 11 | @initializers = initializers 12 | end 13 | 14 | attr_reader :host, :base_dir, :initializers 15 | 16 | def run(dry_run: false) 17 | raise NotImplementedError 18 | end 19 | 20 | def finalize 21 | end 22 | 23 | def run_list 24 | [*initializers, *host.run_list] 25 | end 26 | 27 | private 28 | 29 | def node_json 30 | host.attributes.to_json 31 | end 32 | 33 | def with_node_json_file 34 | begin 35 | f = Tempfile.new('node-json') 36 | f.puts node_json 37 | f.flush 38 | yield f.path 39 | ensure 40 | f.close! 41 | end 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/hocho/drivers/bundler.rb: -------------------------------------------------------------------------------- 1 | require 'hocho/drivers/ssh_base' 2 | 3 | module Hocho 4 | module Drivers 5 | class Bundler < SshBase 6 | def initialize(host, base_dir: '.', initializers: [], itamae_options: [], bundle_without: [], bundle_path: nil, deploy_options: {}) 7 | super host, base_dir: base_dir, initializers: initializers 8 | 9 | @itamae_options = itamae_options 10 | @bundle_without = bundle_without 11 | @bundle_path = bundle_path 12 | @deploy_options = deploy_options 13 | end 14 | 15 | def run(dry_run: false) 16 | ssh # Test connection 17 | deploy(**@deploy_options) do 18 | bundle_install 19 | run_itamae(dry_run: dry_run) 20 | end 21 | end 22 | 23 | 24 | def bundle_install 25 | bundle_path_env = @bundle_path ? "BUNDLE_PATH=#{@bundle_path.shellescape} " : nil 26 | check_exitstatus, check_exitsignal = ssh_run("cd #{host_basedir.shellescape} && #{bundle_path_env}bundle check", error: false) 27 | return if check_exitstatus == 0 28 | 29 | prepare_sudo do |sh, sudovars, sudocmd| 30 | bundle_install = [host.bundler_cmd, 'install'] 31 | bundle_install.push('--path', @bundle_path) if @bundle_path 32 | bundle_install.push('--without', [*@bundle_without].join(?:)) if @bundle_without 33 | 34 | puts "=> #{host.name} # #{bundle_install.shelljoin}" 35 | 36 | ssh_run("bash") do |c| 37 | set_ssh_output_hook(c) 38 | 39 | c.send_data("cd #{host_basedir.shellescape}\n#{sudovars}\n#{sudocmd}#{bundle_install.shelljoin}\n") 40 | c.eof! 41 | end 42 | end 43 | end 44 | 45 | def run_itamae(dry_run: false) 46 | with_host_node_json_file do 47 | itamae_cmd = ['itamae', 'local', '-j', host_node_json_path, *@itamae_options] 48 | itamae_cmd.push('--dry-run') if dry_run 49 | itamae_cmd.push('--color') if $stdout.tty? 50 | itamae_cmd.push(*run_list) 51 | 52 | prepare_sudo do |sh, sudovars, sudocmd| 53 | puts "=> #{host.name} # #{host.bundler_cmd} exec #{itamae_cmd.shelljoin}" 54 | ssh_run("bash") do |c| 55 | set_ssh_output_hook(c) 56 | 57 | c.send_data("cd #{host_basedir.shellescape}\n#{sudovars}\n#{sudocmd}#{host.bundler_cmd} exec #{itamae_cmd.shelljoin}\n") 58 | c.eof! 59 | end 60 | end 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/hocho/drivers/itamae_ssh.rb: -------------------------------------------------------------------------------- 1 | require 'hocho/drivers/base' 2 | require 'shellwords' 3 | 4 | module Hocho 5 | module Drivers 6 | class ItamaeSsh < Base 7 | def initialize(host, base_dir: '.', initializers: [], itamae_options: []) 8 | super host, base_dir: base_dir, initializers: initializers 9 | @itamae_options = itamae_options 10 | end 11 | 12 | def run(dry_run: false) 13 | with_node_json_file do |node_json| 14 | env = {}.tap do |e| 15 | e['SUDO_PASSWORD'] = host.sudo_password if host.sudo_password 16 | end 17 | cmd = ["itamae", "ssh", *@itamae_options, "-j", node_json, "-h", host.hostname] 18 | 19 | cmd.push('-u', host.user) if host.user 20 | cmd.push('-p', host.ssh_port.to_s) if host.ssh_port 21 | cmd.push('--dry-run') if dry_run 22 | cmd.push('--color') if $stdout.tty? 23 | 24 | cmd.push(*run_list) 25 | 26 | puts "=> $ #{cmd.shelljoin}" 27 | system(env, *cmd, chdir: base_dir) or raise "itamae ssh failed" 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/hocho/drivers/mitamae.rb: -------------------------------------------------------------------------------- 1 | require 'hocho/drivers/ssh_base' 2 | 3 | module Hocho 4 | module Drivers 5 | class Mitamae < SshBase 6 | def initialize(host, base_dir: '.', mitamae_path: 'mitamae', mitamae_prepare_script: [], mitamae_outdate_check_script: nil, initializers: [], mitamae_options: [], deploy_options: {}) 7 | super host, base_dir: base_dir, initializers: initializers 8 | 9 | @mitamae_path = mitamae_path 10 | @mitamae_prepare_script = mitamae_prepare_script 11 | @mitamae_outdate_check_script = mitamae_outdate_check_script 12 | @mitamae_options = mitamae_options 13 | @deploy_options = deploy_options 14 | end 15 | 16 | def run(dry_run: false) 17 | ssh # Test connection 18 | deploy(**@deploy_options) do 19 | prepare_mitamae 20 | run_mitamae(dry_run: dry_run) 21 | end 22 | end 23 | 24 | def mitamae_available? 25 | exitstatus, _ = if @mitamae_path.start_with?('/') 26 | ssh_run("test -x #{@mitamae_path.shellescape}", error: false) 27 | else 28 | ssh_run("#{@mitamae_path.shellescape} version 2>/dev/null >/dev/null", error: false) 29 | end 30 | exitstatus == 0 31 | end 32 | 33 | def mitamae_outdated? 34 | if @mitamae_outdate_check_script 35 | exitstatus, _ = ssh_run("export HOCHO_MITAMAE_PATH=#{@mitamae_path.shellescape}; #{@mitamae_outdate_check_script}", error: false) 36 | exitstatus == 0 37 | else 38 | false 39 | end 40 | end 41 | 42 | def prepare_mitamae 43 | return if mitamae_available? && !mitamae_outdated? 44 | scripts = [*@mitamae_prepare_script] 45 | if scripts.empty? 46 | raise "We have to prepare MItamae, but not mitamae_prepare_script is specified" 47 | end 48 | prepare_sudo do |sh, sudovars, sudocmd| 49 | scripts.each do |script| 50 | log_prefix = "=> #{host.name} # " 51 | log_prefix_white = ' ' * log_prefix.size 52 | puts "#{log_prefix}#{script.each_line.map{ |_| "#{log_prefix_white}#{_.chomp}" }.join("\n")}" 53 | 54 | ssh_run("bash") do |c| 55 | set_ssh_output_hook(c) 56 | 57 | c.send_data("cd #{host_basedir.shellescape}\n#{sudovars}\n#{sudocmd} bash <<-'HOCHOEOS'\n#{script}\nHOCHOEOS\n") 58 | c.eof! 59 | end 60 | end 61 | end 62 | availability, outdated = mitamae_available?, mitamae_outdated? 63 | if !availability || outdated 64 | status = [availability ? nil : 'unavailable', outdated ? 'outdated' : nil].compact.join(' and ') 65 | raise "prepared MItamae, but it's still #{status}" 66 | end 67 | end 68 | 69 | def run_mitamae(dry_run: false) 70 | with_host_node_json_file do 71 | itamae_cmd = [@mitamae_path, 'local', '-j', host_node_json_path, *@mitamae_options] 72 | itamae_cmd.push('--dry-run') if dry_run 73 | # itamae_cmd.push('--color') if $stdout.tty? 74 | itamae_cmd.push(*run_list) 75 | 76 | prepare_sudo do |sh, sudovars, sudocmd| 77 | puts "=> #{host.name} # #{itamae_cmd.shelljoin}" 78 | ssh_run("bash") do |c| 79 | set_ssh_output_hook(c) 80 | 81 | c.send_data("cd #{host_basedir.shellescape}\n#{sudovars}\n#{sudocmd} #{itamae_cmd.shelljoin}\n") 82 | c.eof! 83 | end 84 | end 85 | end 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/hocho/drivers/ssh_base.rb: -------------------------------------------------------------------------------- 1 | require 'hocho/drivers/base' 2 | require 'securerandom' 3 | require 'shellwords' 4 | 5 | module Hocho 6 | module Drivers 7 | class SshBase < Base 8 | def ssh 9 | host.ssh_connection 10 | end 11 | 12 | def finalize 13 | remove_host_tmpdir! 14 | remove_host_shmdir! 15 | end 16 | 17 | def deploy(deploy_dir: nil, shm_prefix: []) 18 | @host_basedir = deploy_dir if deploy_dir 19 | 20 | shm_prefix = [*shm_prefix] 21 | 22 | ssh_cmd = ['ssh', *host.openssh_config.flat_map { |l| ['-o', "\"#{l}\""] }].join(' ') 23 | shm_exclude = shm_prefix.map{ |_| "--exclude=#{_}" } 24 | compress = host.compress? ? ['-z'] : [] 25 | hostname = host.hostname.include?(?:) ? "[#{host.hostname}]" : host.hostname # surround with square bracket for ipv6 address 26 | rsync_cmd = [*%w(rsync -a --copy-links --copy-unsafe-links --delete --exclude=.git), *compress, *shm_exclude, '--rsh', ssh_cmd, '.', "#{hostname}:#{host_basedir}"] 27 | 28 | puts "=> $ #{rsync_cmd.shelljoin}" 29 | system(*rsync_cmd, chdir: base_dir) or raise 'failed to rsync' 30 | 31 | unless shm_prefix.empty? 32 | shm_include = shm_prefix.map{ |_| "--include=#{_.sub(%r{/\z},'')}/***" } 33 | rsync_cmd = [*%w(rsync -a --copy-links --copy-unsafe-links --delete), *compress, *shm_include, '--exclude=*', '--rsh', ssh_cmd, '.', "#{hostname}:#{host_shm_basedir}"] 34 | puts "=> $ #{rsync_cmd.shelljoin}" 35 | system(*rsync_cmd, chdir: base_dir) or raise 'failed to rsync' 36 | shm_prefix.each do |x| 37 | mkdir = if %r{\A[^/].*\/.+\z} === x 38 | %Q(mkdir -vp "$(basename #{x.shellescape})" &&) 39 | else 40 | nil 41 | end 42 | ssh_run(%Q(cd #{host_basedir} && #{mkdir} ln -sfv #{host_shm_basedir}/#{x.shellescape} ./#{x.sub(%r{/\z},'').shellescape})) 43 | end 44 | end 45 | 46 | yield 47 | ensure 48 | if !deploy_dir || !keep_synced_files 49 | cmd = "rm -rf #{host_basedir.shellescape}" 50 | puts "=> #{host.name} $ #{cmd}" 51 | ssh_run(cmd, error: false) 52 | end 53 | end 54 | 55 | private 56 | 57 | def prepare_sudo(password = host.sudo_password) 58 | raise "sudo password not present" if host.sudo_required? && !host.nopasswd_sudo? && password.nil? 59 | 60 | unless host.sudo_required? 61 | yield nil, nil, "" 62 | return 63 | end 64 | 65 | if host.nopasswd_sudo? 66 | yield nil, nil, "sudo " 67 | return 68 | end 69 | 70 | passphrase_env_name = "HOCHO_PA_#{SecureRandom.hex(8).upcase}" 71 | # password_env_name = "HOCHO_PB_#{SecureRandom.hex(8).upcase}" 72 | 73 | temporary_passphrase = SecureRandom.base64(129).chomp 74 | 75 | local_supports_pbkdf2 = system(*%w(openssl enc -pbkdf2), in: File::NULL, out: File::NULL, err: [:child, :out]) 76 | remote_supports_pbkdf2 = begin 77 | exitstatus, * = ssh_run("openssl enc -pbkdf2", error: false, &:eof!) 78 | exitstatus == 0 79 | end 80 | derive = local_supports_pbkdf2 && remote_supports_pbkdf2 ? %w(-pbkdf2) : [] 81 | 82 | encrypted_password = IO.pipe do |r,w| 83 | w.write temporary_passphrase 84 | w.close 85 | IO.popen([*%w(openssl enc -aes-128-cbc -pass fd:5 -a -md sha256), *derive, 5 => r], "r+") do |io| 86 | io.puts password 87 | io.close_write 88 | io.read.chomp 89 | end 90 | end 91 | 92 | begin 93 | tmpdir = host_shmdir ? "TMPDIR=#{host_shmdir.shellescape} " : nil 94 | temp_executable = ssh.exec!("#{tmpdir}mktemp").chomp 95 | raise unless temp_executable.start_with?('/') 96 | 97 | ssh_run("chmod 0700 #{temp_executable.shellescape}; cat > #{temp_executable.shellescape}; chmod +x #{temp_executable.shellescape}") do |ch| 98 | ch.send_data("#!/bin/bash\nexec openssl enc -aes-128-cbc -d -a -md sha256 #{derive.shelljoin} -pass env:#{passphrase_env_name} <<< #{encrypted_password.shellescape}\n") 99 | ch.eof! 100 | end 101 | 102 | sh = "#{passphrase_env_name}=#{temporary_passphrase.shellescape} SUDO_ASKPASS=#{temp_executable.shellescape} sudo -A " 103 | exp = "export #{passphrase_env_name}=#{temporary_passphrase.shellescape}\nexport SUDO_ASKPASS=#{temp_executable.shellescape}\n" 104 | cmd = "sudo -A " 105 | yield sh, exp, cmd 106 | 107 | ensure 108 | ssh_run("shred --remove #{temp_executable.shellescape}") 109 | end 110 | end 111 | 112 | def set_ssh_output_hook(c) 113 | check = ->(prefix, data, buf) do 114 | data = buf + data unless buf.empty? 115 | return if data.empty? 116 | 117 | lines = data.lines 118 | 119 | # If data is not NL-terminated, its last line is carried over to next check.call 120 | if lines.last.end_with?("\n") 121 | buf.clear 122 | else 123 | buf.replace(lines.pop) 124 | end 125 | 126 | lines.each do |line| 127 | puts "#{prefix}#{line}" 128 | end 129 | end 130 | 131 | outbuf, errbuf = +"", +"" 132 | outpre, errpre = "[#{host.name}] ", "[#{host.name}/ERR] " 133 | 134 | c.on_data do |c, data| 135 | check.call outpre, data, outbuf 136 | end 137 | c.on_extended_data do |c, _, data| 138 | check.call errpre, data, errbuf 139 | end 140 | c.on_close do 141 | puts "#{outpre}#{outbuf}" unless outbuf.empty? 142 | puts "#{errpre}#{errbuf}" unless errbuf.empty? 143 | end 144 | end 145 | 146 | def host_basedir 147 | @host_basedir || "#{host_tmpdir}/itamae" 148 | end 149 | 150 | def host_shm_basedir 151 | host_shmdir && "#{host_shmdir}/itamae" 152 | end 153 | 154 | def host_shmdir 155 | return @host_shmdir if @host_shmdir 156 | return nil if @host_shmdir == false 157 | 158 | shmdir = host.shmdir 159 | unless shmdir 160 | if ssh.exec!('uname').chomp == 'Linux' 161 | shmdir = '/dev/shm' 162 | mount = ssh.exec!("grep -F #{shmdir.shellescape} /proc/mounts").each_line.map{ |_| _.chomp.split(' ') } 163 | unless mount.find { |_| _[1] == shmdir }&.first == 'tmpfs' 164 | @host_shmdir = false 165 | return nil 166 | end 167 | else 168 | @host_shmdir = false 169 | return nil 170 | end 171 | end 172 | 173 | mktemp_cmd = "TMPDIR=#{shmdir.shellescape} #{%w(mktemp -d -t hocho-run-XXXXXXXXX).shelljoin}" 174 | 175 | res = ssh.exec!(mktemp_cmd) 176 | unless res.start_with?(shmdir) 177 | raise "Failed to shm mktemp #{mktemp_cmd.inspect} -> #{res.inspect}" 178 | end 179 | @host_shmdir = res.chomp 180 | end 181 | 182 | def host_tmpdir 183 | @host_tmpdir ||= begin 184 | mktemp_cmd = %w(mktemp -d -t hocho-run-XXXXXXXXX).shelljoin 185 | mktemp_cmd.prepend("TMPDIR=#{host.tmpdir.shellescape} ") if host.tmpdir 186 | 187 | res = ssh.exec!(mktemp_cmd) 188 | unless res.start_with?(host.tmpdir || '/') 189 | raise "Failed to mktemp #{mktemp_cmd.inspect} -> #{res.inspect}" 190 | end 191 | res.chomp 192 | end 193 | end 194 | 195 | def remove_host_tmpdir! 196 | if @host_tmpdir 197 | host_tmpdir, @host_tmpdir = @host_tmpdir, nil 198 | ssh.exec!("rm -rf #{host_tmpdir.shellescape}") 199 | end 200 | end 201 | 202 | def remove_host_shmdir! 203 | if @host_shmdir 204 | host_shmdir, @host_shmdir = @host_shmdir, nil 205 | ssh.exec!("rm -rf #{host_shmdir.shellescape}") 206 | end 207 | end 208 | 209 | 210 | def host_node_json_path 211 | "#{host_tmpdir}/node.json" 212 | end 213 | 214 | def with_host_node_json_file 215 | ssh_run("umask 0077 && cat > #{host_node_json_path.shellescape}") do |c| 216 | c.send_data "#{node_json}\n" 217 | c.eof! 218 | end 219 | 220 | yield host_node_json_path 221 | ensure 222 | ssh.exec!("rm #{host_node_json_path.shellescape}") 223 | end 224 | 225 | def ssh_run(cmd, error: true) 226 | exitstatus, exitsignal = nil 227 | 228 | puts "$ #{cmd}" 229 | cha = ssh.open_channel do |ch| 230 | ch.exec(cmd) do |c, success| 231 | raise "execution failed on #{host.name}: #{cmd.inspect}" if !success && error 232 | 233 | c.on_request("exit-status") { |c, data| exitstatus = data.read_long } 234 | c.on_request("exit-signal") { |c, data| exitsignal = data.read_long } 235 | 236 | yield c if block_given? 237 | end 238 | end 239 | cha.wait 240 | raise "execution failed on #{host.name} (status=#{exitstatus.inspect}, signal=#{exitsignal.inspect}): #{cmd.inspect}" if (exitstatus != 0 || exitsignal) && error 241 | [exitstatus, exitsignal] 242 | end 243 | end 244 | end 245 | end 246 | -------------------------------------------------------------------------------- /lib/hocho/host.rb: -------------------------------------------------------------------------------- 1 | require 'hocho/utils/symbolize' 2 | require 'hashie' 3 | require 'net/ssh' 4 | require 'net/ssh/proxy/jump' 5 | require 'net/ssh/proxy/command' 6 | 7 | module Hocho 8 | class Host 9 | def initialize(name, provider: nil, providers: nil, properties: {}, tags: {}, ssh_options: nil, tmpdir: nil, shmdir: nil, sudo_password: nil) 10 | if provider 11 | warn "DEPRECATION WARNING: #{caller[1]}: Hocho::Host.new(provider:) is deprecated. Use providers: instead " 12 | end 13 | 14 | @name = name 15 | @providers = [*provider, *providers] 16 | self.properties = properties 17 | @tags = tags 18 | @override_ssh_options = ssh_options 19 | @tmpdir = tmpdir 20 | @shmdir = shmdir 21 | @sudo_password = sudo_password 22 | end 23 | 24 | attr_reader :name, :providers, :properties, :tmpdir, :shmdir 25 | attr_writer :sudo_password 26 | attr_accessor :tags 27 | 28 | def to_h 29 | { 30 | name: name, 31 | providers: providers, 32 | tags: tags.to_h, 33 | properties: properties.to_h, 34 | }.tap do |h| 35 | h[:tmpdir] = tmpdir if tmpdir 36 | h[:shmdir] = shmdir if shmdir 37 | h[:ssh_options] = @override_ssh_options if @override_ssh_options 38 | end 39 | end 40 | 41 | def properties=(other) 42 | @properties = Hashie::Mash.new(other) 43 | end 44 | 45 | def merge!(other) 46 | @tags.merge!(other.tags) if other.tags 47 | @tmpdir = other.tmpdir if other.tmpdir 48 | @shmdir = other.shmdir if other.shmdir 49 | @properties.merge!(other.properties) 50 | end 51 | 52 | def apply_property_providers(providers) 53 | providers.each do |provider| 54 | provider.determine(self) 55 | end 56 | end 57 | 58 | def ssh_name 59 | properties[:ssh_name] || name 60 | end 61 | 62 | def run_list 63 | properties[:run_list] || [] 64 | end 65 | 66 | def attributes 67 | properties[:attributes] || {} 68 | end 69 | 70 | def sudo_password 71 | @sudo_password || properties[:sudo_password] || ENV['SUDO_PASSWORD'] 72 | end 73 | 74 | def sudo_required? 75 | properties.fetch(:sudo_required, true) 76 | end 77 | 78 | def nopasswd_sudo? 79 | !!properties[:nopasswd_sudo] 80 | end 81 | 82 | def ssh_options 83 | @validated_ssh_options || normal_ssh_options 84 | end 85 | 86 | def candidate_ssh_options 87 | [ 88 | normal_ssh_options, 89 | *alternate_ssh_options, 90 | ] 91 | end 92 | 93 | def normal_ssh_options 94 | (Net::SSH::Config.for(ssh_name) || {}).merge(Hocho::Utils::Symbolize.keys_of(properties[:ssh_options] || {})).merge(@override_ssh_options || {}) 95 | end 96 | 97 | def alternate_ssh_options 98 | alts = properties.fetch(:alternate_ssh_options, nil) 99 | list = case alts 100 | when Hash 101 | [alts] 102 | when Array 103 | alts 104 | when nil 105 | [] 106 | else 107 | raise TypeError, "alternate_ssh_options should be a Hash or Array" 108 | end 109 | list.map do |opts| 110 | normal_ssh_options.merge(Hocho::Utils::Symbolize.keys_of(opts)) 111 | end 112 | end 113 | 114 | def openssh_config(separator='=') 115 | ssh_options.flat_map do |key, value| 116 | case key 117 | when :encryption 118 | [["Ciphers", [*value].join(?,)]] 119 | when :compression 120 | [["Compression", value ? 'yes' : 'no']] 121 | when :compression_level 122 | [["CompressionLevel", value]] 123 | when :timeout 124 | [["ConnectTimeout", value]] 125 | when :forward_agent 126 | [["ForwardAgent", value ? 'yes' : 'no']] 127 | when :keys_only 128 | [["IdentitiesOnly", value ? 'yes' : 'no']] 129 | when :global_known_hosts_file 130 | [["GlobalKnownHostsFile", value]] 131 | when :auth_methods 132 | [].tap do |lines| 133 | methods = value.dup 134 | value.each do |val| 135 | case val 136 | when 'hostbased' 137 | lines << ["HostBasedAuthentication", "yes"] 138 | when 'password' 139 | lines << ["PasswordAuthentication", "yes"] 140 | when 'publickey' 141 | lines << ["PubkeyAuthentication", "yes"] 142 | end 143 | end 144 | unless methods.empty? 145 | lines << ["PreferredAuthentications", methods.join(?,)] 146 | end 147 | end 148 | when :host_key 149 | [["HostKeyAlgorithms", [*value].join(?,)]] 150 | when :host_key_alias 151 | [["HostKeyAlias", value]] 152 | when :host_name 153 | [["HostName", value]] 154 | when :keys 155 | [*value].map do |val| 156 | ["IdentityFile", val] 157 | end 158 | when :hmac 159 | [["Macs", [*value].join(?,)]] 160 | when :port 161 | [["Port", value]] 162 | when :proxy 163 | case value 164 | when Net::SSH::Proxy::Jump 165 | [["ProxyJump", value.jump_proxies]] 166 | when Net::SSH::Proxy::Command 167 | [["ProxyCommand", value.command_line_template]] 168 | when false 169 | [["ProxyCommand", 'none']] 170 | else 171 | [["ProxyCommand", value]] 172 | end 173 | when :rekey_limit 174 | [["RekeyLimit", value]] 175 | when :user 176 | [["User", value]] 177 | when :user_known_hosts_file 178 | [["UserKnownHostsFile", value]] 179 | when :verify_host_key 180 | case value 181 | when :never 182 | [["StrictHostKeyChecking", "no"]] 183 | when :accept_new_or_local_tunnel 184 | [["StrictHostKeyChecking", "accept-new"]] 185 | when :accept_new 186 | [["StrictHostKeyChecking", "accept-new"]] 187 | when :always 188 | [["StrictHostKeyChecking", "yes"]] 189 | end 190 | end 191 | end.compact.map do |keyval| 192 | keyval.join(separator) 193 | end 194 | end 195 | 196 | def hostname 197 | ssh_options[:host_name] || name 198 | end 199 | 200 | def user 201 | ssh_options[:user] 202 | end 203 | 204 | def ssh_port 205 | ssh_options[:port] 206 | end 207 | 208 | def preferred_driver 209 | properties[:preferred_driver] && properties[:preferred_driver].to_sym 210 | end 211 | 212 | def bundler_cmd 213 | properties[:bundler_cmd] || 'bundle' 214 | end 215 | 216 | def ssh_connection 217 | @ssh ||= make_ssh_connection 218 | end 219 | 220 | def make_ssh_connection 221 | ssh_options_candidates = candidate_ssh_options() 222 | ssh_options_candidates_size = ssh_options_candidates.size 223 | tries = 1 224 | begin 225 | # A workaround for a bug on net-ssh: https://github.com/net-ssh/net-ssh/issues/764 226 | # :strict_host_key_checking is translated from ssh config. However, Net::SSH.start does not accept 227 | # the option as valid one. Remove this part when net-ssh fixes the bug. 228 | options = ssh_options_candidates[0] 229 | unless Net::SSH::VALID_OPTIONS.include?(:strict_host_key_checking) 230 | options.delete(:strict_host_key_checking) 231 | end 232 | retval = Net::SSH.start(name, nil, options) 233 | @validated_ssh_options = options 234 | retval 235 | rescue Net::SSH::Exception, Errno::ECONNREFUSED, Net::SSH::Proxy::ConnectError => e 236 | raise unless ssh_options_candidates.shift 237 | tries += 1 238 | puts "[#{name}] Trying alternate ssh options due to #{e.inspect} (#{tries}/#{ssh_options_candidates_size})" 239 | retry 240 | end 241 | end 242 | 243 | def compress? 244 | properties.fetch(:compress, true) 245 | end 246 | end 247 | end 248 | -------------------------------------------------------------------------------- /lib/hocho/inventory.rb: -------------------------------------------------------------------------------- 1 | module Hocho 2 | class Inventory 3 | def initialize(providers, property_providers) 4 | @providers = providers 5 | @property_providers = property_providers 6 | end 7 | 8 | def hosts 9 | @hosts ||= @providers.inject({}) do |r,provider| 10 | provider.hosts.each do |host| 11 | if r.key?(host.name) 12 | r[host.name].merge!(host) 13 | else 14 | r[host.name] = host 15 | end 16 | end 17 | r 18 | end.each do |name, host| 19 | host.apply_property_providers(@property_providers) 20 | end.values 21 | end 22 | 23 | def filter(include_filters, exclude_filters: []) 24 | include_filters, exclude_filters = [include_filters, exclude_filters].map do |f| 25 | f.map do |name, value| 26 | values = value.to_s.split(?,).map! do |_| 27 | if _[0] == '/' && _[-1] == '/' 28 | Regexp.new(_[1...-1]) 29 | else 30 | /#{Regexp.escape(_).gsub(/\*/,'.*')}/ 31 | end 32 | end 33 | [name.to_s, values] 34 | end.to_h 35 | end 36 | 37 | filters = include_filters.map do |name, conditions| 38 | [name, [conditions, exclude_filters.fetch(name, [])]] 39 | end 40 | 41 | hosts.select do |host| 42 | filters.all? do |name, (conditions, exclude_conditions)| 43 | case name 44 | when 'name' 45 | conditions.any? { |c| host.name.match(c) } && !exclude_conditions.any? { |c| host.name.match(c) } 46 | else 47 | v = (host.attributes[name] || host.attributes[name.to_sym] || host.tags[name] || host.tags[name.to_sym]) 48 | v && conditions.any? { |c| v.to_s.match(c) } && !exclude_conditions.any?{ |c| v.to_s.match(c) } 49 | end 50 | end 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/hocho/inventory_providers.rb: -------------------------------------------------------------------------------- 1 | require 'hocho/utils/finder' 2 | 3 | module Hocho 4 | module InventoryProviders 5 | def self.find(name) 6 | Hocho::Utils::Finder.find(self, 'hocho/inventory_providers', name) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/hocho/inventory_providers/base.rb: -------------------------------------------------------------------------------- 1 | module Hocho 2 | module InventoryProviders 3 | class Base 4 | def hosts 5 | raise NotImplementedError 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/hocho/inventory_providers/file.rb: -------------------------------------------------------------------------------- 1 | require 'hocho/utils/symbolize' 2 | require 'hocho/inventory_providers/base' 3 | require 'hocho/host' 4 | require 'yaml' 5 | 6 | module Hocho 7 | module InventoryProviders 8 | class File < Base 9 | def initialize(path:) 10 | @path = path 11 | end 12 | 13 | attr_reader :path 14 | 15 | def files 16 | @files ||= case 17 | when ::File.directory?(path) 18 | Dir[::File.join(path, "*.yml")] 19 | else 20 | [path] 21 | end 22 | end 23 | 24 | def hosts 25 | @hosts ||= files.flat_map do |file| 26 | content = Hocho::Utils::Symbolize.keys_of(YAML.load_file(file)) 27 | content.map do |name, value| 28 | Host.new( 29 | name.to_s, 30 | providers: self.class, 31 | properties: value[:properties] || {}, 32 | tags: value[:tags] || {}, 33 | ssh_options: value[:ssh_options], 34 | ) 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/hocho/property_providers.rb: -------------------------------------------------------------------------------- 1 | require 'hocho/utils/finder' 2 | 3 | module Hocho 4 | module PropertyProviders 5 | def self.find(name) 6 | Hocho::Utils::Finder.find(self, 'hocho/property_providers', name) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/hocho/property_providers/add_default.rb: -------------------------------------------------------------------------------- 1 | require 'hocho/property_providers/base' 2 | 3 | module Hocho 4 | module PropertyProviders 5 | class AddDefault < Base 6 | def initialize(properties: {}) 7 | @properties = properties 8 | end 9 | 10 | def determine(host) 11 | host.properties.replace(host.properties.reverse_merge(@properties)) 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/hocho/property_providers/base.rb: -------------------------------------------------------------------------------- 1 | module Hocho 2 | module PropertyProviders 3 | class Base 4 | def determine(host) 5 | raise NotImplementedError 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/hocho/property_providers/ruby_script.rb: -------------------------------------------------------------------------------- 1 | module Hocho 2 | module PropertyProviders 3 | class RubyScript 4 | def initialize(name: nil, script: nil, file: nil) 5 | @template = case 6 | when script 7 | compile(script, "(#{name || 'ruby_script'})") 8 | when file 9 | compile(File.read(file), name ? "(#{name})" : file) 10 | end 11 | end 12 | 13 | def determine(host) 14 | @template.new(host).run 15 | nil 16 | end 17 | 18 | private 19 | 20 | Template = Struct.new(:host) 21 | 22 | def compile(script, name) 23 | binding.eval("Class.new(Template) { def run;\n#{script}\nend; }", name, 0) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/hocho/runner.rb: -------------------------------------------------------------------------------- 1 | require 'hocho/drivers' 2 | 3 | module Hocho 4 | class Runner 5 | def initialize(host, driver: nil, base_dir: '.', initializers: [], driver_options: {}) 6 | @host = host 7 | @driver = driver && driver.to_sym 8 | @base_dir = base_dir 9 | @initializers = initializers 10 | @driver_options = driver_options 11 | 12 | @bundler_support = nil 13 | end 14 | 15 | attr_reader :host, :driver, :base_dir, :initializers 16 | 17 | def run(dry_run: false) 18 | puts "=> Running on #{host.name} using #{best_driver_name}" 19 | driver_options = @driver_options[best_driver_name] || {} 20 | driver = best_driver.new(host, base_dir: base_dir, initializers: initializers, **driver_options) 21 | driver.run(dry_run: dry_run) 22 | ensure 23 | driver.finalize if driver 24 | end 25 | 26 | def ssh 27 | host.ssh_connection 28 | end 29 | 30 | private 31 | 32 | def best_driver_name 33 | @best_driver_name ||= case 34 | when @driver 35 | @driver 36 | when @host.preferred_driver 37 | @host.preferred_driver 38 | when !bundler_support? 39 | :itamae_ssh 40 | else 41 | :bundler 42 | end 43 | end 44 | 45 | def best_driver 46 | @best_driver ||= Hocho::Drivers.find(best_driver_name) 47 | end 48 | 49 | def bundler_support? 50 | # ssh_askpass 51 | return @bundler_support unless @bundler_support.nil? 52 | @bundler_support = (ssh.exec!("#{host.bundler_cmd} -v") || '').match(/^Bundler version/) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /lib/hocho/utils/finder.rb: -------------------------------------------------------------------------------- 1 | module Hocho 2 | module Utils 3 | module Finder 4 | class NotFound < StandardError; end 5 | def self.find(const, prefix, name) 6 | retried = false 7 | constant_name = name.to_s.gsub(/\A.|_./) { |s| s[-1].upcase } 8 | 9 | begin 10 | const.const_get constant_name, false 11 | rescue NameError 12 | unless retried 13 | begin 14 | require "#{prefix}/#{name}" 15 | rescue LoadError 16 | end 17 | 18 | retried = true 19 | retry 20 | end 21 | raise NotFound, "Couldn't find #{prefix}/#{name}" 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/hocho/utils/symbolize.rb: -------------------------------------------------------------------------------- 1 | module Hocho 2 | module Utils 3 | module Symbolize 4 | def self.keys_of(obj) 5 | case obj 6 | when Hash 7 | Hash[obj.map { |k, v| [k.is_a?(String) ? k.to_sym : k, keys_of(v)] }] 8 | when Array 9 | obj.map { |v| keys_of(v) } 10 | else 11 | obj 12 | end 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/hocho/version.rb: -------------------------------------------------------------------------------- 1 | module Hocho 2 | VERSION = "0.3.8" 3 | end 4 | -------------------------------------------------------------------------------- /script/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "hocho" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /script/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /spec/property_providers/ruby_script_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'hocho/property_providers/ruby_script' 3 | 4 | RSpec.describe Hocho::PropertyProviders::RubyScript do 5 | let(:host) { double(:host) } 6 | subject { described_class.new(script: 'host.properties[:a] = :b') } 7 | 8 | describe "#determine" do 9 | it "runs template" do 10 | properties = {} 11 | allow(host).to receive(:properties).and_return(properties) 12 | subject.determine(host) 13 | expect(properties[:a]).to eq(:b) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'hocho' 3 | --------------------------------------------------------------------------------