initial checkin of the vserver tools
[infrastructure.git] / scripts / vserver_tools.py
1 #!/usr/bin/python
2 #
3 # $Id$
4 # $URL$
5
6 import sys
7 import os.path
8 import pdb
9 import subprocess
10 from optparse import OptionParser
11 import socket
12
13 #
14 # Thierry Parmentelat - INRIA
15 #
16 # class for issuing commands on a box, either local or remote
17 #
18 # the notion of 'buildname' is for providing each test run with a dir of its own
19 # buildname is generally the name of the build being tested, and can be considered unique
20 #
21 # thus 'run_in_buildname' mostly :
22 # (*) either runs locally in . - as on a local node we are already in a dedicated directory
23 # (*) or makes sure that there's a remote dir called 'buildname' and runs in it
24 #
25 # also, the copy operations
26 # (*) either do nothing if ran locally
27 # (*) or copy a local file into the remote 'buildname' 
28
29
30 ########################################
31 class Logger:
32
33     def __init__(self):
34         pass
35
36     def log (self,msg):
37         print >>sys.stderr, msg
38
39 logger=Logger()
40
41 ########################################
42 # bool_to_exit_code
43 def b2e (b):
44     if b: return 0
45     else: return 1
46 # exit_to_bool_code
47 def e2b (b):
48     return b==0
49
50 verbose=False
51 verbose=True
52 ########################################
53 class Shell:
54     def __init__(self,dry_run=False):
55         self.dry_run=dry_run
56
57     def header (self,message):
58         logger.log("==============="+message)
59
60     # return an exit code
61     def run (self, argv, trash_stdout=False, trash_stderr=False, message="", force_dry_run=False):
62         if self.dry_run:
63             print 'DRY_RUN',message+'RUNNING '+" ".join(argv)
64             return 0
65         else:
66             if message: self.header(message),
67             if verbose: logger.log ('RUNNING '+" ".join(argv))
68             if trash_stdout and trash_stderr:
69                 return subprocess.call(argv,stdout=file('/dev/null','w'),stderr=subprocess.STDOUT)
70             elif trash_stdout and not trash_stderr:
71                 return subprocess.call(argv,stdout=file('/dev/null','w'))
72             elif not trash_stdout and trash_stderr:
73                 return subprocess.call(argv,stderr=file('/dev/null','w'))
74             elif not trash_stdout and not trash_stderr:
75                 return subprocess.call(argv)
76             
77     # like shell's $(...) which used to be `...`
78     def backquote (self, argv, trash_stderr=False,message=""):
79         if self.dry_run or verbose:
80             if verbose: logger.log("WARNING: " + message + " backquote is actually running "+" ".join(argv))
81         if not trash_stderr:
82             return subprocess.Popen(argv,stdout=subprocess.PIPE).communicate()[0]
83         else:
84             return subprocess.Popen(argv,stdout=subprocess.PIPE,stderr=file('/dev/null','w')).communicate()[0]
85             
86 ####################
87 class Ssh:
88     
89     #################### the class
90     def __init__(self,hostname=None,key=None, username='root',dry_run=False):
91         self.hostname=hostname
92         if not hostname: self.hostname='localhost'
93         self.key=key
94         self.username=username
95         self.dry_run=dry_run
96
97     # inserts a backslash before each occurence of the following chars
98     # \ " ' < > & | ; ( ) $ * ~ 
99     @staticmethod
100     def backslash_shell_specials (command):
101         result=''
102         for char in command:
103             if char in "\\\"'<>&|;()$*~":
104                 result +='\\'+char
105             else:
106                 result +=char
107         return result
108
109     # check main IP address against the provided hostname
110     @staticmethod
111     def is_local_hostname (hostname):
112         if hostname == "localhost":
113             return True
114         try:
115             local_ip = socket.gethostbyname(socket.gethostname())
116             remote_ip = socket.gethostbyname(hostname)
117             return local_ip==remote_ip
118         except:
119             logger.log("WARNING : something wrong in is_local_hostname with hostname=%s"%hostname)
120             return False
121
122     @staticmethod
123     def canonical_name (hostname):
124         try:
125             ip=socket.gethostbyname(hostname)
126             return socket.gethostbyaddr(ip)[0]
127         except:
128             logger.log( 'Warning - could not find canonical name for host %s'%hostname)
129             return hostname
130
131
132     ########## building argv
133     def is_local(self):
134         return Ssh.is_local_hostname(self.hostname)
135      
136     def key_argv (self):
137         if not self.key:
138             return []
139         return ["-i",self.key]
140
141     def hostname_arg (self):
142         if not self.username:
143             return self.hostname
144         else:
145             return "%s@%s"%(self.username,self.hostname)
146     
147     std_options="-o BatchMode=yes -o StrictHostKeyChecking=no -o CheckHostIP=no -o ConnectTimeout=5"
148     
149     # command gets run on the right box
150     def argv (self, argv, keep_stdin=False):
151         if self.is_local():
152             return argv
153         ssh_argv = ["ssh"]
154         if not keep_stdin: ssh_argv.append('-n')
155         ssh_argv += Ssh.std_options.split()
156         ssh_argv += self.key_argv()
157         ssh_argv.append (self.hostname_arg())
158         # using subprocess removes the need for special chars at this point
159         #ssh_argv += [Ssh.backslash_shell_specials(x) for x in argv]
160         ssh_argv += argv
161         return ssh_argv
162
163     # argv is a list
164     def run(self, argv,*args, **kwds):
165         return Shell(self.dry_run).run(self.argv(argv),*args, **kwds)
166     # command is a string
167     def run_string(self, command, *args, **kwds):
168         return Shell(self.dry_run).run(self.argv(command.split()),*args, **kwds)
169
170     # argv is a list
171     def backquote(self, argv, *args, **kwds):
172         return Shell(self.dry_run).backquote(self.argv(argv),*args, **kwds)
173     # command is a string
174     def backquote_string(self, command, *args, **kwds):
175         return Shell(self.dry_run).backquote(self.argv(command.split()),*args, **kwds)
176
177 ############################################################
178 class Vserver:
179
180     def __init__ (self, name, dry_run=False):
181         try:
182             (self.name,self.hostname)=name.split("@")
183         except:
184             (self.name,self.hostname)=(name,'localhost')
185         self.dry_run=dry_run
186         self.ssh=Ssh(self.hostname,username='root',dry_run=self.dry_run)
187         self._is_running=None
188
189     # cache run mode
190     def is_running (self):
191         if self._is_running is not None: return self._is_running
192         self._is_running = e2b(self.ssh.run_string('vserver %s status'%self.name,
193                                                    trash_stdout=True,trash_stderr=True,
194                                                    message='retrieving status for %s'%self.name))
195     def running_char (self):
196         if self._is_running is None:    return '?'
197         elif not self._is_running:      return '-'
198         else:                           return '+'
199
200     def stop (self):
201         # uncache
202         self._is_running=None
203         return e2b(self.ssh.run_string("vserver %s stop"%self.name,message='Stopping %s'%self.name))
204
205     def start (self):
206         # uncache
207         self._is_running=None
208         return e2b(self.ssh.run_string("vserver %s start"%self.name,message='Starting %s'%self.name))
209
210     def printable_simple(self): return self.name + '@' + self.hostname
211     def printable_dbg(self): return "%s@%s (%s)"%(self.name,self.hostname,self.running_char())
212     def printable(self): 
213         # force fetch
214         self.is_running()
215         return "%s@%s (%s)"%(self.name,self.hostname,self.running_char())
216
217     def is_local (self):
218         return self.ssh.is_local()
219
220     @staticmethod
221     def probe_host (hostname, dry_run=False):
222         canonical=Ssh.canonical_name(hostname)
223         ssh=Ssh(hostname,dry_run=dry_run)
224         ls=ssh.backquote_string("ls -d1 /vservers/*",trash_stderr=True)
225         return [ '%s@%s'%(path.replace('/vservers/',''),canonical) for path in ls.split()]
226
227     # renaming
228     def rename (self, newname):
229         target=Vserver(newname)
230         if self.hostname != target.hostname:
231             # make user's like easier, no need to mention hostname again
232             if target.is_local():
233                 target.hostname=self.hostname
234             else:
235                 logger.log('rename cannot rename (%s->%s) - must be on same box'%(self.hostname, target.hostname))
236                 return False
237         logger.log("Renaming %s into %s"%(self.printable(),target.printable_simple()))
238
239         ssh=self.ssh
240         oldname=self.name
241         newname=target.name
242         xid=ssh.backquote(['cat','/etc/vservers/%(oldname)s/context'%locals()])
243         currently_running=self.is_running()
244         result=True
245         if currently_running:
246             result = result and self.stop()
247         result = result \
248             and e2b(ssh.run_string("mv /vservers/%(oldname)s /vservers/%(newname)s"%locals())) \
249             and e2b(ssh.run_string("mv /etc/vservers/%(oldname)s /etc/vservers/%(newname)s"%locals())) \
250             and e2b(ssh.run_string("rm /etc/vservers/%(newname)s/run"%locals())) \
251             and e2b(ssh.run_string("ln -s /var/run/vservers/%(newname)s /etc/vservers/%(newname)s/run"%locals())) \
252             and e2b(ssh.run_string("rm /etc/vservers/%(newname)s/vdir"%locals())) \
253             and e2b(ssh.run_string("ln -s /etc/vservers/.defaults/vdirbase/%(newname)s /etc/vservers/%(newname)s/vdir"%locals())) \
254             and e2b(ssh.run_string("rm /etc/vservers/%(newname)s/cache"%locals())) \
255             and e2b(ssh.run_string("ln -s /etc/vservers/.defaults/cachebase/%(newname)s /etc/vservers/%(newname)s/cache"%locals())) \
256             and e2b(ssh.run_string("rm /var/run/vservers.rev/%(xid)s"%locals())) \
257             and e2b(ssh.run_string("ln -s /etc/vserver/%(newname)s /var/run/vservers.rev/%(xid)s"%locals()))
258         if currently_running:
259             result = result and self.start()
260         return result
261             
262     def ok(self,message): logger.log('OK %s: %s'%(self.printable(),message))
263     def error(self,message): logger.log('ERROR %s: %s'%(self.printable(),message))
264     def warning(self,message): logger.log( 'WARNING %s: %s'%(self.printable(),message))
265
266     def netcheck(self):
267         result=True
268         ssh=self.ssh
269         name=self.name
270         hostname=ssh.backquote_string("cat /etc/vservers/%(name)s/uts/nodename"%locals()).strip()
271         if not hostname: 
272             self.error('no uts/nodename')
273             result=False
274         ip=ssh.backquote_string("cat /etc/vservers/%(name)s/interfaces/0/ip"%locals()).strip()
275         if not ip: 
276             self.error('no interfaces/0/ip')
277             result=False
278         try:
279             expected_ip=socket.gethostbyname(hostname)
280             if expected_ip == ip:
281                 self.ok('hostname %s maps to IP %s'%(hostname,ip))
282             else:
283                 self.error('IP mismatch, current %s -- expected %s'%(ip,expected_ip))
284                 result=False
285         except:
286             self.warning ('could not resolve hostname <%s>'%hostname)
287             expected_ip='255.255.255.255'
288             result=False
289         mask_path="/etc/vservers/%(name)s/interfaces/mask"%locals()
290         mask_exists = e2b(ssh.run_string('ls %s'%mask_path,trash_stderr=True))
291         if mask_exists:
292             self.error('found a mask file in %s'%(self.printable(),mask_path))
293             result=False
294         else:
295             self.ok('no mask file %s'%mask_path)
296         return result
297         
298 class Main:
299
300     modes={ 
301         # expected arg numbers can be either
302         # negative int, e.g. -1 means at least one arg
303         # positive int, exactly that number of args
304         # list of ints : allowed number of args
305         'probe' : "hostname(s)\n\tprobes all hostnames and returns the found vservers",
306         'netcheck' : "vsname\n\tchecks the network config for a vserver",
307         'rename' : "oldname newname\n\trename a vserver - both named must be local",
308         'migrate' : "oldname hostname@newname\n\tmigrate a vserver over to a new box; must be run on the current box\n\tnot implemented yet",
309         }
310
311     def read_args_from_file (self,args_from_file):
312         if not args_from_file: return []
313         try:
314             raw=file(args_from_file).read().split()
315             return [x for x in raw if x.find('#')<0]
316         except:
317             logger.log('ERROR: Could not open args file %s'%args_from_file)
318             return []
319
320     def run (self):
321
322         mode=None
323         for function in Main.modes.keys():
324             if sys.argv[0].find(function) >= 0:
325                 mode = function
326                 mode_desc = Main.modes[mode]
327                 break
328         if not mode:
329             print "Unsupported command",sys.argv[0]
330             print "Supported commands:" + " ".join(Main.modes.keys())
331             sys.exit(1)
332
333         parser=OptionParser(usage="%prog " + mode_desc)
334         parser.add_option('-n','--dry-run',dest='dry_run',action='store_true',default=False,
335                           help="dry run")
336         if mode == 'probe' or mode == 'netcheck':
337             parser.add_option ('-f','--file',action='store',dest='args_from_file',default=None,
338                                help='read args from file')
339
340         (options,args) = parser.parse_args()
341         parser.set_usage("%prog " + mode_desc)
342
343         # check number of arguments
344         def bail_out (msg):
345             parser.print_help(msg)
346             sys.exit(1)
347
348         # 
349         if mode == 'probe':
350             names=[]
351             if not options.args_from_file and len(args)==0: bail_out('no arg specified')
352             for arg in args + self.read_args_from_file(options.args_from_file):
353                 names += Vserver.probe_host(arg,options.dry_run)
354             names.sort()
355             for name in names: print name
356                 
357         elif mode=='netcheck':
358             result=True
359             if not options.args_from_file and len(args)==0: bail_out('no arg specified')
360             for arg in args + self.read_args_from_file(options.args_from_file):
361                 if not b2e(Vserver(arg,options.dry_run).netcheck()): result=False
362             return result
363         elif mode=='rename':
364             [x,y]=args
365             return b2e(Vserver(x,options.dry_run).rename(y))
366         elif mode=='migrate':
367             print 'migrate not yet implemented'
368             return 1
369         else:
370             print 'unknown mode %s'%mode
371             return b2e(False)
372
373 if __name__ == '__main__':
374     sys.exit( Main().run() )