salticid 0.9.0
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +7 -0
- data/README.markdown +11 -0
- data/lib/monkeypatch.rb +21 -0
- data/lib/salticid.rb +205 -0
- data/lib/salticid/config.rb +17 -0
- data/lib/salticid/gateway.rb +23 -0
- data/lib/salticid/group.rb +99 -0
- data/lib/salticid/host.rb +600 -0
- data/lib/salticid/host/ip.rb +19 -0
- data/lib/salticid/interface.rb +192 -0
- data/lib/salticid/interface/conversation_view.rb +150 -0
- data/lib/salticid/interface/host_view.rb +123 -0
- data/lib/salticid/interface/ncurses.rb +30 -0
- data/lib/salticid/interface/resizable.rb +28 -0
- data/lib/salticid/interface/tab_view.rb +134 -0
- data/lib/salticid/interface/view.rb +49 -0
- data/lib/salticid/message.rb +8 -0
- data/lib/salticid/role.rb +98 -0
- data/lib/salticid/role_proxy.rb +26 -0
- data/lib/salticid/task.rb +49 -0
- data/lib/salticid/version.rb +3 -0
- data/lib/snippets/init.rb +5 -0
- data/lib/snippets/object/__dir__.rb +23 -0
- data/lib/snippets/object/instance_exec.rb +14 -0
- data/lib/snippets/string/slash.rb +6 -0
- data/lib/snippets/symbol/slash.rb +6 -0
- data/lib/snippets/symbol/to_proc.rb +15 -0
- metadata +167 -0
data/LICENSE
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
Copyright (c) 2012 Kyle Kingsbury
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
4
|
+
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
6
|
+
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.markdown
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
Salticid
|
2
|
+
=====
|
3
|
+
|
4
|
+
Salticid is a deployment tool I wrote for Vodpod and Showyou in a couple of weekends. Its only design goals were:
|
5
|
+
|
6
|
+
1. Magic
|
7
|
+
2. More Magic
|
8
|
+
|
9
|
+
It violates every principle of good software development possible. It is clunky, slow, buggy, and dangerous.
|
10
|
+
|
11
|
+
It also worked surprisingly well. YMMV. d=('_~)=b
|
data/lib/monkeypatch.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
module Net
|
2
|
+
module SSH
|
3
|
+
class Shell
|
4
|
+
# Like execute! but returns an array: the status and the output.
|
5
|
+
def exec!(command, klass=Net::SSH::Shell::Process, &callback)
|
6
|
+
result = ''
|
7
|
+
|
8
|
+
process = klass.new(self, command, callback)
|
9
|
+
process.on_output do |p, output|
|
10
|
+
result << output
|
11
|
+
end
|
12
|
+
|
13
|
+
process.run if processes.empty?
|
14
|
+
processes << process
|
15
|
+
wait!
|
16
|
+
|
17
|
+
[process.exit_status, result]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/lib/salticid.rb
ADDED
@@ -0,0 +1,205 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'net/ssh'
|
3
|
+
require 'net/scp'
|
4
|
+
require 'net/ssh/gateway'
|
5
|
+
|
6
|
+
$LOAD_PATH.unshift(File.dirname(__FILE__))
|
7
|
+
|
8
|
+
require 'salticid/version'
|
9
|
+
|
10
|
+
class Salticid
|
11
|
+
def self.log(str)
|
12
|
+
File.open('salticid.log', 'a') do |f|
|
13
|
+
f.puts str
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), 'net-ssh-shell', 'lib'))
|
18
|
+
require 'monkeypatch'
|
19
|
+
require 'snippets/init'
|
20
|
+
require 'salticid/message'
|
21
|
+
require 'salticid/task'
|
22
|
+
require 'salticid/role'
|
23
|
+
require 'salticid/role_proxy'
|
24
|
+
require 'salticid/host'
|
25
|
+
require 'salticid/gateway'
|
26
|
+
require 'salticid/group'
|
27
|
+
|
28
|
+
attr_accessor :gw, :groups, :hosts, :roles, :tasks
|
29
|
+
|
30
|
+
def initialize
|
31
|
+
@gw = nil
|
32
|
+
@hosts = []
|
33
|
+
@groups = []
|
34
|
+
@roles = []
|
35
|
+
@tasks = []
|
36
|
+
end
|
37
|
+
|
38
|
+
# Define a gateway.
|
39
|
+
def gw(name = nil, &block)
|
40
|
+
if name == nil
|
41
|
+
return @gw
|
42
|
+
end
|
43
|
+
|
44
|
+
# Get gateway from cache or set new one.
|
45
|
+
name = name.to_s
|
46
|
+
|
47
|
+
unless gw = @hosts.find{|h| h.name == name}
|
48
|
+
gw = Salticid::Gateway.new(name, :salticid => self)
|
49
|
+
@hosts << gw
|
50
|
+
# Set default gw
|
51
|
+
@gw = gw
|
52
|
+
end
|
53
|
+
|
54
|
+
|
55
|
+
if block_given?
|
56
|
+
gw.instance_exec &block
|
57
|
+
end
|
58
|
+
|
59
|
+
gw
|
60
|
+
end
|
61
|
+
|
62
|
+
def host(name, &block)
|
63
|
+
name = name.to_s
|
64
|
+
unless host = @hosts.find{|h| h.name == name}
|
65
|
+
host = Salticid::Host.new(name, :salticid => self)
|
66
|
+
@hosts << host
|
67
|
+
end
|
68
|
+
|
69
|
+
if block_given?
|
70
|
+
host.instance_exec &block
|
71
|
+
end
|
72
|
+
|
73
|
+
host
|
74
|
+
end
|
75
|
+
|
76
|
+
# Tries to guess what hosts we would run the given string on.
|
77
|
+
def hosts_for(string)
|
78
|
+
first = string[/^(\w+)\.\w+/, 1]
|
79
|
+
if role = @roles.find { |r| r.name == first }
|
80
|
+
return role.hosts
|
81
|
+
else
|
82
|
+
raise "Sorry, I didn't understand what hosts to run #{string.inspect} on."
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Assigns a group to this Salticid. Runs the optional block in the group's
|
87
|
+
# context. Returns the group.
|
88
|
+
def group(name, &block)
|
89
|
+
# Get group
|
90
|
+
group = name if name.kind_of? Salticid::Group
|
91
|
+
name = name.to_s
|
92
|
+
group ||= @groups.find{|g| g.name == name}
|
93
|
+
group ||= Salticid::Group.new(name, :salticid => self)
|
94
|
+
|
95
|
+
# Store
|
96
|
+
@groups |= [group]
|
97
|
+
|
98
|
+
# Run block
|
99
|
+
if block_given?
|
100
|
+
group.instance_exec &block
|
101
|
+
end
|
102
|
+
|
103
|
+
group
|
104
|
+
end
|
105
|
+
|
106
|
+
# Loads one or more file globs into the current salticid.
|
107
|
+
def load(*globs)
|
108
|
+
skips = globs.grep(/^-/)
|
109
|
+
(globs - skips).each do |glob|
|
110
|
+
glob += '.rb' if glob =~ /\*$/
|
111
|
+
Dir.glob(glob).sort.each do |path|
|
112
|
+
next unless File.file? path
|
113
|
+
next if skips.find {|pat| path =~ /#{pat[1..-1]}$/}
|
114
|
+
instance_eval(File.read(path), path)
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Defines a new role. A role is a package of tasks.
|
120
|
+
def role(name, &block)
|
121
|
+
name = name.to_s
|
122
|
+
|
123
|
+
unless role = @roles.find{|r| r.name == name}
|
124
|
+
role = Salticid::Role.new(name, :salticid => self)
|
125
|
+
@roles << role
|
126
|
+
end
|
127
|
+
|
128
|
+
if block_given?
|
129
|
+
role.instance_eval &block
|
130
|
+
end
|
131
|
+
|
132
|
+
role
|
133
|
+
end
|
134
|
+
|
135
|
+
# Finds (and optionally defines) a task.
|
136
|
+
# task :foo => returns a Task
|
137
|
+
# task :foo do ... end => defines a Task with given block
|
138
|
+
def task(name, &block)
|
139
|
+
name = name.to_s
|
140
|
+
|
141
|
+
unless task = @tasks.find{|t| t.name == name}
|
142
|
+
task = Salticid::Task.new(name, :salticid => self)
|
143
|
+
@tasks << task
|
144
|
+
end
|
145
|
+
|
146
|
+
if block_given?
|
147
|
+
task.block = block
|
148
|
+
end
|
149
|
+
|
150
|
+
task
|
151
|
+
end
|
152
|
+
|
153
|
+
def to_s
|
154
|
+
"Salticid"
|
155
|
+
end
|
156
|
+
|
157
|
+
# An involved description of the salticid
|
158
|
+
def to_string
|
159
|
+
h = ''
|
160
|
+
h << "Groups\n"
|
161
|
+
groups.each do |group|
|
162
|
+
h << " #{group}\n"
|
163
|
+
end
|
164
|
+
|
165
|
+
h << "\nHosts:\n"
|
166
|
+
hosts.each do |host|
|
167
|
+
h << " #{host}\n"
|
168
|
+
end
|
169
|
+
|
170
|
+
h << "\nRoles\n"
|
171
|
+
roles.each do |role|
|
172
|
+
h << " #{role}\n"
|
173
|
+
end
|
174
|
+
|
175
|
+
h << "\nTop-level tasks\n"
|
176
|
+
tasks.each do |task|
|
177
|
+
h << " #{task}\n"
|
178
|
+
end
|
179
|
+
|
180
|
+
h
|
181
|
+
end
|
182
|
+
|
183
|
+
# Unknown methods are resolved as groups, then hosts, then roles, then tasks.
|
184
|
+
# Can you think of a better order?
|
185
|
+
#
|
186
|
+
# Blocks are instance_exec'd in the context of the found object.
|
187
|
+
def method_missing(meth, &block)
|
188
|
+
name = meth.to_s
|
189
|
+
|
190
|
+
found = @groups.find { |g| g.name == name }
|
191
|
+
found ||= @hosts.find { |h| h.name == name }
|
192
|
+
found ||= @roles.find { |r| r.name == name }
|
193
|
+
found ||= @tasks.find { |t| t.name == name }
|
194
|
+
|
195
|
+
unless found
|
196
|
+
raise NoMethodError
|
197
|
+
end
|
198
|
+
|
199
|
+
if block
|
200
|
+
found.instance_exec &block
|
201
|
+
end
|
202
|
+
|
203
|
+
found
|
204
|
+
end
|
205
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
class Salticid::Gateway < Salticid::Host
|
2
|
+
def initialize(*args)
|
3
|
+
@tunnel_lock = Mutex.new
|
4
|
+
super *args
|
5
|
+
end
|
6
|
+
|
7
|
+
# Gateways don't need gateways.
|
8
|
+
def gw
|
9
|
+
end
|
10
|
+
|
11
|
+
# Tunnel for connecting to other hosts.
|
12
|
+
def gateway_tunnel
|
13
|
+
# Multiple hosts will be asking for this tunnel at the same time.
|
14
|
+
# We need to only create one.
|
15
|
+
@tunnel_lock.synchronize do
|
16
|
+
@gateway_tunnel ||= Net::SSH::Gateway.new(name, user)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# We don't need tunnels either
|
21
|
+
def tunnel
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
class Salticid::Group
|
2
|
+
# A collection of hosts and other groups.
|
3
|
+
|
4
|
+
attr_reader :name
|
5
|
+
attr_accessor :parent
|
6
|
+
attr_accessor :groups, :hosts
|
7
|
+
|
8
|
+
def initialize(name, opts = {})
|
9
|
+
@name = name.to_s
|
10
|
+
@salticid = opts[:salticid]
|
11
|
+
@parent = opts[:parent]
|
12
|
+
@hosts = []
|
13
|
+
@groups = []
|
14
|
+
end
|
15
|
+
|
16
|
+
def ==(other)
|
17
|
+
self.class == other.class and
|
18
|
+
self.name == other.name and
|
19
|
+
self.parent == other.parent
|
20
|
+
end
|
21
|
+
|
22
|
+
# Runs the block in the context of each.
|
23
|
+
def each_host(&block)
|
24
|
+
hosts.each do |host|
|
25
|
+
host.instance_exec &block
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Finds all hosts (recursively) that are members of this group or subgroups.
|
30
|
+
def hosts
|
31
|
+
@hosts + @groups.map { |m|
|
32
|
+
m.hosts
|
33
|
+
}.flatten.uniq
|
34
|
+
end
|
35
|
+
|
36
|
+
# Creates a sub-group of this group.
|
37
|
+
def group(name, &block)
|
38
|
+
# Get group
|
39
|
+
name = name.to_s
|
40
|
+
group = @groups.find{|g| g.name == name}
|
41
|
+
group ||= Salticid::Group.new(name, :salticid => @salticid, :parent => self)
|
42
|
+
|
43
|
+
# Store
|
44
|
+
@groups |= [group]
|
45
|
+
|
46
|
+
# Run block
|
47
|
+
if block
|
48
|
+
group.instance_exec &block
|
49
|
+
end
|
50
|
+
|
51
|
+
group
|
52
|
+
end
|
53
|
+
|
54
|
+
# Adds a host (by name) to the group. Returns the host.
|
55
|
+
def host(name)
|
56
|
+
host = @salticid.host name
|
57
|
+
host.groups |= [self]
|
58
|
+
@hosts |= [host]
|
59
|
+
end
|
60
|
+
|
61
|
+
def inspect
|
62
|
+
"#<Salticid::Group #{path}>"
|
63
|
+
end
|
64
|
+
|
65
|
+
# Unknown methods are resolved as groups, then hosts. Blocks are instance_exec'd in the found context.
|
66
|
+
def method_missing(meth, &block)
|
67
|
+
name = meth.to_s
|
68
|
+
found = @groups.find { |g| g.name == name }
|
69
|
+
found ||= @hosts.find { |h| h.name == name }
|
70
|
+
|
71
|
+
unless found
|
72
|
+
raise NoMethodError
|
73
|
+
end
|
74
|
+
|
75
|
+
if block
|
76
|
+
found.instance_exec &block
|
77
|
+
end
|
78
|
+
|
79
|
+
found
|
80
|
+
end
|
81
|
+
|
82
|
+
def path
|
83
|
+
if @parent
|
84
|
+
@parent.path + '/' + @name
|
85
|
+
else
|
86
|
+
'/' + @name
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def to_s
|
91
|
+
@name
|
92
|
+
end
|
93
|
+
|
94
|
+
def to_string
|
95
|
+
h = "Group #{@name}:\n"
|
96
|
+
h << " Hosts:\n"
|
97
|
+
h << hosts.map { |h| " #{h}" }.join("\n")
|
98
|
+
end
|
99
|
+
end
|
@@ -0,0 +1,600 @@
|
|
1
|
+
class Salticid::Host
|
2
|
+
attr_accessor :env, :name, :user, :groups, :roles, :tasks, :salticid, :password
|
3
|
+
|
4
|
+
def initialize(name, opts = {})
|
5
|
+
@name = name.to_s
|
6
|
+
@user = opts[:user].to_s
|
7
|
+
@groups = opts[:groups] || []
|
8
|
+
@roles = opts[:roles] || []
|
9
|
+
@tasks = opts[:tasks] || []
|
10
|
+
@salticid = opts[:salticid]
|
11
|
+
@sudo = nil
|
12
|
+
|
13
|
+
@on_log = proc { |message| }
|
14
|
+
|
15
|
+
@ssh_lock = Mutex.new
|
16
|
+
|
17
|
+
@env = {}
|
18
|
+
@cwd = nil
|
19
|
+
@role_proxies = {}
|
20
|
+
end
|
21
|
+
|
22
|
+
def ==(other)
|
23
|
+
self.name == other.name
|
24
|
+
end
|
25
|
+
|
26
|
+
# Appends the given string to a file.
|
27
|
+
# Pass :uniq => true to only append if the string is not already present in
|
28
|
+
# the file.
|
29
|
+
def append(str, file, opts = {})
|
30
|
+
file = expand_path(file)
|
31
|
+
if opts[:uniq] and exists? file
|
32
|
+
# Check to ensure the file does not contain the line already.
|
33
|
+
begin
|
34
|
+
grep(str, file) or raise
|
35
|
+
rescue
|
36
|
+
# We're clear, go ahead.
|
37
|
+
tee '-a', file, :stdin => str
|
38
|
+
end
|
39
|
+
else
|
40
|
+
# No need to check, just append.
|
41
|
+
tee '-a', file, :stdin => str
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# All calls to exec! within this block are prefixed by sudoing to the user.
|
46
|
+
def as(user = nil)
|
47
|
+
old_sudo, @sudo = @sudo, (user || 'root')
|
48
|
+
yield
|
49
|
+
@sudo = old_sudo
|
50
|
+
end
|
51
|
+
|
52
|
+
# Changes our working directory.
|
53
|
+
def cd(dir = nil)
|
54
|
+
dir ||= homedir
|
55
|
+
dir = expand_path(dir)
|
56
|
+
@cwd = dir
|
57
|
+
end
|
58
|
+
|
59
|
+
# Changes the mode of a file. Mode is numeric.
|
60
|
+
def chmod(mode, path)
|
61
|
+
exec! "chmod #{mode.to_s(8)} #{escape(expand_path(path))}"
|
62
|
+
end
|
63
|
+
|
64
|
+
# Changes the mode of a file, recursively. Mode is numeric.
|
65
|
+
def chmod_r(mode, path)
|
66
|
+
exec! "chmod -R #{mode.to_s(8)} #{escape(expand_path(path))}"
|
67
|
+
end
|
68
|
+
|
69
|
+
# Returns current working directory. Tries to obtain it from exec 'pwd',
|
70
|
+
# but falls back to /.
|
71
|
+
def cwd
|
72
|
+
@cwd ||= begin
|
73
|
+
exec! 'pwd'
|
74
|
+
rescue => e
|
75
|
+
raise e
|
76
|
+
'/'
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Returns true if a directory exists
|
81
|
+
def dir?(path)
|
82
|
+
begin
|
83
|
+
ftype(path) == 'directory'
|
84
|
+
rescue
|
85
|
+
false
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Downloads a file from the remote server. Local defaults to remote filename
|
90
|
+
# (in current path) if not specified.
|
91
|
+
def download(remote, local = nil, opts = {})
|
92
|
+
remote_filename ||= File.split(remote).last
|
93
|
+
if File.directory? local
|
94
|
+
local = File.join(local, remote_filename)
|
95
|
+
else
|
96
|
+
local = remote_filename
|
97
|
+
end
|
98
|
+
|
99
|
+
remote = expand_path remote
|
100
|
+
log "downloading from #{remote.inspect} to #{local.inspect}"
|
101
|
+
ssh.scp.download!(remote, local, opts)
|
102
|
+
end
|
103
|
+
|
104
|
+
# Quotes a string for inclusion in a bash command line
|
105
|
+
def escape(string)
|
106
|
+
return '' if string.nil?
|
107
|
+
return string unless string.to_s =~ /[\\\$`" ]/
|
108
|
+
'"' + string.to_s.gsub(/[\\\$`"]/) { |match| '\\' + match } + '"'
|
109
|
+
end
|
110
|
+
|
111
|
+
# Runs a remote command. If a block is given, it is run in a new thread
|
112
|
+
# after stdin is sent. Its sole argument is the SSH channel for this command:
|
113
|
+
# you may use send_data to write to the processes stdin, and use ch.eof! to
|
114
|
+
# close stdin. ch.close will stop the remote process.
|
115
|
+
#
|
116
|
+
# Options:
|
117
|
+
# :stdin => Data piped to the process' stdin.
|
118
|
+
# :stdout => A callback invoked when stdout is received from the process.
|
119
|
+
# The argument is the data received.
|
120
|
+
# :stderr => Like stdout, but for stderr.
|
121
|
+
# :echo => Prints stdout and stderr using print, if true.
|
122
|
+
# :to => Shell output redirection to file. (like cmd >/foo)
|
123
|
+
# :from => Shell input redirection from file. (like cmd </foo)
|
124
|
+
def exec!(command, opts = {}, &block)
|
125
|
+
# Options
|
126
|
+
stdout = ''
|
127
|
+
stderr = ''
|
128
|
+
defaults = {
|
129
|
+
:check_exit_status => true
|
130
|
+
}
|
131
|
+
|
132
|
+
opts = defaults.merge opts
|
133
|
+
|
134
|
+
# First, set up the environment...
|
135
|
+
if @env.size > 0
|
136
|
+
command = (
|
137
|
+
@env.map { |k,v| k.to_s.upcase + '=' + v } << command
|
138
|
+
).join(' ')
|
139
|
+
end
|
140
|
+
|
141
|
+
# Before execution, cd to cwd
|
142
|
+
command = "cd #{escape(@cwd)}; " + command
|
143
|
+
|
144
|
+
# Input redirection
|
145
|
+
if opts[:from]
|
146
|
+
command += " <#{escape(opts[:from])}"
|
147
|
+
end
|
148
|
+
|
149
|
+
# Output redirection
|
150
|
+
if opts[:to]
|
151
|
+
command += " >#{escape(opts[:to])}"
|
152
|
+
end
|
153
|
+
|
154
|
+
# After command, add a semicolon...
|
155
|
+
unless command =~ /;\s*$/
|
156
|
+
command += ';'
|
157
|
+
end
|
158
|
+
|
159
|
+
# Then echo the exit status.
|
160
|
+
command += ' echo $?; '
|
161
|
+
|
162
|
+
|
163
|
+
# If applicable, wrap the command in a sudo subshell...
|
164
|
+
if @sudo
|
165
|
+
command = "sudo -S -u #{@sudo} bash -c #{escape(command)}"
|
166
|
+
opts[:stdin] = @password + "\n" + opts[:stdin].to_s
|
167
|
+
end
|
168
|
+
|
169
|
+
buffer = ''
|
170
|
+
echoed = 0
|
171
|
+
status = nil
|
172
|
+
written = false
|
173
|
+
|
174
|
+
# Run ze command with callbacks.
|
175
|
+
# Return status.
|
176
|
+
channel = ssh.open_channel do |ch|
|
177
|
+
ch.exec command do |ch, success|
|
178
|
+
raise "could not execute command" unless success
|
179
|
+
|
180
|
+
# Handle STDOUT
|
181
|
+
ch.on_data do |c, data|
|
182
|
+
# Could this data be the status code?
|
183
|
+
if pos = (data =~ /(\d{1,3})\n$/)
|
184
|
+
# Set status
|
185
|
+
status = $1
|
186
|
+
|
187
|
+
# Flush old buffer
|
188
|
+
opts[:stdout].call(buffer) if opts[:stdout]
|
189
|
+
stdout << buffer
|
190
|
+
|
191
|
+
# Save candidate status code
|
192
|
+
buffer = data[pos .. -1]
|
193
|
+
|
194
|
+
# Write the other part of the string to the callback
|
195
|
+
opts[:stdout].call(data[0...pos]) if opts[:stdout]
|
196
|
+
stdout << data[0...pos]
|
197
|
+
else
|
198
|
+
# Write buffer + data to callback
|
199
|
+
opts[:stdout].call(buffer + data) if opts[:stdout]
|
200
|
+
stdout << buffer + data
|
201
|
+
buffer = ''
|
202
|
+
end
|
203
|
+
|
204
|
+
if opts[:echo] and echoed < stdout.length
|
205
|
+
stdout[echoed..-1].split("\n")[0..-2].each do |fragment|
|
206
|
+
echoed += fragment.length + 1
|
207
|
+
log fragment
|
208
|
+
end
|
209
|
+
end
|
210
|
+
end
|
211
|
+
|
212
|
+
# Handle STDERR
|
213
|
+
ch.on_extended_data do |c, type, data|
|
214
|
+
if type == 1
|
215
|
+
# STDERR
|
216
|
+
opts[:stderr].call(data) if opts[:stderr]
|
217
|
+
stderr << data
|
218
|
+
log :stderr, stderr if opts[:echo]
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
# Write stdin
|
223
|
+
if opts[:stdin]
|
224
|
+
ch.on_process do
|
225
|
+
unless written
|
226
|
+
ch.send_data opts[:stdin]
|
227
|
+
written = true
|
228
|
+
else
|
229
|
+
# Okay, we wrote stdin
|
230
|
+
unless block or ch.eof?
|
231
|
+
ch.eof!
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
# Handle close
|
238
|
+
ch.on_close do
|
239
|
+
if opts[:echo]
|
240
|
+
# Echo last of input data
|
241
|
+
stdout[echoed..-1].split("\n").each do |fragment|
|
242
|
+
echoed += fragment.length + 1
|
243
|
+
log fragment
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
248
|
+
end
|
249
|
+
|
250
|
+
if block
|
251
|
+
# Run the callback
|
252
|
+
callback_thread = Thread.new do
|
253
|
+
if opts[:stdin]
|
254
|
+
# Wait for stdin to be written before calling...
|
255
|
+
until written
|
256
|
+
sleep 0.1
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
block.call(channel)
|
261
|
+
end
|
262
|
+
end
|
263
|
+
|
264
|
+
# Wait for the command to complete.
|
265
|
+
channel.wait
|
266
|
+
|
267
|
+
# Let the callback thread finish as well
|
268
|
+
callback_thread.join if callback_thread
|
269
|
+
|
270
|
+
if opts[:check_exit_status]
|
271
|
+
# Make sure we have our status.
|
272
|
+
if status.nil? or status.empty?
|
273
|
+
raise "empty status in host#exec() for #{command}, hmmm"
|
274
|
+
end
|
275
|
+
|
276
|
+
# Check status.
|
277
|
+
status = status.to_i
|
278
|
+
if status != 0
|
279
|
+
raise "#{command} exited with non-zero status #{status}!\nSTDERR:\n#{stderr}\nSTDOUT:\n#{stdout}"
|
280
|
+
end
|
281
|
+
end
|
282
|
+
|
283
|
+
stdout.chomp
|
284
|
+
end
|
285
|
+
|
286
|
+
# Returns true when a file exists, otherwise false
|
287
|
+
def exists?(path)
|
288
|
+
true if ftype(path) rescue false
|
289
|
+
end
|
290
|
+
|
291
|
+
# Generates a full path for the given remote path.
|
292
|
+
def expand_path(path)
|
293
|
+
path = path.to_s.gsub(/~(\w+)?/) { |m| homedir($1) }
|
294
|
+
File.expand_path(path, cwd.to_s)
|
295
|
+
end
|
296
|
+
|
297
|
+
# Returns true if a regular file exists.
|
298
|
+
def file?(path)
|
299
|
+
ftype(path) == 'file' rescue false
|
300
|
+
end
|
301
|
+
|
302
|
+
# Returns the filetype, as string. Raises exceptions on failed stat.
|
303
|
+
def ftype(path)
|
304
|
+
path = expand_path(path)
|
305
|
+
begin
|
306
|
+
str = self.stat('-c', '%F', path).strip
|
307
|
+
case str
|
308
|
+
when /no such file or directory/i
|
309
|
+
raise Errno::ENOENT, "#{self}:#{path} does not exist"
|
310
|
+
when 'regular file'
|
311
|
+
'file'
|
312
|
+
when 'regular empty file'
|
313
|
+
'file'
|
314
|
+
when 'directory'
|
315
|
+
'directory'
|
316
|
+
when 'character special file'
|
317
|
+
'characterSpecial'
|
318
|
+
when 'block special file'
|
319
|
+
'blockSpecial'
|
320
|
+
when /link/
|
321
|
+
'link'
|
322
|
+
when /socket/
|
323
|
+
'socket'
|
324
|
+
when /fifo|pipe/
|
325
|
+
'fifo'
|
326
|
+
else
|
327
|
+
raise RuntimeError, "unknown filetype #{str}"
|
328
|
+
end
|
329
|
+
rescue
|
330
|
+
raise RuntimeError, "stat #{self}:#{path} failed - #{str}"
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
# Abusing convention slightly...
|
335
|
+
# Returns the group by name if this host belongs to it, otherwise false.
|
336
|
+
def group?(name)
|
337
|
+
name = name.to_s
|
338
|
+
@groups.find{ |g| g.name == name } || false
|
339
|
+
end
|
340
|
+
|
341
|
+
# Adds this host to a group.
|
342
|
+
def group(name)
|
343
|
+
group = name if name.kind_of? Salticid::Group
|
344
|
+
group ||= @salticid.group name
|
345
|
+
group.hosts |= [self]
|
346
|
+
@groups |= [group]
|
347
|
+
group
|
348
|
+
end
|
349
|
+
|
350
|
+
# Returns the gateway for this host.
|
351
|
+
def gw(gw = nil)
|
352
|
+
if gw
|
353
|
+
@gw = @salticid.host(gw)
|
354
|
+
else
|
355
|
+
@gw
|
356
|
+
end
|
357
|
+
end
|
358
|
+
|
359
|
+
# Returns the home directory of the given user, or the current user if
|
360
|
+
# none specified.
|
361
|
+
def homedir(user = (@sudo||@user))
|
362
|
+
exec! "awk -F: -v v=#{escape(user)} '{if ($1==v) print $6}' /etc/passwd"
|
363
|
+
end
|
364
|
+
|
365
|
+
def inspect
|
366
|
+
"#<#{@user}@#{@name} roles=#{@roles.inspect} tasks=#{@tasks.inspect}>"
|
367
|
+
end
|
368
|
+
|
369
|
+
# Issues a logging statement to this host's log.
|
370
|
+
# log :error, "message"
|
371
|
+
# log "message" is the same as log "info", "message"
|
372
|
+
def log(*args)
|
373
|
+
begin
|
374
|
+
@on_log.call Message.new(*args)
|
375
|
+
rescue
|
376
|
+
# If the log handler is broken, keep going.
|
377
|
+
end
|
378
|
+
end
|
379
|
+
|
380
|
+
# Missing methods are resolved as follows:
|
381
|
+
# 0. Create a RoleProxy from a Role on this host
|
382
|
+
# 1. From task_resolve
|
383
|
+
# 2. Converted to a command string and exec!'ed
|
384
|
+
def method_missing(meth, *args, &block)
|
385
|
+
if meth.to_s == "to_ary"
|
386
|
+
raise NoMethodError
|
387
|
+
end
|
388
|
+
|
389
|
+
if args.empty? and rp = role_proxy(meth)
|
390
|
+
rp
|
391
|
+
elsif task = resolve_task(meth)
|
392
|
+
task.run(self, *args, &block)
|
393
|
+
else
|
394
|
+
if args.last.kind_of? Hash
|
395
|
+
opts = args.pop
|
396
|
+
else
|
397
|
+
opts = {}
|
398
|
+
end
|
399
|
+
str = ([meth] + args.map{|a| escape(a)}).join(' ')
|
400
|
+
exec! str, opts, &block
|
401
|
+
end
|
402
|
+
end
|
403
|
+
|
404
|
+
# Returns the file mode of a remote file.
|
405
|
+
def mode(path)
|
406
|
+
stat('-c', '%a', path).oct
|
407
|
+
end
|
408
|
+
|
409
|
+
# Sets or gets the name of this host.
|
410
|
+
def name(name = nil)
|
411
|
+
if name
|
412
|
+
@name = name.to_s
|
413
|
+
else
|
414
|
+
@name
|
415
|
+
end
|
416
|
+
end
|
417
|
+
|
418
|
+
def on_log(&block)
|
419
|
+
@on_log = block
|
420
|
+
end
|
421
|
+
|
422
|
+
# Finds a task for this host, by name.
|
423
|
+
def resolve_task(name)
|
424
|
+
name = name.to_s
|
425
|
+
@tasks.each do |task|
|
426
|
+
return task if task.name == name
|
427
|
+
end
|
428
|
+
@salticid.tasks.each do |task|
|
429
|
+
return task if task.name == name
|
430
|
+
end
|
431
|
+
nil
|
432
|
+
end
|
433
|
+
|
434
|
+
# Assigns roles to a host from the Salticid. Roles are unique in hosts; repeat
|
435
|
+
# assignments will not result in more than one copy of the role.
|
436
|
+
def role(role)
|
437
|
+
@roles = @roles | [@salticid.role(role)]
|
438
|
+
end
|
439
|
+
|
440
|
+
# Does this host have the given role?
|
441
|
+
def role?(role)
|
442
|
+
@roles.any? { |r| r.name == role.to_s }
|
443
|
+
end
|
444
|
+
|
445
|
+
# Returns a role proxy for role on this host, if we have the role.
|
446
|
+
def role_proxy(name)
|
447
|
+
if role = roles.find { |r| r.name == name.to_s }
|
448
|
+
@role_proxies[name.to_s] ||= RoleProxy.new(self, role)
|
449
|
+
end
|
450
|
+
end
|
451
|
+
|
452
|
+
# Runs the specified task on the given role. Raises NoMethodError if
|
453
|
+
# either the role or task do not exist.
|
454
|
+
def run(role, task, *args)
|
455
|
+
if rp = role_proxy(role)
|
456
|
+
rp.__send__(task, *args)
|
457
|
+
else
|
458
|
+
raise NoMethodError, "No such role #{role.inspect} on #{self}"
|
459
|
+
end
|
460
|
+
end
|
461
|
+
|
462
|
+
# Opens an SSH connection and stores the connection in @ssh.
|
463
|
+
def ssh
|
464
|
+
@ssh_lock.synchronize do
|
465
|
+
if @ssh and not @ssh.closed?
|
466
|
+
return @ssh
|
467
|
+
end
|
468
|
+
|
469
|
+
if tunnel
|
470
|
+
@ssh = tunnel.ssh(name, user)
|
471
|
+
else
|
472
|
+
@ssh = Net::SSH.start(name, user)
|
473
|
+
end
|
474
|
+
end
|
475
|
+
end
|
476
|
+
|
477
|
+
# If a block is given, works like #as. Otherwise, just execs sudo with the
|
478
|
+
# given arguments.
|
479
|
+
def sudo(*args, &block)
|
480
|
+
if block_given?
|
481
|
+
as *args, &block
|
482
|
+
else
|
483
|
+
method_missing(:sudo, *args)
|
484
|
+
end
|
485
|
+
end
|
486
|
+
|
487
|
+
# Uploads a file and places it in the final destination as root.
|
488
|
+
# If the file already exists, its ownership and mode are used for
|
489
|
+
# the replacement. Otherwise it inherits ownership from the parent directory.
|
490
|
+
def sudo_upload(local, remote, opts={})
|
491
|
+
remote = expand_path remote
|
492
|
+
|
493
|
+
# TODO: umask this?
|
494
|
+
local_mode = File.stat(local).mode & 07777
|
495
|
+
File.chmod 0600, local
|
496
|
+
|
497
|
+
|
498
|
+
# Get temporary filename
|
499
|
+
tmpfile = '/'
|
500
|
+
while exists? tmpfile
|
501
|
+
tmpfile = '/tmp/sudo_upload_' + Time.now.to_f.to_s
|
502
|
+
end
|
503
|
+
|
504
|
+
# Upload
|
505
|
+
upload local, tmpfile, opts
|
506
|
+
|
507
|
+
# Get remote mode/user/group
|
508
|
+
sudo do
|
509
|
+
if exists? remote
|
510
|
+
mode = self.mode remote
|
511
|
+
user = stat('-c', '%U', remote).strip
|
512
|
+
group = stat('-c', '%G', remote).strip
|
513
|
+
else
|
514
|
+
user = stat('-c', '%U', File.dirname(remote)).strip
|
515
|
+
group = stat('-c', '%G', File.dirname(remote)).strip
|
516
|
+
mode = local_mode
|
517
|
+
end
|
518
|
+
|
519
|
+
# Move and chmod
|
520
|
+
mv tmpfile, remote
|
521
|
+
chmod mode, remote
|
522
|
+
chown "#{user}:#{group}", remote
|
523
|
+
end
|
524
|
+
end
|
525
|
+
|
526
|
+
# Finds (and optionally defines) a task.
|
527
|
+
#
|
528
|
+
# Tasks are first resolved in the host's task list, then in the Salticid's task
|
529
|
+
# list. Finally, tasks are created from scratch. Any invocation of task adds
|
530
|
+
# that task to this host.
|
531
|
+
#
|
532
|
+
# If a block is given, the block is assigned to the local (host) task. The
|
533
|
+
# task is dup'ed to prevent modifying a possible global task.
|
534
|
+
#
|
535
|
+
# The task is returned at the end of the method.
|
536
|
+
def task(name, &block)
|
537
|
+
name = name.to_s
|
538
|
+
|
539
|
+
if task = @tasks.find{|t| t.name == name}
|
540
|
+
# Found in self
|
541
|
+
elsif (task = @salticid.tasks.find{|t| t.name == name}) and not block_given?
|
542
|
+
# Found in salticid
|
543
|
+
@tasks << task
|
544
|
+
else
|
545
|
+
# Create new task in self
|
546
|
+
task = Salticid::Task.new(name, :salticid => @salticid)
|
547
|
+
@tasks << task
|
548
|
+
end
|
549
|
+
|
550
|
+
if block_given?
|
551
|
+
# Remove the task from our list, and replace it with a copy.
|
552
|
+
# This is to prevent local declarations from clobbering global tasks.
|
553
|
+
i = @tasks.index(task) || @task.size
|
554
|
+
task = task.dup
|
555
|
+
task.block = block
|
556
|
+
@tasks[i] = task
|
557
|
+
end
|
558
|
+
|
559
|
+
task
|
560
|
+
end
|
561
|
+
|
562
|
+
def to_s
|
563
|
+
@name.to_s
|
564
|
+
end
|
565
|
+
|
566
|
+
def to_string
|
567
|
+
h = "Host #{@name}:\n"
|
568
|
+
h << " Groups: #{groups.map(&:to_s).sort.join(', ')}\n"
|
569
|
+
h << " Roles: #{roles.map(&:to_s).sort.join(', ')}\n"
|
570
|
+
h << " Tasks:\n"
|
571
|
+
tasks = self.tasks.map(&:to_s)
|
572
|
+
tasks += roles.map { |r| r.tasks.map { |t| " #{r}.#{t}" }}
|
573
|
+
h << tasks.flatten!.sort!.join("\n")
|
574
|
+
end
|
575
|
+
|
576
|
+
# Returns an SSH::Gateway object for connecting to this host, or nil if no
|
577
|
+
# gateway is needed.
|
578
|
+
def tunnel
|
579
|
+
if gw
|
580
|
+
# We have a gateway host.
|
581
|
+
@tunnel ||= gw.gateway_tunnel
|
582
|
+
end
|
583
|
+
end
|
584
|
+
|
585
|
+
# Upload a file to the server. Remote defaults to local's filename (without
|
586
|
+
# path) if not specified.
|
587
|
+
def upload(local, remote = nil, opts={})
|
588
|
+
remote ||= File.split(local).last
|
589
|
+
remote = expand_path remote
|
590
|
+
ssh.scp.upload!(local, remote, opts)
|
591
|
+
end
|
592
|
+
|
593
|
+
def user(user = nil)
|
594
|
+
if user
|
595
|
+
@user = user
|
596
|
+
else
|
597
|
+
@user
|
598
|
+
end
|
599
|
+
end
|
600
|
+
end
|