class Hg(Service): """ Mercurial repository. """ __metaclass__ = HgBase repository = required_property() repository_location = required_property() default_changeset = 'default' commands = {} # Extra hg commands. Map function name to hg command. @dont_isolate_yet def checkout(self, changeset=None): if not changeset: commit = input('Hg changeset', default=self.default_changeset) if not commit: raise Exception('No changeset given') self._checkout(changeset) def _checkout(self, changeset): # Clone the fist time existed = self.host.exists(self.repository_location) if not existed: self.host.run( "hg clone '%s' '%s'" % (esc1(self.repository), esc1(self.repository_location))) # Checkout with self.host.cd(self.repository_location): self.host.run("hg checkout '%s'" % esc1(changeset))
class MySQLClient(Service): """ For simple Mysql operations, on a remote host. """ hostname = required_property() username = required_property() password = required_property() database = required_property() @isolate_one_only def restore_backup_from_url(self): backup_url = input('Enter the URL of the backup location (an .sql.gz file)') self.hosts.run("curl '%s' | gunzip | /usr/bin/mysql --user '%s' --password='******' --host '%s' '%s' " % (esc1(backup_url), esc1(self.username), esc1(self.password), esc1(self.hostname), esc1(self.database))) @isolate_one_only def shell(self): self.hosts.run("/usr/bin/mysql --user '%s' --password='******' --host '%s' '%s' " % (esc1(self.username), esc1(self.password), esc1(self.hostname), esc1(self.database)))
class User(Service): """ Unix/Linux user management. """ username = required_property() groupname = Q.username has_home_directory = True home_directory_base = None shell = '/bin/bash' def create(self): """ Create this user and home directory. (Does not fail when the user or directory already exists.) """ if self.exists(): return useradd_args = [] useradd_args.append("'%s'" % esc1(self.username)) useradd_args.append("-s '%s'" % self.shell) if self.has_home_directory: useradd_args.append('-m') if self.home_directory_base: useradd_args.append("-b '%s'" % self.home_directory_base) else: useradd_args.append('-M') # Group if self.username == self.groupname: useradd_args.append('-U') else: if self.groupname: self.host.sudo( "grep '%s' /etc/group || groupadd '%s'" % esc1(self.groupname), esc1(self.groupname)) useradd_args.append("-g '%s'" % esc1(self.groupname)) self.host.sudo("useradd " + " ".join(useradd_args)) def exists(self): """ Return true when this user account was already created. """ try: self.host.sudo("grep '%s' /etc/passwd" % self.username) return True except: return False
class SysVInitService(Service): slug = required_property() no_pty = False def _make_command(command): def run(self): self.hosts.sudo("service '%s' %s" % (esc1(self.slug), command), interactive=not self.no_pty) return run stop = _make_command('stop') start = _make_command('start') status = _make_command('status') restart = _make_command('restart') reload = _make_command('reload') def install(self, runlevels='defaults', priority='20'): self.hosts.sudo("update-rc.d '%s' %s %s" % (esc1(self.slug), runlevels, priority)) def uninstall(self): self.hosts.sudo("update-rc.d '%s' remove" % esc1(self.slug))
class Redis(Service): """ Key/Value storage server """ # Port and slug should be unique between all redis installations on one # host. port = 6379 database = 0 password = None slug = required_property() timeout = 0 # username for the server process username = required_property() # Bind to interface, e.g. '127.0.0.1' bind = None # Download URL redis_download_url = 'http://redis.googlecode.com/files/redis-2.6.13.tar.gz' # directory for the database file, or None for the home directory @property def directory(self): # Fallback to home directory return self.host.get_home_directory() # Make persisntent True, when you want to save this database to the disk. persistent = False @property def database_file(self): return 'redis-db-%s.rdb' % self.slug @property def config_file(self): return '/etc/redis-%s.conf' % self.slug @property def logfile(self): return '/var/log/redis-%s.log' % self.slug class packages(AptGet): # Packages required for building redis packages = ('make', 'gcc', 'telnet', 'build-essential') # Depending on the system (x86 or 64bit), some packages are not available. packages_if_available = ('libc6-dev', 'libc6-dev-amd64', 'libc6-dev-i386', 'libjemalloc-dev') class upstart_service(UpstartService): """ Redis upstart service. """ slug = Q('redis-%s') % Q.parent.slug chdir = '/' user = Q.parent.username command = Q('/usr/local/bin/redis-server %s') % Q.parent.config_file def setup(self): # Also make sure that redis was not yet installed if self.is_already_installed: print 'Warning: Redis is already installed' if not confirm('Redis is already installed. Reinstall?'): return # Install dependencies self.packages.install() # Download, compile and install redis # If not yet installed if not self.host.has_command('redis-server'): # Download redis self.host.run(wget(self.redis_download_url, 'redis.tgz')) self.host.run('tar xvzf redis.tgz') # Unset ARCH variable, otherwise redis doesn't compile. # http://comments.gmane.org/gmane.linux.slackware.slackbuilds.user/6686 with self.host.env('ARCH', ''): # Make and install with self.host.cd('redis-2.*'): if self.host.is_64_bit: self.host.run('make ARCH="-m64"') else: self.host.run('make 32bit') self.host.sudo('make install') self.config.setup() # Install upstart config, and run self.upstart_service.setup() self.upstart_service.start() print 'Redis setup successfully on host' class config(Config): remote_path = Q.parent.config_file lexer = IniLexer @property def content(self): self = self.parent return config_template % { 'database_file': self.database_file, 'directory': self.directory, 'password': ('requirepass %s' % self.password if self.password else ''), 'port': self.port, 'auto_save': 'save 60 1' if self.persistent else '', 'bind': ('bind %s' % self.bind if self.bind else ''), 'timeout': str(int(self.timeout)), 'logfile': self.logfile, } def setup(self): Config.setup(self) self.host.sudo("chown '%s' '%s' " % (self.parent.username, self.remote_path)) def touch_logfile(self): # Touch and chown logfile. self.host.sudo("touch '%s'" % esc1(self.logfile)) self.host.sudo("chown '%s' '%s'" % (esc1(self.username), esc1(self.logfile))) def tail_logfile(self): self.host.sudo("tail -n 20 -f '%s'" % esc1(self.logfile)) @property def is_already_installed(self): """ Returns true when redis was already installed on all hosts """ return self.host.exists( self.config_file) and self.upstart_service.is_already_installed() def shell(self): print 'Opening telnet connection to Redis... Press Ctrl-C to exit.' print self.host.run('redis-cli -h localhost -a "%s" -p %s' % (self.password or '', self.port)) def monitor(self): """ Monitor all commands that are currently executed on this redis database. """ self.host.run('echo "MONITOR" | redis-cli -h localhost -a "%s" -p %s' % (self.password or '', self.port)) def dbsize(self): """ Return the number of keys in the selected database. """ self.host.run('echo "DBSIZE" | redis-cli -h localhost -a "%s" -p %s' % (self.password or '', self.port)) def info(self): """ Get information and statistics about the server """ self.host.run('echo "INFO" | redis-cli -h localhost -a "%s" -p %s' % (self.password or '', self.port))
class Config(Service): """ Base class for all configuration files. """ # Full path of the location where this config should be stored. (Start with slash) remote_path = required_property() # The textual content that should be saved in this place. content = required_property() # Pygments Lexer lexer = TextLexer use_sudo = True make_executable = False always_backup_existing_config = False # TODO: maybe we should make this True by default, # but don't backup when the 'diff' is empty. def show_new_config(self): """ Show the new configuration file. (What will be installed on 'setup') """ print highlight(self.content, self.lexer(), Formatter()) def show(self): """ Show the currently installed configuration file. """ print highlight(self.current_content, self.lexer(), Formatter()) @property def current_content(self): """ Return the content which currently exists in this file. """ return self.host.open(self.remote_path, 'rb', use_sudo=True).read() @supress_action_result def diff(self): """ Show changes to be written to the file. (diff between the current and the new config.) """ # Split new and existing content in lines current_content = self.current_content.splitlines(1) new_content = self.content.splitlines(1) # Call difflib diff = ''.join(difflib.unified_diff(current_content, new_content)) print highlight(diff, DiffLexer(), Formatter()) return diff @supress_action_result def exists(self): """ True when this config exists. """ if self.host.exists(self.remote_path): print 'Yes, config exists already.' return True else: print 'Config doesn\'t exist yet' return False def changed(self): """ Return True when there are configuration changes. (Or when the file does not yet exist) """ if self.exists(): return self.current_content != self.content else: return True def setup(self): """ Install config on remote machines. """ # Backup existing configuration if self.always_backup_existing_config: self.backup() self.host.open(self.remote_path, 'wb', use_sudo=self.use_sudo).write(self.content) if self.make_executable: self.host.sudo("chmod a+x '%s'" % esc1(self.host.expand_path(self.remote_path))) def backup(self): """ Create a backup of this configuration file on the same host, in the same directory. """ import datetime suffix = datetime.datetime.now().strftime('%Y-%m-%d--%H-%M-%S') self.host.sudo("test -f '%s' && cp --archive '%s' '%s.%s'" % (esc1(self.remote_path), esc1(self.remote_path), esc1(self.remote_path), esc1(suffix))) def edit_in_vim(self): """ Edit this configuration manually in Vim. """ self.host.sudo("vim '%s'" % esc1(self.remote_path))
class Cron(Service): # ===============[ Cron config ]================ interval = '20 * * * *' # Every hour by default # Username of the user as which the cron should be executed. e.g.: 'username' username = required_property() # The command that this cron has to execute. e.g.: 'echo "dummy command"' command = required_property() # Should be unique between all crons. slug = required_property() @property def doc(self): return self.slug # ===============[ Tasks ]================ def activate_all(self, host=None): hosts = [host] if host else self.hosts for host in hosts: home = host.get_home_directory(self.username) host.sudo('cat %s/.deployer-crons/* | crontab' % home, user=self.username) # Deprecated (confusing naming) def install(self): self.add(skip_activate=False) def add(self, skip_activate=False): """ Install cronjob (This will leave the other cronjobs, created by this service intact.) """ for host in self.hosts: # Get home directory for this user home = host.get_home_directory(self.username) # Create a subdirectory .deployer-crons if this does not yet exist host.sudo('mkdir -p %s/.deployer-crons' % home) host.sudo('chown %s %s/.deployer-crons' % (self.username, home)) # Write this cronjob into deployer-crons/slug host.open('%s/.deployer-crons/%s' % (home, self.slug), 'wb', use_sudo=True).write(self.cron_line) host.sudo('chown %s %s/.deployer-crons/%s' % (self.username, home, self.slug)) if not skip_activate: self.activate_all(host) # Deprecated (confusing naming) def uninstall(self): self.remove(skip_activate=False) def remove(self, skip_activate=False): """ Uninstall cronjob """ for host in self.hosts: # Get home directory for this user home = host.get_home_directory(self.username) # Remove this cronjob path = '%s/.deployer-crons/%s' % (home, self.slug) if host.exists(path): host.sudo("rm '%s' " % path) if not skip_activate: self.activate_all(host) @property def cron_line(self): cron_line = '%s %s\n' % (self.interval, self.command) if self.doc: cron_line = "\n".join(["# %s" % l for l in self.doc.split('\n')] + [cron_line]) return cron_line def show_new_line(self): print self.cron_line def run_now(self): self.hosts.sudo(self.command, user=self.username) def list_all_crons(self): for host in self.hosts: # Get home directory for this user home = host.get_home_directory(self.username) # Print crontabs host.sudo('cat %s/.deployer-crons/* ' % home, user=self.username)
class Git(Service): """ Manage the git checkout of a project """ __metaclass__ = GitBase repository = required_property() repository_location = required_property() default_revision = 'master' commands = { } # Extra git commands. Map function name to git command. @dont_isolate_yet def checkout(self, commit=None): # NOTE: this public 'checkout'-method uses @dont_isolate_yet, so that # in case of a parrallel checkout, we only ask once for the commit # name, and fork only to several threads after calling '_checkout'. # If no commit was given, ask for commit. if not commit: commit = input('Git commit', default=self.default_revision) if not commit: raise Exception('No commit given') self._checkout(commit) def _checkout(self, commit): """ This will either clone or checkout the given commit. Changes in the repository are always stashed before checking out, and stash-popped afterwards. """ # Checkout on every host. for host in self.hosts: existed = host.exists(self.repository_location) if not existed: # Do a new checkout host.run('git clone --recursive %s %s' % (self.repository, self.repository_location)) with host.cd(self.repository_location): host.run('git fetch --all --prune') # Stash if existed: host.run('git stash') # Checkout try: host.run("git checkout '%s'" % esc1(commit)) host.run("git submodule update --init") # Also load submodules. finally: # Pop stash try: if existed: host.run('git stash pop 2>&1', interactive=False) # will fail when checkout had no local changes except ExecCommandFailed, e: result = e.result if result.strip() not in ('Nothing to apply', 'No stash found.'): print result if not confirm('Should we continue?'): raise Exception('Problem with popping your stash, please check logs and try again.')
class Uwsgi(Service): virtual_env_location = required_property() slug = required_property() uwsgi_socket = 'localhost:3032' # Can be either a tcp socket or unix file socket wsgi_app_location = required_property() run_from_directory = required_property() uwsgi_threads = 10 uwsgi_workers = 2 username = required_property() uwsgi_download_url = 'http://projects.unbit.it/downloads/uwsgi-1.4.8.tar.gz' # HTTP use_http = False http_port = 80 @map_roles.just_one class _packages(AptGet): # libxml2-dev is required for compiling uwsgi packages = ('libxml2-dev', ) @map_roles.just_one class virtual_env(VirtualEnv): @property def requirements(self): return ( self.parent.uwsgi_download_url, # For monitoring uwsgi. 'uwsgitop', ) virtual_env_location = Q.parent.virtual_env_location def setup(self): self._packages.install() self.virtual_env.upgrade_requirements() self.init_d_file.setup() @property def pidfile(self): return '/tmp/django-uwsgi-%s.pid' % self.slug @property def logfile(self): return '/tmp/uwsgi-log-%s' % self.slug @property def stats_socket(self): return '/tmp/uwsgi-stats-%s' % self.slug def _get_start_command(self, daemonize=True): """ UWSGI startup command Because of --daemonize, we don't need upstart anymore. """ if self.use_http: socket = '--http 127.0.0.1:%s' % self.http_port else: socket = '-s %s' % self.uwsgi_socket return '%(virtual_env)s/bin/uwsgi -H %(virtual_env)s %(uwsgi_socket)s --threads %(threads)i --workers %(workers)i --stats %(stats)s ' \ '%(pidfile)s %(daemonize)s %(log)s --logfile-chown %(username)s -M %(wsgi_app)s --uid %(username)s --chmod-socket %(chmod_socket)s' % { 'virtual_env': self.virtual_env_location, 'uwsgi_socket': socket, 'pidfile': ('--pidfile=%s' % self.pidfile if daemonize else ''), 'daemonize': '--daemonize' if daemonize else '', 'log': self.logfile, 'wsgi_app': self.wsgi_app_location, 'threads': self.uwsgi_threads, 'workers': self.uwsgi_workers, 'stats': self.stats_socket, 'username': self.username, 'chmod_socket': 666, } @property def reload_command(self): return "kill -SIGHUP ` cat '%s' ` " % esc1(self.pidfile) @property def stop_command(self): return "kill -SIGQUIT ` cat '%s' ` && rm '%s' " % (esc1( self.pidfile), esc1(self.pidfile)) def monitor_log(self): """ Show uwsgi tail. """ self.hosts.sudo("tail -f '%s' " % esc1(self.logfile), ignore_exit_status=True) def top(self): """ Run uwsgi top """ with self.hosts.prefix(self.virtual_env.activate_cmd): self.hosts.sudo("uwsgitop '%s'" % self.stats_socket) def start(self, daemonize=True): """ Start uWSGI stack """ for h in self.hosts: if not h.exists(self.pidfile): with h.cd(self.run_from_directory): h.sudo(self._get_start_command(daemonize)) else: print 'Pidfile %s already exists' % self.pidfile def run_in_shell(self): self.start(daemonize=False) def stop(self): """ Kill all the uWSGI stack. """ self.hosts.sudo(self.stop_command) def status(self): """ True when this uwsgi process is running """ for h in self.hosts: if h.exists(self.pidfile): print 'Running' def reload(self): """ Reload (gracefully) all the workers and the master process. """ self.hosts.sudo(self.reload_command) def rmpidfile(self): """ Remove pidfile, sometimes it can happen that the pidfile was created, and the server crached due to a bad configuration, without removing the pidfile. """ if input('Remove pidfile', answers=['y', 'n']) == 'y': self.hosts.sudo("kill -SIGQUIT ` cat '%s' ` || rm '%s' " % (esc1(self.pidfile), esc1(self.pidfile))) class init_d_file(Config): @property def slug(self): return 'uwsgi-%s' % self.parent.slug @property def remote_path(self): return '/etc/init.d/%s' % self.slug lexer = BashLexer use_sudo = True make_executable = True @property def content(self): self = self.parent return init_d_template % { 'slug': self.slug, 'run_from_directory': self.run_from_directory, 'pidfile': self.pidfile, 'start_command': self._get_start_command(True), 'reload_command': self.reload_command, 'stop_command': self.stop_command, } def setup(self): Config.setup(self) # Automatically start on system boot. self.hosts.sudo("update-rc.d '%s' defaults" % self.slug)
class Django(Service): __metaclass__ = DjangoBase # Location of the virtual env virtual_env_location = '' # Django project location. This is the directory which contains the # manage.py file. django_project = '' # Django commands (mapping from command name, to ./manage.py parameter) commands = { } # User for the upstart service username = required_property() slug = 'default-django-app' uwsgi_socket = 'localhost:3032' # Can be either a tcp socket or unix file socket uwsgi_threads = 10 uwsgi_workers = 2 uwsgi_use_http = False # When true, we will use the same port as runserver. # this has the advantage that Django's runserver and # uwsgi can be used interchangable. # HTTP Server http_port = 8000 uwsgi_auto_reload = False def _get_management_command(self, command): """ Create the call for a management command. (For use in cronjobs, etc...) NOTE: The command itself is not shell-escaped, be sure to use proper quoting if necessary! """ parent_directory, dir2 = self.django_project.rsplit('/', 1) return "cd '%s'; '%s/bin/python' '%s/manage.py' %s" % ( parent_directory, self.virtual_env_location, dir2, command) def _run_management_command(self, command): self.hosts.run(self._get_management_command(command)) @isolate_one_only @alias('manage.py') def _manage_py(self, command=None): command = command or input('python manage.py (...)') self._run_management_command(command) @property def settings_module(self): return self.django_project.rstrip('/').rsplit('/', 1)[-1] + '.settings' @property def wsgi_app_location(self): return '/etc/wsgi-apps/%s.py' % self.slug # ===========[ WSGI setup ]============ @map_roles.just_one class uwsgi(Uwsgi): uwsgi_socket = Q.parent.uwsgi_socket slug = Q.parent.slug wsgi_app_location = Q.parent.wsgi_app_location uwsgi_threads = Q.parent.uwsgi_threads uwsgi_workers = Q.parent.uwsgi_workers virtual_env_location = Q.parent.virtual_env_location username = Q.parent.username use_http = Q.parent.uwsgi_use_http @property def run_from_directory(self): return self.parent.django_project + '/..' def setup(self): Uwsgi.setup(self) self.parent.wsgi_app.setup() class wsgi_app(Config): remote_path = Q.parent.wsgi_app_location @property def content(self): self = self.parent return wsgi_app_template % { 'auto_reload': repr(self.uwsgi_auto_reload), 'settings_module': repr(self.settings_module), } def setup(self): self.host.sudo("mkdir -p $(dirname '%s')" % self.remote_path) Config.setup(self) self.host.sudo("chown %s '%s'" % (self.parent.username, self.remote_path))
class Variants(Config): """ Simple server-side persistent key-value list of attributes. Mostly useful for an installation of a service on a server: what version is installed with what options? Do we need to install it again or extend it with new options for this extra service? Similar to variants for MacPorts. Variants are conditional modifications of installations. They are flags, saved on the target system. If we detect that the variants don't match with those that we expect, then we know that we have to reinstall the service. This is useful, for when some services are installed system-wide from several set-ups. Each set-up can add their own variants. If some variants are already in place, and our service adds another variant, then we should probably reinstall the service, combining all these variants. Variants can be specified as a list/set or as a dict, but the helper attributes convert them to dicts. This allows you to use keys and values, which can be compared (like version numbers). It is possible that we will drop list support in the future. Example: variants = ('version:1.2', 'plugin_foo') Equivalent: variants = {'version': '1.2', 'plugin_foo': True} If the server would already contain ('version:1.1', 'plugin_bar'), you know you will have to install Version 1.2 with plugins foo and bar to satisfy your own service and other services on the same host that depend on it. """ # Override variants = set() slug = required_property() @property def remote_path(self): return '/etc/variants/%s' % self.slug @property def content(self): final_variants = self._as_list(self.variants_final) # Return result return ' '.join(final_variants) def _as_dict(self, var_list): if isinstance(var_list, dict): return var_list var_dict = {} for var in var_list: var_parts = var.split(':') if len(var_parts) == 1: var_dict[var_parts[0]] = True elif len(var_parts) > 2: raise Exception('Variant %s contains more than one colon' % var) else: var_dict[var_parts[0]] = var_parts[1] return var_dict def _as_list(self, var_dict): if isinstance(var_dict, list): return var_dict var_list = [] for v, s in var_dict.iteritems(): if not isinstance(s, bool): var_list.append('%s:%s' % (v, s)) else: var_list.append(v) var_list.sort() return var_list @property def variants_installed(self): """ The currently installed variants. """ if self.host.exists(self.remote_path): return self.current_content.split() return {} @property def variants_final(self): """ Merge variants installed with requested. If you (re-)install a service, use this as the guide of what to install. """ vars_installed = self._as_dict(self.variants_installed) vars_installed.update(self.variants_to_update) return vars_installed @property def variants_to_update(self): """ Compare the installed variants with the variants requested by this service. This will return the variants that need to change, with their final version. You can check this property to determine whether you need to reinstall the service. If you decide to reinstall, use variants_final as a guide of what to install, because variants_to_update will not include variants that have not changed. """ vars_installed = self._as_dict(self.variants_installed) vars_requested = self._as_dict(self.variants) vars_to_update = {} for var_req, var_spec_req in vars_requested.iteritems(): if var_req not in vars_installed: # The variant is not yet installed # Install the requested version vars_to_update[var_req] = var_spec_req else: # The variant is already installed if isinstance(var_spec_req, bool): # We do not request a specific version # No need to update pass else: var_spec_cur = vars_installed[var_req] # We request a specific version if isinstance(var_spec_cur, bool): # We currently have an unspecified version # Go to the specific version vars_to_update[var_req] = var_spec_req else: # Comparison time! from distutils.version import LooseVersion if LooseVersion(var_spec_cur) < LooseVersion( var_spec_req): # We currently have a lower version, install ours vars_to_update[var_req] = var_spec_req return vars_to_update @property def clear(self): """ Clear (reset) the variants file. """ self.host.sudo("rm '/etc/variants/%s'" % esc1(self.remote_path)) def setup(self): self.host.sudo('mkdir -p /etc/variants') Config.setup(self)
class UpstartService(Service): chdir = '/' user = '******' author = '(author)' command = required_property() # e.g. '/bin/sleep 1000' pre_start_script = '' post_start_script = '' pre_stop_script = '' post_stop_script = '' extra = '' slug = required_property() # A /etc/init/(slug).conf file will be created @property def description(self): # Can be more verbose than the slug, e.g. 'Upstart Service' return self.slug @property def config_file(self): return '/etc/init/%s.conf' % self.slug @property def full_command(self): if self.user and self.user != 'root': return "su -c '%s' '%s' " % (esc1(self.command), esc1(self.user)) else: return self.command @map_roles.just_one # The parent, UpstartService already has host isolation. class config(Config): remote_path = Q.parent.config_file use_sudo = True lexer = BashLexer # No UpstartLexer available yet? @property def content(self): self = self.parent extra_scripts = '' for s in ('start', 'stop'): for p in ('pre', 'post'): script = getattr(self, '%s_%s_script' % (p, s), '') if script: extra_scripts += """ %s-%s script %s end script """ % (p, s, indent(script)) return upstart_template % { 'description': esc1(self.description), 'author': esc1(self.author), 'chdir': esc1(self.chdir), 'command': self.full_command, 'user': esc1(self.user), 'extra': self.extra, 'extra_scripts': extra_scripts, } def setup(self): """ Install upstart configuration """ self.config.setup() def start(self): self.hosts.sudo('start "%s" || true' % self.slug) def stop(self): self.hosts.sudo('stop "%s" || true' % self.slug) def restart(self): self.hosts.sudo('restart "%s" || true' % self.slug) def status(self): self.hosts.sudo('status "%s"' % self.slug) def run_in_shell(self): with self.hosts.cd(self.chdir): self.hosts.sudo(self.full_command) def is_already_installed(self): """ True when this service is installed. """ # Note: thanks to @isolate_host, there can only be one host in # self.hosts.filter('host') return self.hosts.filter('host')[0].exists(self.config_file)