amoeba_deploy_tools 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ require 'json'
2
+
3
+ module AmoebaDeployTools
4
+ class Node < Command
5
+ include AmoebaDeployTools::Concerns::SSH
6
+
7
+ desc 'bootstrap', 'Setup the node initially (run the first time you want to deploy a node)'
8
+ long_desc <<-LONGDESC
9
+ `bootstrap` will load Chef and the `basenode` recipe on the node. This is effectively the way
10
+ to setup a node initially. Subsequent runs can use the `node push` command.
11
+ LONGDESC
12
+ option :version, desc: 'Chef version to bootstrap', default: '11.8.2'
13
+ option :interactive, desc: 'Run interactively (useful for entering passwords)', type: :boolean, default: false
14
+ def bootstrap
15
+ logger.info 'Starting `bootstrap`!'
16
+
17
+ refresh
18
+ knife_solo :prepare, 'bootstrap-version' => options[:version], ssh: true, interactive: options[:interactive]
19
+
20
+ knife_solo :cook, ssh: true, include_private_key: true, interactive: options[:interactive] do |j|
21
+ j.run_list = ['role[base]']
22
+ end
23
+
24
+ force_deployer
25
+
26
+ pull
27
+
28
+ logger.warn 'Node bootstrapped successfully, you can now push to the node:'
29
+ logger.warn "\tamoeba node push --node #{options[:node]}\n"
30
+ end
31
+
32
+ desc 'force_deployer', 'Forces the deploy user to be `deploy` or that specified in node.json'
33
+ def force_deployer
34
+ logger.info 'Starting force_deployer'
35
+ data_bag(:nodes)[node.name] = { deployment: { user: node.depoyment_.user || 'deploy' } }
36
+ puts "Remote node: #{remote_node}"
37
+ end
38
+
39
+ desc 'push', 'Push any changes to the node'
40
+ def push
41
+ logger.info 'Starting push...'
42
+ refresh
43
+ knife_solo :cook, ssh: true, include_private_key: true
44
+ pull
45
+ end
46
+
47
+ desc 'pull', 'Pull down node state and store in local node databag (run automatically after push)'
48
+ def pull
49
+ logger.info 'Starting `pull`!'
50
+ force_deployer unless remote_node.deployment_.user
51
+
52
+ raw_json = ssh_run('sudo cat ~deploy/node.json', silent: true)
53
+ data_bag(:nodes)[node.name] = JSON.load raw_json
54
+ end
55
+
56
+ desc 'list', 'Show available nodes in kitchen'
57
+ def list
58
+ inside_kitchen do
59
+ puts Dir.glob('nodes/*.json').sort.map {|n| File.basename(n).sub(/\.json$/, '')}
60
+ end
61
+ end
62
+
63
+ desc 'refresh', 'Refresh data bags based on node config. Note this is normally run automatically.'
64
+ long_desc <<-DESC
65
+ Normally, you should not need to run `refresh`. It is run automatically before every `push` or
66
+ `bootstrap`. This command prepares data bags for the node. Presently, this is only used for
67
+ SSH keys. Thus, `refresh` will go through all the `authorized_keys` folders and generate
68
+ data_bags for each user. These data_bags are then used by the Chef Cookbooks during pushes.
69
+ DESC
70
+ def refresh
71
+ logger.info "Starting `refresh`!"
72
+ inside_kitchen do
73
+ # Handle authorized_keys
74
+ logger.debug '# Refreshing authorized_keys'
75
+ Dir.glob('authorized_keys/*') do |user_dir|
76
+ if File.directory? user_dir
77
+ user_name = File.basename(user_dir)
78
+ logger.info "Processing SSH keys for user #{user_name}."
79
+ user_bag = data_bag(:authorized_keys)[user_name]
80
+ user_bag[:keys] = []
81
+
82
+ Dir.glob(File.join(user_dir, '*')) do |key_file|
83
+ logger.debug "Reading key file: #{key_file}"
84
+ user_bag[:keys] << File.read(key_file).strip
85
+ end
86
+
87
+ logger.info "Writing #{user_bag.options[:filename]}"
88
+ user_bag.save
89
+ else
90
+ logger.info "Ignoring file in authorized_keys (must be inside a directory): #{f}"
91
+ end
92
+ end
93
+
94
+ logger.debug '# Ensuring bundle is up to date'
95
+ # Handle bundler, ensure it's up to date
96
+ unless system('bundle check > /dev/null 2>&1')
97
+ logger.info "Bundle out of date! Running bundle update..."
98
+ Cocaine::CommandLine.new('bundle', 'install').run
99
+ end
100
+ end
101
+ end
102
+
103
+ desc 'exec "CMD"', 'Execute given command (via SSH) on node, as deploy user (can sudo)'
104
+ def exec(cmd)
105
+ ssh_run(cmd, interactive: true)
106
+ end
107
+
108
+ desc 'ssh', 'SSHs to the node as the deploy user (can sudo)'
109
+ def ssh
110
+ exec nil
111
+ end
112
+
113
+ desc 'sudo "CMD"', 'Executes the given command as root on the node'
114
+ def sudo(cmd)
115
+ # pull args off of cmd
116
+ exec("sudo #{cmd}")
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,45 @@
1
+ module AmoebaDeployTools
2
+ class Ssl < Command
3
+ # Needed for knife solo stuff
4
+ include AmoebaDeployTools::Concerns::SSH
5
+
6
+ desc 'import', 'Import an SSL certificate and add to encrypted data bag (encryption key `default` used unless specified)'
7
+ option :privateKey, desc: 'Name of the private key to use. Will create if missing', default: 'default'
8
+ option :key, desc: 'SSL private key file name / path', required: true
9
+ option :cert, desc: 'SSL public certificate file name / path', required: true
10
+ option :ca, desc: 'SSL intermediary CA certificate name / path'
11
+ def import(cert_name=nil)
12
+ logger.debug "Starting SSL import!"
13
+ validate_chef_id!(cert_name)
14
+
15
+ private_key = options[:privateKey]
16
+
17
+ json_data = { id: cert_name }
18
+ [:key, :cert, :ca].each do |c|
19
+ # read certificates before we get in the kitchen
20
+ if File.exist? options[c]
21
+ json_data[c] = File.read(options[c])
22
+ else
23
+ logger.error "Cannot find certificate file to import (ignoring): #{options[c]}"
24
+ options[c] = nil
25
+ end
26
+ end
27
+
28
+ # Ensure key exists
29
+ unless config.private_keys_[private_key]
30
+ logger.warn "Private key missing: #{options[:privateKey]}, running `amoeba key create #{options[:privateKey]}`"
31
+ AmoebaDeployTools::Key.new.create(options[:privateKey])
32
+ end
33
+
34
+ inside_kitchen do
35
+ # Import to certs databag
36
+ with_tmpfile( json_data.to_json, name: [cert_name, '.json'] ) do |file_name|
37
+ knife_solo "data bag create certs #{cert_name}",
38
+ 'json-file' => file_name,
39
+ 'secret' => "'#{config.reload!.private_keys[private_key]}'"
40
+ end
41
+ end
42
+ end
43
+
44
+ end
45
+ end
@@ -0,0 +1,133 @@
1
+ require 'yaml'
2
+ require 'json'
3
+ require 'fileutils'
4
+ require 'hashie/mash'
5
+
6
+ module AmoebaDeployTools
7
+ class Config < Hashie::Mash
8
+ def self.load(filename, opts={})
9
+ opts[:filename] = File.expand_path filename
10
+ Config.new.tap do |c|
11
+ c.restore(opts)
12
+ end
13
+ end
14
+
15
+ def self.create(filename, opts={})
16
+ opts.merge!({
17
+ filename: File.expand_path(filename),
18
+ create: true
19
+ })
20
+
21
+ Config.new.tap do |c|
22
+ c.options(opts)
23
+ end
24
+ end
25
+
26
+ def options(opts=nil)
27
+ @opts ||= { format: :yaml }
28
+ @opts.merge! opts if opts
29
+ @opts
30
+ end
31
+
32
+ def restore(opts=nil)
33
+ options(opts)
34
+
35
+ return unless filename = options[:filename]
36
+
37
+ self.clear.deep_merge! deserialize(File.read(filename))
38
+
39
+ self
40
+ rescue Errno::ENOENT
41
+ @new_file = true
42
+ FileUtils.touch(filename) and retry if options[:create]
43
+ end
44
+
45
+ def reload!
46
+ restore(filename: options[:filename])
47
+ end
48
+
49
+ def new_file?
50
+ !!@new_file
51
+ end
52
+
53
+ def save(opts=nil)
54
+ options(opts)
55
+
56
+ return unless filename = options[:filename]
57
+
58
+ File.open(filename, 'w') do |fh|
59
+ fh.write(serialize(self.to_hash))
60
+ end
61
+
62
+ self
63
+ rescue Errno::ENOENT
64
+ FileUtils.touch(filename) and retry if options[:create]
65
+ end
66
+
67
+ def [](k)
68
+ chain = k.to_s.split('.')
69
+ cur = self
70
+
71
+ return super if chain.count <= 1
72
+
73
+ for c in chain[0..-2]
74
+ if cur and cur.key? c
75
+ cur = cur.regular_reader(c)
76
+ else
77
+ return
78
+ end
79
+ end
80
+
81
+ cur[chain[-1]]
82
+ end
83
+
84
+ def []=(k, v)
85
+ chain = k.to_s.split('.')
86
+ cur = self
87
+
88
+ return super if chain.count <= 1
89
+
90
+ for c in chain[0..-2]
91
+ cur = cur.initializing_reader(c)
92
+ end
93
+
94
+ cur[chain[-1]] = v
95
+ end
96
+
97
+ def flatten
98
+ flat = {}
99
+
100
+ each do |k1, v1|
101
+ if v1.class == self.class
102
+ v1.flatten.each do |k2, v2|
103
+ flat["#{k1}.#{k2}"] = v2
104
+ end
105
+ else
106
+ flat[k1] = v1
107
+ end
108
+ end
109
+
110
+ flat
111
+ end
112
+
113
+
114
+ def to_s
115
+ to_hash.to_s
116
+ end
117
+
118
+ protected
119
+
120
+ @@formats = {
121
+ json: JSON,
122
+ yaml: YAML
123
+ }
124
+
125
+ def serialize(d)
126
+ @@formats[options[:format]].dump(d)
127
+ end
128
+
129
+ def deserialize(d)
130
+ @@formats[options[:format]].load(d) || {}
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,30 @@
1
+ require 'json'
2
+
3
+ module AmoebaDeployTools
4
+ class DataBag
5
+ def initialize(bag, kitchen)
6
+ @bag_dir = File.join(kitchen, 'data_bags', bag.to_s)
7
+ Dir.mkdir @bag_dir unless Dir.exists? @bag_dir
8
+ end
9
+
10
+ def []=(id, item)
11
+ bag_item = DataBagItem.create(item_filename(id), format: :json)
12
+ bag_item.clear.deep_merge!(item.to_hash)
13
+ bag_item.id = id
14
+ bag_item.save
15
+ end
16
+
17
+ def [](id)
18
+ DataBagItem.load(item_filename(id), format: :json, create: true).tap do |i|
19
+ i.id = id
20
+ end
21
+ end
22
+
23
+ def item_filename(id)
24
+ File.join(@bag_dir, "#{id}.json")
25
+ end
26
+ end
27
+
28
+ class DataBagItem < Config
29
+ end
30
+ end
@@ -0,0 +1,51 @@
1
+ require 'tempfile'
2
+ require 'pathname'
3
+ require 'tmpdir'
4
+
5
+ def with_tmpfile(content=nil, options={} )
6
+ name = options[:name] || 'amoebatmp'
7
+
8
+ tmpf = Tempfile.new name
9
+ tmpf.write content
10
+ tmpf.close
11
+
12
+ results = yield tmpf.path, tmpf
13
+
14
+ tmpf.unlink
15
+ results
16
+ end
17
+
18
+ def in_tmpdir
19
+ Dir.mktmpdir do |tmpd|
20
+ Dir.chdir tmpd do
21
+ yield
22
+ end
23
+ end
24
+ end
25
+
26
+ def dedent(s)
27
+ indent = s.split("\n").reject {|l| l =~ /^\s*$/}.map {|l| l.index /\S/ }.min
28
+ s.sub(/^\n/, '').gsub(/ +$/, '').gsub(/^ {#{indent}}/, '')
29
+ end
30
+
31
+ def indent(s, indent=4)
32
+ s.gsub(/^/, ' ' * indent)
33
+ end
34
+
35
+ def say_bold(text)
36
+ say text, :bold
37
+ end
38
+
39
+ def say_fatal(text)
40
+ say text, :red
41
+ exit 1
42
+ end
43
+
44
+ class Exception
45
+ def bt
46
+ backtrace.map do |l|
47
+ cwd = Dir.pwd
48
+ l.start_with?(cwd) ? l.sub(cwd, '.') : l
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,27 @@
1
+ # coding: UTF-8
2
+ require 'pty'
3
+
4
+ module AmoebaDeployTools
5
+ class InteractiveCocaineRunner
6
+ def self.supported?
7
+ true
8
+ end
9
+
10
+ def supported?
11
+ self.class.supported?
12
+ end
13
+
14
+ def call(command, env = {})
15
+ with_modified_environment(env) do
16
+ system(command)
17
+ end
18
+ ''
19
+ end
20
+
21
+ private
22
+
23
+ def with_modified_environment(env, &block)
24
+ ClimateControl.modify(env, &block)
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,67 @@
1
+ require 'logger'
2
+
3
+ module AmoebaDeployTools
4
+ class Logger < ::Logger
5
+ module Colors
6
+ VERSION = '1.0.0'
7
+
8
+ NOTHING = '0;0'
9
+ BLACK = '0;30'
10
+ RED = '0;31'
11
+ GREEN = '0;32'
12
+ BROWN = '0;33'
13
+ BLUE = '0;34'
14
+ PURPLE = '0;35'
15
+ CYAN = '0;36'
16
+ LIGHT_GRAY = '0;37'
17
+ DARK_GRAY = '1;30'
18
+ LIGHT_RED = '1;31'
19
+ LIGHT_GREEN = '1;32'
20
+ YELLOW = '1;33'
21
+ LIGHT_BLUE = '1;34'
22
+ LIGHT_PURPLE = '1;35'
23
+ LIGHT_CYAN = '1;36'
24
+ WHITE = '1;37'
25
+
26
+ SCHEMA = {
27
+ STDOUT => %w[nothing green brown red purple cyan],
28
+ STDERR => %w[nothing green yellow light_red light_purple light_cyan],
29
+ }
30
+ end
31
+
32
+ class SimpleFormatter < ::Logger::Formatter
33
+ def call(severity, time, progname, msg)
34
+ "%s\n" % msg2str(msg)
35
+ end
36
+ end
37
+
38
+ def format_message(level, *args)
39
+ if Logger::Colors::SCHEMA[@logdev.dev]
40
+ color = begin
41
+ Logger::Colors.const_get \
42
+ Logger::Colors::SCHEMA[@logdev.dev][Logger.const_get(level.sub "ANY","UNKNOWN")].to_s.upcase
43
+ rescue NameError
44
+ "0;0"
45
+ end
46
+ "\e[#{ color }m#{ super(level, *args) }\e[0;0m"
47
+ else
48
+ super(level, *args)
49
+ end
50
+ end
51
+
52
+ def initialize(logdev)
53
+ super
54
+
55
+ @formatter = SimpleFormatter.new
56
+ end
57
+
58
+ @@instance = self.new(STDOUT)
59
+
60
+ def self.instance
61
+ return @@instance
62
+ end
63
+
64
+ # Prevent folks from instantiating one
65
+ private_class_method :new
66
+ end
67
+ end
@@ -0,0 +1,42 @@
1
+ # coding: UTF-8
2
+ require 'pty'
3
+
4
+ module AmoebaDeployTools
5
+ class NoiseyCocaineRunner
6
+ def self.supported?
7
+ true
8
+ end
9
+
10
+ def supported?
11
+ self.class.supported?
12
+ end
13
+
14
+ def call(command, env = {})
15
+ buffer = ''
16
+ with_modified_environment(env) do
17
+ begin
18
+ # Use PTY.spawn so we don't buffer anything. r will contain the output (stdout & stderr)
19
+ PTY.spawn(command) do |r,w,pid|
20
+ begin
21
+ r.each { |line| Cocaine::CommandLine.logger.debug line.strip; buffer << line }
22
+ rescue Errno::EIO
23
+ # Output is done
24
+ end
25
+ # Note: This requires ruby 1.9.2!
26
+ Process.wait(pid) # Wait for the process to die (so $? is set)
27
+ end
28
+ rescue PTY::ChildExited
29
+ # The command is done
30
+ # $!.status.exitstatus would likely contain the exit code
31
+ end
32
+ end
33
+ buffer
34
+ end
35
+
36
+ private
37
+
38
+ def with_modified_environment(env, &block)
39
+ ClimateControl.modify(env, &block)
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,3 @@
1
+ module AmoebaDeployTools
2
+ VERSION = "0.0.2"
3
+ end
@@ -0,0 +1,4 @@
1
+ require 'spec_helper'
2
+
3
+ describe AmoebaDeployTools::Command do
4
+ end
@@ -0,0 +1,91 @@
1
+ require 'spec_helper'
2
+ require 'stringio'
3
+ require 'fileutils'
4
+
5
+ class AmoebaDeployTools
6
+ describe 'Amoeba command' do
7
+ def run_cmd(argv)
8
+ $stdout = StringIO.new
9
+ $stderr = StringIO.new
10
+
11
+ object = Amoeba.new(*argv).tap {|c| c.run(false) }
12
+
13
+ @stdout = $stdout.tap {|f| f.seek(0) }.read
14
+ @stderr = $stderr.tap {|f| f.seek(0) }.read
15
+ ensure
16
+ $stdout = STDOUT
17
+ $stderr = STDERR
18
+
19
+ return object
20
+ end
21
+
22
+ def self.context(*a, **kw)
23
+ if a.first.is_a? Array
24
+ kw[:argv] = a[0]
25
+ a[0] = "[#{a[0].join(' ')}]"
26
+ end
27
+
28
+ super
29
+ end
30
+
31
+ @@subject = nil
32
+ def rerun_cmd
33
+ @@subject = run_cmd(example.metadata[:argv])
34
+ end
35
+
36
+ subject { rerun_cmd }
37
+ before { rerun_cmd }
38
+
39
+ shared_context require_kitchen: true do
40
+ around do |example|
41
+ in_tmpdir do
42
+ %w(
43
+ .amoeba
44
+ .amoeba/nodes
45
+ .amoeba/roles
46
+ ).map {|p| Dir.mkdir p}
47
+
48
+ example.metadata[:nodes].each do |d|
49
+ FileUtils.touch ".amoeba/nodes/#{d}.json"
50
+ end if example.metadata[:nodes].is_a? Enumerable
51
+
52
+ example.run
53
+ end
54
+ end
55
+
56
+ context 'when missing kitchen' do
57
+ before { FileUtils.rm_rf '.amoeba' }
58
+
59
+ its (:status) { should eq(1) }
60
+ end
61
+ end
62
+
63
+ context %w(help) do
64
+ its (:cmd) { should eq(:amoeba) }
65
+ its (:subcmd) { should eq(:help) }
66
+
67
+ its (:status) { should eq(1) }
68
+ end
69
+
70
+ context %w(node list), :require_kitchen do
71
+ its (:cmd) { should eq(:node) }
72
+ its (:subcmd) { should eq(:list) }
73
+
74
+ context 'without nodes' do
75
+ it 'returns empty' do
76
+ expect(@stdout).to eq('')
77
+ end
78
+
79
+ its (:status) { should eq(0) }
80
+ end
81
+
82
+ context 'with nodes', nodes: %w( a.example.com b.example.com ) do
83
+ it 'lists nodes' do
84
+ expect(@stdout).to eq(example.metadata[:nodes].map {|n| n + "\n"}.join)
85
+ end
86
+
87
+ its (:status) { should eq(0) }
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,48 @@
1
+ require 'spec_helper'
2
+ require 'yaml'
3
+
4
+ describe AmoebaDeployTools::Config do
5
+ let (:config) { subject }
6
+
7
+ let :given do
8
+ {'foo' => 'bar'}
9
+ end
10
+
11
+ before merge: true do
12
+ config.merge! given
13
+ end
14
+
15
+ it 'saves config files', :merge do
16
+ with_tmpfile do |f|
17
+ config.save filename: f
18
+ end
19
+
20
+ expect(config).to eq(given)
21
+ end
22
+
23
+ it 'loads config files' do
24
+ with_tmpfile YAML.dump(given) do |f|
25
+ config.restore filename: f
26
+ end
27
+
28
+ expect(config).to eq(given)
29
+ end
30
+
31
+ context 'with a nested structure' do
32
+ let :given do
33
+ {'foo' => {'bar' => {'baz' => 'garply'}}}
34
+ end
35
+
36
+ it 'expands dotted keys' do
37
+ config['foo.bar.baz'] = 'garply'
38
+
39
+ expect(config).to eq(given)
40
+ end
41
+
42
+ it 'flattens into dotted keys', :merge do
43
+ expect(config.flatten).to eq({
44
+ 'foo.bar.baz' => 'garply'
45
+ })
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,18 @@
1
+ require 'spec_helper'
2
+
3
+ describe :dedent do
4
+ it 'correctly removes indentation' do
5
+ expect(dedent %{
6
+ foo
7
+ bar
8
+ baz
9
+ garply
10
+ }).to eq("foo\n bar\nbaz\n garply\n")
11
+ end
12
+ end
13
+
14
+ describe :indent do
15
+ it 'indents each line by the specified amount' do
16
+ expect(indent("foo\n bar\nbaz\n garply\n", 3)).to eq(" foo\n bar\n baz\n garply\n")
17
+ end
18
+ end